From cade6728065d815727a447771526a82d116252bd Mon Sep 17 00:00:00 2001 From: Jett Chen Date: Fri, 2 Feb 2024 14:48:13 +0800 Subject: [PATCH 001/102] Add Support for IPV6 and Arbitrary Server Address Add IPV6 support for Apple Devices Note: Works in GUI not CLI Adds Support for Arbitrary Server Address --- Apple/NetworkExtension/DataTypes.swift | 4 +- .../PacketTunnelProvider.swift | 21 +++- Cargo.lock | 16 +-- Dockerfile | 2 +- Makefile | 25 ++++- burrow-server-compose.yml | 38 +++++++ burrow/src/daemon/response.rs | 4 +- ...ommand__daemoncommand_serialization-2.snap | 2 +- ..._command__daemoncommand_serialization.snap | 2 +- ...n__response__response_serialization-4.snap | 2 +- burrow/src/main.rs | 2 +- burrow/src/tracing.rs | 1 + burrow/src/wireguard/config.rs | 10 +- server_patch.txt | 21 ++++ tun/Cargo.toml | 2 +- tun/src/options.rs | 6 +- tun/src/unix/apple/kern_control.rs | 2 +- tun/src/unix/apple/mod.rs | 55 +++++++-- tun/src/unix/apple/sys.rs | 104 +++++++++++++++++- tun/tests/packets.rs | 13 +++ 20 files changed, 276 insertions(+), 56 deletions(-) create mode 100644 burrow-server-compose.yml create mode 100644 server_patch.txt diff --git a/Apple/NetworkExtension/DataTypes.swift b/Apple/NetworkExtension/DataTypes.swift index 391bfed..1409fde 100644 --- a/Apple/NetworkExtension/DataTypes.swift +++ b/Apple/NetworkExtension/DataTypes.swift @@ -31,7 +31,7 @@ struct BurrowStartRequest: Codable { let no_pi: Bool let tun_excl: Bool let tun_retrieve: Bool - let address: String? + let address: [String] } struct StartOptions: Codable { let tun: TunOptions @@ -51,7 +51,7 @@ struct BurrowResult: Codable where T: Codable { struct ServerConfigData: Codable { struct InternalConfig: Codable { - let address: String? + let address: [String] let name: String? let mtu: Int32? } diff --git a/Apple/NetworkExtension/PacketTunnelProvider.swift b/Apple/NetworkExtension/PacketTunnelProvider.swift index 9231676..bfdb34a 100644 --- a/Apple/NetworkExtension/PacketTunnelProvider.swift +++ b/Apple/NetworkExtension/PacketTunnelProvider.swift @@ -31,7 +31,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { command: BurrowStartRequest( Start: BurrowStartRequest.StartOptions( tun: BurrowStartRequest.TunOptions( - name: nil, no_pi: false, tun_excl: false, tun_retrieve: true, address: nil + name: nil, no_pi: false, tun_excl: false, tun_retrieve: true, address: [] ) ) ) @@ -46,12 +46,21 @@ class PacketTunnelProvider: NEPacketTunnelProvider { private func generateTunSettings(from: ServerConfigData) -> NETunnelNetworkSettings? { let cfig = from.ServerConfig - guard let addr = cfig.address else { - return nil - } - // Using a makeshift remote tunnel address let nst = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "1.1.1.1") - nst.ipv4Settings = NEIPv4Settings(addresses: [addr], subnetMasks: ["255.255.255.0"]) + var v4Addresses = [String]() + var v6Addresses = [String]() + for addr in cfig.address { + if IPv4Address(addr) != nil { + v6Addresses.append(addr) + } + if IPv6Address(addr) != nil { + v4Addresses.append(addr) + } + } + nst.ipv4Settings = NEIPv4Settings(addresses: v4Addresses, subnetMasks: v4Addresses.map { _ in + "255.255.255.0" + }) + nst.ipv6Settings = NEIPv6Settings(addresses: v6Addresses, networkPrefixLengths: v6Addresses.map { _ in 64 }) logger.log("Initialized ipv4 settings: \(nst.ipv4Settings)") return nst } diff --git a/Cargo.lock b/Cargo.lock index 85f11e7..a75bd28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1074,7 +1074,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2 0.5.5", + "socket2", "tokio", "tower-service", "tracing", @@ -2114,16 +2114,6 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" -[[package]] -name = "socket2" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "socket2" version = "0.5.5" @@ -2305,7 +2295,7 @@ dependencies = [ "mio", "num_cpus", "pin-project-lite", - "socket2 0.5.5", + "socket2", "tokio-macros", "tracing", "windows-sys 0.48.0", @@ -2547,7 +2537,7 @@ dependencies = [ "reqwest", "schemars", "serde", - "socket2 0.4.10", + "socket2", "ssri", "tempfile", "tokio", diff --git a/Dockerfile b/Dockerfile index 3c12d45..9f54478 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM docker.io/library/rust:1.74.0-slim-bookworm AS builder +FROM docker.io/library/rust:1.76.0-slim-bookworm AS builder ARG TARGETPLATFORM ARG LLVM_VERSION=16 diff --git a/Makefile b/Makefile index 18b4b27..97d2d5a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -tun_num := $(shell ifconfig | awk -F 'utun|[: ]' '/utun[0-9]/ {print $$2}' | tail -n 1) +tun := $(shell ifconfig -l | sed 's/ /\n/g' | grep utun | tail -n 1) cargo_console := RUST_BACKTRACE=1 RUST_LOG=debug RUSTFLAGS='--cfg tokio_unstable' cargo run --all-features cargo_norm := RUST_BACKTRACE=1 RUST_LOG=debug cargo run @@ -19,15 +19,28 @@ start: test-dns: @sudo route delete 8.8.8.8 - @sudo route add 8.8.8.8 -interface utun$(tun_num) + @sudo route add 8.8.8.8 -interface $(tun) @dig @8.8.8.8 hackclub.com test-https: @sudo route delete 193.183.0.162 - @sudo route add 193.183.0.162 -interface utun$(tun_num) + @sudo route add 193.183.0.162 -interface $(tun) @curl -vv https://search.marginalia.nu +v4_target := 146.190.62.39 test-http: - @sudo route delete 146.190.62.39 - @sudo route add 146.190.62.39 -interface utun$(tun_num) - @curl -vv 146.190.62.39:80 + @sudo route delete ${v4_target} + @sudo route add ${v4_target} -interface $(tun) + @curl -vv ${v4_target}:80 + +test-ipv4: + @sudo route delete ${v4_target} + @sudo route add ${v4_target} -interface $(tun) + @ping ${v4_target} + +v6_target := 2001:4860:4860::8888 +test-ipv6: + @sudo route delete ${v6_target} + @sudo route -n add -inet6 ${v6_target} -interface $(tun) + @echo preparing + @sudo ping6 -v ${v6_target} diff --git a/burrow-server-compose.yml b/burrow-server-compose.yml new file mode 100644 index 0000000..4ba31ee --- /dev/null +++ b/burrow-server-compose.yml @@ -0,0 +1,38 @@ +version: "2.1" +networks: + wg6: + enable_ipv6: true + ipam: + driver: default + config: + - subnet: "aa:bb:cc:de::/64" +services: + burrow: + image: lscr.io/linuxserver/wireguard:latest + privileged: true + container_name: burrow_server + cap_add: + - NET_ADMIN + - SYS_MODULE + environment: + - PUID=1000 + - PGID=1000 + - TZ=Asia/Calcutta + - SERVERURL=wg.burrow.rs + - SERVERPORT=51820 + - PEERS=10 + - PEERDNS=1.1.1.1 + - INTERNAL_SUBNET=10.13.13.0 + - ALLOWEDIPS=0.0.0.0/0, ::/0 + - PERSISTENTKEEPALIVE_PEERS=all + - LOG_CONFS=true #optional + volumes: + - ./config:/config + - /lib/modules:/lib/modules + ports: + - 51820:51820/udp + sysctls: + - net.ipv4.conf.all.src_valid_mark=1 + - net.ipv6.conf.all.disable_ipv6=0 + - net.ipv6.conf.eth0.proxy_ndp=1 + restart: unless-stopped \ No newline at end of file diff --git a/burrow/src/daemon/response.rs b/burrow/src/daemon/response.rs index 172d4c7..37ee5d9 100644 --- a/burrow/src/daemon/response.rs +++ b/burrow/src/daemon/response.rs @@ -57,7 +57,7 @@ impl TryFrom<&TunInterface> for ServerInfo { #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct ServerConfig { - pub address: Option, + pub address: Vec, pub name: Option, pub mtu: Option, } @@ -65,7 +65,7 @@ pub struct ServerConfig { impl Default for ServerConfig { fn default() -> Self { Self { - address: Some("10.13.13.2".to_string()), // Dummy remote address + address: vec!["10.13.13.2".to_string()], // Dummy remote address name: None, mtu: None, } diff --git a/burrow/src/daemon/snapshots/burrow__daemon__command__daemoncommand_serialization-2.snap b/burrow/src/daemon/snapshots/burrow__daemon__command__daemoncommand_serialization-2.snap index 0eb9096..f78eeaa 100644 --- a/burrow/src/daemon/snapshots/burrow__daemon__command__daemoncommand_serialization-2.snap +++ b/burrow/src/daemon/snapshots/burrow__daemon__command__daemoncommand_serialization-2.snap @@ -2,4 +2,4 @@ source: burrow/src/daemon/command.rs expression: "serde_json::to_string(&DaemonCommand::Start(DaemonStartOptions {\n tun: TunOptions { ..TunOptions::default() },\n })).unwrap()" --- -{"Start":{"tun":{"name":null,"no_pi":false,"tun_excl":false,"tun_retrieve":false,"address":null}}} +{"Start":{"tun":{"name":null,"no_pi":false,"tun_excl":false,"tun_retrieve":false,"address":[]}}} diff --git a/burrow/src/daemon/snapshots/burrow__daemon__command__daemoncommand_serialization.snap b/burrow/src/daemon/snapshots/burrow__daemon__command__daemoncommand_serialization.snap index bfd5117..eee563d 100644 --- a/burrow/src/daemon/snapshots/burrow__daemon__command__daemoncommand_serialization.snap +++ b/burrow/src/daemon/snapshots/burrow__daemon__command__daemoncommand_serialization.snap @@ -2,4 +2,4 @@ source: burrow/src/daemon/command.rs expression: "serde_json::to_string(&DaemonCommand::Start(DaemonStartOptions::default())).unwrap()" --- -{"Start":{"tun":{"name":null,"no_pi":false,"tun_excl":false,"tun_retrieve":false,"address":null}}} +{"Start":{"tun":{"name":null,"no_pi":false,"tun_excl":false,"tun_retrieve":false,"address":[]}}} diff --git a/burrow/src/daemon/snapshots/burrow__daemon__response__response_serialization-4.snap b/burrow/src/daemon/snapshots/burrow__daemon__response__response_serialization-4.snap index 9752ebc..0b9385c 100644 --- a/burrow/src/daemon/snapshots/burrow__daemon__response__response_serialization-4.snap +++ b/burrow/src/daemon/snapshots/burrow__daemon__response__response_serialization-4.snap @@ -2,4 +2,4 @@ source: burrow/src/daemon/response.rs expression: "serde_json::to_string(&DaemonResponse::new(Ok::(DaemonResponseData::ServerConfig(ServerConfig::default()))))?" --- -{"result":{"Ok":{"ServerConfig":{"address":"10.13.13.2","name":null,"mtu":null}}},"id":0} +{"result":{"Ok":{"ServerConfig":{"address":["10.13.13.2"],"name":null,"mtu":null}}},"id":0} diff --git a/burrow/src/main.rs b/burrow/src/main.rs index 79bb70b..71d1c02 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -55,7 +55,7 @@ async fn try_start() -> Result<()> { let mut client = DaemonClient::new().await?; client .send_command(DaemonCommand::Start(DaemonStartOptions { - tun: TunOptions::new().address("10.13.13.2"), + tun: TunOptions::new().address(vec!["10.13.13.2", "::2"]), })) .await .map(|_| ()) diff --git a/burrow/src/tracing.rs b/burrow/src/tracing.rs index 279f45d..861b41f 100644 --- a/burrow/src/tracing.rs +++ b/burrow/src/tracing.rs @@ -39,6 +39,7 @@ pub fn initialize() { tracing_subscriber::fmt::layer() .with_level(true) .with_writer(std::io::stderr) + .with_line_number(true) .compact() .with_filter(EnvFilter::from_default_env()) }); diff --git a/burrow/src/wireguard/config.rs b/burrow/src/wireguard/config.rs index afe7499..ed7b3cd 100644 --- a/burrow/src/wireguard/config.rs +++ b/burrow/src/wireguard/config.rs @@ -42,7 +42,7 @@ pub struct Peer { pub struct Interface { pub private_key: String, - pub address: String, + pub address: Vec, pub listen_port: u32, pub dns: Vec, pub mtu: Option, @@ -93,8 +93,8 @@ impl Default for Config { fn default() -> Self { Self { interface: Interface { - private_key: "GNqIAOCRxjl/cicZyvkvpTklgQuUmGUIEkH7IXF/sEE=".into(), - address: "10.13.13.2/24".into(), + private_key: "OEPVdomeLTxTIBvv3TYsJRge0Hp9NMiY0sIrhT8OWG8=".into(), + address: vec!["10.13.13.2/24".into()], listen_port: 51820, dns: Default::default(), mtu: Default::default(), @@ -102,8 +102,8 @@ impl Default for Config { peers: vec![Peer { endpoint: "wg.burrow.rs:51820".into(), allowed_ips: vec!["8.8.8.8/32".into(), "0.0.0.0/0".into()], - public_key: "uy75leriJay0+oHLhRMpV+A5xAQ0hCJ+q7Ww81AOvT4=".into(), - preshared_key: Some("s7lx/mg+reVEMnGnqeyYOQkzD86n2+gYnx1M9ygi08k=".into()), + public_key: "8GaFjVO6c4luCHG4ONO+1bFG8tO+Zz5/Gy+Geht1USM=".into(), + preshared_key: Some("ha7j4BjD49sIzyF9SNlbueK0AMHghlj6+u0G3bzC698=".into()), persistent_keepalive: Default::default(), name: Default::default(), }], diff --git a/server_patch.txt b/server_patch.txt new file mode 100644 index 0000000..de8e14c --- /dev/null +++ b/server_patch.txt @@ -0,0 +1,21 @@ +# Add this to ~/server/wg0.conf upon regeneration + +PostUp = iptables -A FORWARD -i %i -j ACCEPT + +PostUp = iptables -A FORWARD -o %i -j ACCEPT + +PostUp = iptables -t nat -A POSTROUTING -o eth+ -j MASQUERADE + +PostUp = ip6tables -A FORWARD -i %i -j ACCEPT + +PostUp = ip6tables -A FORWARD -o %i -j ACCEPT + +PostDown = iptables -D FORWARD -i %i -j ACCEPT + +PostDown = iptables -D FORWARD -o %i -j ACCEPT + +PostDown = iptables -t nat -D POSTROUTING -o eth+ -j MASQUERADE + +PostDown = ip6tables -D FORWARD -i %i -j ACCEPT + +PostDown = ip6tables -D FORWARD -o %i -j ACCEPT \ No newline at end of file diff --git a/tun/Cargo.toml b/tun/Cargo.toml index e67e45f..7413f65 100644 --- a/tun/Cargo.toml +++ b/tun/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" libc = "0.2" fehler = "1.0" nix = { version = "0.26", features = ["ioctl"] } -socket2 = "0.4" +socket2 = "0.5" tokio = { version = "1.28", features = [] } byteorder = "1.4" tracing = "0.1" diff --git a/tun/src/options.rs b/tun/src/options.rs index 339f71a..e21bf5f 100644 --- a/tun/src/options.rs +++ b/tun/src/options.rs @@ -21,7 +21,7 @@ pub struct TunOptions { /// (Apple) Retrieve the tun interface pub tun_retrieve: bool, /// (Linux) The IP address of the tun interface. - pub address: Option, + pub address: Vec, } impl TunOptions { @@ -44,8 +44,8 @@ impl TunOptions { self } - pub fn address(mut self, address: impl ToString) -> Self { - self.address = Some(address.to_string()); + pub fn address(mut self, address: Vec) -> Self { + self.address = address.iter().map(|x| x.to_string()).collect(); self } diff --git a/tun/src/unix/apple/kern_control.rs b/tun/src/unix/apple/kern_control.rs index 76e576f..6075233 100644 --- a/tun/src/unix/apple/kern_control.rs +++ b/tun/src/unix/apple/kern_control.rs @@ -21,7 +21,7 @@ impl SysControlSocket for socket2::Socket { unsafe { sys::resolve_ctl_info(self.as_raw_fd(), &mut info as *mut sys::ctl_info)? }; let (_, addr) = unsafe { - socket2::SockAddr::init(|addr_storage, len| { + socket2::SockAddr::try_init(|addr_storage, len| { *len = size_of::() as u32; let addr: &mut sys::sockaddr_ctl = &mut *addr_storage.cast(); diff --git a/tun/src/unix/apple/mod.rs b/tun/src/unix/apple/mod.rs index 2787cde..6e859ca 100644 --- a/tun/src/unix/apple/mod.rs +++ b/tun/src/unix/apple/mod.rs @@ -1,13 +1,11 @@ -use std::{ - io::{Error, IoSlice}, - mem, - net::{Ipv4Addr, SocketAddrV4}, - os::fd::{AsRawFd, FromRawFd, RawFd}, -}; +use std::{io::{Error, IoSlice}, mem, net::{Ipv4Addr, SocketAddrV4}, os::fd::{AsRawFd, FromRawFd, RawFd}, ptr}; +use std::net::{IpAddr, Ipv6Addr, SocketAddrV6}; +use std::ptr::addr_of; use byteorder::{ByteOrder, NetworkEndian}; use fehler::throws; -use libc::{c_char, iovec, writev, AF_INET, AF_INET6}; +use libc::{c_char, iovec, writev, AF_INET, AF_INET6, sockaddr_in6}; +use nix::sys::socket::SockaddrIn6; use socket2::{Domain, SockAddr, Socket, Type}; use tracing::{self, instrument}; @@ -49,7 +47,7 @@ impl TunInterface { pub fn retrieve() -> Option { (3..100) .filter_map(|fd| unsafe { - let peer_addr = socket2::SockAddr::init(|storage, len| { + let peer_addr = socket2::SockAddr::try_init(|storage, len| { *len = mem::size_of::() as u32; libc::getpeername(fd, storage as *mut _, len); Ok(()) @@ -71,9 +69,12 @@ impl TunInterface { #[throws] fn configure(&self, options: TunOptions) { - if let Some(addr) = options.address { - if let Ok(addr) = addr.parse() { - self.set_ipv4_addr(addr)?; + for addr in options.address{ + if let Ok(addr) = addr.parse::() { + match addr { + IpAddr::V4(addr) => {self.set_ipv4_addr(addr)?} + IpAddr::V6(addr) => {self.set_ipv6_addr(addr)?} + } } } } @@ -117,6 +118,14 @@ impl TunInterface { iff } + #[throws] + #[instrument] + fn in6_ifreq(&self) -> sys::in6_ifreq { + let mut iff: sys::in6_ifreq = unsafe { mem::zeroed() }; + iff.ifr_name = string_to_ifname(&self.name()?); + iff + } + #[throws] #[instrument] pub fn set_ipv4_addr(&self, addr: Ipv4Addr) { @@ -136,6 +145,21 @@ impl TunInterface { Ipv4Addr::from(u32::from_be(addr.sin_addr.s_addr)) } + #[throws] + pub fn set_ipv6_addr(&self, addr: Ipv6Addr) { + // let addr = SockAddr::from(SocketAddrV6::new(addr, 0, 0, 0)); + // println!("addr: {:?}", addr); + // let mut iff = self.in6_ifreq()?; + // let sto = addr.as_storage(); + // let ifadddr_ptr: *const sockaddr_in6 = addr_of!(sto).cast(); + // iff.ifr_ifru.ifru_addr = unsafe { *ifadddr_ptr }; + // println!("ifru addr set"); + // println!("{:?}", sys::SIOCSIFADDR_IN6); + // self.perform6(|fd| unsafe { sys::if_set_addr6(fd, &iff) })?; + // tracing::info!("ipv6_addr_set"); + tracing::warn!("Setting IPV6 address on MacOS CLI mode is not supported yet."); + } + #[throws] fn perform(&self, perform: impl FnOnce(RawFd) -> Result) -> R { let span = tracing::info_span!("perform", fd = self.as_raw_fd()); @@ -145,6 +169,15 @@ impl TunInterface { perform(socket.as_raw_fd())? } + #[throws] + fn perform6(&self, perform: impl FnOnce(RawFd) -> Result) -> R { + let span = tracing::info_span!("perform6", fd = self.as_raw_fd()); + let _enter = span.enter(); + + let socket = Socket::new(Domain::IPV6, Type::DGRAM, None)?; + perform(socket.as_raw_fd())? + } + #[throws] #[instrument] pub fn mtu(&self) -> i32 { diff --git a/tun/src/unix/apple/sys.rs b/tun/src/unix/apple/sys.rs index b4d4a6a..d48d6ee 100644 --- a/tun/src/unix/apple/sys.rs +++ b/tun/src/unix/apple/sys.rs @@ -1,6 +1,6 @@ use std::mem; -use libc::{c_char, c_int, c_short, c_uint, c_ulong, sockaddr}; +use libc::{c_char, c_int, c_short, c_uint, c_ulong, sockaddr, sockaddr_in6, time_t}; pub use libc::{ c_void, sockaddr_ctl, @@ -23,6 +23,7 @@ pub const UTUN_CONTROL_NAME: &str = "com.apple.net.utun_control"; pub const UTUN_OPT_IFNAME: libc::c_int = 2; pub const MAX_KCTL_NAME: usize = 96; +pub const SCOPE6_ID_MAX: usize = 16; #[repr(C)] #[derive(Copy, Clone, Debug)] @@ -74,7 +75,107 @@ pub struct ifreq { pub ifr_ifru: ifr_ifru, } +#[repr(C)] +#[derive(Copy, Clone, Debug)] +pub struct in6_addrlifetime{ + pub ia6t_expire: time_t, + pub ia6t_preferred: time_t, + pub ia6t_vltime: u32, + pub ia6t_pltime: u32, +} + +#[repr(C)] +#[derive(Copy, Clone, Debug)] +pub struct in6_ifstat { + pub ifs6_in_receive: u64, + pub ifs6_in_hdrerr: u64, + pub ifs6_in_toobig: u64, + pub ifs6_in_noroute: u64, + pub ifs6_in_addrerr: u64, + pub ifs6_in_protounknown: u64, + pub ifs6_in_truncated: u64, + pub ifs6_in_discard: u64, + pub ifs6_in_deliver: u64, + pub ifs6_out_forward: u64, + pub ifs6_out_request: u64, + pub ifs6_out_discard: u64, + pub ifs6_out_fragok: u64, + pub ifs6_out_fragfail: u64, + pub ifs6_out_fragcreat: u64, + pub ifs6_reass_reqd: u64, + pub ifs6_reass_ok: u64, + pub ifs6_atmfrag_rcvd: u64, + pub ifs6_reass_fail: u64, + pub ifs6_in_mcast: u64, + pub ifs6_out_mcast: u64, + pub ifs6_cantfoward_icmp6: u64, + pub ifs6_addr_expiry_cnt: u64, + pub ifs6_pfx_expiry_cnt: u64, + pub ifs6_defrtr_expiry_cnt: u64, +} + +#[repr(C)] +#[derive(Copy, Clone, Debug)] +pub struct icmp6_ifstat { + pub ifs6_in_msg: u64, + pub ifs6_in_error: u64, + pub ifs6_in_dstunreach: u64, + pub ifs6_in_adminprohib: u64, + pub ifs6_in_timeexceed: u64, + pub ifs6_in_paramprob: u64, + pub ifs6_in_pkttoobig: u64, + pub ifs6_in_echo: u64, + pub ifs6_in_echoreply: u64, + pub ifs6_in_routersolicit: u64, + pub ifs6_in_routeradvert: u64, + pub ifs6_in_neighborsolicit: u64, + pub ifs6_in_neighboradvert: u64, + pub ifs6_in_redirect: u64, + pub ifs6_in_mldquery: u64, + pub ifs6_in_mldreport: u64, + pub ifs6_in_mlddone: u64, + pub ifs6_out_msg: u64, + pub ifs6_out_error: u64, + pub ifs6_out_dstunreach: u64, + pub ifs6_out_adminprohib: u64, + pub ifs6_out_timeexceed: u64, + pub ifs6_out_paramprob: u64, + pub ifs6_out_pkttoobig: u64, + pub ifs6_out_echo: u64, + pub ifs6_out_echoreply: u64, + pub ifs6_out_routersolicit: u64, + pub ifs6_out_routeradvert: u64, + pub ifs6_out_neighborsolicit: u64, + pub ifs6_out_neighboradvert: u64, + pub ifs6_out_redirect: u64, + pub ifs6_out_mldquery: u64, + pub ifs6_out_mldreport: u64, + pub ifs6_out_mlddone: u64, +} + +#[repr(C)] +pub union ifr_ifru6 { + pub ifru_addr: sockaddr_in6, + pub ifru_dstaddr: sockaddr_in6, + pub ifru_flags: c_int, + pub ifru_flags6: c_int, + pub ifru_metric: c_int, + pub ifru_intval: c_int, + pub ifru_data: *mut c_char, + pub ifru_lifetime: in6_addrlifetime, // ifru_lifetime + pub ifru_stat: in6_ifstat, + pub ifru_icmp6stat: icmp6_ifstat, + pub ifru_scope_id: [u32; SCOPE6_ID_MAX] +} + +#[repr(C)] +pub struct in6_ifreq { + pub ifr_name: [c_char; IFNAMSIZ], + pub ifr_ifru: ifr_ifru6, +} + pub const SIOCSIFADDR: c_ulong = request_code_write!(b'i', 12, mem::size_of::()); +pub const SIOCSIFADDR_IN6: c_ulong = request_code_write!(b'i', 12, mem::size_of::()); pub const SIOCGIFMTU: c_ulong = request_code_readwrite!(b'i', 51, mem::size_of::()); pub const SIOCSIFMTU: c_ulong = request_code_write!(b'i', 52, mem::size_of::()); pub const SIOCGIFNETMASK: c_ulong = request_code_readwrite!(b'i', 37, mem::size_of::()); @@ -97,5 +198,6 @@ ioctl_read_bad!(if_get_addr, libc::SIOCGIFADDR, ifreq); ioctl_read_bad!(if_get_mtu, SIOCGIFMTU, ifreq); ioctl_read_bad!(if_get_netmask, SIOCGIFNETMASK, ifreq); ioctl_write_ptr_bad!(if_set_addr, SIOCSIFADDR, ifreq); +ioctl_write_ptr_bad!(if_set_addr6, SIOCSIFADDR_IN6, in6_ifreq); ioctl_write_ptr_bad!(if_set_mtu, SIOCSIFMTU, ifreq); ioctl_write_ptr_bad!(if_set_netmask, SIOCSIFNETMASK, ifreq); diff --git a/tun/tests/packets.rs b/tun/tests/packets.rs index 28090a2..80c078b 100644 --- a/tun/tests/packets.rs +++ b/tun/tests/packets.rs @@ -1,4 +1,5 @@ use std::{io::Error, net::Ipv4Addr}; +use std::net::Ipv6Addr; use fehler::throws; use tun::TunInterface; @@ -33,3 +34,15 @@ fn write_packets() { let bytes_written = tun.send(&buf)?; assert_eq!(bytes_written, 1504); } + +#[test] +#[throws] +#[ignore = "requires interactivity"] +#[cfg(not(target_os = "windows"))] +fn set_ipv6() { + let tun = TunInterface::new()?; + println!("tun name: {:?}", tun.name()?); + let targ_addr: Ipv6Addr = "::1".parse().unwrap(); + println!("v6 addr: {:?}", targ_addr); + tun.set_ipv6_addr(targ_addr)?; +} \ No newline at end of file From 2088ae6ede685880ae3f18401812a34e5c0253b9 Mon Sep 17 00:00:00 2001 From: Jett Chen Date: Fri, 2 Feb 2024 14:48:13 +0800 Subject: [PATCH 002/102] Add Support for IPV6 and Arbitrary Server Address Add IPV6 support for Apple Devices Note: Works in GUI not CLI Adds Support for Arbitrary Server Address --- Apple/NetworkExtension/DataTypes.swift | 4 +- .../PacketTunnelProvider.swift | 21 +++- Cargo.lock | 16 +-- Dockerfile | 2 +- Makefile | 25 ++++- burrow-server-compose.yml | 38 +++++++ burrow/src/daemon/response.rs | 4 +- ...ommand__daemoncommand_serialization-2.snap | 2 +- ..._command__daemoncommand_serialization.snap | 2 +- ...n__response__response_serialization-4.snap | 2 +- burrow/src/main.rs | 2 +- burrow/src/tracing.rs | 1 + burrow/src/wireguard/config.rs | 10 +- server_patch.txt | 21 ++++ tun/Cargo.toml | 2 +- tun/src/options.rs | 6 +- tun/src/unix/apple/kern_control.rs | 2 +- tun/src/unix/apple/mod.rs | 55 +++++++-- tun/src/unix/apple/sys.rs | 104 +++++++++++++++++- tun/tests/packets.rs | 13 +++ 20 files changed, 276 insertions(+), 56 deletions(-) create mode 100644 burrow-server-compose.yml create mode 100644 server_patch.txt diff --git a/Apple/NetworkExtension/DataTypes.swift b/Apple/NetworkExtension/DataTypes.swift index 391bfed..1409fde 100644 --- a/Apple/NetworkExtension/DataTypes.swift +++ b/Apple/NetworkExtension/DataTypes.swift @@ -31,7 +31,7 @@ struct BurrowStartRequest: Codable { let no_pi: Bool let tun_excl: Bool let tun_retrieve: Bool - let address: String? + let address: [String] } struct StartOptions: Codable { let tun: TunOptions @@ -51,7 +51,7 @@ struct BurrowResult: Codable where T: Codable { struct ServerConfigData: Codable { struct InternalConfig: Codable { - let address: String? + let address: [String] let name: String? let mtu: Int32? } diff --git a/Apple/NetworkExtension/PacketTunnelProvider.swift b/Apple/NetworkExtension/PacketTunnelProvider.swift index 9231676..bfdb34a 100644 --- a/Apple/NetworkExtension/PacketTunnelProvider.swift +++ b/Apple/NetworkExtension/PacketTunnelProvider.swift @@ -31,7 +31,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { command: BurrowStartRequest( Start: BurrowStartRequest.StartOptions( tun: BurrowStartRequest.TunOptions( - name: nil, no_pi: false, tun_excl: false, tun_retrieve: true, address: nil + name: nil, no_pi: false, tun_excl: false, tun_retrieve: true, address: [] ) ) ) @@ -46,12 +46,21 @@ class PacketTunnelProvider: NEPacketTunnelProvider { private func generateTunSettings(from: ServerConfigData) -> NETunnelNetworkSettings? { let cfig = from.ServerConfig - guard let addr = cfig.address else { - return nil - } - // Using a makeshift remote tunnel address let nst = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "1.1.1.1") - nst.ipv4Settings = NEIPv4Settings(addresses: [addr], subnetMasks: ["255.255.255.0"]) + var v4Addresses = [String]() + var v6Addresses = [String]() + for addr in cfig.address { + if IPv4Address(addr) != nil { + v6Addresses.append(addr) + } + if IPv6Address(addr) != nil { + v4Addresses.append(addr) + } + } + nst.ipv4Settings = NEIPv4Settings(addresses: v4Addresses, subnetMasks: v4Addresses.map { _ in + "255.255.255.0" + }) + nst.ipv6Settings = NEIPv6Settings(addresses: v6Addresses, networkPrefixLengths: v6Addresses.map { _ in 64 }) logger.log("Initialized ipv4 settings: \(nst.ipv4Settings)") return nst } diff --git a/Cargo.lock b/Cargo.lock index 85f11e7..a75bd28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1074,7 +1074,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2 0.5.5", + "socket2", "tokio", "tower-service", "tracing", @@ -2114,16 +2114,6 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" -[[package]] -name = "socket2" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "socket2" version = "0.5.5" @@ -2305,7 +2295,7 @@ dependencies = [ "mio", "num_cpus", "pin-project-lite", - "socket2 0.5.5", + "socket2", "tokio-macros", "tracing", "windows-sys 0.48.0", @@ -2547,7 +2537,7 @@ dependencies = [ "reqwest", "schemars", "serde", - "socket2 0.4.10", + "socket2", "ssri", "tempfile", "tokio", diff --git a/Dockerfile b/Dockerfile index 3c12d45..9f54478 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM docker.io/library/rust:1.74.0-slim-bookworm AS builder +FROM docker.io/library/rust:1.76.0-slim-bookworm AS builder ARG TARGETPLATFORM ARG LLVM_VERSION=16 diff --git a/Makefile b/Makefile index 18b4b27..97d2d5a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -tun_num := $(shell ifconfig | awk -F 'utun|[: ]' '/utun[0-9]/ {print $$2}' | tail -n 1) +tun := $(shell ifconfig -l | sed 's/ /\n/g' | grep utun | tail -n 1) cargo_console := RUST_BACKTRACE=1 RUST_LOG=debug RUSTFLAGS='--cfg tokio_unstable' cargo run --all-features cargo_norm := RUST_BACKTRACE=1 RUST_LOG=debug cargo run @@ -19,15 +19,28 @@ start: test-dns: @sudo route delete 8.8.8.8 - @sudo route add 8.8.8.8 -interface utun$(tun_num) + @sudo route add 8.8.8.8 -interface $(tun) @dig @8.8.8.8 hackclub.com test-https: @sudo route delete 193.183.0.162 - @sudo route add 193.183.0.162 -interface utun$(tun_num) + @sudo route add 193.183.0.162 -interface $(tun) @curl -vv https://search.marginalia.nu +v4_target := 146.190.62.39 test-http: - @sudo route delete 146.190.62.39 - @sudo route add 146.190.62.39 -interface utun$(tun_num) - @curl -vv 146.190.62.39:80 + @sudo route delete ${v4_target} + @sudo route add ${v4_target} -interface $(tun) + @curl -vv ${v4_target}:80 + +test-ipv4: + @sudo route delete ${v4_target} + @sudo route add ${v4_target} -interface $(tun) + @ping ${v4_target} + +v6_target := 2001:4860:4860::8888 +test-ipv6: + @sudo route delete ${v6_target} + @sudo route -n add -inet6 ${v6_target} -interface $(tun) + @echo preparing + @sudo ping6 -v ${v6_target} diff --git a/burrow-server-compose.yml b/burrow-server-compose.yml new file mode 100644 index 0000000..4ba31ee --- /dev/null +++ b/burrow-server-compose.yml @@ -0,0 +1,38 @@ +version: "2.1" +networks: + wg6: + enable_ipv6: true + ipam: + driver: default + config: + - subnet: "aa:bb:cc:de::/64" +services: + burrow: + image: lscr.io/linuxserver/wireguard:latest + privileged: true + container_name: burrow_server + cap_add: + - NET_ADMIN + - SYS_MODULE + environment: + - PUID=1000 + - PGID=1000 + - TZ=Asia/Calcutta + - SERVERURL=wg.burrow.rs + - SERVERPORT=51820 + - PEERS=10 + - PEERDNS=1.1.1.1 + - INTERNAL_SUBNET=10.13.13.0 + - ALLOWEDIPS=0.0.0.0/0, ::/0 + - PERSISTENTKEEPALIVE_PEERS=all + - LOG_CONFS=true #optional + volumes: + - ./config:/config + - /lib/modules:/lib/modules + ports: + - 51820:51820/udp + sysctls: + - net.ipv4.conf.all.src_valid_mark=1 + - net.ipv6.conf.all.disable_ipv6=0 + - net.ipv6.conf.eth0.proxy_ndp=1 + restart: unless-stopped \ No newline at end of file diff --git a/burrow/src/daemon/response.rs b/burrow/src/daemon/response.rs index 172d4c7..37ee5d9 100644 --- a/burrow/src/daemon/response.rs +++ b/burrow/src/daemon/response.rs @@ -57,7 +57,7 @@ impl TryFrom<&TunInterface> for ServerInfo { #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct ServerConfig { - pub address: Option, + pub address: Vec, pub name: Option, pub mtu: Option, } @@ -65,7 +65,7 @@ pub struct ServerConfig { impl Default for ServerConfig { fn default() -> Self { Self { - address: Some("10.13.13.2".to_string()), // Dummy remote address + address: vec!["10.13.13.2".to_string()], // Dummy remote address name: None, mtu: None, } diff --git a/burrow/src/daemon/snapshots/burrow__daemon__command__daemoncommand_serialization-2.snap b/burrow/src/daemon/snapshots/burrow__daemon__command__daemoncommand_serialization-2.snap index 0eb9096..f78eeaa 100644 --- a/burrow/src/daemon/snapshots/burrow__daemon__command__daemoncommand_serialization-2.snap +++ b/burrow/src/daemon/snapshots/burrow__daemon__command__daemoncommand_serialization-2.snap @@ -2,4 +2,4 @@ source: burrow/src/daemon/command.rs expression: "serde_json::to_string(&DaemonCommand::Start(DaemonStartOptions {\n tun: TunOptions { ..TunOptions::default() },\n })).unwrap()" --- -{"Start":{"tun":{"name":null,"no_pi":false,"tun_excl":false,"tun_retrieve":false,"address":null}}} +{"Start":{"tun":{"name":null,"no_pi":false,"tun_excl":false,"tun_retrieve":false,"address":[]}}} diff --git a/burrow/src/daemon/snapshots/burrow__daemon__command__daemoncommand_serialization.snap b/burrow/src/daemon/snapshots/burrow__daemon__command__daemoncommand_serialization.snap index bfd5117..eee563d 100644 --- a/burrow/src/daemon/snapshots/burrow__daemon__command__daemoncommand_serialization.snap +++ b/burrow/src/daemon/snapshots/burrow__daemon__command__daemoncommand_serialization.snap @@ -2,4 +2,4 @@ source: burrow/src/daemon/command.rs expression: "serde_json::to_string(&DaemonCommand::Start(DaemonStartOptions::default())).unwrap()" --- -{"Start":{"tun":{"name":null,"no_pi":false,"tun_excl":false,"tun_retrieve":false,"address":null}}} +{"Start":{"tun":{"name":null,"no_pi":false,"tun_excl":false,"tun_retrieve":false,"address":[]}}} diff --git a/burrow/src/daemon/snapshots/burrow__daemon__response__response_serialization-4.snap b/burrow/src/daemon/snapshots/burrow__daemon__response__response_serialization-4.snap index 9752ebc..0b9385c 100644 --- a/burrow/src/daemon/snapshots/burrow__daemon__response__response_serialization-4.snap +++ b/burrow/src/daemon/snapshots/burrow__daemon__response__response_serialization-4.snap @@ -2,4 +2,4 @@ source: burrow/src/daemon/response.rs expression: "serde_json::to_string(&DaemonResponse::new(Ok::(DaemonResponseData::ServerConfig(ServerConfig::default()))))?" --- -{"result":{"Ok":{"ServerConfig":{"address":"10.13.13.2","name":null,"mtu":null}}},"id":0} +{"result":{"Ok":{"ServerConfig":{"address":["10.13.13.2"],"name":null,"mtu":null}}},"id":0} diff --git a/burrow/src/main.rs b/burrow/src/main.rs index 79bb70b..71d1c02 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -55,7 +55,7 @@ async fn try_start() -> Result<()> { let mut client = DaemonClient::new().await?; client .send_command(DaemonCommand::Start(DaemonStartOptions { - tun: TunOptions::new().address("10.13.13.2"), + tun: TunOptions::new().address(vec!["10.13.13.2", "::2"]), })) .await .map(|_| ()) diff --git a/burrow/src/tracing.rs b/burrow/src/tracing.rs index 279f45d..861b41f 100644 --- a/burrow/src/tracing.rs +++ b/burrow/src/tracing.rs @@ -39,6 +39,7 @@ pub fn initialize() { tracing_subscriber::fmt::layer() .with_level(true) .with_writer(std::io::stderr) + .with_line_number(true) .compact() .with_filter(EnvFilter::from_default_env()) }); diff --git a/burrow/src/wireguard/config.rs b/burrow/src/wireguard/config.rs index afe7499..ed7b3cd 100644 --- a/burrow/src/wireguard/config.rs +++ b/burrow/src/wireguard/config.rs @@ -42,7 +42,7 @@ pub struct Peer { pub struct Interface { pub private_key: String, - pub address: String, + pub address: Vec, pub listen_port: u32, pub dns: Vec, pub mtu: Option, @@ -93,8 +93,8 @@ impl Default for Config { fn default() -> Self { Self { interface: Interface { - private_key: "GNqIAOCRxjl/cicZyvkvpTklgQuUmGUIEkH7IXF/sEE=".into(), - address: "10.13.13.2/24".into(), + private_key: "OEPVdomeLTxTIBvv3TYsJRge0Hp9NMiY0sIrhT8OWG8=".into(), + address: vec!["10.13.13.2/24".into()], listen_port: 51820, dns: Default::default(), mtu: Default::default(), @@ -102,8 +102,8 @@ impl Default for Config { peers: vec![Peer { endpoint: "wg.burrow.rs:51820".into(), allowed_ips: vec!["8.8.8.8/32".into(), "0.0.0.0/0".into()], - public_key: "uy75leriJay0+oHLhRMpV+A5xAQ0hCJ+q7Ww81AOvT4=".into(), - preshared_key: Some("s7lx/mg+reVEMnGnqeyYOQkzD86n2+gYnx1M9ygi08k=".into()), + public_key: "8GaFjVO6c4luCHG4ONO+1bFG8tO+Zz5/Gy+Geht1USM=".into(), + preshared_key: Some("ha7j4BjD49sIzyF9SNlbueK0AMHghlj6+u0G3bzC698=".into()), persistent_keepalive: Default::default(), name: Default::default(), }], diff --git a/server_patch.txt b/server_patch.txt new file mode 100644 index 0000000..de8e14c --- /dev/null +++ b/server_patch.txt @@ -0,0 +1,21 @@ +# Add this to ~/server/wg0.conf upon regeneration + +PostUp = iptables -A FORWARD -i %i -j ACCEPT + +PostUp = iptables -A FORWARD -o %i -j ACCEPT + +PostUp = iptables -t nat -A POSTROUTING -o eth+ -j MASQUERADE + +PostUp = ip6tables -A FORWARD -i %i -j ACCEPT + +PostUp = ip6tables -A FORWARD -o %i -j ACCEPT + +PostDown = iptables -D FORWARD -i %i -j ACCEPT + +PostDown = iptables -D FORWARD -o %i -j ACCEPT + +PostDown = iptables -t nat -D POSTROUTING -o eth+ -j MASQUERADE + +PostDown = ip6tables -D FORWARD -i %i -j ACCEPT + +PostDown = ip6tables -D FORWARD -o %i -j ACCEPT \ No newline at end of file diff --git a/tun/Cargo.toml b/tun/Cargo.toml index e67e45f..7413f65 100644 --- a/tun/Cargo.toml +++ b/tun/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" libc = "0.2" fehler = "1.0" nix = { version = "0.26", features = ["ioctl"] } -socket2 = "0.4" +socket2 = "0.5" tokio = { version = "1.28", features = [] } byteorder = "1.4" tracing = "0.1" diff --git a/tun/src/options.rs b/tun/src/options.rs index 339f71a..e21bf5f 100644 --- a/tun/src/options.rs +++ b/tun/src/options.rs @@ -21,7 +21,7 @@ pub struct TunOptions { /// (Apple) Retrieve the tun interface pub tun_retrieve: bool, /// (Linux) The IP address of the tun interface. - pub address: Option, + pub address: Vec, } impl TunOptions { @@ -44,8 +44,8 @@ impl TunOptions { self } - pub fn address(mut self, address: impl ToString) -> Self { - self.address = Some(address.to_string()); + pub fn address(mut self, address: Vec) -> Self { + self.address = address.iter().map(|x| x.to_string()).collect(); self } diff --git a/tun/src/unix/apple/kern_control.rs b/tun/src/unix/apple/kern_control.rs index 76e576f..6075233 100644 --- a/tun/src/unix/apple/kern_control.rs +++ b/tun/src/unix/apple/kern_control.rs @@ -21,7 +21,7 @@ impl SysControlSocket for socket2::Socket { unsafe { sys::resolve_ctl_info(self.as_raw_fd(), &mut info as *mut sys::ctl_info)? }; let (_, addr) = unsafe { - socket2::SockAddr::init(|addr_storage, len| { + socket2::SockAddr::try_init(|addr_storage, len| { *len = size_of::() as u32; let addr: &mut sys::sockaddr_ctl = &mut *addr_storage.cast(); diff --git a/tun/src/unix/apple/mod.rs b/tun/src/unix/apple/mod.rs index 2787cde..6e859ca 100644 --- a/tun/src/unix/apple/mod.rs +++ b/tun/src/unix/apple/mod.rs @@ -1,13 +1,11 @@ -use std::{ - io::{Error, IoSlice}, - mem, - net::{Ipv4Addr, SocketAddrV4}, - os::fd::{AsRawFd, FromRawFd, RawFd}, -}; +use std::{io::{Error, IoSlice}, mem, net::{Ipv4Addr, SocketAddrV4}, os::fd::{AsRawFd, FromRawFd, RawFd}, ptr}; +use std::net::{IpAddr, Ipv6Addr, SocketAddrV6}; +use std::ptr::addr_of; use byteorder::{ByteOrder, NetworkEndian}; use fehler::throws; -use libc::{c_char, iovec, writev, AF_INET, AF_INET6}; +use libc::{c_char, iovec, writev, AF_INET, AF_INET6, sockaddr_in6}; +use nix::sys::socket::SockaddrIn6; use socket2::{Domain, SockAddr, Socket, Type}; use tracing::{self, instrument}; @@ -49,7 +47,7 @@ impl TunInterface { pub fn retrieve() -> Option { (3..100) .filter_map(|fd| unsafe { - let peer_addr = socket2::SockAddr::init(|storage, len| { + let peer_addr = socket2::SockAddr::try_init(|storage, len| { *len = mem::size_of::() as u32; libc::getpeername(fd, storage as *mut _, len); Ok(()) @@ -71,9 +69,12 @@ impl TunInterface { #[throws] fn configure(&self, options: TunOptions) { - if let Some(addr) = options.address { - if let Ok(addr) = addr.parse() { - self.set_ipv4_addr(addr)?; + for addr in options.address{ + if let Ok(addr) = addr.parse::() { + match addr { + IpAddr::V4(addr) => {self.set_ipv4_addr(addr)?} + IpAddr::V6(addr) => {self.set_ipv6_addr(addr)?} + } } } } @@ -117,6 +118,14 @@ impl TunInterface { iff } + #[throws] + #[instrument] + fn in6_ifreq(&self) -> sys::in6_ifreq { + let mut iff: sys::in6_ifreq = unsafe { mem::zeroed() }; + iff.ifr_name = string_to_ifname(&self.name()?); + iff + } + #[throws] #[instrument] pub fn set_ipv4_addr(&self, addr: Ipv4Addr) { @@ -136,6 +145,21 @@ impl TunInterface { Ipv4Addr::from(u32::from_be(addr.sin_addr.s_addr)) } + #[throws] + pub fn set_ipv6_addr(&self, addr: Ipv6Addr) { + // let addr = SockAddr::from(SocketAddrV6::new(addr, 0, 0, 0)); + // println!("addr: {:?}", addr); + // let mut iff = self.in6_ifreq()?; + // let sto = addr.as_storage(); + // let ifadddr_ptr: *const sockaddr_in6 = addr_of!(sto).cast(); + // iff.ifr_ifru.ifru_addr = unsafe { *ifadddr_ptr }; + // println!("ifru addr set"); + // println!("{:?}", sys::SIOCSIFADDR_IN6); + // self.perform6(|fd| unsafe { sys::if_set_addr6(fd, &iff) })?; + // tracing::info!("ipv6_addr_set"); + tracing::warn!("Setting IPV6 address on MacOS CLI mode is not supported yet."); + } + #[throws] fn perform(&self, perform: impl FnOnce(RawFd) -> Result) -> R { let span = tracing::info_span!("perform", fd = self.as_raw_fd()); @@ -145,6 +169,15 @@ impl TunInterface { perform(socket.as_raw_fd())? } + #[throws] + fn perform6(&self, perform: impl FnOnce(RawFd) -> Result) -> R { + let span = tracing::info_span!("perform6", fd = self.as_raw_fd()); + let _enter = span.enter(); + + let socket = Socket::new(Domain::IPV6, Type::DGRAM, None)?; + perform(socket.as_raw_fd())? + } + #[throws] #[instrument] pub fn mtu(&self) -> i32 { diff --git a/tun/src/unix/apple/sys.rs b/tun/src/unix/apple/sys.rs index b4d4a6a..d48d6ee 100644 --- a/tun/src/unix/apple/sys.rs +++ b/tun/src/unix/apple/sys.rs @@ -1,6 +1,6 @@ use std::mem; -use libc::{c_char, c_int, c_short, c_uint, c_ulong, sockaddr}; +use libc::{c_char, c_int, c_short, c_uint, c_ulong, sockaddr, sockaddr_in6, time_t}; pub use libc::{ c_void, sockaddr_ctl, @@ -23,6 +23,7 @@ pub const UTUN_CONTROL_NAME: &str = "com.apple.net.utun_control"; pub const UTUN_OPT_IFNAME: libc::c_int = 2; pub const MAX_KCTL_NAME: usize = 96; +pub const SCOPE6_ID_MAX: usize = 16; #[repr(C)] #[derive(Copy, Clone, Debug)] @@ -74,7 +75,107 @@ pub struct ifreq { pub ifr_ifru: ifr_ifru, } +#[repr(C)] +#[derive(Copy, Clone, Debug)] +pub struct in6_addrlifetime{ + pub ia6t_expire: time_t, + pub ia6t_preferred: time_t, + pub ia6t_vltime: u32, + pub ia6t_pltime: u32, +} + +#[repr(C)] +#[derive(Copy, Clone, Debug)] +pub struct in6_ifstat { + pub ifs6_in_receive: u64, + pub ifs6_in_hdrerr: u64, + pub ifs6_in_toobig: u64, + pub ifs6_in_noroute: u64, + pub ifs6_in_addrerr: u64, + pub ifs6_in_protounknown: u64, + pub ifs6_in_truncated: u64, + pub ifs6_in_discard: u64, + pub ifs6_in_deliver: u64, + pub ifs6_out_forward: u64, + pub ifs6_out_request: u64, + pub ifs6_out_discard: u64, + pub ifs6_out_fragok: u64, + pub ifs6_out_fragfail: u64, + pub ifs6_out_fragcreat: u64, + pub ifs6_reass_reqd: u64, + pub ifs6_reass_ok: u64, + pub ifs6_atmfrag_rcvd: u64, + pub ifs6_reass_fail: u64, + pub ifs6_in_mcast: u64, + pub ifs6_out_mcast: u64, + pub ifs6_cantfoward_icmp6: u64, + pub ifs6_addr_expiry_cnt: u64, + pub ifs6_pfx_expiry_cnt: u64, + pub ifs6_defrtr_expiry_cnt: u64, +} + +#[repr(C)] +#[derive(Copy, Clone, Debug)] +pub struct icmp6_ifstat { + pub ifs6_in_msg: u64, + pub ifs6_in_error: u64, + pub ifs6_in_dstunreach: u64, + pub ifs6_in_adminprohib: u64, + pub ifs6_in_timeexceed: u64, + pub ifs6_in_paramprob: u64, + pub ifs6_in_pkttoobig: u64, + pub ifs6_in_echo: u64, + pub ifs6_in_echoreply: u64, + pub ifs6_in_routersolicit: u64, + pub ifs6_in_routeradvert: u64, + pub ifs6_in_neighborsolicit: u64, + pub ifs6_in_neighboradvert: u64, + pub ifs6_in_redirect: u64, + pub ifs6_in_mldquery: u64, + pub ifs6_in_mldreport: u64, + pub ifs6_in_mlddone: u64, + pub ifs6_out_msg: u64, + pub ifs6_out_error: u64, + pub ifs6_out_dstunreach: u64, + pub ifs6_out_adminprohib: u64, + pub ifs6_out_timeexceed: u64, + pub ifs6_out_paramprob: u64, + pub ifs6_out_pkttoobig: u64, + pub ifs6_out_echo: u64, + pub ifs6_out_echoreply: u64, + pub ifs6_out_routersolicit: u64, + pub ifs6_out_routeradvert: u64, + pub ifs6_out_neighborsolicit: u64, + pub ifs6_out_neighboradvert: u64, + pub ifs6_out_redirect: u64, + pub ifs6_out_mldquery: u64, + pub ifs6_out_mldreport: u64, + pub ifs6_out_mlddone: u64, +} + +#[repr(C)] +pub union ifr_ifru6 { + pub ifru_addr: sockaddr_in6, + pub ifru_dstaddr: sockaddr_in6, + pub ifru_flags: c_int, + pub ifru_flags6: c_int, + pub ifru_metric: c_int, + pub ifru_intval: c_int, + pub ifru_data: *mut c_char, + pub ifru_lifetime: in6_addrlifetime, // ifru_lifetime + pub ifru_stat: in6_ifstat, + pub ifru_icmp6stat: icmp6_ifstat, + pub ifru_scope_id: [u32; SCOPE6_ID_MAX] +} + +#[repr(C)] +pub struct in6_ifreq { + pub ifr_name: [c_char; IFNAMSIZ], + pub ifr_ifru: ifr_ifru6, +} + pub const SIOCSIFADDR: c_ulong = request_code_write!(b'i', 12, mem::size_of::()); +pub const SIOCSIFADDR_IN6: c_ulong = request_code_write!(b'i', 12, mem::size_of::()); pub const SIOCGIFMTU: c_ulong = request_code_readwrite!(b'i', 51, mem::size_of::()); pub const SIOCSIFMTU: c_ulong = request_code_write!(b'i', 52, mem::size_of::()); pub const SIOCGIFNETMASK: c_ulong = request_code_readwrite!(b'i', 37, mem::size_of::()); @@ -97,5 +198,6 @@ ioctl_read_bad!(if_get_addr, libc::SIOCGIFADDR, ifreq); ioctl_read_bad!(if_get_mtu, SIOCGIFMTU, ifreq); ioctl_read_bad!(if_get_netmask, SIOCGIFNETMASK, ifreq); ioctl_write_ptr_bad!(if_set_addr, SIOCSIFADDR, ifreq); +ioctl_write_ptr_bad!(if_set_addr6, SIOCSIFADDR_IN6, in6_ifreq); ioctl_write_ptr_bad!(if_set_mtu, SIOCSIFMTU, ifreq); ioctl_write_ptr_bad!(if_set_netmask, SIOCSIFNETMASK, ifreq); diff --git a/tun/tests/packets.rs b/tun/tests/packets.rs index 28090a2..80c078b 100644 --- a/tun/tests/packets.rs +++ b/tun/tests/packets.rs @@ -1,4 +1,5 @@ use std::{io::Error, net::Ipv4Addr}; +use std::net::Ipv6Addr; use fehler::throws; use tun::TunInterface; @@ -33,3 +34,15 @@ fn write_packets() { let bytes_written = tun.send(&buf)?; assert_eq!(bytes_written, 1504); } + +#[test] +#[throws] +#[ignore = "requires interactivity"] +#[cfg(not(target_os = "windows"))] +fn set_ipv6() { + let tun = TunInterface::new()?; + println!("tun name: {:?}", tun.name()?); + let targ_addr: Ipv6Addr = "::1".parse().unwrap(); + println!("v6 addr: {:?}", targ_addr); + tun.set_ipv6_addr(targ_addr)?; +} \ No newline at end of file From 29d2bfae3faefe70af393896bd1cdfc9a4174611 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 19 Feb 2024 11:28:00 +0000 Subject: [PATCH 003/102] Remove redundant type annotation --- Apple/NetworkExtension/Client.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Apple/NetworkExtension/Client.swift b/Apple/NetworkExtension/Client.swift index a924c29..e7c1bc8 100644 --- a/Apple/NetworkExtension/Client.swift +++ b/Apple/NetworkExtension/Client.swift @@ -5,7 +5,7 @@ import Network final class Client { let connection: NWConnection - private let logger: Logger = Logger.logger(for: Client.self) + private let logger = Logger.logger(for: Client.self) private var generator = SystemRandomNumberGenerator() convenience init() throws { From c4c342dc8b79872989a02b99fc21235dacf30eea Mon Sep 17 00:00:00 2001 From: Jett Chen Date: Sun, 11 Feb 2024 03:17:14 +0800 Subject: [PATCH 004/102] Add implementation for stop command This adds implementation for stopping the tunnel via the `Stop` command. --- .../PacketTunnelProvider.swift | 11 +++ Makefile | 3 + burrow/src/daemon/instance.rs | 26 +++---- burrow/src/wireguard/iface.rs | 73 ++++++++++++++----- burrow/src/wireguard/pcb.rs | 6 +- 5 files changed, 81 insertions(+), 38 deletions(-) diff --git a/Apple/NetworkExtension/PacketTunnelProvider.swift b/Apple/NetworkExtension/PacketTunnelProvider.swift index bfdb34a..7073401 100644 --- a/Apple/NetworkExtension/PacketTunnelProvider.swift +++ b/Apple/NetworkExtension/PacketTunnelProvider.swift @@ -44,6 +44,17 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } } + override func stopTunnel(with reason: NEProviderStopReason) async { + do { + let client = try Client() + let command = BurrowRequest(id: 0, command: "Stop") + let data = try await client.request(command, type: Response>.self) + self.logger.log("Stopped client.") + } catch { + self.logger.error("Failed to stop tunnel: \(error)") + } + } + private func generateTunSettings(from: ServerConfigData) -> NETunnelNetworkSettings? { let cfig = from.ServerConfig let nst = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "1.1.1.1") diff --git a/Makefile b/Makefile index 97d2d5a..d0c9bd9 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,9 @@ daemon: start: @$(cargo_norm) start +stop: + @$(cargo_norm) stop + test-dns: @sudo route delete 8.8.8.8 @sudo route add 8.8.8.8 -interface $(tun) diff --git a/burrow/src/daemon/instance.rs b/burrow/src/daemon/instance.rs index 34e9878..0d3e726 100644 --- a/burrow/src/daemon/instance.rs +++ b/burrow/src/daemon/instance.rs @@ -21,7 +21,7 @@ enum RunState { pub struct DaemonInstance { rx: async_channel::Receiver, sx: async_channel::Sender, - tun_interface: Option>>, + tun_interface: Arc>>, wg_interface: Arc>, wg_state: RunState, } @@ -36,7 +36,7 @@ impl DaemonInstance { rx, sx, wg_interface, - tun_interface: None, + tun_interface: Arc::new(RwLock::new(None)), wg_state: RunState::Idle, } } @@ -50,15 +50,15 @@ impl DaemonInstance { warn!("Got start, but tun interface already up."); } RunState::Idle => { - let tun_if = Arc::new(RwLock::new(st.tun.open()?)); + let tun_if = st.tun.open()?; + debug!("Setting tun on wg_interface"); + self.wg_interface.read().await.set_tun(tun_if).await; + debug!("tun set on wg_interface"); debug!("Setting tun_interface"); - self.tun_interface = Some(tun_if.clone()); + self.tun_interface = self.wg_interface.read().await.get_tun(); debug!("tun_interface set: {:?}", self.tun_interface); - debug!("Setting tun on wg_interface"); - self.wg_interface.write().await.set_tun(tun_if); - debug!("tun set on wg_interface"); debug!("Cloning wg_interface"); let tmp_wg = self.wg_interface.clone(); @@ -82,22 +82,18 @@ impl DaemonInstance { } Ok(DaemonResponseData::None) } - DaemonCommand::ServerInfo => match &self.tun_interface { + DaemonCommand::ServerInfo => match &self.tun_interface.read().await.as_ref() { None => Ok(DaemonResponseData::None), Some(ti) => { info!("{:?}", ti); Ok(DaemonResponseData::ServerInfo(ServerInfo::try_from( - ti.read().await.inner.get_ref(), + ti.inner.get_ref(), )?)) } }, DaemonCommand::Stop => { - if self.tun_interface.is_some() { - self.tun_interface = None; - info!("Daemon stopping tun interface."); - } else { - warn!("Got stop, but tun interface is not up.") - } + self.wg_interface.read().await.remove_tun().await; + self.wg_state = RunState::Idle; Ok(DaemonResponseData::None) } DaemonCommand::ServerConfig => { diff --git a/burrow/src/wireguard/iface.rs b/burrow/src/wireguard/iface.rs index 620c96c..6097082 100755 --- a/burrow/src/wireguard/iface.rs +++ b/burrow/src/wireguard/iface.rs @@ -1,10 +1,11 @@ use std::{net::IpAddr, sync::Arc}; +use std::ops::Deref; use anyhow::Error; use fehler::throws; use futures::future::join_all; use ip_network_table::IpNetworkTable; -use tokio::sync::RwLock; +use tokio::sync::{RwLock, Notify}; use tracing::{debug, error}; use tun::tokio::TunInterface; @@ -46,9 +47,21 @@ impl FromIterator for IndexedPcbs { } } +enum IfaceStatus { + Running, + Idle +} + pub struct Interface { - tun: Option>>, + tun: Arc>>, pcbs: Arc, + status: Arc>, + stop_notifier: Arc, +} + +async fn is_running(status: Arc>) -> bool { + let st = status.read().await; + matches!(st.deref(), IfaceStatus::Running) } impl Interface { @@ -60,35 +73,54 @@ impl Interface { .collect::>()?; let pcbs = Arc::new(pcbs); - Self { pcbs, tun: None } + Self { pcbs, tun: Arc::new(RwLock::new(None)), status: Arc::new(RwLock::new(IfaceStatus::Idle)), stop_notifier: Arc::new(Notify::new()) } } - pub fn set_tun(&mut self, tun: Arc>) { - self.tun = Some(tun); + pub async fn set_tun(&self, tun: TunInterface) { + debug!("Setting tun interface"); + self.tun.write().await.replace(tun); + let mut st = self.status.write().await; + *st = IfaceStatus::Running; + } + + pub fn get_tun(&self) -> Arc>> { + self.tun.clone() + } + + pub async fn remove_tun(&self){ + let mut st = self.status.write().await; + self.stop_notifier.notify_waiters(); + *st = IfaceStatus::Idle; } pub async fn run(&self) -> anyhow::Result<()> { let pcbs = self.pcbs.clone(); let tun = self .tun - .clone() - .ok_or(anyhow::anyhow!("tun interface does not exist"))?; + .clone(); + let status = self.status.clone(); + let stop_notifier = self.stop_notifier.clone(); log::info!("Starting interface"); let outgoing = async move { - loop { + while is_running(status.clone()).await { let mut buf = [0u8; 3000]; let src = { - let src = match tun.read().await.recv(&mut buf[..]).await { - Ok(len) => &buf[..len], - Err(e) => { - error!("Failed to read from interface: {}", e); - continue - } + let t = tun.read().await; + let Some(_tun) = t.as_ref() else { + continue; }; - debug!("Read {} bytes from interface", src.len()); - src + tokio::select! { + _ = stop_notifier.notified() => continue, + pkg = _tun.recv(&mut buf[..]) => match pkg { + Ok(len) => &buf[..len], + Err(e) => { + error!("Failed to read from interface: {}", e); + continue + } + }, + } }; let dst_addr = match Tunnel::dst_address(src) { @@ -123,8 +155,7 @@ impl Interface { let mut tsks = vec![]; let tun = self .tun - .clone() - .ok_or(anyhow::anyhow!("tun interface does not exist"))?; + .clone(); let outgoing = tokio::task::spawn(outgoing); tsks.push(outgoing); debug!("preparing to spawn read tasks"); @@ -149,9 +180,10 @@ impl Interface { }; let pcb = pcbs.pcbs[i].clone(); + let status = self.status.clone(); let update_timers_tsk = async move { let mut buf = [0u8; 65535]; - loop { + while is_running(status.clone()).await { tokio::time::sleep(tokio::time::Duration::from_millis(250)).await; match pcb.update_timers(&mut buf).await { Ok(..) => (), @@ -164,8 +196,9 @@ impl Interface { }; let pcb = pcbs.pcbs[i].clone(); + let status = self.status.clone(); let reset_rate_limiter_tsk = async move { - loop { + while is_running(status.clone()).await { tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; pcb.reset_rate_limiter().await; } diff --git a/burrow/src/wireguard/pcb.rs b/burrow/src/wireguard/pcb.rs index db57968..974d84e 100755 --- a/burrow/src/wireguard/pcb.rs +++ b/burrow/src/wireguard/pcb.rs @@ -54,7 +54,7 @@ impl PeerPcb { Ok(()) } - pub async fn run(&self, tun_interface: Arc>) -> Result<(), Error> { + pub async fn run(&self, tun_interface: Arc>>) -> Result<(), Error> { tracing::debug!("starting read loop for pcb... for {:?}", &self); let rid: i32 = random(); let mut buf: [u8; 3000] = [0u8; 3000]; @@ -106,12 +106,12 @@ impl PeerPcb { } TunnResult::WriteToTunnelV4(packet, addr) => { tracing::debug!("WriteToTunnelV4: {:?}, {:?}", packet, addr); - tun_interface.read().await.send(packet).await?; + tun_interface.read().await.as_ref().ok_or(anyhow::anyhow!("tun interface does not exist"))?.send(packet).await?; break } TunnResult::WriteToTunnelV6(packet, addr) => { tracing::debug!("WriteToTunnelV6: {:?}, {:?}", packet, addr); - tun_interface.read().await.send(packet).await?; + tun_interface.read().await.as_ref().ok_or(anyhow::anyhow!("tun interface does not exist"))?.send(packet).await?; break } } From c755f752a0a621c742011af183627c11eeca6ce7 Mon Sep 17 00:00:00 2001 From: David Zhong <91637806+davnotdev@users.noreply.github.com> Date: Sat, 9 Mar 2024 17:52:59 -0800 Subject: [PATCH 005/102] Implement launching a local daemon (#261) Allow AppImage and non-systemd systems to launch a local burrow daemon. --- README.md | 3 +- burrow-gtk/build-aux/build_appimage.sh | 6 +- burrow-gtk/src/components/app.rs | 21 +++- burrow-gtk/src/components/mod.rs | 1 + .../src/components/settings/daemon_group.rs | 111 ++++++++++++++++++ .../src/components/settings/diag_group.rs | 16 +-- burrow-gtk/src/components/settings/mod.rs | 5 +- burrow-gtk/src/components/settings_screen.rs | 41 +++++-- burrow-gtk/src/diag.rs | 15 ++- 9 files changed, 192 insertions(+), 27 deletions(-) create mode 100644 burrow-gtk/src/components/settings/daemon_group.rs diff --git a/README.md b/README.md index 7492039..89914d0 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,14 @@ Burrow is an open source tool for burrowing through firewalls, built by teenager ## Contributing -Burrow is fully open source, you can fork the repo and start contributing easily. For more information and in-depth discussions, visit the `#burrow` channel on the [Hack Club Slack](https://hackclub.com/slack/), here you can ask for help and talk with other people interested in burrow! For more information on how to contribute, please see [CONTRIBUTING.md] +Burrow is fully open source, you can fork the repo and start contributing easily. For more information and in-depth discussions, visit the `#burrow` channel on the [Hack Club Slack](https://hackclub.com/slack/), here you can ask for help and talk with other people interested in burrow! Checkout [GETTING_STARTED.md](./docs/GETTING_STARTED.md) for build instructions and [GTK_APP.md](./docs/GTK_APP.md) for the Linux app. The project structure is divided in the following folders: ``` Apple/ # Xcode project for burrow on macOS and iOS burrow/ # Higher-level API library for tun and tun-async +burrow-gtk/ # GTK project for burrow on Linux tun/ # Low-level interface to OS networking src/ tokio/ # Async/Tokio code diff --git a/burrow-gtk/build-aux/build_appimage.sh b/burrow-gtk/build-aux/build_appimage.sh index 248cca7..cd58c17 100755 --- a/burrow-gtk/build-aux/build_appimage.sh +++ b/burrow-gtk/build-aux/build_appimage.sh @@ -5,6 +5,7 @@ set -ex BURROW_GTK_ROOT="$(readlink -f $(dirname -- "$(readlink -f -- "$BASH_SOURCE")")/..)" BURROW_GTK_BUILD="$BURROW_GTK_ROOT/build-appimage" LINUXDEPLOY_VERSION="${LINUXDEPLOY_VERSION:-"1-alpha-20240109-1"}" +BURROW_BUILD_TYPE="${BURROW_BUILD_TYPE:-"release"}" if [ "$BURROW_GTK_ROOT" != $(pwd) ]; then echo "Make sure to cd into burrow-gtk" @@ -21,8 +22,9 @@ elif [ "$ARCHITECTURE" == "aarch64" ]; then chmod a+x /tmp/linuxdeploy fi -meson setup $BURROW_GTK_BUILD --bindir bin --prefix /usr +meson setup $BURROW_GTK_BUILD --bindir bin --prefix /usr --buildtype $BURROW_BUILD_TYPE meson compile -C $BURROW_GTK_BUILD DESTDIR=AppDir meson install -C $BURROW_GTK_BUILD -/tmp/linuxdeploy --appimage-extract-and-run --appdir $BURROW_GTK_BUILD/AppDir --output appimage +cargo b --$BURROW_BUILD_TYPE --manifest-path=../Cargo.toml +/tmp/linuxdeploy --appimage-extract-and-run --appdir $BURROW_GTK_BUILD/AppDir -e $BURROW_GTK_BUILD/../../target/$BURROW_BUILD_TYPE/burrow --output appimage mv *.AppImage $BURROW_GTK_BUILD diff --git a/burrow-gtk/src/components/app.rs b/burrow-gtk/src/components/app.rs index 57348ef..62c98c0 100644 --- a/burrow-gtk/src/components/app.rs +++ b/burrow-gtk/src/components/app.rs @@ -6,7 +6,7 @@ const RECONNECT_POLL_TIME: Duration = Duration::from_secs(5); pub struct App { daemon_client: Arc>>, - _settings_screen: Controller, + settings_screen: Controller, switch_screen: AsyncController, } @@ -109,7 +109,7 @@ impl AsyncComponent for App { let model = App { daemon_client, switch_screen, - _settings_screen: settings_screen, + settings_screen, }; AsyncComponentParts { model, widgets } @@ -132,14 +132,23 @@ impl AsyncComponent for App { disconnected_daemon_client = true; self.switch_screen .emit(switch_screen::SwitchScreenMsg::DaemonDisconnect); + self.settings_screen + .emit(settings_screen::SettingsScreenMsg::DaemonStateChange) } } if disconnected_daemon_client || daemon_client.is_none() { - *daemon_client = DaemonClient::new().await.ok(); - if daemon_client.is_some() { - self.switch_screen - .emit(switch_screen::SwitchScreenMsg::DaemonReconnect); + match DaemonClient::new().await { + Ok(new_daemon_client) => { + *daemon_client = Some(new_daemon_client); + self.switch_screen + .emit(switch_screen::SwitchScreenMsg::DaemonReconnect); + self.settings_screen + .emit(settings_screen::SettingsScreenMsg::DaemonStateChange) + } + Err(_e) => { + // TODO: Handle Error + } } } } diff --git a/burrow-gtk/src/components/mod.rs b/burrow-gtk/src/components/mod.rs index b1cc938..b134809 100644 --- a/burrow-gtk/src/components/mod.rs +++ b/burrow-gtk/src/components/mod.rs @@ -18,3 +18,4 @@ mod settings_screen; mod switch_screen; pub use app::*; +pub use settings::{DaemonGroupMsg, DiagGroupMsg}; diff --git a/burrow-gtk/src/components/settings/daemon_group.rs b/burrow-gtk/src/components/settings/daemon_group.rs new file mode 100644 index 0000000..3817ca6 --- /dev/null +++ b/burrow-gtk/src/components/settings/daemon_group.rs @@ -0,0 +1,111 @@ +use super::*; +use std::process::Command; + +#[derive(Debug)] +pub struct DaemonGroup { + system_setup: SystemSetup, + daemon_client: Arc>>, + already_running: bool, +} + +pub struct DaemonGroupInit { + pub daemon_client: Arc>>, + pub system_setup: SystemSetup, +} + +#[derive(Debug)] +pub enum DaemonGroupMsg { + LaunchLocal, + DaemonStateChange, +} + +#[relm4::component(pub, async)] +impl AsyncComponent for DaemonGroup { + type Init = DaemonGroupInit; + type Input = DaemonGroupMsg; + type Output = (); + type CommandOutput = (); + + view! { + #[name(group)] + adw::PreferencesGroup { + #[watch] + set_sensitive: + (model.system_setup == SystemSetup::AppImage || model.system_setup == SystemSetup::Other) && + !model.already_running, + set_title: "Local Daemon", + set_description: Some("Run Local Daemon"), + + gtk::Button { + set_label: "Launch", + connect_clicked => DaemonGroupMsg::LaunchLocal + } + } + } + + async fn init( + init: Self::Init, + root: Self::Root, + sender: AsyncComponentSender, + ) -> AsyncComponentParts { + // Should be impossible to panic here + let model = DaemonGroup { + system_setup: init.system_setup, + daemon_client: init.daemon_client.clone(), + already_running: init.daemon_client.lock().await.is_some(), + }; + + let widgets = view_output!(); + + AsyncComponentParts { model, widgets } + } + + async fn update( + &mut self, + msg: Self::Input, + _sender: AsyncComponentSender, + _root: &Self::Root, + ) { + match msg { + DaemonGroupMsg::LaunchLocal => { + let burrow_original_bin = std::env::vars() + .find(|(k, _)| k == "APPDIR") + .map(|(_, v)| v + "/usr/bin/burrow") + .unwrap_or("/usr/bin/burrow".to_owned()); + + let mut burrow_bin = + String::from_utf8(Command::new("mktemp").output().unwrap().stdout).unwrap(); + burrow_bin.pop(); + + let privileged_spawn_script = format!( + r#"TEMP=$(mktemp -p /root) +cp {} $TEMP +chmod +x $TEMP +setcap CAP_NET_BIND_SERVICE,CAP_NET_ADMIN+eip $TEMP +mv $TEMP /tmp/burrow-detached-daemon"#, + burrow_original_bin + ) + .replace('\n', "&&"); + + // TODO: Handle error condition + + Command::new("pkexec") + .arg("sh") + .arg("-c") + .arg(privileged_spawn_script) + .arg(&burrow_bin) + .output() + .unwrap(); + + Command::new("/tmp/burrow-detached-daemon") + .env("RUST_LOG", "debug") + .arg("daemon") + .spawn() + .unwrap(); + } + DaemonGroupMsg::DaemonStateChange => { + self.already_running = self.daemon_client.lock().await.is_some(); + } + } + } +} diff --git a/burrow-gtk/src/components/settings/diag_group.rs b/burrow-gtk/src/components/settings/diag_group.rs index be542cd..a15e0ea 100644 --- a/burrow-gtk/src/components/settings/diag_group.rs +++ b/burrow-gtk/src/components/settings/diag_group.rs @@ -1,11 +1,10 @@ use super::*; -use diag::{StatusTernary, SystemSetup}; #[derive(Debug)] pub struct DiagGroup { daemon_client: Arc>>, - init_system: SystemSetup, + system_setup: SystemSetup, service_installed: StatusTernary, socket_installed: StatusTernary, socket_enabled: StatusTernary, @@ -14,19 +13,20 @@ pub struct DiagGroup { pub struct DiagGroupInit { pub daemon_client: Arc>>, + pub system_setup: SystemSetup, } impl DiagGroup { async fn new(daemon_client: Arc>>) -> Result { - let setup = SystemSetup::new(); + let system_setup = SystemSetup::new(); let daemon_running = daemon_client.lock().await.is_some(); Ok(Self { - service_installed: setup.is_service_installed()?, - socket_installed: setup.is_socket_installed()?, - socket_enabled: setup.is_socket_enabled()?, + service_installed: system_setup.is_service_installed()?, + socket_installed: system_setup.is_socket_installed()?, + socket_enabled: system_setup.is_socket_enabled()?, daemon_running, - init_system: setup, + system_setup, daemon_client, }) } @@ -52,7 +52,7 @@ impl AsyncComponent for DiagGroup { adw::ActionRow { #[watch] - set_title: &format!("Init System: {}", model.init_system) + set_title: &format!("System Type: {}", model.system_setup) }, adw::ActionRow { #[watch] diff --git a/burrow-gtk/src/components/settings/mod.rs b/burrow-gtk/src/components/settings/mod.rs index 53f46d4..aa87db2 100644 --- a/burrow-gtk/src/components/settings/mod.rs +++ b/burrow-gtk/src/components/settings/mod.rs @@ -1,5 +1,8 @@ use super::*; +use diag::{StatusTernary, SystemSetup}; +mod daemon_group; mod diag_group; -pub use diag_group::{DiagGroup, DiagGroupInit}; +pub use daemon_group::{DaemonGroup, DaemonGroupInit, DaemonGroupMsg}; +pub use diag_group::{DiagGroup, DiagGroupInit, DiagGroupMsg}; diff --git a/burrow-gtk/src/components/settings_screen.rs b/burrow-gtk/src/components/settings_screen.rs index 0a29e43..971f262 100644 --- a/burrow-gtk/src/components/settings_screen.rs +++ b/burrow-gtk/src/components/settings_screen.rs @@ -1,17 +1,24 @@ use super::*; +use diag::SystemSetup; pub struct SettingsScreen { - _diag_group: AsyncController, + diag_group: AsyncController, + daemon_group: AsyncController, } pub struct SettingsScreenInit { pub daemon_client: Arc>>, } +#[derive(Debug, PartialEq, Eq)] +pub enum SettingsScreenMsg { + DaemonStateChange, +} + #[relm4::component(pub)] impl SimpleComponent for SettingsScreen { type Init = SettingsScreenInit; - type Input = (); + type Input = SettingsScreenMsg; type Output = (); view! { @@ -24,21 +31,41 @@ impl SimpleComponent for SettingsScreen { root: &Self::Root, sender: ComponentSender, ) -> ComponentParts { + let system_setup = SystemSetup::new(); + let diag_group = settings::DiagGroup::builder() .launch(settings::DiagGroupInit { + system_setup, daemon_client: Arc::clone(&init.daemon_client), }) - .forward(sender.input_sender(), |_| ()); + .forward(sender.input_sender(), |_| { + SettingsScreenMsg::DaemonStateChange + }); + + let daemon_group = settings::DaemonGroup::builder() + .launch(settings::DaemonGroupInit { + system_setup, + daemon_client: Arc::clone(&init.daemon_client), + }) + .forward(sender.input_sender(), |_| { + SettingsScreenMsg::DaemonStateChange + }); let widgets = view_output!(); widgets.preferences.add(diag_group.widget()); + widgets.preferences.add(daemon_group.widget()); - let model = SettingsScreen { - _diag_group: diag_group, - }; + let model = SettingsScreen { diag_group, daemon_group }; ComponentParts { model, widgets } } - fn update(&mut self, _: Self::Input, _sender: ComponentSender) {} + fn update(&mut self, _: Self::Input, _sender: ComponentSender) { + // Currently, `SettingsScreenMsg` only has one variant, so the if is ambiguous. + // + // if let SettingsScreenMsg::DaemonStateChange = msg { + self.diag_group.emit(DiagGroupMsg::Refresh); + self.daemon_group.emit(DaemonGroupMsg::DaemonStateChange); + // } + } } diff --git a/burrow-gtk/src/diag.rs b/burrow-gtk/src/diag.rs index 348293e..ab4757e 100644 --- a/burrow-gtk/src/diag.rs +++ b/burrow-gtk/src/diag.rs @@ -15,15 +15,18 @@ pub enum StatusTernary { // Realistically, we may not explicitly "support" non-systemd platforms which would simply this // code greatly. // Along with replacing [`StatusTernary`] with good old [`bool`]. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SystemSetup { Systemd, + AppImage, Other, } impl SystemSetup { pub fn new() -> Self { - if Command::new("systemctl").arg("--version").output().is_ok() { + if is_appimage() { + SystemSetup::AppImage + } else if Command::new("systemctl").arg("--version").output().is_ok() { SystemSetup::Systemd } else { SystemSetup::Other @@ -33,6 +36,7 @@ impl SystemSetup { pub fn is_service_installed(&self) -> Result { match self { SystemSetup::Systemd => Ok(fs::metadata(SYSTEMD_SERVICE_LOC).is_ok().into()), + SystemSetup::AppImage => Ok(StatusTernary::NA), SystemSetup::Other => Ok(StatusTernary::NA), } } @@ -40,6 +44,7 @@ impl SystemSetup { pub fn is_socket_installed(&self) -> Result { match self { SystemSetup::Systemd => Ok(fs::metadata(SYSTEMD_SOCKET_LOC).is_ok().into()), + SystemSetup::AppImage => Ok(StatusTernary::NA), SystemSetup::Other => Ok(StatusTernary::NA), } } @@ -55,6 +60,7 @@ impl SystemSetup { let output = String::from_utf8(output)?; Ok((output == "enabled\n").into()) } + SystemSetup::AppImage => Ok(StatusTernary::NA), SystemSetup::Other => Ok(StatusTernary::NA), } } @@ -74,7 +80,12 @@ impl Display for SystemSetup { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { SystemSetup::Systemd => "Systemd", + SystemSetup::AppImage => "AppImage", SystemSetup::Other => "Other", }) } } + +pub fn is_appimage() -> bool { + std::env::vars().any(|(k, _)| k == "APPDIR") +} From 51fd638b7250f81b3899113033e2668d6975847c Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 24 Feb 2024 09:51:38 -0800 Subject: [PATCH 006/102] Update for Xcode 15.2 --- Apple/Burrow.xcodeproj/project.pbxproj | 2 +- Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme | 2 +- .../xcshareddata/xcschemes/NetworkExtension.xcscheme | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index 428d9ab..6127e1a 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -319,7 +319,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1510; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1520; TargetAttributes = { D00117372B30341C00D87C25 = { CreatedOnToolsVersion = 15.1; diff --git a/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme index c63f8e6..670823d 100644 --- a/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme +++ b/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -1,6 +1,6 @@ Date: Sat, 24 Feb 2024 09:49:07 -0800 Subject: [PATCH 007/102] Introduce initial UI for connecting to networks --- .swiftlint.yml | 5 +- Apple/App/App.xcconfig | 5 + Apple/App/AppDelegate.swift | 3 +- .../HackClub.colorset/Contents.json | 20 + .../HackClub.imageset/Contents.json | 12 + .../flag-standalone-wtransparent.pdf | Bin 0 -> 3501 bytes .../WireGuard.colorset/Contents.json | 20 + .../WireGuard.imageset/Contents.json | 15 + .../WireGuard.imageset/WireGuard.svg | 6 + .../WireGuardTitle.imageset/Contents.json | 21 + .../WireGuardTitle.svg | 3 + Apple/App/BurrowApp.swift | 16 +- Apple/App/BurrowView.swift | 26 + Apple/App/FloatingButtonStyle.swift | 50 ++ Apple/App/MainMenu.xib | 679 ++++++++++++++++++ Apple/App/Menu/MenuView.swift | 60 -- Apple/App/MenuItemToggleView.swift | 64 ++ Apple/App/NetworkExtensionTunnel.swift | 167 +++++ Apple/App/NetworkView.swift | 88 +++ Apple/App/Networks/HackClub.swift | 23 + Apple/App/Networks/Network.swift | 10 + Apple/App/Networks/WireGuard.swift | 30 + Apple/App/Status.swift | 42 -- Apple/App/Tunnel.swift | 178 ++--- Apple/App/TunnelButton.swift | 61 ++ Apple/App/TunnelStatusView.swift | 37 + Apple/App/TunnelView.swift | 34 - Apple/Burrow.xcodeproj/project.pbxproj | 76 +- .../xcshareddata/swiftpm/Package.resolved | 16 +- .../PacketTunnelProvider.swift | 8 +- Apple/Shared/Constants.swift | 1 + Apple/Shared/Constants/Constants.h | 1 + Apple/Shared/Shared.xcconfig | 2 +- 33 files changed, 1458 insertions(+), 321 deletions(-) create mode 100644 Apple/App/Assets.xcassets/HackClub.colorset/Contents.json create mode 100644 Apple/App/Assets.xcassets/HackClub.imageset/Contents.json create mode 100644 Apple/App/Assets.xcassets/HackClub.imageset/flag-standalone-wtransparent.pdf create mode 100644 Apple/App/Assets.xcassets/WireGuard.colorset/Contents.json create mode 100644 Apple/App/Assets.xcassets/WireGuard.imageset/Contents.json create mode 100644 Apple/App/Assets.xcassets/WireGuard.imageset/WireGuard.svg create mode 100644 Apple/App/Assets.xcassets/WireGuardTitle.imageset/Contents.json create mode 100644 Apple/App/Assets.xcassets/WireGuardTitle.imageset/WireGuardTitle.svg create mode 100644 Apple/App/BurrowView.swift create mode 100644 Apple/App/FloatingButtonStyle.swift create mode 100644 Apple/App/MainMenu.xib delete mode 100644 Apple/App/Menu/MenuView.swift create mode 100644 Apple/App/MenuItemToggleView.swift create mode 100644 Apple/App/NetworkExtensionTunnel.swift create mode 100644 Apple/App/NetworkView.swift create mode 100644 Apple/App/Networks/HackClub.swift create mode 100644 Apple/App/Networks/Network.swift create mode 100644 Apple/App/Networks/WireGuard.swift delete mode 100644 Apple/App/Status.swift create mode 100644 Apple/App/TunnelButton.swift create mode 100644 Apple/App/TunnelStatusView.swift delete mode 100644 Apple/App/TunnelView.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index d609718..22ef035 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -46,7 +46,6 @@ opt_in_rules: - multiline_parameters - multiline_parameters_brackets - no_extension_access_modifier -- no_grouping_extension - nslocalizedstring_key - nslocalizedstring_require_bundle - number_separator @@ -76,9 +75,7 @@ opt_in_rules: - sorted_first_last - sorted_imports - static_operator -- strict_fileprivate - strong_iboutlet -- switch_case_on_newline - test_case_accessibility - toggle_bool - trailing_closure @@ -97,3 +94,5 @@ disabled_rules: - force_try - nesting - todo +- trailing_comma +- switch_case_on_newline diff --git a/Apple/App/App.xcconfig b/Apple/App/App.xcconfig index 1d63205..4e42ddc 100644 --- a/Apple/App/App.xcconfig +++ b/Apple/App/App.xcconfig @@ -11,7 +11,12 @@ INFOPLIST_KEY_UIStatusBarStyle[sdk=iphone*] = UIStatusBarStyleDefault INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad[sdk=iphone*] = UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone[sdk=iphone*] = UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight TARGETED_DEVICE_FAMILY[sdk=iphone*] = 1,2 +EXCLUDED_SOURCE_FILE_NAMES = MainMenu.xib +EXCLUDED_SOURCE_FILE_NAMES[sdk=macosx*] = +INFOPLIST_KEY_LSUIElement[sdk=macosx*] = YES +INFOPLIST_KEY_NSMainNibFile[sdk=macosx*] = MainMenu +INFOPLIST_KEY_NSPrincipalClass[sdk=macosx*] = NSApplication INFOPLIST_KEY_LSApplicationCategoryType[sdk=macosx*] = public.app-category.utilities CODE_SIGN_ENTITLEMENTS = App/App-iOS.entitlements diff --git a/Apple/App/AppDelegate.swift b/Apple/App/AppDelegate.swift index f42b52f..6085d85 100644 --- a/Apple/App/AppDelegate.swift +++ b/Apple/App/AppDelegate.swift @@ -3,6 +3,7 @@ import AppKit import SwiftUI @MainActor +@NSApplicationMain class AppDelegate: NSObject, NSApplicationDelegate { private let quitItem: NSMenuItem = { let quitItem = NSMenuItem( @@ -16,7 +17,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { }() private let toggleItem: NSMenuItem = { - let toggleView = NSHostingView(rootView: MenuItemToggleView(tunnel: BurrowApp.tunnel)) + let toggleView = NSHostingView(rootView: MenuItemToggleView()) toggleView.frame.size = CGSize(width: 300, height: 32) toggleView.autoresizingMask = [.width] diff --git a/Apple/App/Assets.xcassets/HackClub.colorset/Contents.json b/Apple/App/Assets.xcassets/HackClub.colorset/Contents.json new file mode 100644 index 0000000..911b4b1 --- /dev/null +++ b/Apple/App/Assets.xcassets/HackClub.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x50", + "green" : "0x37", + "red" : "0xEC" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Apple/App/Assets.xcassets/HackClub.imageset/Contents.json b/Apple/App/Assets.xcassets/HackClub.imageset/Contents.json new file mode 100644 index 0000000..ddd0664 --- /dev/null +++ b/Apple/App/Assets.xcassets/HackClub.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "flag-standalone-wtransparent.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Apple/App/Assets.xcassets/HackClub.imageset/flag-standalone-wtransparent.pdf b/Apple/App/Assets.xcassets/HackClub.imageset/flag-standalone-wtransparent.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1506fe9390e19160cb5f8732ffe6ba2c488975ce GIT binary patch literal 3501 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 + + + + + \ No newline at end of file diff --git a/Apple/App/Assets.xcassets/WireGuardTitle.imageset/Contents.json b/Apple/App/Assets.xcassets/WireGuardTitle.imageset/Contents.json new file mode 100644 index 0000000..782dd12 --- /dev/null +++ b/Apple/App/Assets.xcassets/WireGuardTitle.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "WireGuardTitle.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Apple/App/Assets.xcassets/WireGuardTitle.imageset/WireGuardTitle.svg b/Apple/App/Assets.xcassets/WireGuardTitle.imageset/WireGuardTitle.svg new file mode 100644 index 0000000..64946da --- /dev/null +++ b/Apple/App/Assets.xcassets/WireGuardTitle.imageset/WireGuardTitle.svg @@ -0,0 +1,3 @@ + + + diff --git a/Apple/App/BurrowApp.swift b/Apple/App/BurrowApp.swift index e8aed86..21ebf84 100644 --- a/Apple/App/BurrowApp.swift +++ b/Apple/App/BurrowApp.swift @@ -1,21 +1,13 @@ import SwiftUI -@main +#if !os(macOS) @MainActor +@main struct BurrowApp: App { - static let tunnel = Tunnel { manager, proto in - proto.serverAddress = "hackclub.com" - manager.localizedDescription = "Burrow" - } - - #if os(macOS) - @NSApplicationDelegateAdaptor(AppDelegate.self) - var delegate - #endif - var body: some Scene { WindowGroup { - TunnelView(tunnel: Self.tunnel) + BurrowView() } } } +#endif diff --git a/Apple/App/BurrowView.swift b/Apple/App/BurrowView.swift new file mode 100644 index 0000000..b78b1e1 --- /dev/null +++ b/Apple/App/BurrowView.swift @@ -0,0 +1,26 @@ +import SwiftUI + +struct BurrowView: View { + var body: some View { + NavigationStack { + VStack { + NetworkCarouselView() + Spacer() + TunnelStatusView() + TunnelButton() + .padding(.bottom) + } + .padding() + .navigationTitle("Networks") + } + } +} + +#if DEBUG +struct NetworkView_Previews: PreviewProvider { + static var previews: some View { + BurrowView() + .environment(\.tunnel, PreviewTunnel()) + } +} +#endif diff --git a/Apple/App/FloatingButtonStyle.swift b/Apple/App/FloatingButtonStyle.swift new file mode 100644 index 0000000..53ab5ed --- /dev/null +++ b/Apple/App/FloatingButtonStyle.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct FloatingButtonStyle: ButtonStyle { + static let duration = 0.08 + + var color: Color + var cornerRadius: CGFloat + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.headline) + .foregroundColor(.white) + .frame(minHeight: 48) + .padding(.horizontal) + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill( + LinearGradient( + colors: [ + configuration.isPressed ? color.opacity(0.9) : color.opacity(0.9), + configuration.isPressed ? color.opacity(0.9) : color + ], + startPoint: .init(x: 0.2, y: 0), + endPoint: .init(x: 0.8, y: 1) + ) + ) + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(configuration.isPressed ? .black : .white) + ) + ) + .shadow(color: .black.opacity(configuration.isPressed ? 0.0 : 0.1), radius: 2.5, x: 0, y: 2) + .scaleEffect(configuration.isPressed ? 0.975 : 1.0) + .padding(.bottom, 2) + .animation( + configuration.isPressed ? .easeOut(duration: Self.duration) : .easeIn(duration: Self.duration), + value: configuration.isPressed + ) + } +} + +extension ButtonStyle where Self == FloatingButtonStyle { + static var floating: FloatingButtonStyle { + floating() + } + + static func floating(color: Color = .accentColor, cornerRadius: CGFloat = 10) -> FloatingButtonStyle { + FloatingButtonStyle(color: color, cornerRadius: cornerRadius) + } +} diff --git a/Apple/App/MainMenu.xib b/Apple/App/MainMenu.xib new file mode 100644 index 0000000..8933f30 --- /dev/null +++ b/Apple/App/MainMenu.xib @@ -0,0 +1,679 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Default + + + + + + + Left to Right + + + + + + + Right to Left + + + + + + + + + + + Default + + + + + + + Left to Right + + + + + + + Right to Left + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Apple/App/Menu/MenuView.swift b/Apple/App/Menu/MenuView.swift deleted file mode 100644 index eab8da2..0000000 --- a/Apple/App/Menu/MenuView.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// MenuView.swift -// App -// -// Created by Thomas Stubblefield on 5/13/23. -// - -import SwiftUI - -struct MenuItemToggleView: View { - var tunnel: Tunnel - - var body: some View { - HStack { - Text("Burrow") - .font(.headline) - Spacer() - Toggle("Burrow", isOn: tunnel.isOn) - .labelsHidden() - .disabled(tunnel.isDisabled) - .toggleStyle(.switch) - } - .padding(.horizontal, 4) - .padding(10) - .frame(minWidth: 300, minHeight: 32, maxHeight: 32) - } -} - -extension Tunnel { - var isDisabled: Bool { - switch self.status { - case .disconnected, .permissionRequired, .connected: - return false - case .unknown, .disabled, .connecting, .reasserting, .disconnecting, .invalid, .configurationReadWriteFailed: - return true - } - } - - var isOn: Binding { - Binding { - switch self.status { - case .connecting, .reasserting, .connected: - true - default: - false - } - } set: { newValue in - switch (self.status, newValue) { - case (.permissionRequired, true): - Task { try await self.configure() } - case (.disconnected, true): - try? self.start() - case (.connected, false): - self.stop() - default: - return - } - } - } -} diff --git a/Apple/App/MenuItemToggleView.swift b/Apple/App/MenuItemToggleView.swift new file mode 100644 index 0000000..07db51d --- /dev/null +++ b/Apple/App/MenuItemToggleView.swift @@ -0,0 +1,64 @@ +// +// MenuItemToggleView.swift +// App +// +// Created by Thomas Stubblefield on 5/13/23. +// + +import SwiftUI + +struct MenuItemToggleView: View { + @Environment(\.tunnel) + var tunnel: Tunnel + + var body: some View { + HStack { + VStack(alignment: .leading) { + Text("Burrow") + .font(.headline) + Text(tunnel.status.description) + .font(.subheadline) + } + Spacer() + Toggle(isOn: tunnel.toggleIsOn) { + } + .disabled(tunnel.toggleDisabled) + .toggleStyle(.switch) + } + .accessibilityElement(children: .combine) + .padding(.horizontal, 4) + .padding(10) + .frame(minWidth: 300, minHeight: 32, maxHeight: 32) + } +} + +extension Tunnel { + fileprivate var toggleDisabled: Bool { + switch status { + case .disconnected, .permissionRequired, .connected, .disconnecting: + false + case .unknown, .disabled, .connecting, .reasserting, .invalid, .configurationReadWriteFailed: + true + } + } + + var toggleIsOn: Binding { + Binding { + switch status { + case .connecting, .reasserting, .connected: + true + default: + false + } + } set: { newValue in + switch (status, newValue) { + case (.permissionRequired, true): + enable() + case (_, true): + start() + case (_, false): + stop() + } + } + } +} diff --git a/Apple/App/NetworkExtensionTunnel.swift b/Apple/App/NetworkExtensionTunnel.swift new file mode 100644 index 0000000..08002de --- /dev/null +++ b/Apple/App/NetworkExtensionTunnel.swift @@ -0,0 +1,167 @@ +import BurrowShared +import NetworkExtension + +@Observable +class NetworkExtensionTunnel: Tunnel { + @MainActor private(set) var status: TunnelStatus = .unknown + private var error: NEVPNError? + + private let logger = Logger.logger(for: Tunnel.self) + private let bundleIdentifier: String + private var tasks: [Task] = [] + + // Each manager corresponds to one entry in the Settings app. + // Our goal is to maintain a single manager, so we create one if none exist and delete any extra. + private var managers: [NEVPNManager]? { + didSet { Task { await updateStatus() } } + } + + private var currentStatus: TunnelStatus { + guard let managers = managers else { + guard let error = error else { + return .unknown + } + + switch error.code { + case .configurationReadWriteFailed: + return .configurationReadWriteFailed + default: + return .unknown + } + } + + guard let manager = managers.first else { + return .permissionRequired + } + + guard manager.isEnabled else { + return .disabled + } + + return manager.connection.tunnelStatus + } + + convenience init() { + self.init(Constants.networkExtensionBundleIdentifier) + } + + init(_ bundleIdentifier: String) { + self.bundleIdentifier = bundleIdentifier + + let center = NotificationCenter.default + let configurationChanged = Task { [weak self] in + for try await _ in center.notifications(named: .NEVPNConfigurationChange).map({ _ in () }) { + await self?.update() + } + } + let statusChanged = Task { [weak self] in + for try await _ in center.notifications(named: .NEVPNStatusDidChange).map({ _ in () }) { + await self?.updateStatus() + } + } + tasks = [configurationChanged, statusChanged] + + Task { await update() } + } + + private func update() async { + do { + managers = try await NETunnelProviderManager.managers + await self.updateStatus() + } catch let vpnError as NEVPNError { + error = vpnError + } catch { + logger.error("Failed to update VPN configurations: \(error)") + } + } + + private func updateStatus() async { + await MainActor.run { + status = currentStatus + } + } + + func configure() async throws { + if managers == nil { + await update() + } + + guard let managers = managers else { return } + + if managers.count > 1 { + try await withThrowingTaskGroup(of: Void.self, returning: Void.self) { group in + for manager in managers.suffix(from: 1) { + group.addTask { try await manager.remove() } + } + try await group.waitForAll() + } + } + + guard managers.isEmpty else { return } + + let manager = NETunnelProviderManager() + manager.localizedDescription = "Burrow" + + let proto = NETunnelProviderProtocol() + proto.providerBundleIdentifier = bundleIdentifier + proto.serverAddress = "hackclub.com" + + manager.protocolConfiguration = proto + try await manager.save() + } + + func start() { + guard let manager = managers?.first else { return } + Task { + do { + if !manager.isEnabled { + manager.isEnabled = true + try await manager.save() + } + try manager.connection.startVPNTunnel() + } catch { + logger.error("Failed to start: \(error)") + } + } + } + + func stop() { + guard let manager = managers?.first else { return } + manager.connection.stopVPNTunnel() + } + + func enable() { + Task { + do { + try await configure() + } catch { + logger.error("Failed to enable: \(error)") + } + } + } + + deinit { + tasks.forEach { $0.cancel() } + } +} + +extension NEVPNConnection { + fileprivate var tunnelStatus: TunnelStatus { + switch status { + case .connected: + .connected(connectedDate!) + case .connecting: + .connecting + case .disconnecting: + .disconnecting + case .disconnected: + .disconnected + case .reasserting: + .reasserting + case .invalid: + .invalid + @unknown default: + .unknown + } + } +} diff --git a/Apple/App/NetworkView.swift b/Apple/App/NetworkView.swift new file mode 100644 index 0000000..290254c --- /dev/null +++ b/Apple/App/NetworkView.swift @@ -0,0 +1,88 @@ +import SwiftUI + +struct NetworkView: View { + var color: Color + var content: () -> Content + + private var gradient: LinearGradient { + LinearGradient( + colors: [ + color.opacity(0.8), + color + ], + startPoint: .init(x: 0.2, y: 0), + endPoint: .init(x: 0.8, y: 1) + ) + } + + var body: some View { + content() + .frame(maxWidth: .infinity, minHeight: 175, maxHeight: 175) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(gradient) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(.white) + ) + ) + .shadow(color: .black.opacity(0.1), radius: 3.0, x: 0, y: 2) + } +} + +struct AddNetworkView: View { + var body: some View { + Text("Add Network") + .frame(maxWidth: .infinity, minHeight: 175, maxHeight: 175) + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(style: .init(lineWidth: 2, dash: [6])) + ) + } +} + +extension NetworkView where Content == AnyView { + init(network: any Network) { + color = network.backgroundColor + content = { AnyView(network.label) } + } +} + +struct NetworkCarouselView: View { + var networks: [any Network] = [ + HackClub(id: "1"), + HackClub(id: "2"), + WireGuard(id: "4"), + HackClub(id: "5"), + ] + + var body: some View { + ScrollView(.horizontal) { + LazyHStack { + ForEach(networks, id: \.id) { network in + NetworkView(network: network) + .containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center) + .scrollTransition(.interactive, axis: .horizontal) { content, phase in + content + .scaleEffect(1.0 - abs(phase.value) * 0.1) + } + } + AddNetworkView() + } + .scrollTargetLayout() + } + .scrollClipDisabled() + .scrollIndicators(.hidden) + .defaultScrollAnchor(.center) + .scrollTargetBehavior(.viewAligned) + .containerRelativeFrame(.horizontal) + } +} + +#if DEBUG +struct NetworkCarouselView_Previews: PreviewProvider { + static var previews: some View { + NetworkCarouselView() + } +} +#endif diff --git a/Apple/App/Networks/HackClub.swift b/Apple/App/Networks/HackClub.swift new file mode 100644 index 0000000..f7df674 --- /dev/null +++ b/Apple/App/Networks/HackClub.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct HackClub: Network { + var id: String + var backgroundColor: Color { .init("HackClub") } + + var label: some View { + GeometryReader { reader in + VStack(alignment: .leading) { + Image("HackClub") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: reader.size.height / 4) + Spacer() + Text("@conradev") + .foregroundStyle(.white) + .font(.body.monospaced()) + } + .padding() + .frame(maxWidth: .infinity) + } + } +} diff --git a/Apple/App/Networks/Network.swift b/Apple/App/Networks/Network.swift new file mode 100644 index 0000000..d441d24 --- /dev/null +++ b/Apple/App/Networks/Network.swift @@ -0,0 +1,10 @@ +import SwiftUI + +protocol Network { + associatedtype Label: View + + var id: String { get } + var backgroundColor: Color { get } + + var label: Label { get } +} diff --git a/Apple/App/Networks/WireGuard.swift b/Apple/App/Networks/WireGuard.swift new file mode 100644 index 0000000..499288a --- /dev/null +++ b/Apple/App/Networks/WireGuard.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct WireGuard: Network { + var id: String + var backgroundColor: Color { .init("WireGuard") } + + var label: some View { + GeometryReader { reader in + VStack(alignment: .leading) { + HStack { + Image("WireGuard") + .resizable() + .aspectRatio(contentMode: .fit) + Image("WireGuardTitle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: reader.size.width / 2) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: reader.size.height / 4) + Spacer() + Text("@conradev") + .foregroundStyle(.white) + .font(.body.monospaced()) + } + .padding() + .frame(maxWidth: .infinity) + } + } +} diff --git a/Apple/App/Status.swift b/Apple/App/Status.swift deleted file mode 100644 index c08cdd1..0000000 --- a/Apple/App/Status.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation -import NetworkExtension - -extension Tunnel { - enum Status: CustomStringConvertible, Equatable, Hashable { - case unknown - case permissionRequired - case disabled - case connecting - case connected(Date) - case disconnecting - case disconnected - case reasserting - case invalid - case configurationReadWriteFailed - - var description: String { - switch self { - case .unknown: - return "Unknown" - case .permissionRequired: - return "Permission Required" - case .disconnected: - return "Disconnected" - case .disabled: - return "Disabled" - case .connecting: - return "Connecting" - case .connected: - return "Connected" - case .disconnecting: - return "Disconnecting" - case .reasserting: - return "Reasserting" - case .invalid: - return "Invalid" - case .configurationReadWriteFailed: - return "System Error" - } - } - } -} diff --git a/Apple/App/Tunnel.swift b/Apple/App/Tunnel.swift index 5542170..8db366f 100644 --- a/Apple/App/Tunnel.swift +++ b/Apple/App/Tunnel.swift @@ -1,146 +1,50 @@ -import BurrowShared -import NetworkExtension import SwiftUI +protocol Tunnel { + var status: TunnelStatus { get } + + func start() + func stop() + func enable() +} + +enum TunnelStatus: Equatable, Hashable { + case unknown + case permissionRequired + case disabled + case connecting + case connected(Date) + case disconnecting + case disconnected + case reasserting + case invalid + case configurationReadWriteFailed +} + +struct TunnelKey: EnvironmentKey { + static let defaultValue: any Tunnel = NetworkExtensionTunnel() +} + +extension EnvironmentValues { + var tunnel: any Tunnel { + get { self[TunnelKey.self] } + set { self[TunnelKey.self] = newValue } + } +} + +#if DEBUG @Observable -class Tunnel { - private(set) var status: Status = .unknown - private var error: NEVPNError? +class PreviewTunnel: Tunnel { + var status: TunnelStatus = .permissionRequired - private let logger = Logger.logger(for: Tunnel.self) - private let bundleIdentifier: String - private let configure: (NETunnelProviderManager, NETunnelProviderProtocol) -> Void - private var tasks: [Task] = [] - - // Each manager corresponds to one entry in the Settings app. - // Our goal is to maintain a single manager, so we create one if none exist and delete extra if there are any. - private var managers: [NEVPNManager]? { - didSet { status = currentStatus } + func start() { + status = .connected(.now) } - - private var currentStatus: Status { - guard let managers = managers else { - guard let error = error else { - return .unknown - } - - switch error.code { - case .configurationReadWriteFailed: - return .configurationReadWriteFailed - default: - return .unknown - } - } - - guard let manager = managers.first else { - return .permissionRequired - } - - guard manager.isEnabled else { - return .disabled - } - - return manager.connection.tunnelStatus - } - - convenience init(configure: @escaping (NETunnelProviderManager, NETunnelProviderProtocol) -> Void) { - self.init("com.hackclub.burrow.network", configure: configure) - } - - init(_ bundleIdentifier: String, configure: @escaping (NETunnelProviderManager, NETunnelProviderProtocol) -> Void) { - self.bundleIdentifier = bundleIdentifier - self.configure = configure - - let center = NotificationCenter.default - let configurationChanged = Task { - for try await _ in center.notifications(named: .NEVPNConfigurationChange).map({ _ in () }) { - await update() - } - } - let statusChanged = Task { - for try await _ in center.notifications(named: .NEVPNStatusDidChange).map({ _ in () }) { - await MainActor.run { - status = currentStatus - } - } - } - tasks = [configurationChanged, statusChanged] - - Task { await update() } - } - - private func update() async { - do { - let updated = try await NETunnelProviderManager.managers - await MainActor.run { - managers = updated - } - } catch let vpnError as NEVPNError { - error = vpnError - } catch { - logger.error("Failed to update VPN configurations: \(error)") - } - } - - func configure() async throws { - if managers == nil { - await update() - } - - guard let managers = managers else { return } - - if managers.count > 1 { - try await withThrowingTaskGroup(of: Void.self, returning: Void.self) { group in - for manager in managers.suffix(from: 1) { - group.addTask { try await manager.remove() } - } - try await group.waitForAll() - } - } - - if managers.isEmpty { - let manager = NETunnelProviderManager() - let proto = NETunnelProviderProtocol() - proto.providerBundleIdentifier = bundleIdentifier - configure(manager, proto) - - manager.protocolConfiguration = proto - try await manager.save() - } - } - - func start() throws { - guard let manager = managers?.first else { return } - try manager.connection.startVPNTunnel() - } - func stop() { - guard let manager = managers?.first else { return } - manager.connection.stopVPNTunnel() + status = .disconnected } - - deinit { - tasks.forEach { $0.cancel() } - } -} - -extension NEVPNConnection { - var tunnelStatus: Tunnel.Status { - switch status { - case .connected: - .connected(connectedDate!) - case .connecting: - .connecting - case .disconnecting: - .disconnecting - case .disconnected: - .disconnected - case .reasserting: - .reasserting - case .invalid: - .invalid - @unknown default: - .unknown - } + func enable() { + status = .disconnected } } +#endif diff --git a/Apple/App/TunnelButton.swift b/Apple/App/TunnelButton.swift new file mode 100644 index 0000000..df8d7e6 --- /dev/null +++ b/Apple/App/TunnelButton.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct TunnelButton: View { + @Environment(\.tunnel) + var tunnel: any Tunnel + + var body: some View { + if let action = tunnel.action { + Button { + tunnel.perform(action) + } label: { + Text(action.description) + } + .padding(.horizontal) + .buttonStyle(.floating) + } + } +} + +extension Tunnel { + fileprivate var action: TunnelButton.Action? { + switch status { + case .permissionRequired, .invalid: + .enable + case .disabled, .disconnecting, .disconnected: + .start + case .connecting, .connected, .reasserting: + .stop + case .unknown, .configurationReadWriteFailed: + nil + } + } +} + +extension TunnelButton { + fileprivate enum Action { + case enable + case start + case stop + } +} + +extension TunnelButton.Action { + var description: LocalizedStringKey { + switch self { + case .enable: "Enable" + case .start: "Start" + case .stop: "Stop" + } + } +} + +extension Tunnel { + fileprivate func perform(_ action: TunnelButton.Action) { + switch action { + case .enable: enable() + case .start: start() + case .stop: stop() + } + } +} diff --git a/Apple/App/TunnelStatusView.swift b/Apple/App/TunnelStatusView.swift new file mode 100644 index 0000000..3593516 --- /dev/null +++ b/Apple/App/TunnelStatusView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct TunnelStatusView: View { + @Environment(\.tunnel) + var tunnel: any Tunnel + + var body: some View { + Text(tunnel.status.description) + } +} + +extension TunnelStatus: CustomStringConvertible { + var description: String { + switch self { + case .unknown: + "Unknown" + case .permissionRequired: + "Permission Required" + case .disconnected: + "Disconnected" + case .disabled: + "Disabled" + case .connecting: + "Connecting…" + case .connected: + "Connected" + case .disconnecting: + "Disconnecting…" + case .reasserting: + "Reasserting…" + case .invalid: + "Invalid" + case .configurationReadWriteFailed: + "System Error" + } + } +} diff --git a/Apple/App/TunnelView.swift b/Apple/App/TunnelView.swift deleted file mode 100644 index dd91603..0000000 --- a/Apple/App/TunnelView.swift +++ /dev/null @@ -1,34 +0,0 @@ -import SwiftUI - -struct TunnelView: View { - var tunnel: Tunnel - - var body: some View { - VStack { - Text(verbatim: tunnel.status.description) - switch tunnel.status { - case .connected: - Button("Disconnect", action: stop) - case .permissionRequired: - Button("Allow", action: configure) - case .disconnected: - Button("Start", action: start) - default: - EmptyView() - } - } - .padding() - } - - private func start() { - try? tunnel.start() - } - - private func stop() { - tunnel.stop() - } - - private func configure() { - Task { try await tunnel.configure() } - } -} diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index 6127e1a..8717a30 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -9,24 +9,32 @@ /* Begin PBXBuildFile section */ 0B28F1562ABF463A000D44B0 /* DataTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B28F1552ABF463A000D44B0 /* DataTypes.swift */; }; 0B46E8E02AC918CA00BA2A3C /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B46E8DF2AC918CA00BA2A3C /* Client.swift */; }; - 43AA26D82A10004900F14CE6 /* MenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AA26D72A10004900F14CE6 /* MenuView.swift */; }; + 43AA26D82A10004900F14CE6 /* MenuItemToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AA26D72A10004900F14CE6 /* MenuItemToggleView.swift */; }; D00117312B2FFFC900D87C25 /* NWConnection+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00117302B2FFFC900D87C25 /* NWConnection+Async.swift */; }; D00117332B3001A400D87C25 /* NewlineProtocolFramer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00117322B3001A400D87C25 /* NewlineProtocolFramer.swift */; }; D001173B2B30341C00D87C25 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D001173A2B30341C00D87C25 /* Logging.swift */; }; D00117442B30372900D87C25 /* libBurrowShared.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D00117382B30341C00D87C25 /* libBurrowShared.a */; }; D00117452B30372C00D87C25 /* libBurrowShared.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D00117382B30341C00D87C25 /* libBurrowShared.a */; }; D00AA8972A4669BC005C8102 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00AA8962A4669BC005C8102 /* AppDelegate.swift */; }; + D01A79312B81630D0024EC91 /* NetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01A79302B81630D0024EC91 /* NetworkView.swift */; }; D020F65829E4A697002790F6 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F65729E4A697002790F6 /* PacketTunnelProvider.swift */; }; D020F65D29E4A697002790F6 /* BurrowNetworkExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + D032E6522B8A79C20006B8AD /* HackClub.swift in Sources */ = {isa = PBXBuildFile; fileRef = D032E6512B8A79C20006B8AD /* HackClub.swift */; }; + D032E6542B8A79DA0006B8AD /* WireGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D032E6532B8A79DA0006B8AD /* WireGuard.swift */; }; D05B9F7629E39EEC008CB1F9 /* BurrowApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05B9F7529E39EEC008CB1F9 /* BurrowApp.swift */; }; - D05B9F7829E39EEC008CB1F9 /* TunnelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05B9F7729E39EEC008CB1F9 /* TunnelView.swift */; }; + D05B9F7829E39EEC008CB1F9 /* BurrowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05B9F7729E39EEC008CB1F9 /* BurrowView.swift */; }; D05B9F7A29E39EED008CB1F9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D05B9F7929E39EED008CB1F9 /* Assets.xcassets */; }; + D05EF8C82B81818D0017AB4F /* FloatingButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05EF8C72B81818D0017AB4F /* FloatingButtonStyle.swift */; }; D08252762B5C9FC4005DA378 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08252752B5C9FC4005DA378 /* Constants.swift */; }; + D09150422B9D2AF700BE3CB0 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = D09150412B9D2AF700BE3CB0 /* MainMenu.xib */; }; D0BCC5FD2A086D4700AD070D /* NetworkExtension+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BCC5FC2A086D4700AD070D /* NetworkExtension+Async.swift */; }; - D0BCC5FF2A086E1C00AD070D /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BCC5FE2A086E1C00AD070D /* Status.swift */; }; D0BCC6082A0981FE00AD070D /* Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B98FC629FDC5B5004E7149 /* Tunnel.swift */; }; D0BCC6092A09A03E00AD070D /* libburrow.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D0BCC6032A09535900AD070D /* libburrow.a */; }; D0BCC60A2A09A0B800AD070D /* build-rust.sh in Resources */ = {isa = PBXBuildFile; fileRef = D0B98FBF29FD8072004E7149 /* build-rust.sh */; }; + D0FAB5922B818A5900F6A84B /* NetworkExtensionTunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FAB5912B818A5900F6A84B /* NetworkExtensionTunnel.swift */; }; + D0FAB5962B818B2900F6A84B /* TunnelButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FAB5952B818B2900F6A84B /* TunnelButton.swift */; }; + D0FAB5982B818B8200F6A84B /* TunnelStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FAB5972B818B8200F6A84B /* TunnelStatusView.swift */; }; + D0FAB59A2B818B9600F6A84B /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FAB5992B818B9600F6A84B /* Network.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -70,7 +78,7 @@ /* Begin PBXFileReference section */ 0B28F1552ABF463A000D44B0 /* DataTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataTypes.swift; sourceTree = ""; }; 0B46E8DF2AC918CA00BA2A3C /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = ""; }; - 43AA26D72A10004900F14CE6 /* MenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuView.swift; sourceTree = ""; }; + 43AA26D72A10004900F14CE6 /* MenuItemToggleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemToggleView.swift; sourceTree = ""; }; D00117302B2FFFC900D87C25 /* NWConnection+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NWConnection+Async.swift"; sourceTree = ""; }; D00117322B3001A400D87C25 /* NewlineProtocolFramer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewlineProtocolFramer.swift; sourceTree = ""; }; D00117382B30341C00D87C25 /* libBurrowShared.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libBurrowShared.a; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -78,6 +86,7 @@ D00117412B30347800D87C25 /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = ""; }; D00117422B30348D00D87C25 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = ""; }; D00AA8962A4669BC005C8102 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + D01A79302B81630D0024EC91 /* NetworkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkView.swift; sourceTree = ""; }; D020F63D29E4A1FF002790F6 /* Identity.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Identity.xcconfig; sourceTree = ""; }; D020F64029E4A1FF002790F6 /* Compiler.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Compiler.xcconfig; sourceTree = ""; }; D020F64229E4A1FF002790F6 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -92,19 +101,26 @@ D020F66729E4A95D002790F6 /* NetworkExtension-iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "NetworkExtension-iOS.entitlements"; sourceTree = ""; }; D020F66829E4AA74002790F6 /* App-iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "App-iOS.entitlements"; sourceTree = ""; }; D020F66929E4AA74002790F6 /* App-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "App-macOS.entitlements"; sourceTree = ""; }; + D032E6512B8A79C20006B8AD /* HackClub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HackClub.swift; sourceTree = ""; }; + D032E6532B8A79DA0006B8AD /* WireGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireGuard.swift; sourceTree = ""; }; D05B9F7229E39EEC008CB1F9 /* Burrow.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Burrow.app; sourceTree = BUILT_PRODUCTS_DIR; }; D05B9F7529E39EEC008CB1F9 /* BurrowApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurrowApp.swift; sourceTree = ""; }; - D05B9F7729E39EEC008CB1F9 /* TunnelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelView.swift; sourceTree = ""; }; + D05B9F7729E39EEC008CB1F9 /* BurrowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurrowView.swift; sourceTree = ""; }; D05B9F7929E39EED008CB1F9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + D05EF8C72B81818D0017AB4F /* FloatingButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FloatingButtonStyle.swift; sourceTree = ""; }; D08252742B5C9DEB005DA378 /* Constants.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Constants.h; sourceTree = ""; }; D08252752B5C9FC4005DA378 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + D09150412B9D2AF700BE3CB0 /* MainMenu.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; }; D0B98FBF29FD8072004E7149 /* build-rust.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-rust.sh"; sourceTree = ""; }; D0B98FC629FDC5B5004E7149 /* Tunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tunnel.swift; sourceTree = ""; }; D0B98FD829FDDB6F004E7149 /* libburrow.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = libburrow.h; sourceTree = ""; }; D0B98FDC29FDDDCF004E7149 /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = ""; }; D0BCC5FC2A086D4700AD070D /* NetworkExtension+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetworkExtension+Async.swift"; sourceTree = ""; }; - D0BCC5FE2A086E1C00AD070D /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; D0BCC6032A09535900AD070D /* libburrow.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libburrow.a; sourceTree = BUILT_PRODUCTS_DIR; }; + D0FAB5912B818A5900F6A84B /* NetworkExtensionTunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkExtensionTunnel.swift; sourceTree = ""; }; + D0FAB5952B818B2900F6A84B /* TunnelButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelButton.swift; sourceTree = ""; }; + D0FAB5972B818B8200F6A84B /* TunnelStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelStatusView.swift; sourceTree = ""; }; + D0FAB5992B818B9600F6A84B /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -135,14 +151,6 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 43AA26D62A0FFFD000F14CE6 /* Menu */ = { - isa = PBXGroup; - children = ( - 43AA26D72A10004900F14CE6 /* MenuView.swift */, - ); - path = Menu; - sourceTree = ""; - }; D00117392B30341C00D87C25 /* Shared */ = { isa = PBXGroup; children = ( @@ -199,6 +207,16 @@ path = NetworkExtension; sourceTree = ""; }; + D032E64D2B8A69C90006B8AD /* Networks */ = { + isa = PBXGroup; + children = ( + D0FAB5992B818B9600F6A84B /* Network.swift */, + D032E6512B8A79C20006B8AD /* HackClub.swift */, + D032E6532B8A79DA0006B8AD /* WireGuard.swift */, + ); + path = Networks; + sourceTree = ""; + }; D05B9F6929E39EEC008CB1F9 = { isa = PBXGroup; children = ( @@ -224,14 +242,20 @@ D05B9F7429E39EEC008CB1F9 /* App */ = { isa = PBXGroup; children = ( - 43AA26D62A0FFFD000F14CE6 /* Menu */, D05B9F7529E39EEC008CB1F9 /* BurrowApp.swift */, D00AA8962A4669BC005C8102 /* AppDelegate.swift */, - D05B9F7729E39EEC008CB1F9 /* TunnelView.swift */, + 43AA26D72A10004900F14CE6 /* MenuItemToggleView.swift */, + D05B9F7729E39EEC008CB1F9 /* BurrowView.swift */, + D01A79302B81630D0024EC91 /* NetworkView.swift */, + D032E64D2B8A69C90006B8AD /* Networks */, + D0FAB5972B818B8200F6A84B /* TunnelStatusView.swift */, + D0FAB5952B818B2900F6A84B /* TunnelButton.swift */, D0B98FC629FDC5B5004E7149 /* Tunnel.swift */, - D0BCC5FE2A086E1C00AD070D /* Status.swift */, + D0FAB5912B818A5900F6A84B /* NetworkExtensionTunnel.swift */, D0BCC5FC2A086D4700AD070D /* NetworkExtension+Async.swift */, + D05EF8C72B81818D0017AB4F /* FloatingButtonStyle.swift */, D05B9F7929E39EED008CB1F9 /* Assets.xcassets */, + D09150412B9D2AF700BE3CB0 /* MainMenu.xib */, D020F66829E4AA74002790F6 /* App-iOS.entitlements */, D020F66929E4AA74002790F6 /* App-macOS.entitlements */, D020F64929E4A34B002790F6 /* App.xcconfig */, @@ -369,6 +393,7 @@ buildActionMask = 2147483647; files = ( D05B9F7A29E39EED008CB1F9 /* Assets.xcassets in Resources */, + D09150422B9D2AF700BE3CB0 /* MainMenu.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -423,12 +448,19 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D0FAB59A2B818B9600F6A84B /* Network.swift in Sources */, D0BCC6082A0981FE00AD070D /* Tunnel.swift in Sources */, - 43AA26D82A10004900F14CE6 /* MenuView.swift in Sources */, - D05B9F7829E39EEC008CB1F9 /* TunnelView.swift in Sources */, - D0BCC5FF2A086E1C00AD070D /* Status.swift in Sources */, + D0FAB5982B818B8200F6A84B /* TunnelStatusView.swift in Sources */, + 43AA26D82A10004900F14CE6 /* MenuItemToggleView.swift in Sources */, + D05B9F7829E39EEC008CB1F9 /* BurrowView.swift in Sources */, + D0FAB5922B818A5900F6A84B /* NetworkExtensionTunnel.swift in Sources */, + D0FAB5962B818B2900F6A84B /* TunnelButton.swift in Sources */, D00AA8972A4669BC005C8102 /* AppDelegate.swift in Sources */, + D05EF8C82B81818D0017AB4F /* FloatingButtonStyle.swift in Sources */, + D032E6522B8A79C20006B8AD /* HackClub.swift in Sources */, D05B9F7629E39EEC008CB1F9 /* BurrowApp.swift in Sources */, + D01A79312B81630D0024EC91 /* NetworkView.swift in Sources */, + D032E6542B8A79DA0006B8AD /* WireGuard.swift in Sources */, D0BCC5FD2A086D4700AD070D /* NetworkExtension+Async.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -568,8 +600,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/realm/SwiftLint.git"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.54.0; + branch = main; + kind = branch; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Apple/Burrow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Apple/Burrow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7522840..9378372 100644 --- a/Apple/Burrow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Apple/Burrow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { - "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a", - "version" : "1.2.2" + "revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531", + "version" : "1.2.3" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax.git", "state" : { - "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", - "version" : "509.0.2" + "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", + "version" : "509.1.1" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/realm/SwiftLint.git", "state" : { - "revision" : "f17a4f9dfb6a6afb0408426354e4180daaf49cee", - "version" : "0.54.0" + "branch" : "main", + "revision" : "7595ad3fafc1a31086dc40ba01fd898bf6b42d5f" } }, { @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/drmohundro/SWXMLHash.git", "state" : { - "revision" : "4d0f62f561458cbe1f732171e625f03195151b60", - "version" : "7.0.1" + "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f", + "version" : "7.0.2" } }, { diff --git a/Apple/NetworkExtension/PacketTunnelProvider.swift b/Apple/NetworkExtension/PacketTunnelProvider.swift index 7073401..a07daa3 100644 --- a/Apple/NetworkExtension/PacketTunnelProvider.swift +++ b/Apple/NetworkExtension/PacketTunnelProvider.swift @@ -6,10 +6,16 @@ import os class PacketTunnelProvider: NEPacketTunnelProvider { private let logger = Logger.logger(for: PacketTunnelProvider.self) - override func startTunnel(options: [String: NSObject]? = nil) async throws { + override init() { do { libburrow.spawnInProcess(socketPath: try Constants.socketURL.path) + } catch { + logger.error("Failed to spawn: \(error)") + } + } + override func startTunnel(options: [String: NSObject]? = nil) async throws { + do { let client = try Client() let command = BurrowRequest(id: 0, command: "ServerConfig") diff --git a/Apple/Shared/Constants.swift b/Apple/Shared/Constants.swift index cb56cb3..634c500 100644 --- a/Apple/Shared/Constants.swift +++ b/Apple/Shared/Constants.swift @@ -7,6 +7,7 @@ public enum Constants { public static let bundleIdentifier = AppBundleIdentifier public static let appGroupIdentifier = AppGroupIdentifier + public static let networkExtensionBundleIdentifier = NetworkExtensionBundleIdentifier public static var groupContainerURL: URL { get throws { try _groupContainerURL.get() } diff --git a/Apple/Shared/Constants/Constants.h b/Apple/Shared/Constants/Constants.h index 09806c5..5278b61 100644 --- a/Apple/Shared/Constants/Constants.h +++ b/Apple/Shared/Constants/Constants.h @@ -7,5 +7,6 @@ NS_ASSUME_NONNULL_BEGIN static NSString * const AppBundleIdentifier = MACRO_STRING(APP_BUNDLE_IDENTIFIER); static NSString * const AppGroupIdentifier = MACRO_STRING(APP_GROUP_IDENTIFIER); +static NSString * const NetworkExtensionBundleIdentifier = MACRO_STRING(NETWORK_EXTENSION_BUNDLE_IDENTIFIER); NS_ASSUME_NONNULL_END diff --git a/Apple/Shared/Shared.xcconfig b/Apple/Shared/Shared.xcconfig index 50718bd..f344e8b 100644 --- a/Apple/Shared/Shared.xcconfig +++ b/Apple/Shared/Shared.xcconfig @@ -2,4 +2,4 @@ PRODUCT_NAME = BurrowShared MERGEABLE_LIBRARY = YES SWIFT_INCLUDE_PATHS = $(PROJECT_DIR)/Shared/Constants -GCC_PREPROCESSOR_DEFINITIONS = APP_BUNDLE_IDENTIFIER=$(APP_BUNDLE_IDENTIFIER) APP_GROUP_IDENTIFIER=$(APP_GROUP_IDENTIFIER) +GCC_PREPROCESSOR_DEFINITIONS = APP_BUNDLE_IDENTIFIER=$(APP_BUNDLE_IDENTIFIER) APP_GROUP_IDENTIFIER=$(APP_GROUP_IDENTIFIER) NETWORK_EXTENSION_BUNDLE_IDENTIFIER=$(NETWORK_EXTENSION_BUNDLE_IDENTIFIER) From 4334f8c9c9e31f92ddfe94f5aaddc6e91432d16d Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 16 Mar 2024 10:34:59 -0700 Subject: [PATCH 008/102] Configure CARGO_TARGET_DIR to be inside of DerivedData --- .github/actions/build-for-testing/action.yml | 10 ++++++---- .github/actions/export/action.yml | 3 +++ Apple/NetworkExtension/libburrow/build-rust.sh | 8 ++++---- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/actions/build-for-testing/action.yml b/.github/actions/build-for-testing/action.yml index ce91b43..2c66963 100644 --- a/.github/actions/build-for-testing/action.yml +++ b/.github/actions/build-for-testing/action.yml @@ -24,6 +24,7 @@ runs: path: | Apple/PackageCache Apple/SourcePackages + Apple/DerivedData key: ${{ runner.os }}-${{ inputs.scheme }}-${{ hashFiles('**/Package.resolved') }} restore-keys: | ${{ runner.os }}-${{ inputs.scheme }}- @@ -33,17 +34,18 @@ runs: run: | echo "${{ inputs.app-store-key }}" > AuthKey_${{ inputs.app-store-key-id }}.p8 - xcodebuild clean build-for-testing \ + xcodebuild build-for-testing \ -allowProvisioningUpdates \ -allowProvisioningDeviceRegistration \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + -onlyUsePackageVersionsFromResolvedFile \ -authenticationKeyID ${{ inputs.app-store-key-id }} \ -authenticationKeyIssuerID ${{ inputs.app-store-key-issuer-id }} \ -authenticationKeyPath "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" \ - -onlyUsePackageVersionsFromResolvedFile \ -clonedSourcePackagesDirPath SourcePackages \ -packageCachePath $PWD/PackageCache \ - -skipPackagePluginValidation \ - -skipMacroValidation \ + -derivedDataPath $PWD/DerivedData \ -scheme '${{ inputs.scheme }}' \ -destination '${{ inputs.destination }}' \ -resultBundlePath BuildResults.xcresult diff --git a/.github/actions/export/action.yml b/.github/actions/export/action.yml index bf007a7..635732c 100644 --- a/.github/actions/export/action.yml +++ b/.github/actions/export/action.yml @@ -37,6 +37,9 @@ runs: -exportArchive \ -allowProvisioningUpdates \ -allowProvisioningDeviceRegistration \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + -onlyUsePackageVersionsFromResolvedFile \ -authenticationKeyID ${{ inputs.app-store-key-id }} \ -authenticationKeyIssuerID ${{ inputs.app-store-key-issuer-id }} \ -authenticationKeyPath "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" \ diff --git a/Apple/NetworkExtension/libburrow/build-rust.sh b/Apple/NetworkExtension/libburrow/build-rust.sh index 1ac73fb..fffa0d0 100755 --- a/Apple/NetworkExtension/libburrow/build-rust.sh +++ b/Apple/NetworkExtension/libburrow/build-rust.sh @@ -56,10 +56,10 @@ CARGO_ARGS+=("--lib") # Pass the configuration (Debug or Release) through to cargo if [[ $SWIFT_ACTIVE_COMPILATION_CONDITIONS == *DEBUG* ]]; then - CARGO_DIR="debug" + CARGO_TARGET_SUBDIR="debug" else CARGO_ARGS+=("--release") - CARGO_DIR="release" + CARGO_TARGET_SUBDIR="release" fi if [[ -x "$(command -v rustup)" ]]; then @@ -70,11 +70,11 @@ fi # Run cargo without the various environment variables set by Xcode. # Those variables can confuse cargo and the build scripts it runs. -env -i PATH="$CARGO_PATH" cargo build "${CARGO_ARGS[@]}" +env -i PATH="$CARGO_PATH" CARGO_TARGET_DIR="${CONFIGURATION_TEMP_DIR}/target" cargo build "${CARGO_ARGS[@]}" mkdir -p "${BUILT_PRODUCTS_DIR}" # Use `lipo` to merge the architectures together into BUILT_PRODUCTS_DIR /usr/bin/xcrun --sdk $PLATFORM_NAME lipo \ - -create $(printf "${PROJECT_DIR}/../target/%q/${CARGO_DIR}/libburrow.a " "${RUST_TARGETS[@]}") \ + -create $(printf "${CONFIGURATION_TEMP_DIR}/target/%q/${CARGO_TARGET_SUBDIR}/libburrow.a " "${RUST_TARGETS[@]}") \ -output "${BUILT_PRODUCTS_DIR}/libburrow.a" From cb1bc1c8aa01e25ed0c7ddc65812d6670288a16b Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 16 Mar 2024 10:40:09 -0700 Subject: [PATCH 009/102] Update release pipelines to upload release artifacts --- .github/actions/archive/action.yml | 7 +- .github/actions/build-for-testing/action.yml | 2 +- .github/actions/export/action.yml | 5 +- .github/actions/notarize/action.yml | 58 ++++ .../actions/test-without-building/action.yml | 7 +- .github/workflows/build-appimage.yml | 7 +- .github/workflows/build-apple.yml | 9 +- .github/workflows/build-docker.yml | 1 + .github/workflows/build-rpm.yml | 8 +- .github/workflows/build-rust.yml | 2 +- .github/workflows/lint-git.yml | 21 +- .github/workflows/lint-swift.yml | 7 +- .github/workflows/release-appimage.yml | 29 ++ .github/workflows/release-apple.yml | 102 +++++-- .github/workflows/release-if-needed.yaml | 21 ++ .github/workflows/release-now.yml | 17 ++ .../AppIcon.appiconset/100.png | Bin 0 -> 4300 bytes .../AppIcon.appiconset/1024.png | Bin 0 -> 97634 bytes .../AppIcon.appiconset/114.png | Bin 0 -> 4903 bytes .../AppIcon.appiconset/120.png | Bin 0 -> 5372 bytes .../AppIcon.appiconset/128.png | Bin 0 -> 5713 bytes .../AppIcon.appiconset/144.png | Bin 0 -> 6559 bytes .../AppIcon.appiconset/152.png | Bin 0 -> 7005 bytes .../Assets.xcassets/AppIcon.appiconset/16.png | Bin 0 -> 684 bytes .../AppIcon.appiconset/167.png | Bin 0 -> 7880 bytes .../AppIcon.appiconset/172.png | Bin 0 -> 7994 bytes .../AppIcon.appiconset/180.png | Bin 0 -> 8614 bytes .../AppIcon.appiconset/196.png | Bin 0 -> 9835 bytes .../Assets.xcassets/AppIcon.appiconset/20.png | Bin 0 -> 927 bytes .../AppIcon.appiconset/216.png | Bin 0 -> 10925 bytes .../AppIcon.appiconset/256.png | Bin 0 -> 12031 bytes .../Assets.xcassets/AppIcon.appiconset/29.png | Bin 0 -> 1308 bytes .../Assets.xcassets/AppIcon.appiconset/32.png | Bin 0 -> 1460 bytes .../Assets.xcassets/AppIcon.appiconset/40.png | Bin 0 -> 1732 bytes .../Assets.xcassets/AppIcon.appiconset/48.png | Bin 0 -> 2060 bytes .../Assets.xcassets/AppIcon.appiconset/50.png | Bin 0 -> 2130 bytes .../AppIcon.appiconset/512.png | Bin 0 -> 30526 bytes .../Assets.xcassets/AppIcon.appiconset/55.png | Bin 0 -> 2298 bytes .../Assets.xcassets/AppIcon.appiconset/57.png | Bin 0 -> 2470 bytes .../Assets.xcassets/AppIcon.appiconset/58.png | Bin 0 -> 2497 bytes .../Assets.xcassets/AppIcon.appiconset/60.png | Bin 0 -> 2536 bytes .../Assets.xcassets/AppIcon.appiconset/64.png | Bin 0 -> 2614 bytes .../Assets.xcassets/AppIcon.appiconset/72.png | Bin 0 -> 2949 bytes .../Assets.xcassets/AppIcon.appiconset/76.png | Bin 0 -> 3340 bytes .../Assets.xcassets/AppIcon.appiconset/80.png | Bin 0 -> 3427 bytes .../Assets.xcassets/AppIcon.appiconset/87.png | Bin 0 -> 3704 bytes .../Assets.xcassets/AppIcon.appiconset/88.png | Bin 0 -> 3738 bytes .../AppIcon.appiconset/Contents.json | 285 +++++++++++++++++- Apple/Burrow.xcodeproj/project.pbxproj | 26 +- Apple/Configuration/Compiler.xcconfig | 2 +- Apple/Configuration/Version.xcconfig | 0 Tools/version.sh | 51 ++++ 52 files changed, 593 insertions(+), 74 deletions(-) create mode 100644 .github/actions/notarize/action.yml create mode 100644 .github/workflows/release-appimage.yml create mode 100644 .github/workflows/release-if-needed.yaml create mode 100644 .github/workflows/release-now.yml create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/100.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/1024.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/114.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/120.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/128.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/144.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/152.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/16.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/167.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/172.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/180.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/196.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/20.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/216.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/256.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/29.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/32.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/40.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/48.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/50.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/512.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/55.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/57.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/58.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/60.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/64.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/72.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/76.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/80.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/87.png create mode 100644 Apple/App/Assets.xcassets/AppIcon.appiconset/88.png create mode 100644 Apple/Configuration/Version.xcconfig create mode 100755 Tools/version.sh diff --git a/.github/actions/archive/action.yml b/.github/actions/archive/action.yml index c34bd3c..37282e1 100644 --- a/.github/actions/archive/action.yml +++ b/.github/actions/archive/action.yml @@ -26,9 +26,12 @@ runs: run: | echo "${{ inputs.app-store-key }}" > AuthKey_${{ inputs.app-store-key-id }}.p8 - xcodebuild archive \ + xcodebuild clean archive \ -allowProvisioningUpdates \ -allowProvisioningDeviceRegistration \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + -onlyUsePackageVersionsFromResolvedFile \ -authenticationKeyID ${{ inputs.app-store-key-id }} \ -authenticationKeyIssuerID ${{ inputs.app-store-key-issuer-id }} \ -authenticationKeyPath "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" \ @@ -38,6 +41,4 @@ runs: -archivePath '${{ inputs.archive-path }}' \ -resultBundlePath BuildResults.xcresult - ./Tools/xcresulttool-github BuildResults.xcresult - rm -rf AuthKey_${{ inputs.app-store-key-id }}.p8 diff --git a/.github/actions/build-for-testing/action.yml b/.github/actions/build-for-testing/action.yml index 2c66963..084ba81 100644 --- a/.github/actions/build-for-testing/action.yml +++ b/.github/actions/build-for-testing/action.yml @@ -18,7 +18,7 @@ inputs: runs: using: composite steps: - - name: Cache Swift Packages + - name: Xcode Cache uses: actions/cache@v3 with: path: | diff --git a/.github/actions/export/action.yml b/.github/actions/export/action.yml index 635732c..8f891be 100644 --- a/.github/actions/export/action.yml +++ b/.github/actions/export/action.yml @@ -1,4 +1,4 @@ -name: Notarize +name: Export inputs: app-store-key: description: App Store key in PEM PKCS#8 format @@ -24,8 +24,7 @@ inputs: runs: using: composite steps: - - id: notarize - shell: bash + - shell: bash working-directory: Apple run: | echo "${{ inputs.app-store-key }}" > AuthKey_${{ inputs.app-store-key-id }}.p8 diff --git a/.github/actions/notarize/action.yml b/.github/actions/notarize/action.yml new file mode 100644 index 0000000..290ed86 --- /dev/null +++ b/.github/actions/notarize/action.yml @@ -0,0 +1,58 @@ +name: Notarize +inputs: + app-store-key: + description: App Store key in PEM PKCS#8 format + required: true + app-store-key-id: + description: App Store key ID + required: true + app-store-key-issuer-id: + description: App Store key issuer ID + required: true + archive-path: + description: Xcode archive path + required: true + export-path: + description: The path to export the archive to + required: true +outputs: + notarized-app: + description: The compressed and notarized app + value: ${{ steps.notarize.outputs.notarized-app }} +runs: + using: composite + steps: + - id: notarize + shell: bash + working-directory: Apple + run: | + echo "${{ inputs.app-store-key }}" > AuthKey_${{ inputs.app-store-key-id }}.p8 + + echo '{"destination":"upload","method":"developer-id"}' \ + | plutil -convert xml1 -o ExportOptions.plist - + + xcodebuild \ + -exportArchive \ + -allowProvisioningUpdates \ + -allowProvisioningDeviceRegistration \ + -authenticationKeyID ${{ inputs.app-store-key-id }} \ + -authenticationKeyIssuerID ${{ inputs.app-store-key-issuer-id }} \ + -authenticationKeyPath "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" \ + -archivePath '${{ inputs.archive-path }}' \ + -exportOptionsPlist ExportOptions.plist + + until xcodebuild \ + -exportNotarizedApp \ + -allowProvisioningUpdates \ + -allowProvisioningDeviceRegistration \ + -authenticationKeyID ${{ inputs.app-store-key-id }} \ + -authenticationKeyIssuerID ${{ inputs.app-store-key-issuer-id }} \ + -authenticationKeyPath "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" \ + -archivePath '${{ inputs.archive-path }}' \ + -exportPath ${{ inputs.export-path }} + do + echo "Failed to export app, trying again in 10s..." + sleep 10 + done + + rm -rf AuthKey_${{ inputs.app-store-key-id }}.p8 ExportOptions.plist diff --git a/.github/actions/test-without-building/action.yml b/.github/actions/test-without-building/action.yml index 5903d07..a097d4a 100644 --- a/.github/actions/test-without-building/action.yml +++ b/.github/actions/test-without-building/action.yml @@ -18,9 +18,6 @@ inputs: runs: using: composite steps: - - shell: bash - id: vars - run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT - shell: bash working-directory: Apple run: | @@ -28,10 +25,10 @@ runs: -scheme '${{ inputs.scheme }}' \ -destination '${{ inputs.destination }}' \ ${{ inputs.test-plan && '-testPlan ' }}${{ inputs.test-plan }} \ - -resultBundlePath "${{ inputs.artifact-prefix }}-${{ steps.vars.outputs.sha_short }}.xcresult" + -resultBundlePath "${{ inputs.artifact-prefix }}.xcresult" - uses: kishikawakatsumi/xcresulttool@v1 if: always() with: - path: Apple/${{ inputs.artifact-prefix }}-${{ steps.vars.outputs.sha_short }}.xcresult + path: Apple/${{ inputs.artifact-prefix }}.xcresult title: ${{ inputs.check-name }} show-passed-tests: false diff --git a/.github/workflows/build-appimage.yml b/.github/workflows/build-appimage.yml index ef5c525..bb510fb 100644 --- a/.github/workflows/build-appimage.yml +++ b/.github/workflows/build-appimage.yml @@ -1,8 +1,11 @@ name: Build AppImage on: push: - branches: [main] + branches: + - main pull_request: + branches: + - "*" jobs: appimage: name: Build AppImage @@ -17,7 +20,7 @@ jobs: docker cp temp:/app/burrow-gtk/build-appimage/Burrow-x86_64.AppImage . docker rm temp - uses: actions/upload-artifact@v4 + name: Upload to GitHub with: name: AppImage path: Burrow-x86_64.AppImage - diff --git a/.github/workflows/build-apple.yml b/.github/workflows/build-apple.yml index da0f56a..00b6bec 100644 --- a/.github/workflows/build-apple.yml +++ b/.github/workflows/build-apple.yml @@ -1,4 +1,4 @@ -name: Apple Build +name: Build Apple Apps on: push: branches: @@ -12,7 +12,7 @@ concurrency: jobs: build: name: Build App (${{ matrix.platform }}) - runs-on: macos-13 + runs-on: macos-14 strategy: fail-fast: false matrix: @@ -53,7 +53,6 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable with: - toolchain: stable targets: ${{ join(matrix.rust-targets, ', ') }} - name: Build id: build @@ -64,7 +63,7 @@ jobs: app-store-key: ${{ secrets.APPSTORE_KEY }} app-store-key-id: ${{ secrets.APPSTORE_KEY_ID }} app-store-key-issuer-id: ${{ secrets.APPSTORE_KEY_ISSUER_ID }} - - name: Xcode Unit Test + - name: Run Unit Tests if: ${{ matrix.xcode-unit-test != '' }} continue-on-error: true uses: ./.github/actions/test-without-building @@ -74,7 +73,7 @@ jobs: test-plan: ${{ matrix.xcode-unit-test }} artifact-prefix: unit-tests-${{ matrix.sdk-name }} check-name: Xcode Unit Tests (${{ matrix.platform }}) - - name: Xcode UI Test + - name: Run UI Tests if: ${{ matrix.xcode-ui-test != '' }} continue-on-error: true uses: ./.github/actions/test-without-building diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 1ce7a9a..307a93c 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -33,6 +33,7 @@ jobs: images: ghcr.io/${{ github.repository }} tags: | type=sha + type=match,pattern=builds/(.*),group=1 type=raw,value=latest,enable={{is_default_branch}} - name: Build and Push uses: docker/build-push-action@v4 diff --git a/.github/workflows/build-rpm.yml b/.github/workflows/build-rpm.yml index fd5837c..e0ce8df 100644 --- a/.github/workflows/build-rpm.yml +++ b/.github/workflows/build-rpm.yml @@ -1,10 +1,5 @@ +on: workflow_dispatch name: Build RPM -on: - push: - branches: [ "main" ] - pull_request: - branches: - - "*" jobs: build: name: Build RPM @@ -20,4 +15,3 @@ jobs: strip -s target/release/burrow - name: Build RPM run: cargo generate-rpm -p burrow - diff --git a/.github/workflows/build-rust.yml b/.github/workflows/build-rust.yml index 4c3782a..3255fc7 100644 --- a/.github/workflows/build-rust.yml +++ b/.github/workflows/build-rust.yml @@ -1,4 +1,4 @@ -name: Rust Build +name: Build Rust Crate on: push: branches: diff --git a/.github/workflows/lint-git.yml b/.github/workflows/lint-git.yml index aefe199..2f7c72e 100644 --- a/.github/workflows/lint-git.yml +++ b/.github/workflows/lint-git.yml @@ -8,13 +8,14 @@ jobs: name: Git Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - - name: Install Gitlint - shell: bash - run: python -m pip install gitlint - - name: Run Gitlint - shell: bash - run: gitlint --commits "${{ github.event.pull_request.base.sha }}..HEAD" + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + - name: Install + shell: bash + run: python -m pip install gitlint + - name: Lint + shell: bash + run: gitlint --commits "${{ github.event.pull_request.base.sha }}..HEAD" diff --git a/.github/workflows/lint-swift.yml b/.github/workflows/lint-swift.yml index 7e62afd..a2cc96a 100644 --- a/.github/workflows/lint-swift.yml +++ b/.github/workflows/lint-swift.yml @@ -1,8 +1,5 @@ name: Swift Lint on: - push: - branches: - - main pull_request: branches: - "*" @@ -14,8 +11,6 @@ jobs: image: ghcr.io/realm/swiftlint:latest steps: - name: Checkout - uses: actions/checkout@v3 - with: - ssh-key: ${{ secrets.DEPLOY_KEY }} + uses: actions/checkout@v4 - name: Lint run: swiftlint lint --reporter github-actions-logging diff --git a/.github/workflows/release-appimage.yml b/.github/workflows/release-appimage.yml new file mode 100644 index 0000000..e566186 --- /dev/null +++ b/.github/workflows/release-appimage.yml @@ -0,0 +1,29 @@ +name: Release (AppImage) +on: + release: + types: + - created +jobs: + appimage: + name: Build AppImage + runs-on: ubuntu-latest + container: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Build + run: | + docker build -t appimage-builder . -f burrow-gtk/build-aux/Dockerfile + docker create --name temp appimage-builder + docker cp temp:/app/burrow-gtk/build-appimage/Burrow-x86_64.AppImage . + docker rm temp + - name: Upload to GitHub + uses: SierraSoftworks/gh-releases@v1.0.7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + release_tag: ${{ github.ref_name }} + overwrite: 'true' + files: | + Burrow-x86_64.AppImage diff --git a/.github/workflows/release-apple.yml b/.github/workflows/release-apple.yml index 3ea185d..786fb54 100644 --- a/.github/workflows/release-apple.yml +++ b/.github/workflows/release-apple.yml @@ -1,65 +1,115 @@ -name: Build Apple Release +name: Release (Apple) on: release: types: - created jobs: build: - name: Build ${{ matrix.configuration['platform'] }} Release - runs-on: macos-13 + name: Build ${{ matrix.platform }} Release + runs-on: macos-14 + permissions: + contents: write strategy: fail-fast: false matrix: - configuration: - - scheme: App (iOS) - destination: generic/platform=iOS + include: + - destination: generic/platform=iOS platform: iOS - method: ad-hoc - artifact-file: Apple/Release/Burrow.ipa - - scheme: App (macOS) - destination: generic/platform=macOS + rust-targets: + - aarch64-apple-ios + - destination: generic/platform=macOS platform: macOS - method: mac-application - artifact-file: Burrow.app.txz + rust-targets: + - x86_64-apple-darwin + - aarch64-apple-darwin env: DEVELOPER_DIR: /Applications/Xcode_15.2.app/Contents/Developer steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: - ssh-key: ${{ secrets.DEPLOY_KEY }} - submodules: recursive + fetch-depth: 0 - name: Import Certificate uses: ./.github/actions/import-cert with: certificate: ${{ secrets.DEVELOPER_CERT }} password: ${{ secrets.DEVELOPER_CERT_PASSWORD }} + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ join(matrix.rust-targets, ', ') }} + - name: Configure Version + shell: bash + run: Tools/version.sh - name: Archive uses: ./.github/actions/archive with: - scheme: ${{ matrix.configuration['scheme'] }} - destination: ${{ matrix.configuration['destination'] }} + scheme: App + destination: ${{ matrix.destination }} app-store-key: ${{ secrets.APPSTORE_KEY }} app-store-key-id: ${{ secrets.APPSTORE_KEY_ID }} app-store-key-issuer-id: ${{ secrets.APPSTORE_KEY_ISSUER_ID }} archive-path: Burrow.xcarchive - - name: Export Locally + - name: Notarize (macOS) + if: ${{ matrix.platform == 'macOS' }} + uses: ./.github/actions/notarize + with: + app-store-key: ${{ secrets.APPSTORE_KEY }} + app-store-key-id: ${{ secrets.APPSTORE_KEY_ID }} + app-store-key-issuer-id: ${{ secrets.APPSTORE_KEY_ISSUER_ID }} + archive-path: Burrow.xcarchive + - name: Export IPA (iOS) + if: ${{ matrix.platform == 'iOS' }} uses: ./.github/actions/export with: - method: ${{ matrix.configuration['method'] }} + method: ad-hoc destination: export app-store-key: ${{ secrets.APPSTORE_KEY }} app-store-key-id: ${{ secrets.APPSTORE_KEY_ID }} app-store-key-issuer-id: ${{ secrets.APPSTORE_KEY_ISSUER_ID }} archive-path: Burrow.xcarchive export-path: Release - - name: Compress - if: ${{ matrix.configuration['platform'] == 'macOS' }} + - name: Compress (iOS) + if: ${{ matrix.platform == 'iOS' }} shell: bash - run: tar --options xz:compression-level=9 -C Apple/Release -cJf Burrow.app.txz ./ - - name: Attach Artifact - uses: SierraSoftworks/gh-releases@v1.0.6 + run: | + cp Apple/Release/Burrow.ipa Burrow.ipa + aa archive -a lzma -b 8m -d Apple -subdir Burrow.xcarchive -o Burrow-${{ matrix.platform }}.xcarchive.aar + rm -rf Apple/Release + - name: Compress (macOS) + if: ${{ matrix.platform == 'macOS' }} + shell: bash + run: | + aa archive -a lzma -b 8m -d Apple/Release -subdir Burrow.app -o Burrow.app.aar + aa archive -a lzma -b 8m -d Apple -subdir Burrow.xcarchive -o Burrow-${{ matrix.platform }}.xcarchive.aar + rm -rf Apple/Release + - name: Upload to GitHub (iOS) + if: ${{ matrix.platform == 'iOS' }} + uses: SierraSoftworks/gh-releases@v1.0.7 with: token: ${{ secrets.GITHUB_TOKEN }} - overwrite: 'false' - files: ${{ matrix.configuration['artifact-file'] }} + release_tag: ${{ github.ref_name }} + overwrite: 'true' + files: | + Burrow.ipa + Burrow-${{ matrix.platform }}.xcarchive.aar + - name: Upload to GitHub (macOS) + if: ${{ matrix.platform == 'macOS' }} + uses: SierraSoftworks/gh-releases@v1.0.7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + release_tag: ${{ github.ref_name }} + overwrite: 'true' + files: | + Burrow.aap.aar + Burrow-${{ matrix.platform }}.xcarchive.aar + - name: Upload to App Store Connect + uses: ./.github/actions/export + with: + method: app-store + destination: upload + app-store-key: ${{ secrets.APPSTORE_KEY }} + app-store-key-id: ${{ secrets.APPSTORE_KEY_ID }} + app-store-key-issuer-id: ${{ secrets.APPSTORE_KEY_ISSUER_ID }} + archive-path: Burrow.xcarchive + export-path: Release diff --git a/.github/workflows/release-if-needed.yaml b/.github/workflows/release-if-needed.yaml new file mode 100644 index 0000000..0d2eb97 --- /dev/null +++ b/.github/workflows/release-if-needed.yaml @@ -0,0 +1,21 @@ +name: Create Release If Needed +on: + workflow_dispatch: + schedule: + - cron: '0 10 * * *' +concurrency: + group: ${{ github.workflow }} +jobs: + create: + name: Create Release If Needed + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - shell: bash + run: | + if [[ $(Tools/version.sh status) == "dirty" ]]; then + gh workflow run release-now.yml + fi diff --git a/.github/workflows/release-now.yml b/.github/workflows/release-now.yml new file mode 100644 index 0000000..229f6c9 --- /dev/null +++ b/.github/workflows/release-now.yml @@ -0,0 +1,17 @@ +name: Create Release +on: workflow_dispatch +concurrency: + group: ${{ github.workflow }} +jobs: + create: + env: + GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }} + name: Create Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - shell: bash + run: Tools/version.sh increment diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/100.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/100.png new file mode 100644 index 0000000000000000000000000000000000000000..f86c1399c3d698390d66fbb50d647227b18e27d3 GIT binary patch literal 4300 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 literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/1024.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000000000000000000000000000000000000..872c9ceebe2427014f0b0df5ac04e039eab233e7 GIT binary patch literal 97634 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 Avj6}9 literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/114.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 0000000000000000000000000000000000000000..3bb278dcb871e79ea334cbf2fb52709ab2f59358 GIT binary patch literal 4903 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 literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/120.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 0000000000000000000000000000000000000000..185615e46f63c82ed3c573b3a40cfe4241415f96 GIT binary patch literal 5372 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 literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/152.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/152.png new file mode 100644 index 0000000000000000000000000000000000000000..c95ea8adb863f3ff13de0196b7c5eead477c92d1 GIT binary patch literal 7005 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 QC_OTGy85}Sb4q9e03{nEdjJ3c literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/167.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 0000000000000000000000000000000000000000..a3ad6a290de1c1239bf2083e1368baa8d02b3a12 GIT binary patch literal 7880 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 YUwSX8Wv;TuO3*B_r>mdKI;Vst0AhsQKmY&$ literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/172.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/172.png new file mode 100644 index 0000000000000000000000000000000000000000..9f3bdb4fad9eb45a63f51fb30f82f441cce4e406 GIT binary patch literal 7994 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 O3kFYDKbLh*2~7Zq8JSD~ literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/196.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/196.png new file mode 100644 index 0000000000000000000000000000000000000000..ea95961f24a3f43ae19a0b98994c3c3eb4e25c0f GIT binary patch literal 9835 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 literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/20.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 0000000000000000000000000000000000000000..aec8236e31d4c3ecfd37aa26067b2d233681dec8 GIT binary patch literal 927 zcmeAS@N?(olHy`uVBq!ia0y~yU=RUe4mJh`hTcwDUIqpR#^NA%Cx&(BWL`2bFu0^f zc&7RKGH5X{FmNz1wr4W1fRr#WFi0_g0P_My24=7bBLl+%Cb+D~0%imoq;S*S4}TdL zm^nRN978MwOGB)^vmFKIxddKtlGbH;ZNlZWB|yMQK!o$ibA65_TphhPZfLj!b9hP$ z=(rpdVRSs`6cVCyL}h~71UJ^B?rSGaKQ}l3(L39n73}9McYpqowr}R$$4P>}`fF-y z1A~Kuo4=aRy?pSi^n>r;%@z9@tt~7RUMyR_+*E9V-t_J}dE14(uk$uAG!_>ZZ*+u$Vu8e)Qj? zM_K)Sd_G*cB69!Uy{Ns$Vpk$wtoZo#>(s*!4fy%_Kc742=j^aGYU}U4du`<;B_9T8 z965jf{3>$^mXe%HFHOFG{aR==bIVn(+GEK_b{?$7 z-M_!}XVQgf4*Ch(Y$ImW9dvhi`S@|OP-n}l#9#-Dva+&E@80Q&a0`nwj$8Nch0U+A^LLuRYu7G@K>xHIi*FuH3cT5=eE$6T&F7y#Uc6ZO@QnWc{+sW= ze?D`jhmWsq_ik%hk!uH5&F`20;YA9CESY+MEjBxQwd}OcM&2aGi7WT+jn%yFo08ACirKVdXWu0w-34pcu6@Y0 z%eO6PrAyH-(*qaG4tP9U(l)Ju@z184Yvjy-C~<0CdKnWR|M|fK1$QOEy2{Ff7rr!j z8|3HYe0cS0)!oD2zI{8!&CUJhYn5cUkTBacl~w0m*Gb35#l4!z#lmD2d!Z!AWa{xG z!&!6YEa~gx6Ed)!yh0%2TR!tEwm&alc3!)7ZOXYzmxOF>Y$n`E{ZwJIW#2wOW@hFs zn>IP^-Mja|#kg4!e=QrBSW>O)nP&R5J%9FW$L`&;pPGGm^G4_XojX_NKIM%+VsSKV z#mc(4M++{S{Xe>TwRWz>Iu#aY#@go#>YV-unQ=WA&ux&f`u}e3zO=vW(FtqxLn;b4 PfHH@ttDnm{r-UW|ePo_4 literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/216.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/216.png new file mode 100644 index 0000000000000000000000000000000000000000..9f0e3ced8cc443ad41167f7c681b2771e2f280a5 GIT binary patch literal 10925 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 T9=^`Nz`)??>gTe~DWM4fJXd8Z literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/256.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/256.png new file mode 100644 index 0000000000000000000000000000000000000000..a82ce93b6e54b92c4a7f6b6b704e6a80cef29b29 GIT binary patch literal 12031 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 XX){xU+Ks9kpvC8&u6{1-oD!MU~ox| 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 Bnf(9& literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/40.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 0000000000000000000000000000000000000000..1f7f5e9b82b370a76fee8759b984ac541f74ca05 GIT binary patch literal 1732 zcmeAS@N?(olHy`uVBq!ia0y~yV9)?z4mJh`hMs>rav2yH7>k44ofy`glX=O&z~GV^ z;hE;^%b>-;z`()4*q+J20#d@jz#zo{0?Z2-8JNK$j0_A5nBcM+3z!jXkix2GX=@l5 z*z`PI977^F&qjEAT!|O?H*?w|wuNdb5)m1bGFxx>s0KmPNu{(<@T(sSke zs^3*Jd31iYpQcb>;4D9V+BBV)*$xg3Thq=;algz`P1q{<=lA#Zq7o7>CLJ%z)#Hr` z`6c7%!oyV55fdFf`|FE~%6(5JD!Z={R`)BI{X%h(9jDTZUXAuGJ9ez_wX(L({QmCl zjN^RqMMXxXB_$cQHx68jc6@Iu)bRi3XZP29yu5*aeteU;I5`)t4qt!i`T6-TPfyn` zd3=nQi=Y4bwl{BbUcG;Rz3jk;wiWIyXFGg-d;%gPXZD48dUDqO`4K2Q;mngKDs%1Y z<6h6Tt)6!5`0=GT6GcjvcAweAayD`1{Q1jYUtb@bk)dI2TlPkRmxm|AqqE2M(&590 zOaA;Q+!nb#Pd0XEQET15KPSsBm|Cwt*wf?Pl=an8V%xTDm#(dizIk$8?Cuu_o7tt9 z_!i`gTVGtaPVd&{bpGTc$BtdPySsdAr!|j;g`mk?+v;yU-`?Lpzd+hNZ^^P{YQAh$ z%hr8MJALEpyE{9b4Gj%@W=@;tws-H|TZcsFF+0xOvv~31l2=zW*$%#1vu@q8_xJZ_ zFPPfqsx2jXgX3Sny#0!G>-w%-J8bvq)2A1AcbBixwMtsW*<>qq{MfOK%gcOoB4T2E zB1Dvo&M$eB%T=?vIO5}`35ymjdhz!5_A>{2mfW+QYAEmf{r&yzSIQ1nzj}Xv|JwNd zbw9W4-(?&$_mb~{4_%DN%&J0d<_Ue>ClG?6ks~f_o!#AwpFK;vn-;(HQbUz-R@SQkCnu)loZH)aH{pswN zq)o$DY}`0eNl8h9C)%aFeEW}tSQpXxHx_(6Ia$5r!2!l?k^Adx@9wFbyy2zu1;(2B zcD0{+6&&77446DqOIuqqz&GX8*|Vy1PM1~w{bedDB9gJUTU`H>rEZmi+UnA)ER0$G zJW4gczGV6?*s^7c_@(RX<2h3tph?g&4hWY#R$47{ZiRo-r zT3h}7U8MhfyRCe3HW`lYrCqr-l}r`B0Y4tM_1xYf5MVCmy*4QO^fcXRQek1ipLeU@<)nBgS>=IV&+GO9sx$Zs(*Mj}Q+gYx99oBrD zU+-6;{j*T1<7bO!@6;|EwO{qBGgg0KjQ{XbOTmw&{LPJnx0|YFerySU&!LyR`onYv z?)$g4X3w0mtEC`v?)P(gBH3c82du60v?C)U_uN!dRSm4J-o4_(&XrYbPfu#pWqB{U zC)Q<2+^G#m1=T-&cc40C;f9mh`A8e3S*c=h@%Dot|*zN=R3K z|8pC$>*wZL?=+rmp0Bp2z18Bwhe{w%Lv-k$o bKl{%ZwTOAY=_1dApc=%})z4*}Q$iB}#*82B literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/48.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/48.png new file mode 100644 index 0000000000000000000000000000000000000000..4a67ebf1ff3082c1593c6123d95178cac73312a3 GIT binary patch literal 2060 zcmeAS@N?(olHy`uVBq!ia0y~yU@!n-4mJh`hH$2z?FU~ox| z@J#ddWzb?^VBlb2Y|mt10V!c%V31+}0pfiP8`}O!N#904^tPFBp6}md@emjq((sccJv)V7My`MPR zEVO_A{k{Eb^`|GDXJ!~W-?AAPJo$MCZe{ywo_@?aZdYN~3Z55npRl3Ti z>dT2=zqhS1JnMCgrdOBe}7uR|A&X!Blp$p zys|Dfx-@WI#6~B(x<4y+ZJ5bBaSP{;%GnPR?(eI;w6C_h`_++7;iv!p{$`tVS$R%c z$Db*h!E54Yo8_i_e|J|*WJ1IGlBGUOiVeo;=REH3tK~h=z{vdM&(F`#`VXvKxI^fc zqn?8L4E?>;-=_sH_lq>IU$WNFQ&pAG-tqn2-RjB5`%K$qt;?n~v-8h7S)wf7_hg}S z`;zSI>(a`vtOzXn{VjKMbd6$Gtcz7-P-$j*F@B9*kG|Hdb^*4fK$%V*sUH42QrdMB`pdT zFqP*AO4?R!xwE72@UN++YnIJOW3Bu9vg-LcSxNi4Jt6DkVhz8%yzIWA%TZrtqC<1)S)6Wc zQ}d%>XVKFxyTum^@@^eg5qjwUq3u_Htn&mGg)i6;)m4@=d*JxgEt&vtZ8QB zO*uQuw8@o)X_0%s+}U{t{{8)Z^}_e}_qQIIKV3im)s;tFjkaeurJjCrcX#=+$IU5= z?|*xFSv_)V*3>KOW~%8dFBm2+72c8<|3v-QgM-aaUtL|@#3E~1w1n|E z-!#syx($rc3on$tQJ!!{P`TaZ{*J=MCV6)v7?m%6{``4sAIs632mJ$@cyb?E&!6hL z;7)py%!9(;4;D#0{O~Sp>WN(IMLUb1zj}CYV)vcQv$wLYtXR0~#o@#IPcK}v^bX4l z$%L2(O4C~=c9%U_Bztvjw0Tkbql~}5zD|@Dt!0~Q(Qr9Ghb2KEur%1MbH~a>sto(< z|Hr+qV4G{98pgyT=Ev`6Y}dc}3}>SSj%qSOdWUVNA50LyW?qq_4bU5N-zD47bbrcZ#vV$P;*KDSG)DKrQXw5mA?8>=Phu+ zSu1W=iRT7QXO<5y@9nKVi4F@8ZNin|o;eI&#`a0D&5!R)zR4k4K)#&6TM`|%@C^+>P z=uIz3?OR>kcVda>WUrVPLH3f2HlG{q>;6P6^Pit*u5jaMl6iNmE{jWWW4oUhQ$WKH zw|jdk7l*%KWo7kZdAi88nOOZog-|QC_d-{k^@}5(}Ld zU7N9Jk{RPIj!p$}HeRU|mt7?13mOhrFp|+~Q58g~S zWGp*U^KE%pg>T<|0RvY9CI;^ZJErjqPpNdiJ#$K=@B(2Abq1AR*Vaa}UhZs&d^F8^ z&$RE%4%IWKJymmPx06t_iff42THx9#|3_n=-tA4gpD$c=)f8*goUUA!!c+gE?7-K^ zIml*oFn3txF9@oM NJzf1=);T3K0RTZ8(J24` literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/50.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/50.png new file mode 100644 index 0000000000000000000000000000000000000000..88985d899a650f753e81263822484c009fc74f37 GIT binary patch literal 2130 zcmeAS@N?(olHy`uVBq!ia0y~yU@!t<4mJh`208nVjSLJ7jKx9jP7LeL$-HD>U~ox| z@J#ddWzb?^VBlb2Y|mt10V!c%V31+}0p~9smxS;sz+S=K@udlC{pKDR*v^%vY>Xy{xkRT=@ zwu0Z^a+_|ykjl8UgmZ1|?rCL_5(}2Su+I43*Vo~<_EZ|1=HI*Xqes?y+Pc`? z%bplzu4MA!I`aMQ_4VQ6n?^ESr1v8UO$NH9K?ttkljRwvJy0NqglJZ*9x<-d*-~ z(?)~sm!gs_wH`d(ePNz$wT8L*bp~EJn;Gx!?bQ}aI?2nx7-jSR-rmK0vQ`|Ee|>#@ z`B<-XuHxeR;t_mzlli`?zc@Wze@pIdv)i+yw&i$6EMK+!yuyUPMnUaU7H-*cC3b&Z z>?PhUg(gLBR!@p@nHI#c{YJgX#uNpm*#|rIzQ4a;e)Q6Ojt{>c(?HT$$RE|=V6V33RIld%kPlM9-2b8~t(U&lO+Rjd2FIas8p z@ZH*&%)T}A@-YTQUd!TVJ)bIpP!#IA7ou9C%vrg{!HWaG`^HOI|_}W zwLZMPy?sl~&mt#=hNB-8zTMwZsGRDM$ngE$U2c^rTeGh(bL|#8xri&wd{>HLhri`{r<+1J-yx_je!Tnw`cgpDix#Yl2OfD9m=YL>& zI>BRa)mNjVR;908Zd^F@^gtu?m95#~i=Ab2qY@Zd*W^aBE^;c__EEaDCD&fz%lrHB zOiW^*E-ZBBWiX1%*|edrkC%A@-@nh{4J!=pe-{*(rW-BB!SSJfN8sA1ttyvYkF7o` zzdHAyb*Mt)+_|l(AzzNQOf8Zrm2sCXe|bsOJolE#?w$uP)N-}M))dHmmC1;FxMRES zOD!Qz0nsjL5sl-UU)-p@v&?t4%=}mup7$sE%CndmZfRVe>(R&{Fr|5tOsUN7nMqTu zZYAv1U|i8~qERAwUt-8~XX#h7c(i<{ykEoE8FVtF;;(%}!oz>zUPiG(Q7+=SMrt=% zxNq)>jFRYA*H)WwYO1z(O69jVGw;r6zIk7-qo|{RL!@8cep=O@4snJ>fvx<1H_3nK zZF}6@W58tG!1y^w{6vG!3+D9?n&&;XPPy2*KFzg8(jjM;Ln7O!>T7Evr%5lZ?nrWN zVZS6QGyUeFFJE2;XI|ddaKYaG!g5W{Sq0%XMNd2~GpEa6SkY(4zko+%e!Ox>?}XsVIJmf%i# z+bWk0d}*Q$L6$BrZ-g}b@O(VmJU`>*rKM#zlCCe(3|=PCuuvoZ>c#rwtV~MrC!U_3 zp84g)#k4e;Mc39wi+|qd&8o)KbTd@k(Q1F{X|YfI(Up~zCGYN7UcS10hH2H*76%9R zr8N#e3NA1AmuGHq7TMRI!MOI)ai&M#lg}J%W@nx+v8(*OpR23uDZdKMgzTO`PcE0^ zKa4pFtjpgmDSLZMaseYV+l7;p)u+aYG^~D+bX?t%l|!k9L&~mZM{oYsRZ}Be)9+1B z_Yn>WVq0WWlUlHKSJ6|itQ#8^YPQT4|0kgJMBc7OK_`T7;g?TOy)zH5txil_7_%+$ z>_YkJ0t{=9PG6XN@?Iu$VB0h?vlD|~p!<{)L7mGX9@*e14)k}2OaPqVs6wlX_; zdUAe!clWl)^;(YmyvC6ywA3$JZ>pC_NY$-%%sS|#y7hoP?}tOBHGf~J`OZpN?tYF(9-TNHRb1jX e%TzQt`44y1boslVv_oct>T^$5KbLh*2~7YSk=^_N literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/512.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/512.png new file mode 100644 index 0000000000000000000000000000000000000000..e5cbf6a41af399c14ec320ed7e32f82d8cb1c8c8 GIT binary patch literal 30526 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 literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/55.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/55.png new file mode 100644 index 0000000000000000000000000000000000000000..dc079ea3ce00174757dc48b083b5852252fdc5f7 GIT binary patch literal 2298 zcmeAS@N?(olHy`uVBq!ia0y~yU@!+^4mJh`h84FjxiK&>Fct^7J29*~C-ahlfx#s; z!ZXd+mqCkxfq{d8u|1Q41*C+5fkBD^1eg~vGBATh7#SEAFu`Tb7ce8(AcdZ-XTlj6 zI8r=a978f#-$tc(hZZ+`b7i-^Sz~ZE$RTt^fFjrN_3`_i z*d?VbH1rqT+gF>d7q=%P;QFf2(10>x1q>UEG+Ei-s#M1kc0Jx=e3orqvjf+9k%vx>dn-SuWnEm<%CstW zw^`|%8xxC)i?^+@{P|&cc8=xbRbgvaCAaZNI*IGW1Q>m2pA+Kk_2ACbrb(OA z&UOV9eRy!NBr&J?1f5T3U-{o9AooPHEBH=zo~C z^835kSr&y#PoF%|IB{{Y`{l2%uTO2B-+F{a{XoKdZZVw&udc4v{=BdDcTi^LN~3lo zr}c9)&Gv{De|r-tWs))BRlrg&(OI_DW_v3?AM@%`x95`KDBN4|@lf8b4xJ52HyXC* z-(Q!-Jmvlx%|t2Q59Q%S@9*txv7LC>id*FT11O6>~v6$C1J+WZMnBE zU0&{=Yv0iQc!R9u4@VZ|Q^hr2GP9%hF1UYVWAa+IcPbrTfe%tTcbC6kcV1$~maMB? zW_fp3NSn8GIwXpfygk(r7ri}isafu=6_tk;eXx(~YZ&P(7+$c<}3si--A+$u(;%*|EdoP_`nMW%;`~pLh>N zGflA(OL)^8_h9Mq9!cj9i`@HUHvT#&engw|;M&;TVaE?;^U7ERqzimhj`=#*x_pXS z=!AD(wlzN{Ec2bc?7V2$iPuZZ-`tpJXR&9}fp7ck{;p!+ld*7^Yh4~Tp<-_2mJGqY zb$_EWV^X&oyB%Iy{r}%y-kkzfi~{Svy}d20q@;O)`PZ9UTeT$$5_&`%l^(e~FucA# z-oLW4auvs|7sB~mQQ?(=0;cac^{+&4%h~AX?eLJb{OhZ$Uw(d0xAvLmY1( zfE$a;-rdpEQ92WDZQEPVI_Cw$gdG77B0kJ(|8C3Jva9g%u`O9wwGMIXcUf@%RzK)p zd4P4EpSzi)II~GkRoby0$!DjgYPWrNKm2>qLyyD%(w8T$3SFJ{a>C{*GV0CzYgqXF zOqw_C;b{A=8oWI(_VDki4xOT9^ zLiz89brv=WA7x}(*40#YV}VPa+kwsvKG**@pZ?OLuFQW7>Ld9sv`+FYLED zTvzkBY4SlCm1y>c+SmI77O=|*Jr5OHCCu8lnALhk-QQnjW#8W3PXE6A)Ku-;yy5TE zpU4<6K1o`%ke#bNH_YDe&aM9&zjQIFt3^aaWSrl4evW1GukY{kEflr|vV@5^wVW&B z`^a9Tz+be{JZ@jj%@4QREDImCT;;D?bcBgrb#d2`whI5ph2NY?`fa#2oevUtZW#Pi zdh@gh0d>a*tv;q)+A@>ltPX}BTlD_k-s-@7VGsKzzb{ia1|4T-ig;^edi&I*lMH9 zODel36}aX7V$6MF)BHzGRkwiYQYqUBsmHs8zkgs!&zdy*&6SnGN3@c!ZkXTp(KJxu zK~iJO<%8n=MgD4Z9$b)tCLD;<|*@v<5g9G|S_dQaE8xFPYdh{yrfhdO^8PVT+1 z?^g1J85U==KDwK?32$z*KWtF5?U+Pw+P){eN6OfK2uoR96jaz-Dco>OR7_){td)h} zk$YE|m@XJxu2kYLJ$7KHlkwHQs`;mzopr0JaY? ARsaA1 literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/57.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 0000000000000000000000000000000000000000..de4fddce4ba58acb4f396557fc65181b9b381877 GIT binary patch literal 2470 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 E08H7PLjV8( literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/60.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 0000000000000000000000000000000000000000..2a9e939762ab0475869f28a49192ff72b839a963 GIT binary patch literal 2536 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 literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/64.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/64.png new file mode 100644 index 0000000000000000000000000000000000000000..c67e407c57ddde65c52019d65d36c4566c883fd2 GIT binary patch literal 2614 zcmeAS@N?(olHy`uVBq!ia0y~yU~m9o4mJh`hEk44ofy`glX=O&z~GV^ z;hE;^%b>-;z`()4*q+J20#d@jz#zo{0?Z2-8JNK$j0_A5nBcMw3z!jXkV3aWrwt4Y zoC`c%978G?-$ti*h+2!BTetW7x(#7(p0!>Xm zf>X6ZPX+DrnQ0WVEhlpIx`>TQCh6y7s=mIOI!$|~!w$8w3%?QOY>tx8`-)Xp-?_4@t&{q?HZ)0S!rYpC!ri2VQecemKXS65eG z*Yv?&o9r;Hs2+yedxf)lAk|5D(lAXGP!-_#ogWJvAaq__FAH4g)Vk%#obf3_#=tN!vr(07JG;{%zS ziO)_???2Wr@6Ru9cO~|$-7cdxrx=DApFf_SuD>Pgs@AHAjf=!Ym`?XB73ra3n*2(2r8eC*W0 zjXV+yE9OjJeWj6^o#Wi(wTzW-Z*9$deQoU-ub&GdDqUF{thLh5&s%zKu66XGkX0d_ zVe8|3#dF1X75kc2UYToMZo{7P?99wB zkB)M?&MoCQs-?nk=i`TmheNhzh4z|=w4ao|yHy(p&)AxKd)kJN4_ShfxgS~C7hS(}ZjR;TT-~O(-ww5M zUp0Aqd;9uH!rYq+G_@FJndh&|w5k0yWtrdHS#JXt$M3Hzd3s9p?z#2;i&pXpWPm ze4V_W`R_+>60WU@3|ZnK*sWgHDXt%PB_(0y1krQ+3_p_?85lTQ?g}2PVhC<=XSU1_ zySKl7J*)B5i&GW3o|!y4JMHy@gsC^=3m(~>QO{&BaK0}2(15w`BFmz%wNYDAPm8fi z{cCbEPgxtab&+#BU!nNc zx(3z4qP4G%bP8Wt=*;e2SXPjKf8W!*d9NoNU`)uIVOVEh`Ke`@&&)|@1C!$4&wKS@ z5%=G9vAdUj6MuH=@acmy_gpyd^GEMx@PU+5sXa?pPME&nh4GJqh7fn(1p0-5Qd)kW2D&@J2w(fh_7oIU!6+6IC@XpF~&b=MW_bt18 z#L?Y?{dmdYryH3jT***Ls_9_%QrPf7sF|H#Ok6KU;Pfo zxMa^!*|Dl=$qQkZ111w*7_d7poD!qMS=prWq;lSb;uxt0flm@KJo0uiQ!lc;-Pym& z=h1uzhJ>FBriZbRcdewTHY}IPk@|*L!zI}s|xWFE*XZf z3nABZKL1|uX$qsjl%|P4{;(!^?Mf zcek@1_#6@;6zcBBQCj};(ow6K2Mdjz(&j9>xwts`$)0HIq9+~8cGvzcyL!aN+u(P_ z)TKq&ZZa=ARJ1vjwMgK`!JR6%9)0vuWPHFk-@blX;p1ZyT_U)90wiC_*;FjZ+?2FS z$))kODigzH2JV=%Gwo`v6n*sDqwFoLo?Cma>7MSP$mo!~d2zVHqLP=FUj2(-QfxKZ zkXxRg;l*hqMYo+rPrWAH&RT22;IQD?jg85(RJUIj^;6Vg$lzwuF{x7)Y292V{8q{| zE8_b7Cv&bW-}WL|ZJ8)HgPyI=%&M=iB8@6yr*|%~dD6wfz@5*?mmeP=pT&RqnrNU%5*vfb-H9K&t}D9P zXI)v5Q_d23B7osT^@qly$!fkz8re~^<`K>9U~JCXyf|ia8gJ;jn43Xt(_{dW2;J}z2M-ESGxZ}?*?pIhh-GpIA<7mdKI;Vst0GtrOb^rhX literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/72.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/72.png new file mode 100644 index 0000000000000000000000000000000000000000..d09aebe2529fad76747e209af0feda3b13f6f319 GIT binary patch literal 2949 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 DBn3h} literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/76.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 0000000000000000000000000000000000000000..3e649b670c64e7412d4090370e63a90a5892b991 GIT binary patch literal 3340 zcmeAS@N?(olHy`uVBq!ia0y~yVDJH94mJh`hU3!%wHX)~7>k44ofy`glX=O&z~GV^ z;hE;^%b>-;z`()4*q+J20#d@jz#zo{0?Z2-8JNK$j0_A5nBcNL3z!jXkV3JY#~&~- z@RWJFIEGX(zKvn;n7UJ}Z}0be(pqa4ElN6Qwo5ySz54wM4Xua+O^a5X?%Lh;TB|JZ z#?}P!YRQ=CTa(2%YKtAYx4ZKB>FW3sf9_R3zc=%C*?pFOo_YUNp3l)&S3l|b&vUuz ziY)>rOfQ@DW`NY-#3LXJ7j(<)oZdiO2sBhxsG!{iu;L&013a{$B3s#d^0oTpjq`bt@Wo z)%@J_?&|94te$O4e1&by*C!wE`}OAL=4tmM7{pBv*wz2bVXgi1W22mX-JWwZjh8cO zBp>S$)5y5BCG+x^9!cYp)nRM5@a$Jf-IQ{2k$bPy()aiG=ie^Ay=|tTmy3*n;R`AL zL&y8&zc#UQm#hq0%EQ6JeCx`ZNaNmxFL{sm%U{pEwPj<&CclYFu3z-`|5^5kul+tZZg<(*shnR8u6)?>b$j03SDWjm zYK4B;Rr)%o_DZKpK_dH^?B#xQEGD`*r?{Cc5Y>x`I6u$Uy0dKugCHY^KLf*~L#^Dk zSueiduV2qEZ+9k(?SqF8Pa^9X(I4O5-mW?^L2=f%&cbJBW_~>}QQ3K$W8Uozjlya^ zA2OMLe|fohecWCt!v}|$Bw7;l_f>s8CGqCZ&(G4ni`)`9mX%HWob`UHc6iDAdwbs+ zsIQILS@g0~SpAi{|GbC?F$Z$fxP(?R@i-q%O}V-%bXU^RE;oj9%YgN9wtLgh%blLb zz2Pj6;zQPV=k5QSOlEtllsR25HcCHg%ZX1f_}&YgP}Q8JbYKt9`}_OjlM7#_?x^|s zsp@E#=ry+1=EUjM`R$3H4yZWUNnBqS8=ZfD-`z0R_Jw&~P96=Fjk^jSHod#Gb#>|l zXQljmd#YYtS^4Yd=jYSZ!-aFxW(ci2wy*ND+y6hG&r40_Vo>(?*q(jeZ@yLOsUY8z zhs-k$#5Bs=)vP$)C%bxc>glu@iyV(jWnNg|`0H4&^fb-w^Un4J99r7isw=i5;suk= zr;dsn8xnt=n`=Fdd;7ezUnX;K8JPWec$nQb|DH|ko{GZ5EaDRrR-~Su_UpyP#nXJe zs?LY`xgN6S+A#G{3bWkDfX7@S8VlV8P=S>Zs?tA3!e~4chq`J5AbK2oVgBo_09+vCtj0gt{8`$regu=g7X^DK7nzZOx)U~*)_+h=EIKPz3( z_x*L}WUph)RSym@etmP(SoK&CXW-*E)M_%=H3C3UbzQ4%Dj@g&ON5*u3jY0{IyS4 zVkt{S;nw^2+2l4k9`l^6CV5U{x4zlmED6DBUJ~1mEstC1#LBwekg4kFrKR4h-23H@ z_11iN;J7;Xg3Fc8P=?$O47^P9jjFXju3h=P8|Ld5JbQFJ=#g%~rjF3(=jWRr4zD_6AnW^pLnNblefITr zD;b&DRDZcJXapkk_%9rINoJqg7mxr=Ji-|p-z-c|hk z+&9_woNH?$&o+I$x3_xR8f%@Lv>C#BycNb9ejZbk*)!REN%Z!-wf(ZzX)6!^2;O&c zvij_rQ+eE$oclMcXzy$|_`h%F=fA(dZ{vKUENzyP(b*L>yJVqL>z6f=o8KG~_!_e? zhk2#X0^eJ{I)%^9&wqb8zU{BdoEbb57{6{v?$k(7@K8Ic@hNP5+*gidgVy7qBi7I6^1UeWny>*HB_jt!^edE!?n6&aP%hoAm61Z)=|!mfb%XY?OD$Vy?0~-(|hqF6j><&Y8YoXuWlQ zo5XR}gcLTPT~6-`owSMh9{bC%80{_3H zYn?NBKOdL)6zhBN$cM}R_L=d*&Ubf}e(jUB&I#`9?6gu;JjitP;dJZryk67E_RV?H z91}XS51iO;?PF+oqVHe@=K|Apt6BN>&$F$LTJVGKumD?Z*oPdWlLFgjI;sS5MO=th z7Sy`RSZzA-XwFQpDH;pw{(cQt4QD*_&4tC_ZSw!^g^%67{(isz_V1($b-CED-|yG2 zPCY$M^Gzme&1JD$J!u_U2?9O8Z5Y(Qxt&Z3KDj8k)BXCJpGQv2ww=#o&+Kt_mg&?A z$t&mPT2JMP3~o=o5Vpnn3ghAi0kK<*4?mjZ-;4SC^?JNl+nkGX858DwlHheYQ?!Cv zw}rS2ncd)tniR(%anV) zsNTCl=be$7#Lh{qXJSpdgwG57FmG_%@MPEIqnaQ=pS+hd*e(ix5PM+9YIf|4UPRXa z1uWlIT67$H7T?GDwrOX=!KRa555*1g3SM7XnY>Mn;pM?*_Epi_^St=H-kvn?_!rDn z(JJOBmJxP;U+vkK7PiVo`?6d(O?;S{*qCnce1CoYe8!YHS=t{vv_39ez0`ZU)Ow~i z9b3+iTrxkJbsmT&oM4qwW#4`5IonKws9W1|S3mG_ioDKKt2|lZow-NqW99Z}Np+*3 zo4+nk{5DVR@M5i=pSk)MxD#*fnWr_q^P<_jgU#%-ZT4}8+`n%9fq$Mvf!F;Vg~_+~ z^(aj~HdAw*ctG5=@1oAG%xor23<_!I=BzAq;7VK~u9Mg>^VaXREUGdMjp^Ii@BNki zo_c=X-I~zv@9tWQY*r6>);{0s;ZW?&M_P|&~??e^fvidiQ6I{&=g ze%~bTih{!ohi|$|8y5O*T}t!;aRT z=1>Fo3lolQExEHM@x$?q6%CF1ZWk6M`aPbl*5uOMcl+Kw{=mCcPqc4X9Br7`;4QUfV zc)sj$N=Q1;s9inNiNT^~UHfcv9)Wvn-=BZ6xaYRzr}a`_E-Z9@`_^fCo6fq)5+#=u z&R#i^$k@ZPqe+?Hn|IxP2C>#dXQl5PIOr?Gb}#D-tHk|;L}b}d9qdi-s^i;Z0;PHX_49Y5FE-F051ap7x^X>8Y9J z>*gN$`n^D+PhlGPXM& zFK7Ajg#Vy*j&ItG|1Kg1pI+UX=OE1d?`~5y<9gR_v0dKN^~4`l()V?-=yBczff-RPGZ^prS9m^FJtCU$ xk;zNJ?$56;%%Q*P>!!0sbnKna7SYl0mwnM0#mC3`m0CbO5l>e?mvv4FO#pVkB%1&L literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/80.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 0000000000000000000000000000000000000000..6dad29fe73c8977d94c44b69bc61aee1c65a2db5 GIT binary patch literal 3427 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 O*XQZ#=d#Wzp$Py3$KQMa literal 0 HcmV?d00001 diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/88.png b/Apple/App/Assets.xcassets/AppIcon.appiconset/88.png new file mode 100644 index 0000000000000000000000000000000000000000..b1a478afd6545419176d90a1f389de4e7084be65 GIT binary patch literal 3738 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 + exit 1 +fi + +BUILD_NUMBER=$LATEST_BUILD_NUMBER + +if [[ $# -gt 0 && "$1" == "increment" ]]; then + NEW_BUILD_NUMBER=$((LATEST_BUILD_NUMBER + 1)) + NEW_TAG="$TAG_PREFIX$NEW_BUILD_NUMBER" + BUILD_NUMBER=$NEW_BUILD_NUMBER + + git tag $NEW_TAG + git push --quiet origin $NEW_TAG + gh release create "$NEW_TAG" -t "Build $BUILD_NUMBER" --verify-tag --generate-notes >/dev/null +fi + +if [[ -z $(grep $BUILD_NUMBER Apple/Configuration/Version.xcconfig 2>/dev/null) ]]; then + echo "CURRENT_PROJECT_VERSION = $BUILD_NUMBER" > Apple/Configuration/Version.xcconfig + git update-index --assume-unchanged Apple/Configuration/Version.xcconfig +fi + +if [[ $# -gt 0 && "$1" == "status" ]]; then + if [[ $CURRENT_BUILD_NUMBER -eq $LATEST_BUILD_NUMBER ]]; then + echo "clean" + else + echo "dirty" + fi + exit 0 +fi + +echo $BUILD_NUMBER From a97063f9b731cf73ffddc4c81826935f6fd1b978 Mon Sep 17 00:00:00 2001 From: "Kartikey S. Chauhan" Date: Sat, 2 Sep 2023 23:18:25 +0530 Subject: [PATCH 010/102] Initial website setup - Created project structure with necessary directories and files - Set up Next.js with Tailwind CSS and Font Awesome - Added base HTML structure and layout components - Configured routing and created the homepage - Styled the homepage with basic styling - Added FontAwesome icons - Configured font imports and styles - Integrated HackClub branding elements This commit establishes the foundation for our website, including the project structure, styling, and initial content. --- site/.eslintrc.json | 3 + site/.gitignore | 5 ++ site/.prettierignore | 6 ++ site/assets/Bold.woff2 | Bin 0 -> 25000 bytes site/assets/Italic.woff2 | Bin 0 -> 21744 bytes site/assets/Regular.woff2 | Bin 0 -> 22408 bytes site/layout/layout.tsx | 47 ++++++++++++ site/next.config.js | 6 ++ site/package.json | 36 +++++++++ site/pages/_app.tsx | 14 ++++ site/pages/_document.tsx | 13 ++++ site/pages/index.tsx | 154 ++++++++++++++++++++++++++++++++++++++ site/postcss.config.js | 6 ++ site/prettier.config.js | 3 + site/public/hackclub.svg | 66 ++++++++++++++++ site/static/globals.css | 3 + site/tailwind.config.ts | 28 +++++++ site/tsconfig.json | 27 +++++++ 18 files changed, 417 insertions(+) create mode 100644 site/.eslintrc.json create mode 100644 site/.gitignore create mode 100644 site/.prettierignore create mode 100644 site/assets/Bold.woff2 create mode 100644 site/assets/Italic.woff2 create mode 100644 site/assets/Regular.woff2 create mode 100644 site/layout/layout.tsx create mode 100644 site/next.config.js create mode 100644 site/package.json create mode 100644 site/pages/_app.tsx create mode 100644 site/pages/_document.tsx create mode 100644 site/pages/index.tsx create mode 100644 site/postcss.config.js create mode 100644 site/prettier.config.js create mode 100644 site/public/hackclub.svg create mode 100644 site/static/globals.css create mode 100644 site/tailwind.config.ts create mode 100644 site/tsconfig.json diff --git a/site/.eslintrc.json b/site/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/site/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/site/.gitignore b/site/.gitignore new file mode 100644 index 0000000..71b863e --- /dev/null +++ b/site/.gitignore @@ -0,0 +1,5 @@ +/node_modules +/out/ + +/.next/ +next-env.d.ts diff --git a/site/.prettierignore b/site/.prettierignore new file mode 100644 index 0000000..fcac576 --- /dev/null +++ b/site/.prettierignore @@ -0,0 +1,6 @@ +# Ignore artifacts: +build +coverage + +# Ignore all HTML files: +**/*.html \ No newline at end of file diff --git a/site/assets/Bold.woff2 b/site/assets/Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..8c00084a985116cb2ef26d1b79acd67af692ba53 GIT binary patch literal 25000 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 z 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 VarXY^{9pFMN%PJW>}6qK001pXiIM;S literal 0 HcmV?d00001 diff --git a/site/assets/Italic.woff2 b/site/assets/Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..056909c32a24bdfed5c6149656be0e42cc6b47ec GIT binary patch literal 21744 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 literal 0 HcmV?d00001 diff --git a/site/assets/Regular.woff2 b/site/assets/Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..d7c3d52c693bb4dfd0786625f21ea2f4726125d5 GIT binary patch literal 22408 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

Ra^9H{`RJM&`B<@1)md zLRJkoy5+7c%es5=-ASuwTSKm|e!cab<%RGWslUGcHo7(M`@Y0b_SdW5HwZ4v+j8oJ zRC@Tzjh0uQN1px0H2G^NyV}>6pH07JPQU+m@2pi(rCI)S*Unb^q^T^yzD%$DK#b?= zpWIzLErWTuPPTKeVXuqQ72Z&za^GXM*3K&F#Sfw-Umchjz-cER%I9*Rb~>Z1f%0?* zjn)>1#}RC%6Jj0ASffv-hc9bBbUfoRvl@??a@{5gR;x1??--q%C(V|+>E$BP$rp~d zez4S7t#D)6#k9^tzZR|S-?U+kZf;V$W%smlNn`tT`HZyHGZ)L8Pwlrozgdo5rh>6} zzV*4LR`m$KX?wLb{CEC+@Z9V7t<2?~o1e|SGqrrrWOlm`8*8qdDpI|7NuJ4mW=gXB z)PHh$i`btxi24{y^6k1}(O{O#qr()n^*o7 zbE(>N@N9J2eT#;J*=5X$!oeTE} z-k7)UtKHW6^9-h8vE5x-^&dDl-Gm1>*yn3=9nu8LQYBSmzk6 z@78D#WJqY3$LPn%z|g~U!I(kg!I!52oHqY1{nh=jPT{%DzQy);z8KiQYm)hGrhB0+ z`~FT1*K6IA7Ul*f@3$&*?2u}iw(@Pm$Nf@M|7v_V_u|b_wxeyQBF`(eH?!P({JO@M ktLMMn{5N$>s@vbz<)-Xu6W!CO^u65RTbIE + {children} + + ); +} diff --git a/site/next.config.js b/site/next.config.js new file mode 100644 index 0000000..a35bfad --- /dev/null +++ b/site/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: "export", +}; + +module.exports = nextConfig; diff --git a/site/package.json b/site/package.json new file mode 100644 index 0000000..5deb167 --- /dev/null +++ b/site/package.json @@ -0,0 +1,36 @@ +{ + "name": "site", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@fortawesome/fontawesome-free": "^6.4.2", + "@fortawesome/fontawesome-svg-core": "^6.4.2", + "@fortawesome/free-brands-svg-icons": "^6.4.2", + "@fortawesome/free-solid-svg-icons": "^6.4.2", + "@fortawesome/react-fontawesome": "^0.2.0", + "@headlessui/react": "^1.7.17", + "@headlessui/tailwindcss": "^0.2.0", + "@types/node": "20.5.8", + "@types/react": "18.2.21", + "@types/react-dom": "18.2.7", + "autoprefixer": "10.4.15", + "eslint": "8.48.0", + "eslint-config-next": "13.4.19", + "next": "13.4.19", + "postcss": "8.4.29", + "react": "18.2.0", + "react-dom": "18.2.0", + "tailwindcss": "3.3.3", + "typescript": "5.2.2" + }, + "devDependencies": { + "prettier": "^3.0.3", + "prettier-plugin-tailwindcss": "^0.5.4" + } +} diff --git a/site/pages/_app.tsx b/site/pages/_app.tsx new file mode 100644 index 0000000..c0572f6 --- /dev/null +++ b/site/pages/_app.tsx @@ -0,0 +1,14 @@ +import Layout from "@/layout/layout"; +import type { AppProps } from "next/app"; +import { config } from "@fortawesome/fontawesome-svg-core"; +import "@fortawesome/fontawesome-svg-core/styles.css"; +config.autoAddCss = false; +import "static/globals.css"; + +export default function App({ Component, pageProps }: AppProps) { + return ( + + + + ); +} diff --git a/site/pages/_document.tsx b/site/pages/_document.tsx new file mode 100644 index 0000000..ce4b5e1 --- /dev/null +++ b/site/pages/_document.tsx @@ -0,0 +1,13 @@ +import { Html, Head, Main, NextScript } from "next/document"; + +export default function Document() { + return ( + + + +

+ + + + ); +} diff --git a/site/pages/index.tsx b/site/pages/index.tsx new file mode 100644 index 0000000..73fbc33 --- /dev/null +++ b/site/pages/index.tsx @@ -0,0 +1,154 @@ +import { faGithub } from "@fortawesome/free-brands-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import Head from "next/head"; +import { + faChevronDown, + faChevronUp, + faUpRightFromSquare, +} from "@fortawesome/free-solid-svg-icons"; +import { Menu, Transition } from "@headlessui/react"; +import { useState, useRef, useEffect } from "react"; +export default function Page() { + const [chevron, setChevron] = useState(false); + const menuButtonRef = useRef(null); + const toggleDropdown = () => { + setChevron(!chevron); + }; + const handleClickOutside = (event: MouseEvent) => { + if ( + menuButtonRef.current && + !menuButtonRef.current.contains(event.target as Node) + ) { + setChevron(false); + } + }; + useEffect(() => { + document.addEventListener("click", handleClickOutside); + + return () => { + document.removeEventListener("click", handleClickOutside); + }; + }, []); + return ( + <> + + Burrow + + + +
+
+

+ Burrow Through{" "} + Firewalls +

+
+

+ Burrow is an open source tool for burrowing through firewalls, + built by teenagers at{" "} + + + Hack Club. + + {" "} + + burrow + {" "} + is a Rust-based VPN for getting around restrictive Internet + censors. +

+
+
+
+ +
+ toggleDropdown()} + ref={menuButtonRef} + className="w-50 h-12 rounded-2xl bg-hackClubRed px-3 font-SpaceMono hover:scale-105 md:h-12 md:w-auto md:rounded-3xl md:text-xl 2xl:h-16 2xl:text-2xl " + > + Install for Linux + {chevron ? ( + + ) : ( + + )} + +
+ + +
+ + {({ active }) => ( + + Install for Windows + + )} + + + + Install for MacOS + + +
+
+
+
+ + + +
+ +
+ {/* Footer */} + {/* */} +
+
+ + ); +} diff --git a/site/postcss.config.js b/site/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/site/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/site/prettier.config.js b/site/prettier.config.js new file mode 100644 index 0000000..d573118 --- /dev/null +++ b/site/prettier.config.js @@ -0,0 +1,3 @@ +module.exports = { + plugins: ["prettier-plugin-tailwindcss"], +}; diff --git a/site/public/hackclub.svg b/site/public/hackclub.svg new file mode 100644 index 0000000..38c2a68 --- /dev/null +++ b/site/public/hackclub.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/site/static/globals.css b/site/static/globals.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/site/static/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/site/tailwind.config.ts b/site/tailwind.config.ts new file mode 100644 index 0000000..3df6f5a --- /dev/null +++ b/site/tailwind.config.ts @@ -0,0 +1,28 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + colors: { + backgroundBlack: "#17171D", + hackClubRed: "#EC3750", + hackClubBlueShade: "#32323D", + hackClubBlue: "#338EDA", + burrowStroke: "#595959", + burrowHover: "#3D3D3D", + }, + fontFamily: { + SpaceMono: ["var(--font-space-mono)"], + Poppins: ["var(--font-poppins)"], + PhantomSans: ["var(--font-phantom-sans)"], + }, + }, + }, + plugins: [require("@headlessui/tailwindcss")({ prefix: "ui" })], +}; +export default config; diff --git a/site/tsconfig.json b/site/tsconfig.json new file mode 100644 index 0000000..c714696 --- /dev/null +++ b/site/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} From ec8cc533abf7502cb09ea6f8033124786afe5fb7 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 30 Mar 2024 17:17:52 -0700 Subject: [PATCH 011/102] Add apple-app-site-association file --- site/bun.lockb | Bin 0 -> 140507 bytes site/next.config.js | 9 +++++++- site/package.json | 2 +- .../.well-known/apple-app-site-association | 21 ++++++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) create mode 100755 site/bun.lockb create mode 100644 site/public/.well-known/apple-app-site-association diff --git a/site/bun.lockb b/site/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..ea2d13747933513bfd419def1a5e0e9b8fb67bdc GIT binary patch literal 140507 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 z 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 literal 0 HcmV?d00001 diff --git a/site/next.config.js b/site/next.config.js index a35bfad..afbb70f 100644 --- a/site/next.config.js +++ b/site/next.config.js @@ -1,6 +1,13 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - output: "export", + headers() { + return [ + { + source: "/.well-known/apple-app-site-association", + headers: [{ key: "Content-Type", value: "application/json" }], + } + ]; + } }; module.exports = nextConfig; diff --git a/site/package.json b/site/package.json index 5deb167..4fcacc8 100644 --- a/site/package.json +++ b/site/package.json @@ -1,5 +1,5 @@ { - "name": "site", + "name": "burrow", "version": "0.1.0", "private": true, "scripts": { diff --git a/site/public/.well-known/apple-app-site-association b/site/public/.well-known/apple-app-site-association new file mode 100644 index 0000000..63262fb --- /dev/null +++ b/site/public/.well-known/apple-app-site-association @@ -0,0 +1,21 @@ +{ + "applinks": { + "details": [ + { + "appIDs": [ + "P6PV2R9443.com.hackclub.burrow" + ], + "components": [ + { + "/": "/callback/*" + } + ] + } + ] + }, + "webcredentials": { + "apps": [ + "P6PV2R9443.com.hackclub.burrow" + ] + } +} From df549d48e6d995f0efacd028f06f0d2e51595a7e Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 30 Mar 2024 16:47:59 -0700 Subject: [PATCH 012/102] Implement Slack authentication on iOS --- .github/actions/archive/action.yml | 1 - .github/actions/notarize/action.yml | 37 ++-- Apple/App/App-iOS.entitlements | 5 + Apple/App/App-macOS.entitlements | 5 + Apple/App/BurrowView.swift | 49 ++++- Apple/App/NetworkCarouselView.swift | 39 ++++ Apple/App/NetworkView.swift | 50 ----- Apple/App/OAuth2.swift | 280 +++++++++++++++++++++++++ Apple/App/TunnelButton.swift | 26 ++- Apple/Burrow.xcodeproj/project.pbxproj | 8 + 10 files changed, 419 insertions(+), 81 deletions(-) create mode 100644 Apple/App/NetworkCarouselView.swift create mode 100644 Apple/App/OAuth2.swift diff --git a/.github/actions/archive/action.yml b/.github/actions/archive/action.yml index 37282e1..e49eb0d 100644 --- a/.github/actions/archive/action.yml +++ b/.github/actions/archive/action.yml @@ -35,7 +35,6 @@ runs: -authenticationKeyID ${{ inputs.app-store-key-id }} \ -authenticationKeyIssuerID ${{ inputs.app-store-key-issuer-id }} \ -authenticationKeyPath "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" \ - -onlyUsePackageVersionsFromResolvedFile \ -scheme '${{ inputs.scheme }}' \ -destination '${{ inputs.destination }}' \ -archivePath '${{ inputs.archive-path }}' \ diff --git a/.github/actions/notarize/action.yml b/.github/actions/notarize/action.yml index 290ed86..f3f98f2 100644 --- a/.github/actions/notarize/action.yml +++ b/.github/actions/notarize/action.yml @@ -15,10 +15,6 @@ inputs: export-path: description: The path to export the archive to required: true -outputs: - notarized-app: - description: The compressed and notarized app - value: ${{ steps.notarize.outputs.notarized-app }} runs: using: composite steps: @@ -28,31 +24,28 @@ runs: run: | echo "${{ inputs.app-store-key }}" > AuthKey_${{ inputs.app-store-key-id }}.p8 - echo '{"destination":"upload","method":"developer-id"}' \ + echo '{"destination":"export","method":"developer-id"}' \ | plutil -convert xml1 -o ExportOptions.plist - - xcodebuild \ - -exportArchive \ + xcodebuild -exportArchive \ -allowProvisioningUpdates \ -allowProvisioningDeviceRegistration \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + -onlyUsePackageVersionsFromResolvedFile \ -authenticationKeyID ${{ inputs.app-store-key-id }} \ -authenticationKeyIssuerID ${{ inputs.app-store-key-issuer-id }} \ -authenticationKeyPath "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" \ - -archivePath '${{ inputs.archive-path }}' \ + -archivePath Wallet.xcarchive \ + -exportPath Release \ -exportOptionsPlist ExportOptions.plist - until xcodebuild \ - -exportNotarizedApp \ - -allowProvisioningUpdates \ - -allowProvisioningDeviceRegistration \ - -authenticationKeyID ${{ inputs.app-store-key-id }} \ - -authenticationKeyIssuerID ${{ inputs.app-store-key-issuer-id }} \ - -authenticationKeyPath "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" \ - -archivePath '${{ inputs.archive-path }}' \ - -exportPath ${{ inputs.export-path }} - do - echo "Failed to export app, trying again in 10s..." - sleep 10 - done + ditto -c -k --keepParent Release/Wallet.app Upload.zip + SUBMISSION_ID=$(xcrun notarytool submit --issuer ${{ inputs.app-store-key-issuer-id }} --key-id ${{ inputs.app-store-key-id }} --key "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" Upload.zip | awk '/ id:/ { print $2; exit }') - rm -rf AuthKey_${{ inputs.app-store-key-id }}.p8 ExportOptions.plist + xcrun notarytool wait $SUBMISSION_ID --issuer ${{ inputs.app-store-key-issuer-id }} --key-id ${{ inputs.app-store-key-id }} --key "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" + xcrun stapler staple Release/Wallet.app + + aa archive -a lzma -b 8m -d Release -subdir Wallet.app -o Wallet.app.aar + + rm -rf Upload.zip Release AuthKey_${{ inputs.app-store-key-id }}.p8 ExportOptions.plist diff --git a/Apple/App/App-iOS.entitlements b/Apple/App/App-iOS.entitlements index 02ee960..53fcbb7 100644 --- a/Apple/App/App-iOS.entitlements +++ b/Apple/App/App-iOS.entitlements @@ -2,6 +2,11 @@ + com.apple.developer.associated-domains + + applinks:burrow.rs?mode=developer + webcredentials:burrow.rs?mode=developer + com.apple.developer.networking.networkextension packet-tunnel-provider diff --git a/Apple/App/App-macOS.entitlements b/Apple/App/App-macOS.entitlements index 02ee960..53fcbb7 100644 --- a/Apple/App/App-macOS.entitlements +++ b/Apple/App/App-macOS.entitlements @@ -2,6 +2,11 @@ + com.apple.developer.associated-domains + + applinks:burrow.rs?mode=developer + webcredentials:burrow.rs?mode=developer + com.apple.developer.networking.networkextension packet-tunnel-provider diff --git a/Apple/App/BurrowView.swift b/Apple/App/BurrowView.swift index b78b1e1..8447592 100644 --- a/Apple/App/BurrowView.swift +++ b/Apple/App/BurrowView.swift @@ -1,9 +1,29 @@ +import AuthenticationServices import SwiftUI +#if !os(macOS) struct BurrowView: View { + @Environment(\.webAuthenticationSession) + private var webAuthenticationSession + var body: some View { NavigationStack { VStack { + HStack { + Text("Networks") + .font(.largeTitle) + .fontWeight(.bold) + Spacer() + Menu { + Button("Hack Club", action: addHackClubNetwork) + Button("WireGuard", action: addWireGuardNetwork) + } label: { + Image(systemName: "plus.circle.fill") + .font(.title) + .accessibilityLabel("Add") + } + } + .padding(.top) NetworkCarouselView() Spacer() TunnelStatusView() @@ -11,9 +31,35 @@ struct BurrowView: View { .padding(.bottom) } .padding() - .navigationTitle("Networks") + .handleOAuth2Callback() } } + + private func addHackClubNetwork() { + Task { + try await authenticateWithSlack() + } + } + + private func addWireGuardNetwork() { + + } + + private func authenticateWithSlack() async throws { + guard + let authorizationEndpoint = URL(string: "https://slack.com/openid/connect/authorize"), + let tokenEndpoint = URL(string: "https://slack.com/api/openid.connect.token"), + let redirectURI = URL(string: "https://burrow.rs/callback/oauth2") else { return } + let session = OAuth2.Session( + authorizationEndpoint: authorizationEndpoint, + tokenEndpoint: tokenEndpoint, + redirectURI: redirectURI, + scopes: ["openid", "profile"], + clientID: "2210535565.6884042183125", + clientSecret: "2793c8a5255cae38830934c664eeb62d" + ) + let response = try await session.authorize(webAuthenticationSession) + } } #if DEBUG @@ -24,3 +70,4 @@ struct NetworkView_Previews: PreviewProvider { } } #endif +#endif diff --git a/Apple/App/NetworkCarouselView.swift b/Apple/App/NetworkCarouselView.swift new file mode 100644 index 0000000..b120c60 --- /dev/null +++ b/Apple/App/NetworkCarouselView.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct NetworkCarouselView: View { + var networks: [any Network] = [ + HackClub(id: "1"), + HackClub(id: "2"), + WireGuard(id: "4"), + HackClub(id: "5"), + ] + + var body: some View { + ScrollView(.horizontal) { + LazyHStack { + ForEach(networks, id: \.id) { network in + NetworkView(network: network) + .containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center) + .scrollTransition(.interactive, axis: .horizontal) { content, phase in + content + .scaleEffect(1.0 - abs(phase.value) * 0.1) + } + } + } + } + .scrollTargetLayout() + .scrollClipDisabled() + .scrollIndicators(.hidden) + .defaultScrollAnchor(.center) + .scrollTargetBehavior(.viewAligned) + .containerRelativeFrame(.horizontal) + } +} + +#if DEBUG +struct NetworkCarouselView_Previews: PreviewProvider { + static var previews: some View { + NetworkCarouselView() + } +} +#endif diff --git a/Apple/App/NetworkView.swift b/Apple/App/NetworkView.swift index 290254c..b839d65 100644 --- a/Apple/App/NetworkView.swift +++ b/Apple/App/NetworkView.swift @@ -30,59 +30,9 @@ struct NetworkView: View { } } -struct AddNetworkView: View { - var body: some View { - Text("Add Network") - .frame(maxWidth: .infinity, minHeight: 175, maxHeight: 175) - .background( - RoundedRectangle(cornerRadius: 10) - .stroke(style: .init(lineWidth: 2, dash: [6])) - ) - } -} - extension NetworkView where Content == AnyView { init(network: any Network) { color = network.backgroundColor content = { AnyView(network.label) } } } - -struct NetworkCarouselView: View { - var networks: [any Network] = [ - HackClub(id: "1"), - HackClub(id: "2"), - WireGuard(id: "4"), - HackClub(id: "5"), - ] - - var body: some View { - ScrollView(.horizontal) { - LazyHStack { - ForEach(networks, id: \.id) { network in - NetworkView(network: network) - .containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center) - .scrollTransition(.interactive, axis: .horizontal) { content, phase in - content - .scaleEffect(1.0 - abs(phase.value) * 0.1) - } - } - AddNetworkView() - } - .scrollTargetLayout() - } - .scrollClipDisabled() - .scrollIndicators(.hidden) - .defaultScrollAnchor(.center) - .scrollTargetBehavior(.viewAligned) - .containerRelativeFrame(.horizontal) - } -} - -#if DEBUG -struct NetworkCarouselView_Previews: PreviewProvider { - static var previews: some View { - NetworkCarouselView() - } -} -#endif diff --git a/Apple/App/OAuth2.swift b/Apple/App/OAuth2.swift new file mode 100644 index 0000000..dc8c62b --- /dev/null +++ b/Apple/App/OAuth2.swift @@ -0,0 +1,280 @@ +import AuthenticationServices +import SwiftUI +import Foundation + +enum OAuth2 { + enum Error: Swift.Error { + case unknown + case invalidAuthorizationURL + case invalidCallbackURL + case invalidRedirectURI + } + + struct Credential { + var accessToken: String + var refreshToken: String? + var expirationDate: Date? + } + + struct Session { + var authorizationEndpoint: URL + var tokenEndpoint: URL + var redirectURI: URL + var responseType = OAuth2.ResponseType.code + var scopes: Set + var clientID: String + var clientSecret: String + + fileprivate static var queue: [Int: CheckedContinuation] = [:] + + fileprivate static func handle(url: URL) { + let continuations = queue + queue.removeAll() + for (_, continuation) in continuations { + continuation.resume(returning: url) + } + } + + public init( + authorizationEndpoint: URL, + tokenEndpoint: URL, + redirectURI: URL, + scopes: Set, + clientID: String, + clientSecret: String + ) { + self.authorizationEndpoint = authorizationEndpoint + self.tokenEndpoint = tokenEndpoint + self.redirectURI = redirectURI + self.scopes = scopes + self.clientID = clientID + self.clientSecret = clientSecret + } + + private var authorizationURL: URL { + get throws { + var queryItems: [URLQueryItem] = [ + .init(name: "client_id", value: clientID), + .init(name: "response_type", value: responseType.rawValue), + .init(name: "redirect_uri", value: redirectURI.absoluteString), + ] + if !scopes.isEmpty { + queryItems.append(.init(name: "scope", value: scopes.joined(separator: ","))) + } + guard var components = URLComponents(url: authorizationEndpoint, resolvingAgainstBaseURL: false) else { + throw OAuth2.Error.invalidAuthorizationURL + } + components.queryItems = queryItems + guard let authorizationURL = components.url else { throw OAuth2.Error.invalidAuthorizationURL } + return authorizationURL + } + } + + private func handle(callbackURL: URL) async throws -> OAuth2.AccessTokenResponse { + switch responseType { + case .code: + guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else { + throw OAuth2.Error.invalidCallbackURL + } + return try await handle(response: try components.decode(OAuth2.CodeResponse.self)) + default: + throw OAuth2.Error.invalidCallbackURL + } + } + + private func handle(response: OAuth2.CodeResponse) async throws -> OAuth2.AccessTokenResponse { + var components = URLComponents() + components.queryItems = [ + .init(name: "client_id", value: clientID), + .init(name: "client_secret", value: clientSecret), + .init(name: "grant_type", value: GrantType.authorizationCode.rawValue), + .init(name: "code", value: response.code), + .init(name: "redirect_uri", value: redirectURI.absoluteString) + ] + let httpBody = Data(components.percentEncodedQuery!.utf8) + + var request = URLRequest(url: tokenEndpoint) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + request.httpBody = httpBody + + let session = URLSession(configuration: .ephemeral) + let (data, _) = try await session.data(for: request) + return try OAuth2.decoder.decode(OAuth2.AccessTokenResponse.self, from: data) + } + + func authorize(_ session: WebAuthenticationSession) async throws -> Credential { + let authorizationURL = try authorizationURL + let callbackURL = try await session.start( + url: authorizationURL, + redirectURI: redirectURI + ) + return try await handle(callbackURL: callbackURL).credential + } + } + + private struct CodeResponse: Codable { + var code: String + var state: String? + } + + private struct AccessTokenResponse: Codable { + var accessToken: String + var tokenType: TokenType + var expiresIn: Double? + var refreshToken: String? + + var credential: Credential { + .init(accessToken: accessToken, refreshToken: refreshToken, expirationDate: expiresIn.map { Date.init(timeIntervalSinceNow: $0) }) + } + } + + enum TokenType: Codable, RawRepresentable { + case bearer + case unknown(String) + + init(rawValue: String) { + self = switch rawValue.lowercased() { + case "bearer": .bearer + default: .unknown(rawValue) + } + } + + var rawValue: String { + switch self { + case .bearer: "bearer" + case .unknown(let type): type + } + } + } + + enum GrantType: Codable, RawRepresentable { + case authorizationCode + case unknown(String) + + init(rawValue: String) { + self = switch rawValue.lowercased() { + case "authorization_code": .authorizationCode + default: .unknown(rawValue) + } + } + + var rawValue: String { + switch self { + case .authorizationCode: "authorization_code" + case .unknown(let type): type + } + } + } + + enum ResponseType: Codable, RawRepresentable { + case code + case idToken + case unknown(String) + + init(rawValue: String) { + self = switch rawValue.lowercased() { + case "code": .code + case "id_token": .idToken + default: .unknown(rawValue) + } + } + + var rawValue: String { + switch self { + case .code: "code" + case .idToken: "id_token" + case .unknown(let type): type + } + } + } + + fileprivate static var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + } + + fileprivate static var encoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + return encoder + } +} + +extension WebAuthenticationSession { + func start(url: URL, redirectURI: URL) async throws -> URL { + #if canImport(BrowserEngineKit) + if #available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *) { + return try await authenticate( + using: url, + callback: try Self.callback(for: redirectURI), + additionalHeaderFields: [:] + ) + } + #endif + + return try await withThrowingTaskGroup(of: URL.self) { group in + group.addTask { + return try await authenticate(using: url, callbackURLScheme: redirectURI.scheme ?? "") + } + + let id = Int.random(in: 0.. ASWebAuthenticationSession.Callback { + switch redirectURI.scheme { + case "https": + guard let host = redirectURI.host else { throw OAuth2.Error.invalidRedirectURI } + return .https(host: host, path: redirectURI.path) + case "http": + throw OAuth2.Error.invalidRedirectURI + case .some(let scheme): + return .customScheme(scheme) + case .none: + throw OAuth2.Error.invalidRedirectURI + } + } + #endif +} + +extension View { + func handleOAuth2Callback() -> some View { + onOpenURL { url in OAuth2.Session.handle(url: url) } + } +} + +extension URLComponents { + fileprivate func decode(_ type: T.Type) throws -> T { + guard let queryItems else { + throw DecodingError.valueNotFound( + T.self, + .init(codingPath: [], debugDescription: "Missing query items") + ) + } + let data = try OAuth2.encoder.encode(try queryItems.values) + return try OAuth2.decoder.decode(T.self, from: data) + } +} + +extension Sequence where Element == URLQueryItem { + fileprivate var values: [String: String?] { + get throws { + try Dictionary(map { ($0.name, $0.value) }) { _, _ in + throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Duplicate query items")) + } + } + } +} diff --git a/Apple/App/TunnelButton.swift b/Apple/App/TunnelButton.swift index df8d7e6..1f5693e 100644 --- a/Apple/App/TunnelButton.swift +++ b/Apple/App/TunnelButton.swift @@ -4,16 +4,19 @@ struct TunnelButton: View { @Environment(\.tunnel) var tunnel: any Tunnel + private var action: Action? { tunnel.action } + var body: some View { - if let action = tunnel.action { - Button { + Button { + if let action { tunnel.perform(action) - } label: { - Text(action.description) } - .padding(.horizontal) - .buttonStyle(.floating) + } label: { + Text(action.description) } + .disabled(action.isDisabled) + .padding(.horizontal) + .buttonStyle(.floating) } } @@ -40,12 +43,21 @@ extension TunnelButton { } } -extension TunnelButton.Action { +extension TunnelButton.Action? { var description: LocalizedStringKey { switch self { case .enable: "Enable" case .start: "Start" case .stop: "Stop" + case .none: "Start" + } + } + + var isDisabled: Bool { + if case .none = self { + true + } else { + false } } } diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index b08c4b0..3ea58b3 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 0B28F1562ABF463A000D44B0 /* DataTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B28F1552ABF463A000D44B0 /* DataTypes.swift */; }; 0B46E8E02AC918CA00BA2A3C /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B46E8DF2AC918CA00BA2A3C /* Client.swift */; }; 43AA26D82A10004900F14CE6 /* MenuItemToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AA26D72A10004900F14CE6 /* MenuItemToggleView.swift */; }; + D000363D2BB8928E00E582EC /* NetworkCarouselView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D000363C2BB8928E00E582EC /* NetworkCarouselView.swift */; }; + D000363F2BB895FB00E582EC /* OAuth2.swift in Sources */ = {isa = PBXBuildFile; fileRef = D000363E2BB895FB00E582EC /* OAuth2.swift */; }; D00117312B2FFFC900D87C25 /* NWConnection+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00117302B2FFFC900D87C25 /* NWConnection+Async.swift */; }; D00117332B3001A400D87C25 /* NewlineProtocolFramer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00117322B3001A400D87C25 /* NewlineProtocolFramer.swift */; }; D001173B2B30341C00D87C25 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D001173A2B30341C00D87C25 /* Logging.swift */; }; @@ -78,6 +80,8 @@ 0B28F1552ABF463A000D44B0 /* DataTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataTypes.swift; sourceTree = ""; }; 0B46E8DF2AC918CA00BA2A3C /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = ""; }; 43AA26D72A10004900F14CE6 /* MenuItemToggleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemToggleView.swift; sourceTree = ""; }; + D000363C2BB8928E00E582EC /* NetworkCarouselView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkCarouselView.swift; sourceTree = ""; }; + D000363E2BB895FB00E582EC /* OAuth2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuth2.swift; sourceTree = ""; }; D00117302B2FFFC900D87C25 /* NWConnection+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NWConnection+Async.swift"; sourceTree = ""; }; D00117322B3001A400D87C25 /* NewlineProtocolFramer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewlineProtocolFramer.swift; sourceTree = ""; }; D00117382B30341C00D87C25 /* libBurrowShared.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libBurrowShared.a; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -247,7 +251,9 @@ D00AA8962A4669BC005C8102 /* AppDelegate.swift */, 43AA26D72A10004900F14CE6 /* MenuItemToggleView.swift */, D05B9F7729E39EEC008CB1F9 /* BurrowView.swift */, + D000363C2BB8928E00E582EC /* NetworkCarouselView.swift */, D01A79302B81630D0024EC91 /* NetworkView.swift */, + D000363E2BB895FB00E582EC /* OAuth2.swift */, D032E64D2B8A69C90006B8AD /* Networks */, D0FAB5972B818B8200F6A84B /* TunnelStatusView.swift */, D0FAB5952B818B2900F6A84B /* TunnelButton.swift */, @@ -476,6 +482,7 @@ 43AA26D82A10004900F14CE6 /* MenuItemToggleView.swift in Sources */, D05B9F7829E39EEC008CB1F9 /* BurrowView.swift in Sources */, D0FAB5922B818A5900F6A84B /* NetworkExtensionTunnel.swift in Sources */, + D000363F2BB895FB00E582EC /* OAuth2.swift in Sources */, D0FAB5962B818B2900F6A84B /* TunnelButton.swift in Sources */, D00AA8972A4669BC005C8102 /* AppDelegate.swift in Sources */, D05EF8C82B81818D0017AB4F /* FloatingButtonStyle.swift in Sources */, @@ -484,6 +491,7 @@ D01A79312B81630D0024EC91 /* NetworkView.swift in Sources */, D032E6542B8A79DA0006B8AD /* WireGuard.swift in Sources */, D0BCC5FD2A086D4700AD070D /* NetworkExtension+Async.swift in Sources */, + D000363D2BB8928E00E582EC /* NetworkCarouselView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From abf1101484166ff434287056e8c0f5af727ef39e Mon Sep 17 00:00:00 2001 From: Jett Chen Date: Mon, 22 Apr 2024 06:01:47 +0800 Subject: [PATCH 013/102] Wireguard Configuration in SQLite (#263) #241 --- .github/workflows/release-linux.yml | 34 ++++ .vscode/settings.json | 35 +++-- Apple/Burrow.xcodeproj/project.pbxproj | 24 +-- Apple/NetworkExtension/Client.swift | 60 -------- Apple/NetworkExtension/DataTypes.swift | 61 -------- .../PacketTunnelProvider.swift | 70 +++++---- Apple/NetworkExtension/libburrow/libburrow.h | 4 +- Apple/Shared/Client.swift | 106 +++++++++++++ Apple/Shared/Constants.swift | 10 ++ Apple/Shared/DataTypes.swift | 139 +++++++++++++++++ .../NWConnection+Async.swift | 0 .../NewlineProtocolFramer.swift | 0 Cargo.lock | 89 +++++++++++ Dockerfile | 26 +++- burrow-gtk/build-aux/Dockerfile | 4 +- burrow-gtk/build-aux/build_appimage.sh | 2 + burrow/Cargo.toml | 22 ++- burrow/burrow.db | Bin 0 -> 20480 bytes burrow/src/daemon/apple.rs | 14 +- burrow/src/daemon/instance.rs | 49 ++++-- burrow/src/daemon/mod.rs | 39 +++-- burrow/src/daemon/net/mod.rs | 11 +- burrow/src/daemon/net/unix.rs | 103 +++++++++---- burrow/src/daemon/rpc/mod.rs | 40 +++++ burrow/src/daemon/rpc/notification.rs | 11 ++ .../src/daemon/{command.rs => rpc/request.rs} | 9 ++ burrow/src/daemon/{ => rpc}/response.rs | 15 ++ ...equest__daemoncommand_serialization-2.snap | 5 + ...equest__daemoncommand_serialization-3.snap | 5 + ...equest__daemoncommand_serialization-4.snap | 5 + ...equest__daemoncommand_serialization-5.snap | 5 + ..._request__daemoncommand_serialization.snap | 5 + ...c__response__response_serialization-2.snap | 5 + ...c__response__response_serialization-3.snap | 5 + ...c__response__response_serialization-4.snap | 5 + ...rpc__response__response_serialization.snap | 5 + burrow/src/database.rs | 145 ++++++++++++++++++ burrow/src/lib.rs | 6 +- burrow/src/main.rs | 79 +++++----- burrow/src/wireguard/config.rs | 3 + burrow/src/wireguard/iface.rs | 36 +++-- burrow/src/wireguard/mod.rs | 2 +- tun/src/unix/apple/mod.rs | 20 +-- 43 files changed, 988 insertions(+), 325 deletions(-) create mode 100644 .github/workflows/release-linux.yml delete mode 100644 Apple/NetworkExtension/Client.swift delete mode 100644 Apple/NetworkExtension/DataTypes.swift create mode 100644 Apple/Shared/Client.swift create mode 100644 Apple/Shared/DataTypes.swift rename Apple/{NetworkExtension => Shared}/NWConnection+Async.swift (100%) rename Apple/{NetworkExtension => Shared}/NewlineProtocolFramer.swift (100%) create mode 100644 burrow/burrow.db create mode 100644 burrow/src/daemon/rpc/mod.rs create mode 100644 burrow/src/daemon/rpc/notification.rs rename burrow/src/daemon/{command.rs => rpc/request.rs} (82%) rename burrow/src/daemon/{ => rpc}/response.rs (89%) create mode 100644 burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization-2.snap create mode 100644 burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization-3.snap create mode 100644 burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization-4.snap create mode 100644 burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization-5.snap create mode 100644 burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization.snap create mode 100644 burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-2.snap create mode 100644 burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-3.snap create mode 100644 burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap create mode 100644 burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization.snap create mode 100644 burrow/src/database.rs diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml new file mode 100644 index 0000000..6709edb --- /dev/null +++ b/.github/workflows/release-linux.yml @@ -0,0 +1,34 @@ +name: Release (Linux) +on: + release: + types: + - created +jobs: + appimage: + name: Build AppImage + runs-on: ubuntu-latest + container: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Build AppImage + run: | + docker build -t appimage-builder . -f burrow-gtk/build-aux/Dockerfile + docker create --name temp appimage-builder + docker cp temp:/app/burrow-gtk/build-appimage/Burrow-x86_64.AppImage . + docker rm temp + - name: Get Build Number + id: version + shell: bash + run: | + echo "BUILD_NUMBER=$(Tools/version.sh)" >> $GITHUB_OUTPUT + - name: Attach Artifacts + uses: SierraSoftworks/gh-releases@v1.0.7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + release_tag: builds/${{ steps.version.outputs.BUILD_NUMBER }} + overwrite: "true" + files: | + Burrow-x86_64.AppImage diff --git a/.vscode/settings.json b/.vscode/settings.json index 3c714be..a760137 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,18 +1,19 @@ { - "files.autoSave": "onFocusChange", - "files.defaultLanguage": "rust", - "editor.formatOnPaste": true, - "editor.formatOnSave": true, - "files.trimTrailingWhitespace": true, - "editor.suggest.preview": true, - "editor.acceptSuggestionOnEnter": "on", - "rust-analyzer.restartServerOnConfigChange": true, - "rust-analyzer.cargo.features": "all", - "rust-analyzer.rustfmt.extraArgs": [ - "+nightly" - ], - "[rust]": { - "editor.defaultFormatter": "rust-lang.rust-analyzer", - }, - "rust-analyzer.inlayHints.typeHints.enable": false -} \ No newline at end of file + "files.autoSave": "onFocusChange", + "files.defaultLanguage": "rust", + "editor.formatOnPaste": true, + "editor.formatOnSave": true, + "files.trimTrailingWhitespace": true, + "editor.suggest.preview": true, + "editor.acceptSuggestionOnEnter": "on", + "rust-analyzer.restartServerOnConfigChange": true, + "rust-analyzer.cargo.features": "all", + "rust-analyzer.rustfmt.extraArgs": ["+nightly"], + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer" + }, + "rust-analyzer.inlayHints.typeHints.enable": false, + "rust-analyzer.linkedProjects": [ + "./burrow/Cargo.toml" + ] +} diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index 3ea58b3..a3be02d 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -7,13 +7,13 @@ objects = { /* Begin PBXBuildFile section */ - 0B28F1562ABF463A000D44B0 /* DataTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B28F1552ABF463A000D44B0 /* DataTypes.swift */; }; - 0B46E8E02AC918CA00BA2A3C /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B46E8DF2AC918CA00BA2A3C /* Client.swift */; }; + 0BA6D73B2BA638D900BD4B55 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B46E8DF2AC918CA00BA2A3C /* Client.swift */; }; + 0BA6D73C2BA6393200BD4B55 /* NWConnection+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00117302B2FFFC900D87C25 /* NWConnection+Async.swift */; }; + 0BA6D73D2BA6393B00BD4B55 /* NewlineProtocolFramer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00117322B3001A400D87C25 /* NewlineProtocolFramer.swift */; }; + 0BA6D73E2BA6394B00BD4B55 /* DataTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B28F1552ABF463A000D44B0 /* DataTypes.swift */; }; 43AA26D82A10004900F14CE6 /* MenuItemToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AA26D72A10004900F14CE6 /* MenuItemToggleView.swift */; }; D000363D2BB8928E00E582EC /* NetworkCarouselView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D000363C2BB8928E00E582EC /* NetworkCarouselView.swift */; }; D000363F2BB895FB00E582EC /* OAuth2.swift in Sources */ = {isa = PBXBuildFile; fileRef = D000363E2BB895FB00E582EC /* OAuth2.swift */; }; - D00117312B2FFFC900D87C25 /* NWConnection+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00117302B2FFFC900D87C25 /* NWConnection+Async.swift */; }; - D00117332B3001A400D87C25 /* NewlineProtocolFramer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00117322B3001A400D87C25 /* NewlineProtocolFramer.swift */; }; D001173B2B30341C00D87C25 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D001173A2B30341C00D87C25 /* Logging.swift */; }; D00117442B30372900D87C25 /* libBurrowShared.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D00117382B30341C00D87C25 /* libBurrowShared.a */; }; D00117452B30372C00D87C25 /* libBurrowShared.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D00117382B30341C00D87C25 /* libBurrowShared.a */; }; @@ -158,6 +158,10 @@ D00117392B30341C00D87C25 /* Shared */ = { isa = PBXGroup; children = ( + 0B28F1552ABF463A000D44B0 /* DataTypes.swift */, + D00117322B3001A400D87C25 /* NewlineProtocolFramer.swift */, + D00117302B2FFFC900D87C25 /* NWConnection+Async.swift */, + 0B46E8DF2AC918CA00BA2A3C /* Client.swift */, D001173A2B30341C00D87C25 /* Logging.swift */, D08252752B5C9FC4005DA378 /* Constants.swift */, D00117422B30348D00D87C25 /* Shared.xcconfig */, @@ -199,10 +203,6 @@ isa = PBXGroup; children = ( D020F65729E4A697002790F6 /* PacketTunnelProvider.swift */, - 0B46E8DF2AC918CA00BA2A3C /* Client.swift */, - 0B28F1552ABF463A000D44B0 /* DataTypes.swift */, - D00117322B3001A400D87C25 /* NewlineProtocolFramer.swift */, - D00117302B2FFFC900D87C25 /* NWConnection+Async.swift */, D020F65929E4A697002790F6 /* Info.plist */, D020F66729E4A95D002790F6 /* NetworkExtension-iOS.entitlements */, D020F66629E4A95D002790F6 /* NetworkExtension-macOS.entitlements */, @@ -456,7 +456,11 @@ buildActionMask = 2147483647; files = ( D001173B2B30341C00D87C25 /* Logging.swift in Sources */, + 0BA6D73C2BA6393200BD4B55 /* NWConnection+Async.swift in Sources */, D08252762B5C9FC4005DA378 /* Constants.swift in Sources */, + 0BA6D73E2BA6394B00BD4B55 /* DataTypes.swift in Sources */, + 0BA6D73B2BA638D900BD4B55 /* Client.swift in Sources */, + 0BA6D73D2BA6393B00BD4B55 /* NewlineProtocolFramer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -464,10 +468,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D00117332B3001A400D87C25 /* NewlineProtocolFramer.swift in Sources */, - 0B28F1562ABF463A000D44B0 /* DataTypes.swift in Sources */, - D00117312B2FFFC900D87C25 /* NWConnection+Async.swift in Sources */, - 0B46E8E02AC918CA00BA2A3C /* Client.swift in Sources */, D020F65829E4A697002790F6 /* PacketTunnelProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Apple/NetworkExtension/Client.swift b/Apple/NetworkExtension/Client.swift deleted file mode 100644 index e7c1bc8..0000000 --- a/Apple/NetworkExtension/Client.swift +++ /dev/null @@ -1,60 +0,0 @@ -import BurrowShared -import Foundation -import Network - -final class Client { - let connection: NWConnection - - private let logger = Logger.logger(for: Client.self) - private var generator = SystemRandomNumberGenerator() - - convenience init() throws { - self.init(url: try Constants.socketURL) - } - - init(url: URL) { - let endpoint: NWEndpoint - if url.isFileURL { - endpoint = .unix(path: url.path(percentEncoded: false)) - } else { - endpoint = .url(url) - } - - let parameters = NWParameters.tcp - parameters.defaultProtocolStack - .applicationProtocols - .insert(NWProtocolFramer.Options(definition: NewlineProtocolFramer.definition), at: 0) - connection = NWConnection(to: endpoint, using: parameters) - connection.start(queue: .global()) - } - - func request(_ request: any Request, type: U.Type = U.self) async throws -> U { - do { - var copy = request - copy.id = generator.next(upperBound: UInt.max) - let content = try JSONEncoder().encode(copy) - logger.debug("> \(String(decoding: content, as: UTF8.self))") - - try await self.connection.send(content: content) - let (response, _, _) = try await connection.receiveMessage() - - logger.debug("< \(String(decoding: response, as: UTF8.self))") - return try JSONDecoder().decode(U.self, from: response) - } catch { - logger.error("\(error, privacy: .public)") - throw error - } - } - - deinit { - connection.cancel() - } -} - -extension Constants { - static var socketURL: URL { - get throws { - try groupContainerURL.appending(component: "burrow.sock", directoryHint: .notDirectory) - } - } -} diff --git a/Apple/NetworkExtension/DataTypes.swift b/Apple/NetworkExtension/DataTypes.swift deleted file mode 100644 index 1409fde..0000000 --- a/Apple/NetworkExtension/DataTypes.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation - -// swiftlint:disable identifier_name -enum BurrowError: Error { - case addrDoesntExist - case resultIsError - case cantParseResult - case resultIsNone -} - -protocol Request: Codable where Command: Codable { - associatedtype Command - - var id: UInt { get set } - var command: Command { get set } -} - -struct BurrowSingleCommand: Request { - var id: UInt - var command: String -} - -struct BurrowRequest: Request where T: Codable { - var id: UInt - var command: T -} - -struct BurrowStartRequest: Codable { - struct TunOptions: Codable { - let name: String? - let no_pi: Bool - let tun_excl: Bool - let tun_retrieve: Bool - let address: [String] - } - struct StartOptions: Codable { - let tun: TunOptions - } - let Start: StartOptions -} - -struct Response: Decodable where T: Decodable { - var id: UInt - var result: T -} - -struct BurrowResult: Codable where T: Codable { - var Ok: T? - var Err: String? -} - -struct ServerConfigData: Codable { - struct InternalConfig: Codable { - let address: [String] - let name: String? - let mtu: Int32? - } - let ServerConfig: InternalConfig -} - -// swiftlint:enable identifier_name diff --git a/Apple/NetworkExtension/PacketTunnelProvider.swift b/Apple/NetworkExtension/PacketTunnelProvider.swift index a07daa3..89e0de6 100644 --- a/Apple/NetworkExtension/PacketTunnelProvider.swift +++ b/Apple/NetworkExtension/PacketTunnelProvider.swift @@ -5,10 +5,14 @@ import os class PacketTunnelProvider: NEPacketTunnelProvider { private let logger = Logger.logger(for: PacketTunnelProvider.self) + private var client: Client? override init() { do { - libburrow.spawnInProcess(socketPath: try Constants.socketURL.path) + libburrow.spawnInProcess( + socketPath: try Constants.socketURL.path(percentEncoded: false), + dbPath: try Constants.dbURL.path(percentEncoded: false) + ) } catch { logger.error("Failed to spawn: \(error)") } @@ -17,33 +21,17 @@ class PacketTunnelProvider: NEPacketTunnelProvider { override func startTunnel(options: [String: NSObject]? = nil) async throws { do { let client = try Client() + self.client = client + register_events(client) - let command = BurrowRequest(id: 0, command: "ServerConfig") - let data = try await client.request(command, type: Response>.self) - - let encoded = try JSONEncoder().encode(data.result) - self.logger.log("Received final data: \(String(decoding: encoded, as: UTF8.self))") - guard let serverconfig = data.result.Ok else { - throw BurrowError.resultIsError - } - guard let tunNs = generateTunSettings(from: serverconfig) else { - throw BurrowError.addrDoesntExist - } - try await self.setTunnelNetworkSettings(tunNs) - self.logger.info("Set remote tunnel address to \(tunNs.tunnelRemoteAddress)") - - let startRequest = BurrowRequest( - id: .random(in: (.min)..<(.max)), - command: BurrowStartRequest( - Start: BurrowStartRequest.StartOptions( - tun: BurrowStartRequest.TunOptions( - name: nil, no_pi: false, tun_excl: false, tun_retrieve: true, address: [] - ) - ) + _ = try await self.loadTunSettings() + let startRequest = Start( + tun: Start.TunOptions( + name: nil, no_pi: false, tun_excl: false, tun_retrieve: true, address: [] ) ) - let response = try await client.request(startRequest, type: Response>.self) - self.logger.log("Received start server response: \(String(describing: response.result))") + let response = try await client.request(startRequest, type: BurrowResult.self) + self.logger.log("Received start server response: \(String(describing: response))") } catch { self.logger.error("Failed to start tunnel: \(error)") throw error @@ -53,20 +41,33 @@ class PacketTunnelProvider: NEPacketTunnelProvider { override func stopTunnel(with reason: NEProviderStopReason) async { do { let client = try Client() - let command = BurrowRequest(id: 0, command: "Stop") - let data = try await client.request(command, type: Response>.self) + _ = try await client.single_request("Stop", type: BurrowResult.self) self.logger.log("Stopped client.") } catch { self.logger.error("Failed to stop tunnel: \(error)") } } - - private func generateTunSettings(from: ServerConfigData) -> NETunnelNetworkSettings? { - let cfig = from.ServerConfig + func loadTunSettings() async throws -> ServerConfig { + guard let client = self.client else { + throw BurrowError.noClient + } + let srvConfig = try await client.single_request("ServerConfig", type: BurrowResult.self) + guard let serverconfig = srvConfig.Ok else { + throw BurrowError.resultIsError + } + guard let tunNs = generateTunSettings(from: serverconfig) else { + throw BurrowError.addrDoesntExist + } + try await self.setTunnelNetworkSettings(tunNs) + self.logger.info("Set remote tunnel address to \(tunNs.tunnelRemoteAddress)") + return serverconfig + } + private func generateTunSettings(from: ServerConfig) -> NETunnelNetworkSettings? { + // Using a makeshift remote tunnel address let nst = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "1.1.1.1") var v4Addresses = [String]() var v6Addresses = [String]() - for addr in cfig.address { + for addr in from.address { if IPv4Address(addr) != nil { v6Addresses.append(addr) } @@ -81,4 +82,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider { logger.log("Initialized ipv4 settings: \(nst.ipv4Settings)") return nst } + func register_events(_ client: Client) { + client.on_event(.ConfigChange) { (cfig: ServerConfig) in + self.logger.info("Config Change Notification: \(String(describing: cfig))") + self.setTunnelNetworkSettings(self.generateTunSettings(from: cfig)) + self.logger.info("Updated Tunnel Network Settings.") + } + } } diff --git a/Apple/NetworkExtension/libburrow/libburrow.h b/Apple/NetworkExtension/libburrow/libburrow.h index e500de4..2b578ab 100644 --- a/Apple/NetworkExtension/libburrow/libburrow.h +++ b/Apple/NetworkExtension/libburrow/libburrow.h @@ -1,2 +1,2 @@ -__attribute__((__swift_name__("spawnInProcess(socketPath:)"))) -extern void spawn_in_process(const char * __nullable path); +__attribute__((__swift_name__("spawnInProcess(socketPath:dbPath:)"))) +extern void spawn_in_process(const char * __nullable socket_path, const char * __nullable db_path); diff --git a/Apple/Shared/Client.swift b/Apple/Shared/Client.swift new file mode 100644 index 0000000..f643c6c --- /dev/null +++ b/Apple/Shared/Client.swift @@ -0,0 +1,106 @@ +import Foundation +import Network + +public final class Client { + let connection: NWConnection + + private let logger = Logger.logger(for: Client.self) + private var generator = SystemRandomNumberGenerator() + private var continuations: [UInt: UnsafeContinuation] = [:] + private var eventMap: [NotificationType: [(Data) throws -> Void]] = [:] + private var task: Task? + + public convenience init() throws { + self.init(url: try Constants.socketURL) + } + + public init(url: URL) { + let endpoint: NWEndpoint + if url.isFileURL { + endpoint = .unix(path: url.path(percentEncoded: false)) + } else { + endpoint = .url(url) + } + + let parameters = NWParameters.tcp + parameters.defaultProtocolStack + .applicationProtocols + .insert(NWProtocolFramer.Options(definition: NewlineProtocolFramer.definition), at: 0) + let connection = NWConnection(to: endpoint, using: parameters) + connection.start(queue: .global()) + self.connection = connection + self.task = Task { [weak self] in + while true { + let (data, _, _) = try await connection.receiveMessage() + let peek = try JSONDecoder().decode(MessagePeek.self, from: data) + switch peek.type { + case .Response: + let response = try JSONDecoder().decode(ResponsePeek.self, from: data) + self?.logger.info("Received response for \(response.id)") + guard let continuations = self?.continuations else {return} + self?.logger.debug("All keys in continuation table: \(continuations.keys)") + guard let continuation = self?.continuations[response.id] else { return } + self?.logger.debug("Got matching continuation") + continuation.resume(returning: data) + case .Notification: + let peek = try JSONDecoder().decode(NotificationPeek.self, from: data) + guard let handlers = self?.eventMap[peek.method] else { continue } + _ = try handlers.map { try $0(data) } + default: + continue + } + } + } + } + private func send(_ request: T) async throws -> U { + let data: Data = try await withUnsafeThrowingContinuation { continuation in + continuations[request.id] = continuation + do { + let data = try JSONEncoder().encode(request) + let completion: NWConnection.SendCompletion = .contentProcessed { error in + guard let error = error else { + return + } + continuation.resume(throwing: error) + } + connection.send(content: data, completion: completion) + } catch { + continuation.resume(throwing: error) + return + } + } + self.logger.debug("Got response data: \(String(describing: data.base64EncodedString()))") + let res = try JSONDecoder().decode(Response.self, from: data) + self.logger.debug("Got response data decoded: \(String(describing: res))") + return res.result + } + public func request(_ request: T, type: U.Type = U.self) async throws -> U { + let req = BurrowRequest( + id: generator.next(upperBound: UInt.max), + command: request + ) + return try await send(req) + } + public func single_request(_ request: String, type: U.Type = U.self) async throws -> U { + let req = BurrowSimpleRequest( + id: generator.next(upperBound: UInt.max), + command: request + ) + return try await send(req) + } + public func on_event(_ event: NotificationType, callable: @escaping (T) throws -> Void) { + let action = { data in + let decoded = try JSONDecoder().decode(Notification.self, from: data) + try callable(decoded.params) + } + if eventMap[event] != nil { + eventMap[event]?.append(action) + } else { + eventMap[event] = [action] + } + } + + deinit { + connection.cancel() + } +} diff --git a/Apple/Shared/Constants.swift b/Apple/Shared/Constants.swift index 634c500..a8207cd 100644 --- a/Apple/Shared/Constants.swift +++ b/Apple/Shared/Constants.swift @@ -20,4 +20,14 @@ public enum Constants { } return .success(groupContainerURL) }() + public static var socketURL: URL { + get throws { + try groupContainerURL.appending(component: "burrow.sock", directoryHint: .notDirectory) + } + } + public static var dbURL: URL { + get throws { + try groupContainerURL.appending(component: "burrow.db", directoryHint: .notDirectory) + } + } } diff --git a/Apple/Shared/DataTypes.swift b/Apple/Shared/DataTypes.swift new file mode 100644 index 0000000..ac49abc --- /dev/null +++ b/Apple/Shared/DataTypes.swift @@ -0,0 +1,139 @@ +import Foundation + +// swiftlint:disable identifier_name raw_value_for_camel_cased_codable_enum +public enum BurrowError: Error { + case addrDoesntExist + case resultIsError + case cantParseResult + case resultIsNone + case noClient +} + +public protocol Request: Codable where Params: Codable { + associatedtype Params + + var id: UInt { get set } + var method: String { get set } + var params: Params? { get set } +} + +public enum MessageType: String, Codable { + case Request + case Response + case Notification +} + +public struct MessagePeek: Codable { + public var type: MessageType + public init(type: MessageType) { + self.type = type + } +} + +public struct BurrowSimpleRequest: Request { + public var id: UInt + public var method: String + public var params: String? + public init(id: UInt, command: String, params: String? = nil) { + self.id = id + self.method = command + self.params = params + } +} + +public struct BurrowRequest: Request where T: Codable { + public var id: UInt + public var method: String + public var params: T? + public init(id: UInt, command: T) { + self.id = id + self.method = "\(T.self)" + self.params = command + } +} + +public struct Response: Decodable where T: Decodable { + public var id: UInt + public var result: T + public init(id: UInt, result: T) { + self.id = id + self.result = result + } +} + +public struct ResponsePeek: Codable { + public var id: UInt + public init(id: UInt) { + self.id = id + } +} + +public enum NotificationType: String, Codable { + case ConfigChange +} + +public struct Notification: Codable where T: Codable { + public var method: NotificationType + public var params: T + public init(method: NotificationType, params: T) { + self.method = method + self.params = params + } +} + +public struct NotificationPeek: Codable { + public var method: NotificationType + public init(method: NotificationType) { + self.method = method + } +} + +public struct AnyResponseData: Codable { + public var type: String + public init(type: String) { + self.type = type + } +} + +public struct BurrowResult: Codable where T: Codable { + public var Ok: T? + public var Err: String? + public init(Ok: T, Err: String? = nil) { + self.Ok = Ok + self.Err = Err + } +} + +public struct ServerConfig: Codable { + public let address: [String] + public let name: String? + public let mtu: Int32? + public init(address: [String], name: String?, mtu: Int32?) { + self.address = address + self.name = name + self.mtu = mtu + } +} + +public struct Start: Codable { + public struct TunOptions: Codable { + public let name: String? + public let no_pi: Bool + public let tun_excl: Bool + public let tun_retrieve: Bool + public let address: [String] + public init(name: String?, no_pi: Bool, tun_excl: Bool, tun_retrieve: Bool, address: [String]) { + self.name = name + self.no_pi = no_pi + self.tun_excl = tun_excl + self.tun_retrieve = tun_retrieve + self.address = address + } + } + public let tun: TunOptions + public init(tun: TunOptions) { + self.tun = tun + } +} + +// swiftlint:enable identifier_name raw_value_for_camel_cased_codable_enum diff --git a/Apple/NetworkExtension/NWConnection+Async.swift b/Apple/Shared/NWConnection+Async.swift similarity index 100% rename from Apple/NetworkExtension/NWConnection+Async.swift rename to Apple/Shared/NWConnection+Async.swift diff --git a/Apple/NetworkExtension/NewlineProtocolFramer.swift b/Apple/Shared/NewlineProtocolFramer.swift similarity index 100% rename from Apple/NetworkExtension/NewlineProtocolFramer.swift rename to Apple/Shared/NewlineProtocolFramer.swift diff --git a/Cargo.lock b/Cargo.lock index a75bd28..628e996 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,18 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "ahash" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d713b3834d76b85304d4d525563c1276e2e30dc97cc67bfb4585a4a29fc2c89f" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.2" @@ -47,6 +59,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + [[package]] name = "anstream" version = "0.6.11" @@ -334,6 +352,7 @@ dependencies = [ "rand", "rand_core", "ring", + "rusqlite", "schemars", "serde", "serde_json", @@ -743,6 +762,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.0.1" @@ -967,6 +998,19 @@ name = "hashbrown" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashlink" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692eaaf7f7607518dd3cef090f1474b61edc5301d8012f09579920df68b725ee" +dependencies = [ + "hashbrown 0.14.3", +] [[package]] name = "hdrhistogram" @@ -1258,6 +1302,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libsystemd" version = "0.7.0" @@ -1877,6 +1932,20 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.4.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -2949,6 +3018,26 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "zerocopy" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "zeroize" version = "1.7.0" diff --git a/Dockerfile b/Dockerfile index 9f54478..afd51ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN set -eux && \ curl --proto '=https' --tlsv1.2 -sSf https://apt.llvm.org/llvm-snapshot.gpg.key | gpg --dearmor --output $KEYRINGS/llvm.gpg && \ echo "deb [signed-by=$KEYRINGS/llvm.gpg] http://apt.llvm.org/bookworm/ llvm-toolchain-bookworm-$LLVM_VERSION main" > /etc/apt/sources.list.d/llvm.list && \ apt-get update && \ - apt-get install --no-install-recommends -y clang-$LLVM_VERSION llvm-$LLVM_VERSION lld-$LLVM_VERSION && \ + apt-get install --no-install-recommends -y clang-$LLVM_VERSION llvm-$LLVM_VERSION lld-$LLVM_VERSION build-essential sqlite3 libsqlite3-dev musl musl-tools musl-dev && \ ln -s clang-$LLVM_VERSION /usr/bin/clang && \ ln -s clang /usr/bin/clang++ && \ ln -s lld-$LLVM_VERSION /usr/bin/ld.lld && \ @@ -24,12 +24,30 @@ RUN set -eux && \ apt-get remove -y --auto-remove && \ rm -rf /var/lib/apt/lists/* +ARG SQLITE_VERSION=3400100 + RUN case $TARGETPLATFORM in \ - "linux/arm64") LLVM_TARGET=aarch64-unknown-linux-musl ;; \ - "linux/amd64") LLVM_TARGET=x86_64-unknown-linux-musl ;; \ + "linux/arm64") LLVM_TARGET=aarch64-unknown-linux-musl MUSL_TARGET=aarch64-linux-musl ;; \ + "linux/amd64") LLVM_TARGET=x86_64-unknown-linux-musl MUSL_TARGET=x86_64-linux-musl ;; \ *) exit 1 ;; \ esac && \ - rustup target add $LLVM_TARGET + rustup target add $LLVM_TARGET && \ + curl --proto '=https' --tlsv1.2 -sSfO https://www.sqlite.org/2022/sqlite-autoconf-$SQLITE_VERSION.tar.gz && \ + tar xf sqlite-autoconf-$SQLITE_VERSION.tar.gz && \ + rm sqlite-autoconf-$SQLITE_VERSION.tar.gz && \ + cd sqlite-autoconf-$SQLITE_VERSION && \ + ./configure --disable-shared \ + CC="clang-$LLVM_VERSION -target $LLVM_TARGET" \ + CFLAGS="-I/usr/local/include -I/usr/include/$MUSL_TARGET" \ + LDFLAGS="-L/usr/local/lib -L/usr/lib/$MUSL_TARGET -L/lib/$MUSL_TARGET" && \ + make && \ + make install && \ + cd .. && \ + rm -rf sqlite-autoconf-$SQLITE_VERSION + +ENV SQLITE3_STATIC=1 \ + SQLITE3_INCLUDE_DIR=/usr/local/include \ + SQLITE3_LIB_DIR=/usr/local/lib ENV CC_x86_64_unknown_linux_musl=clang-$LLVM_VERSION \ AR_x86_64_unknown_linux_musl=llvm-ar-$LLVM_VERSION \ diff --git a/burrow-gtk/build-aux/Dockerfile b/burrow-gtk/build-aux/Dockerfile index df07c4a..4e71c05 100644 --- a/burrow-gtk/build-aux/Dockerfile +++ b/burrow-gtk/build-aux/Dockerfile @@ -4,7 +4,7 @@ ENV DEBIAN_FRONTEND=noninteractive RUN set -eux && \ dnf update -y && \ - dnf install -y clang ninja-build cmake meson gtk4-devel glib2-devel libadwaita-devel desktop-file-utils libappstream-glib util-linux wget fuse fuse-libs file + dnf install -y clang ninja-build cmake meson gtk4-devel glib2-devel libadwaita-devel desktop-file-utils libappstream-glib util-linux wget fuse fuse-libs file sqlite sqlite-devel RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal ENV PATH="/root/.cargo/bin:${PATH}" @@ -12,6 +12,8 @@ ENV PATH="/root/.cargo/bin:${PATH}" WORKDIR /app COPY . /app +ENV SQLITE3_STATIC=1 + RUN cd /app/burrow-gtk/ && \ ./build-aux/build_appimage.sh diff --git a/burrow-gtk/build-aux/build_appimage.sh b/burrow-gtk/build-aux/build_appimage.sh index cd58c17..f054cd9 100755 --- a/burrow-gtk/build-aux/build_appimage.sh +++ b/burrow-gtk/build-aux/build_appimage.sh @@ -22,6 +22,8 @@ elif [ "$ARCHITECTURE" == "aarch64" ]; then chmod a+x /tmp/linuxdeploy fi + +CFLAGS="-I/usr/local/include -I/usr/include/$MUSL_TARGET -fPIE" meson setup $BURROW_GTK_BUILD --bindir bin --prefix /usr --buildtype $BURROW_BUILD_TYPE meson compile -C $BURROW_GTK_BUILD DESTDIR=AppDir meson install -C $BURROW_GTK_BUILD diff --git a/burrow/Cargo.toml b/burrow/Cargo.toml index 4e7688b..0c816f8 100644 --- a/burrow/Cargo.toml +++ b/burrow/Cargo.toml @@ -10,7 +10,15 @@ crate-type = ["lib", "staticlib"] [dependencies] anyhow = "1.0" -tokio = { version = "1.21", features = ["rt", "macros", "sync", "io-util", "rt-multi-thread", "time", "tracing"] } +tokio = { version = "1.21", features = [ + "rt", + "macros", + "sync", + "io-util", + "rt-multi-thread", + "time", + "tracing", +] } tun = { version = "0.1", path = "../tun", features = ["serde", "tokio"] } clap = { version = "4.4", features = ["derive"] } tracing = "0.1" @@ -25,7 +33,10 @@ chacha20poly1305 = "0.10" rand = "0.8" rand_core = "0.6" aead = "0.5" -x25519-dalek = { version = "2.0", features = ["reusable_secrets", "static_secrets"] } +x25519-dalek = { version = "2.0", features = [ + "reusable_secrets", + "static_secrets", +] } ring = "0.17" parking_lot = "0.12" hmac = "0.12" @@ -37,9 +48,12 @@ async-channel = "2.1" schemars = "0.8" futures = "0.3.28" once_cell = "1.19" -console-subscriber = { version = "0.2.0" , optional = true } +console-subscriber = { version = "0.2.0", optional = true } console = "0.15.8" +[dependencies.rusqlite] +version = "0.31.0" + [target.'cfg(target_os = "linux")'.dependencies] caps = "0.5" libsystemd = "0.7" @@ -47,6 +61,7 @@ tracing-journald = "0.3" [target.'cfg(target_vendor = "apple")'.dependencies] nix = { version = "0.27" } +rusqlite = { version = "0.31.0", features = ["bundled"] } [dev-dependencies] insta = { version = "1.32", features = ["yaml"] } @@ -62,3 +77,4 @@ pre_uninstall_script = "../package/rpm/pre_uninstall" [features] tokio-console = ["dep:console-subscriber"] +bundled = ["rusqlite/bundled"] diff --git a/burrow/burrow.db b/burrow/burrow.db new file mode 100644 index 0000000000000000000000000000000000000000..c5b6e2c614ecb4db4c264f50691b6f2cea99772a GIT binary patch literal 20480 zcmWFz^vNtqRY=P(%1ta$FlG>7U}R))P*7lCU=U|uU|?lH0A>aT1{MUDff0#~iz&{a zSJuG`(#R{q!1sc08t)Wd5nPH##YaP6Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONfZicc z$HFcyEzQ^%T#}fSlbV-WQl4Lw4W(F}gIpa$TopnboqSvspn?h-TnbQ-nOBlpl$MyB z8lRb>;OQ5l5ajCS8szHd>>8|4o*oaE*2qlJRPgsx2n}!n8RzU6?Cj{`3N}Wwv7Q<1 zfaXxJ1Ip9m3sO^ypcD&=1E7LbbAS%m1t7nq=A{(mXXceCgt$h8DERq@DENi?_#os9 zN|SOjljE~fD{-kv%*n|wPfdx>EGWjMq@XCZI3uwrH3e=C*nZ6bCN^HWN82kl9QqrXkB9 z2QfHiUEN)S6as=geI0`$6}(*|6&yoD{5}1ggIs-G{X!4{1#$w|{|KR+%;J*Ny!e9r zq7qOV0hxr5%q=O!6f7vpEK4j&g$EOs2uVyyDM~HI8Pq9xXi|`n2KCLkcwHFyck!3- z>+!wdTf`T`C&qh$w~N<>-uZ6SzR?gE4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R80;b7 z!o|VBz|4@U&dYEr$KN#|EG0iT)hDDP#M7y)%s3>n*efVK)xe{`($6khIH_U^2USdAr-~_TR567W$rN|LLQhA3=b(zL9R1|XAY71JH1mFA{7sYRJyiIoQ0`5rzQLB0iH+K#3bj)4Xl&R*Ju=HcZQ zhK~MaAtqSUX|Z`ug??_jc2R1Wt8borUSVowq<>&`m5YU0o{@HXWL|}#uVrO=rh!Ga zZ6hlS#2qXH?G9#$JD3OB9ZV2+Fb%LfSQyzjLFL%MIs?@IXAq!ac|B_MXb6mkz-S1J ihQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2B~hX4R^(?&G_ literal 0 HcmV?d00001 diff --git a/burrow/src/daemon/apple.rs b/burrow/src/daemon/apple.rs index 9460613..c60f131 100644 --- a/burrow/src/daemon/apple.rs +++ b/burrow/src/daemon/apple.rs @@ -18,7 +18,7 @@ static BURROW_NOTIFY: OnceCell> = OnceCell::new(); static BURROW_HANDLE: OnceCell = OnceCell::new(); #[no_mangle] -pub unsafe extern "C" fn spawn_in_process(path: *const c_char) { +pub unsafe extern "C" fn spawn_in_process(path: *const c_char, db_path: *const c_char) { crate::tracing::initialize(); let notify = BURROW_NOTIFY.get_or_init(|| Arc::new(Notify::new())); @@ -28,6 +28,11 @@ pub unsafe extern "C" fn spawn_in_process(path: *const c_char) { } else { Some(PathBuf::from(CStr::from_ptr(path).to_str().unwrap())) }; + let db_path_buf = if db_path.is_null() { + None + } else { + Some(PathBuf::from(CStr::from_ptr(db_path).to_str().unwrap())) + }; let sender = notify.clone(); let (handle_tx, handle_rx) = tokio::sync::oneshot::channel(); @@ -40,7 +45,12 @@ pub unsafe extern "C" fn spawn_in_process(path: *const c_char) { .unwrap(); handle_tx.send(runtime.handle().clone()).unwrap(); runtime.block_on(async { - let result = daemon_main(path_buf.as_deref(), Some(sender.clone())).await; + let result = daemon_main( + path_buf.as_deref(), + db_path_buf.as_deref(), + Some(sender.clone()), + ) + .await; if let Err(error) = result.as_ref() { error!("Burrow thread exited: {}", error); } diff --git a/burrow/src/daemon/instance.rs b/burrow/src/daemon/instance.rs index 0d3e726..bc506bd 100644 --- a/burrow/src/daemon/instance.rs +++ b/burrow/src/daemon/instance.rs @@ -1,4 +1,7 @@ -use std::sync::Arc; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; use anyhow::Result; use tokio::{sync::RwLock, task::JoinHandle}; @@ -6,11 +9,16 @@ use tracing::{debug, info, warn}; use tun::tokio::TunInterface; use crate::{ - daemon::{ - command::DaemonCommand, - response::{DaemonResponse, DaemonResponseData, ServerConfig, ServerInfo}, + daemon::rpc::{ + DaemonCommand, + DaemonNotification, + DaemonResponse, + DaemonResponseData, + ServerConfig, + ServerInfo, }, - wireguard::Interface, + database::{get_connection, load_interface}, + wireguard::{Config, Interface}, }; enum RunState { @@ -21,8 +29,11 @@ enum RunState { pub struct DaemonInstance { rx: async_channel::Receiver, sx: async_channel::Sender, + subx: async_channel::Sender, tun_interface: Arc>>, wg_interface: Arc>, + config: Arc>, + db_path: Option, wg_state: RunState, } @@ -30,13 +41,19 @@ impl DaemonInstance { pub fn new( rx: async_channel::Receiver, sx: async_channel::Sender, + subx: async_channel::Sender, wg_interface: Arc>, + config: Arc>, + db_path: Option<&Path>, ) -> Self { Self { rx, sx, + subx, wg_interface, tun_interface: Arc::new(RwLock::new(None)), + config, + db_path: db_path.map(|p| p.to_owned()), wg_state: RunState::Idle, } } @@ -59,24 +76,13 @@ impl DaemonInstance { self.tun_interface = self.wg_interface.read().await.get_tun(); debug!("tun_interface set: {:?}", self.tun_interface); - debug!("Cloning wg_interface"); let tmp_wg = self.wg_interface.clone(); - debug!("wg_interface cloned"); - - debug!("Spawning run task"); let run_task = tokio::spawn(async move { - debug!("Running wg_interface"); let twlock = tmp_wg.read().await; - debug!("wg_interface read lock acquired"); twlock.run().await }); - debug!("Run task spawned: {:?}", run_task); - - debug!("Setting wg_state to Running"); self.wg_state = RunState::Running(run_task); - debug!("wg_state set to Running"); - info!("Daemon started tun interface"); } } @@ -99,6 +105,17 @@ impl DaemonInstance { DaemonCommand::ServerConfig => { Ok(DaemonResponseData::ServerConfig(ServerConfig::default())) } + DaemonCommand::ReloadConfig(interface_id) => { + let conn = get_connection(self.db_path.as_deref())?; + let cfig = load_interface(&conn, &interface_id)?; + *self.config.write().await = cfig; + self.subx + .send(DaemonNotification::ConfigChange(ServerConfig::try_from( + &self.config.read().await.to_owned(), + )?)) + .await?; + Ok(DaemonResponseData::None) + } } } diff --git a/burrow/src/daemon/mod.rs b/burrow/src/daemon/mod.rs index 2a971dd..4469e90 100644 --- a/burrow/src/daemon/mod.rs +++ b/burrow/src/daemon/mod.rs @@ -1,40 +1,53 @@ use std::{path::Path, sync::Arc}; pub mod apple; -mod command; mod instance; mod net; -mod response; +pub mod rpc; use anyhow::Result; -pub use command::{DaemonCommand, DaemonStartOptions}; use instance::DaemonInstance; pub use net::{DaemonClient, Listener}; -pub use response::{DaemonResponse, DaemonResponseData, ServerInfo}; +pub use rpc::{DaemonCommand, DaemonResponseData, DaemonStartOptions}; use tokio::sync::{Notify, RwLock}; use tracing::{error, info}; -use crate::wireguard::{Config, Interface}; +use crate::{ + database::{get_connection, load_interface}, + wireguard::Interface, +}; -pub async fn daemon_main(path: Option<&Path>, notify_ready: Option>) -> Result<()> { +pub async fn daemon_main( + socket_path: Option<&Path>, + db_path: Option<&Path>, + notify_ready: Option>, +) -> Result<()> { let (commands_tx, commands_rx) = async_channel::unbounded(); let (response_tx, response_rx) = async_channel::unbounded(); + let (subscribe_tx, subscribe_rx) = async_channel::unbounded(); - let listener = if let Some(path) = path { + let listener = if let Some(path) = socket_path { info!("Creating listener... {:?}", path); - Listener::new_with_path(commands_tx, response_rx, path) + Listener::new_with_path(commands_tx, response_rx, subscribe_rx, path) } else { info!("Creating listener..."); - Listener::new(commands_tx, response_rx) + Listener::new(commands_tx, response_rx, subscribe_rx) }; if let Some(n) = notify_ready { n.notify_one() } let listener = listener?; - - let config = Config::default(); - let iface: Interface = config.try_into()?; - let mut instance = DaemonInstance::new(commands_rx, response_tx, Arc::new(RwLock::new(iface))); + let conn = get_connection(db_path)?; + let config = load_interface(&conn, "1")?; + let iface: Interface = config.clone().try_into()?; + let mut instance = DaemonInstance::new( + commands_rx, + response_tx, + subscribe_tx, + Arc::new(RwLock::new(iface)), + Arc::new(RwLock::new(config)), + db_path, + ); info!("Starting daemon..."); diff --git a/burrow/src/daemon/net/mod.rs b/burrow/src/daemon/net/mod.rs index fe35bae..242f479 100644 --- a/burrow/src/daemon/net/mod.rs +++ b/burrow/src/daemon/net/mod.rs @@ -1,6 +1,7 @@ -use serde::{Deserialize, Serialize}; -use super::DaemonCommand; + + + #[cfg(target_family = "unix")] mod unix; @@ -14,8 +15,4 @@ mod windows; #[cfg(target_os = "windows")] pub use windows::{DaemonClient, Listener}; -#[derive(Clone, Serialize, Deserialize)] -pub struct DaemonRequest { - pub id: u64, - pub command: DaemonCommand, -} + diff --git a/burrow/src/daemon/net/unix.rs b/burrow/src/daemon/net/unix.rs index 26e901d..70c4207 100644 --- a/burrow/src/daemon/net/unix.rs +++ b/burrow/src/daemon/net/unix.rs @@ -10,8 +10,14 @@ use tokio::{ }; use tracing::{debug, error, info}; -use super::*; -use crate::daemon::{DaemonCommand, DaemonResponse, DaemonResponseData}; +use crate::daemon::rpc::{ + DaemonCommand, + DaemonMessage, + DaemonNotification, + DaemonRequest, + DaemonResponse, + DaemonResponseData, +}; #[cfg(not(target_vendor = "apple"))] const UNIX_SOCKET_PATH: &str = "/run/burrow.sock"; @@ -19,10 +25,17 @@ const UNIX_SOCKET_PATH: &str = "/run/burrow.sock"; #[cfg(target_vendor = "apple")] const UNIX_SOCKET_PATH: &str = "burrow.sock"; -#[derive(Debug)] +fn get_socket_path() -> String { + if std::env::var("BURROW_SOCKET_PATH").is_ok() { + return std::env::var("BURROW_SOCKET_PATH").unwrap(); + } + UNIX_SOCKET_PATH.to_string() +} + pub struct Listener { cmd_tx: async_channel::Sender, rsp_rx: async_channel::Receiver, + sub_chan: async_channel::Receiver, inner: UnixListener, } @@ -31,9 +44,11 @@ impl Listener { pub fn new( cmd_tx: async_channel::Sender, rsp_rx: async_channel::Receiver, + sub_chan: async_channel::Receiver, ) -> Self { - let path = Path::new(OsStr::new(UNIX_SOCKET_PATH)); - Self::new_with_path(cmd_tx, rsp_rx, path)? + let socket_path = get_socket_path(); + let path = Path::new(OsStr::new(&socket_path)); + Self::new_with_path(cmd_tx, rsp_rx, sub_chan, path)? } #[throws] @@ -41,10 +56,16 @@ impl Listener { pub fn new_with_path( cmd_tx: async_channel::Sender, rsp_rx: async_channel::Receiver, + sub_chan: async_channel::Receiver, path: &Path, ) -> Self { let inner = listener_from_path_or_fd(&path, raw_fd())?; - Self { cmd_tx, rsp_rx, inner } + Self { + cmd_tx, + rsp_rx, + sub_chan, + inner, + } } #[throws] @@ -52,10 +73,16 @@ impl Listener { pub fn new_with_path( cmd_tx: async_channel::Sender, rsp_rx: async_channel::Receiver, + sub_chan: async_channel::Receiver, path: &Path, ) -> Self { let inner = listener_from_path(path)?; - Self { cmd_tx, rsp_rx, inner } + Self { + cmd_tx, + rsp_rx, + inner, + sub_chan, + } } pub async fn run(&self) -> Result<()> { @@ -64,9 +91,10 @@ impl Listener { let (stream, _) = self.inner.accept().await?; let cmd_tx = self.cmd_tx.clone(); let rsp_rxc = self.rsp_rx.clone(); + let sub_chan = self.sub_chan.clone(); tokio::task::spawn(async move { info!("Got connection: {:?}", stream); - Self::stream(stream, cmd_tx, rsp_rxc).await; + Self::stream(stream, cmd_tx, rsp_rxc, sub_chan).await; }); } } @@ -75,34 +103,46 @@ impl Listener { stream: UnixStream, cmd_tx: async_channel::Sender, rsp_rxc: async_channel::Receiver, + sub_chan: async_channel::Receiver, ) { let mut stream = stream; let (mut read_stream, mut write_stream) = stream.split(); let buf_reader = BufReader::new(&mut read_stream); let mut lines = buf_reader.lines(); - while let Ok(Some(line)) = lines.next_line().await { - info!("Line: {}", line); - let mut res: DaemonResponse = DaemonResponseData::None.into(); - let req = match serde_json::from_str::(&line) { - Ok(req) => Some(req), - Err(e) => { - res.result = Err(e.to_string()); - error!("Failed to parse request: {}", e); - None - } - }; - let mut res = serde_json::to_string(&res).unwrap(); - res.push('\n'); + loop { + tokio::select! { + Ok(Some(line)) = lines.next_line() => { + info!("Line: {}", line); + let mut res: DaemonResponse = DaemonResponseData::None.into(); + let req = match serde_json::from_str::(&line) { + Ok(req) => Some(req), + Err(e) => { + res.result = Err(e.to_string()); + error!("Failed to parse request: {}", e); + None + } + }; - if let Some(req) = req { - cmd_tx.send(req.command).await.unwrap(); - let res = rsp_rxc.recv().await.unwrap().with_id(req.id); - let mut retres = serde_json::to_string(&res).unwrap(); - retres.push('\n'); - info!("Sending response: {}", retres); - write_stream.write_all(retres.as_bytes()).await.unwrap(); - } else { - write_stream.write_all(res.as_bytes()).await.unwrap(); + let res = serde_json::to_string(&DaemonMessage::from(res)).unwrap(); + + if let Some(req) = req { + cmd_tx.send(req.command).await.unwrap(); + let res = rsp_rxc.recv().await.unwrap().with_id(req.id); + let mut payload = serde_json::to_string(&DaemonMessage::from(res)).unwrap(); + payload.push('\n'); + info!("Sending response: {}", payload); + write_stream.write_all(payload.as_bytes()).await.unwrap(); + } else { + write_stream.write_all(res.as_bytes()).await.unwrap(); + } + } + Ok(cmd) = sub_chan.recv() => { + info!("Got subscription command: {:?}", cmd); + let msg = DaemonMessage::from(cmd); + let mut payload = serde_json::to_string(&msg).unwrap(); + payload.push('\n'); + write_stream.write_all(payload.as_bytes()).await.unwrap(); + } } } } @@ -176,7 +216,8 @@ pub struct DaemonClient { impl DaemonClient { pub async fn new() -> Result { - let path = Path::new(OsStr::new(UNIX_SOCKET_PATH)); + let socket_path = get_socket_path(); + let path = Path::new(OsStr::new(&socket_path)); Self::new_with_path(path).await } diff --git a/burrow/src/daemon/rpc/mod.rs b/burrow/src/daemon/rpc/mod.rs new file mode 100644 index 0000000..4146e71 --- /dev/null +++ b/burrow/src/daemon/rpc/mod.rs @@ -0,0 +1,40 @@ +pub mod notification; +pub mod request; +pub mod response; + +pub use notification::DaemonNotification; +pub use request::{DaemonCommand, DaemonRequest, DaemonStartOptions}; +pub use response::{DaemonResponse, DaemonResponseData, ServerConfig, ServerInfo}; +use serde::{Deserialize, Serialize}; + +/// The `Message` object contains either a `DaemonRequest` or a `DaemonResponse` to be serialized / deserialized +/// for our IPC communication. Our IPC protocol is based on jsonrpc (https://www.jsonrpc.org/specification#overview), +/// but deviates from it in a few ways: +/// - We differentiate Notifications from Requests explicitly. +/// - We have a "type" field to differentiate between a request, a response, and a notification. +/// - The params field may receive any json value(such as a string), not just an object or an array. +#[derive(Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum DaemonMessage { + Request(DaemonRequest), + Response(DaemonResponse), + Notification(DaemonNotification), +} + +impl From for DaemonMessage { + fn from(request: DaemonRequest) -> Self { + DaemonMessage::Request(request) + } +} + +impl From for DaemonMessage { + fn from(response: DaemonResponse) -> Self { + DaemonMessage::Response(response) + } +} + +impl From for DaemonMessage { + fn from(notification: DaemonNotification) -> Self { + DaemonMessage::Notification(notification) + } +} diff --git a/burrow/src/daemon/rpc/notification.rs b/burrow/src/daemon/rpc/notification.rs new file mode 100644 index 0000000..135b0e4 --- /dev/null +++ b/burrow/src/daemon/rpc/notification.rs @@ -0,0 +1,11 @@ +use rpc::ServerConfig; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::daemon::rpc; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "method", content = "params")] +pub enum DaemonNotification { + ConfigChange(ServerConfig), +} diff --git a/burrow/src/daemon/command.rs b/burrow/src/daemon/rpc/request.rs similarity index 82% rename from burrow/src/daemon/command.rs rename to burrow/src/daemon/rpc/request.rs index 53b4108..e9480aa 100644 --- a/burrow/src/daemon/command.rs +++ b/burrow/src/daemon/rpc/request.rs @@ -3,11 +3,13 @@ use serde::{Deserialize, Serialize}; use tun::TunOptions; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag="method", content="params")] pub enum DaemonCommand { Start(DaemonStartOptions), ServerInfo, ServerConfig, Stop, + ReloadConfig(String), } #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] @@ -15,6 +17,13 @@ pub struct DaemonStartOptions { pub tun: TunOptions, } +#[derive(Clone, Serialize, Deserialize)] +pub struct DaemonRequest { + pub id: u64, + #[serde(flatten)] + pub command: DaemonCommand, +} + #[test] fn test_daemoncommand_serialization() { insta::assert_snapshot!(serde_json::to_string(&DaemonCommand::Start( diff --git a/burrow/src/daemon/response.rs b/burrow/src/daemon/rpc/response.rs similarity index 89% rename from burrow/src/daemon/response.rs rename to burrow/src/daemon/rpc/response.rs index 37ee5d9..61c9c50 100644 --- a/burrow/src/daemon/response.rs +++ b/burrow/src/daemon/rpc/response.rs @@ -2,6 +2,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tun::TunInterface; +use crate::wireguard::Config; + #[derive(Clone, Serialize, Deserialize, Debug, JsonSchema)] pub struct DaemonResponse { // Error types can't be serialized, so this is the second best option. @@ -62,6 +64,18 @@ pub struct ServerConfig { pub mtu: Option, } +impl TryFrom<&Config> for ServerConfig { + type Error = anyhow::Error; + + fn try_from(config: &Config) -> anyhow::Result { + Ok(ServerConfig { + address: config.interface.address.clone(), + name: None, + mtu: config.interface.mtu.map(|mtu| mtu as i32), + }) + } +} + impl Default for ServerConfig { fn default() -> Self { Self { @@ -73,6 +87,7 @@ impl Default for ServerConfig { } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type")] pub enum DaemonResponseData { ServerInfo(ServerInfo), ServerConfig(ServerConfig), diff --git a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization-2.snap b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization-2.snap new file mode 100644 index 0000000..01ec8a7 --- /dev/null +++ b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization-2.snap @@ -0,0 +1,5 @@ +--- +source: burrow/src/daemon/rpc/request.rs +expression: "serde_json::to_string(&DaemonCommand::Start(DaemonStartOptions {\n tun: TunOptions { ..TunOptions::default() },\n })).unwrap()" +--- +{"method":"Start","params":{"tun":{"name":null,"no_pi":false,"tun_excl":false,"tun_retrieve":false,"address":[]}}} diff --git a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization-3.snap b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization-3.snap new file mode 100644 index 0000000..a6a0466 --- /dev/null +++ b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization-3.snap @@ -0,0 +1,5 @@ +--- +source: burrow/src/daemon/rpc/request.rs +expression: "serde_json::to_string(&DaemonCommand::ServerInfo).unwrap()" +--- +{"method":"ServerInfo"} diff --git a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization-4.snap b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization-4.snap new file mode 100644 index 0000000..f930051 --- /dev/null +++ b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization-4.snap @@ -0,0 +1,5 @@ +--- +source: burrow/src/daemon/rpc/request.rs +expression: "serde_json::to_string(&DaemonCommand::Stop).unwrap()" +--- +{"method":"Stop"} diff --git a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization-5.snap b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization-5.snap new file mode 100644 index 0000000..89dc42c --- /dev/null +++ b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization-5.snap @@ -0,0 +1,5 @@ +--- +source: burrow/src/daemon/rpc/request.rs +expression: "serde_json::to_string(&DaemonCommand::ServerConfig).unwrap()" +--- +{"method":"ServerConfig"} diff --git a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization.snap b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization.snap new file mode 100644 index 0000000..aeca659 --- /dev/null +++ b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__request__daemoncommand_serialization.snap @@ -0,0 +1,5 @@ +--- +source: burrow/src/daemon/rpc/request.rs +expression: "serde_json::to_string(&DaemonCommand::Start(DaemonStartOptions::default())).unwrap()" +--- +{"method":"Start","params":{"tun":{"name":null,"no_pi":false,"tun_excl":false,"tun_retrieve":false,"address":[]}}} diff --git a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-2.snap b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-2.snap new file mode 100644 index 0000000..d7bd712 --- /dev/null +++ b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-2.snap @@ -0,0 +1,5 @@ +--- +source: burrow/src/daemon/rpc/response.rs +expression: "serde_json::to_string(&DaemonResponse::new(Ok::(DaemonResponseData::ServerInfo(ServerInfo {\n name: Some(\"burrow\".to_string()),\n ip: None,\n mtu: Some(1500),\n }))))?" +--- +{"result":{"Ok":{"type":"ServerInfo","name":"burrow","ip":null,"mtu":1500}},"id":0} diff --git a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-3.snap b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-3.snap new file mode 100644 index 0000000..30068f3 --- /dev/null +++ b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-3.snap @@ -0,0 +1,5 @@ +--- +source: burrow/src/daemon/rpc/response.rs +expression: "serde_json::to_string(&DaemonResponse::new(Err::(\"error\".to_string())))?" +--- +{"result":{"Err":"error"},"id":0} diff --git a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap new file mode 100644 index 0000000..c40db25 --- /dev/null +++ b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap @@ -0,0 +1,5 @@ +--- +source: burrow/src/daemon/rpc/response.rs +expression: "serde_json::to_string(&DaemonResponse::new(Ok::(DaemonResponseData::ServerConfig(ServerConfig::default()))))?" +--- +{"result":{"Ok":{"type":"ServerConfig","address":["10.13.13.2"],"name":null,"mtu":null}},"id":0} diff --git a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization.snap b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization.snap new file mode 100644 index 0000000..31bd84b --- /dev/null +++ b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization.snap @@ -0,0 +1,5 @@ +--- +source: burrow/src/daemon/rpc/response.rs +expression: "serde_json::to_string(&DaemonResponse::new(Ok::(DaemonResponseData::None)))?" +--- +{"result":{"Ok":{"type":"None"}},"id":0} diff --git a/burrow/src/database.rs b/burrow/src/database.rs new file mode 100644 index 0000000..0047b01 --- /dev/null +++ b/burrow/src/database.rs @@ -0,0 +1,145 @@ +use std::path::Path; + +use anyhow::Result; +use rusqlite::{params, Connection}; + +use crate::wireguard::config::{Config, Interface, Peer}; + +#[cfg(target_vendor = "apple")] +const DB_PATH: &str = "burrow.db"; + +#[cfg(not(target_vendor = "apple"))] +const DB_PATH: &str = "/var/lib/burrow/burrow.db"; + +const CREATE_WG_INTERFACE_TABLE: &str = "CREATE TABLE IF NOT EXISTS wg_interface ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + listen_port INTEGER, + mtu INTEGER, + private_key TEXT NOT NULL, + address TEXT NOT NULL, + dns TEXT NOT NULL +)"; + +const CREATE_WG_PEER_TABLE: &str = "CREATE TABLE IF NOT EXISTS wg_peer ( + interface_id INT REFERENCES wg_interface(id) ON UPDATE CASCADE, + endpoint TEXT NOT NULL, + public_key TEXT NOT NULL, + allowed_ips TEXT NOT NULL, + preshared_key TEXT +)"; + +const CREATE_NETWORK_TABLE: &str = "CREATE TABLE IF NOT EXISTS network ( + interface_id INT REFERENCES wg_interface(id) ON UPDATE CASCADE +)"; + +pub fn initialize_tables(conn: &Connection) -> Result<()> { + conn.execute(CREATE_WG_INTERFACE_TABLE, [])?; + conn.execute(CREATE_WG_PEER_TABLE, [])?; + conn.execute(CREATE_NETWORK_TABLE, [])?; + Ok(()) +} + +fn parse_lst(s: &str) -> Vec { + if s.is_empty() { + return vec![]; + } + s.split(',').map(|s| s.to_string()).collect() +} + +fn to_lst(v: &Vec) -> String { + v.iter() + .map(|s| s.to_string()) + .collect::>() + .join(",") +} + +pub fn load_interface(conn: &Connection, interface_id: &str) -> Result { + let iface = conn.query_row( + "SELECT private_key, dns, address, listen_port, mtu FROM wg_interface WHERE id = ?", + [&interface_id], + |row| { + let dns_rw: String = row.get(1)?; + let dns = parse_lst(&dns_rw); + let address_rw: String = row.get(2)?; + let address = parse_lst(&address_rw); + Ok(Interface { + private_key: row.get(0)?, + dns, + address, + mtu: row.get(4)?, + listen_port: row.get(3)?, + }) + }, + )?; + let mut peers_stmt = conn.prepare("SELECT public_key, preshared_key, allowed_ips, endpoint FROM wg_peer WHERE interface_id = ?")?; + let peers = peers_stmt + .query_map([&interface_id], |row| { + let preshared_key: Option = row.get(1)?; + let allowed_ips_rw: String = row.get(2)?; + let allowed_ips: Vec = + allowed_ips_rw.split(',').map(|s| s.to_string()).collect(); + Ok(Peer { + public_key: row.get(0)?, + preshared_key, + allowed_ips, + endpoint: row.get(3)?, + persistent_keepalive: None, + name: None, + }) + })? + .collect::>>()?; + Ok(Config { interface: iface, peers }) +} + +pub fn dump_interface(conn: &Connection, config: &Config) -> Result<()> { + let mut stmt = conn.prepare("INSERT INTO wg_interface (private_key, dns, address, listen_port, mtu) VALUES (?, ?, ?, ?, ?)")?; + let cif = &config.interface; + stmt.execute(params![ + cif.private_key, + to_lst(&cif.dns), + to_lst(&cif.address), + cif.listen_port, + cif.mtu + ])?; + let interface_id = conn.last_insert_rowid(); + let mut stmt = conn.prepare("INSERT INTO wg_peer (interface_id, public_key, preshared_key, allowed_ips, endpoint) VALUES (?, ?, ?, ?, ?)")?; + for peer in &config.peers { + stmt.execute(params![ + &interface_id, + &peer.public_key, + &peer.preshared_key, + &peer.allowed_ips.join(","), + &peer.endpoint + ])?; + } + Ok(()) +} + +pub fn get_connection(path: Option<&Path>) -> Result { + let p = path.unwrap_or_else(|| std::path::Path::new(DB_PATH)); + if !p.exists() { + let conn = Connection::open(p)?; + initialize_tables(&conn)?; + dump_interface(&conn, &Config::default())?; + return Ok(conn); + } + Ok(Connection::open(p)?) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::*; + + #[test] + fn test_db() { + let conn = Connection::open_in_memory().unwrap(); + initialize_tables(&conn).unwrap(); + let config = Config::default(); + dump_interface(&conn, &config).unwrap(); + let loaded = load_interface(&conn, "1").unwrap(); + assert_eq!(config, loaded); + } +} diff --git a/burrow/src/lib.rs b/burrow/src/lib.rs index c5406b2..d9ebf7e 100644 --- a/burrow/src/lib.rs +++ b/burrow/src/lib.rs @@ -3,16 +3,18 @@ pub mod wireguard; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod daemon; +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +pub mod database; pub(crate) mod tracing; #[cfg(target_vendor = "apple")] pub use daemon::apple::spawn_in_process; #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub use daemon::{ + rpc::DaemonResponse, + rpc::ServerInfo, DaemonClient, DaemonCommand, - DaemonResponse, DaemonResponseData, DaemonStartOptions, - ServerInfo, }; diff --git a/burrow/src/main.rs b/burrow/src/main.rs index 71d1c02..295373a 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -14,6 +14,9 @@ use tun::TunOptions; #[cfg(any(target_os = "linux", target_vendor = "apple"))] use crate::daemon::DaemonResponseData; +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +pub mod database; + #[derive(Parser)] #[command(name = "Burrow")] #[command(author = "Hack Club ")] @@ -42,6 +45,14 @@ enum Commands { ServerInfo, /// Server config ServerConfig, + /// Reload Config + ReloadConfig(ReloadConfigArgs), +} + +#[derive(Args)] +struct ReloadConfigArgs { + #[clap(long, short)] + interface_id: String, } #[derive(Args)] @@ -69,13 +80,8 @@ async fn try_stop() -> Result<()> { } #[cfg(any(target_os = "linux", target_vendor = "apple"))] -async fn try_serverinfo() -> Result<()> { - let mut client = DaemonClient::new().await?; - let res = client.send_command(DaemonCommand::ServerInfo).await?; - match res.result { - Ok(DaemonResponseData::ServerInfo(si)) => { - println!("Got Result! {:?}", si); - } +fn handle_unexpected(res: Result) { + match res { Ok(DaemonResponseData::None) => { println!("Server not started.") } @@ -86,6 +92,17 @@ async fn try_serverinfo() -> Result<()> { println!("Error when retrieving from server: {}", e) } } +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_serverinfo() -> Result<()> { + let mut client = DaemonClient::new().await?; + let res = client.send_command(DaemonCommand::ServerInfo).await?; + if let Ok(DaemonResponseData::ServerInfo(si)) = res.result { + println!("Got Result! {:?}", si); + } else { + handle_unexpected(res.result); + } Ok(()) } @@ -93,40 +110,25 @@ async fn try_serverinfo() -> Result<()> { async fn try_serverconfig() -> Result<()> { let mut client = DaemonClient::new().await?; let res = client.send_command(DaemonCommand::ServerConfig).await?; - match res.result { - Ok(DaemonResponseData::ServerConfig(cfig)) => { - println!("Got Result! {:?}", cfig); - } - Ok(DaemonResponseData::None) => { - println!("Server not started.") - } - Ok(res) => { - println!("Unexpected Response: {:?}", res) - } - Err(e) => { - println!("Error when retrieving from server: {}", e) - } + if let Ok(DaemonResponseData::ServerConfig(cfig)) = res.result { + println!("Got Result! {:?}", cfig); + } else { + handle_unexpected(res.result); } Ok(()) } -#[cfg(not(any(target_os = "linux", target_vendor = "apple")))] -async fn try_start() -> Result<()> { - Ok(()) -} - -#[cfg(not(any(target_os = "linux", target_vendor = "apple")))] -async fn try_stop() -> Result<()> { - Ok(()) -} - -#[cfg(not(any(target_os = "linux", target_vendor = "apple")))] -async fn try_serverinfo() -> Result<()> { - Ok(()) -} - -#[cfg(not(any(target_os = "linux", target_vendor = "apple")))] -async fn try_serverconfig() -> Result<()> { +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_reloadconfig(interface_id: String) -> Result<()> { + let mut client = DaemonClient::new().await?; + let res = client + .send_command(DaemonCommand::ReloadConfig(interface_id)) + .await?; + if let Ok(DaemonResponseData::ServerConfig(cfig)) = res.result { + println!("Got Result! {:?}", cfig); + } else { + handle_unexpected(res.result); + } Ok(()) } @@ -139,9 +141,10 @@ async fn main() -> Result<()> { match &cli.command { Commands::Start(..) => try_start().await?, Commands::Stop => try_stop().await?, - Commands::Daemon(_) => daemon::daemon_main(None, None).await?, + Commands::Daemon(_) => daemon::daemon_main(None, None, None).await?, Commands::ServerInfo => try_serverinfo().await?, Commands::ServerConfig => try_serverconfig().await?, + Commands::ReloadConfig(args) => try_reloadconfig(args.interface_id.clone()).await?, } Ok(()) diff --git a/burrow/src/wireguard/config.rs b/burrow/src/wireguard/config.rs index ed7b3cd..bd86a9f 100644 --- a/burrow/src/wireguard/config.rs +++ b/burrow/src/wireguard/config.rs @@ -31,6 +31,7 @@ fn parse_public_key(string: &str) -> PublicKey { /// A raw version of Peer Config that can be used later to reflect configuration files. /// This should be later converted to a `WgPeer`. /// Refers to https://github.com/pirate/wireguard-docs?tab=readme-ov-file#overview +#[derive(Debug, Clone, Eq, PartialEq)] pub struct Peer { pub public_key: String, pub preshared_key: Option, @@ -40,6 +41,7 @@ pub struct Peer { pub name: Option, } +#[derive(Debug, Clone, Eq, PartialEq)] pub struct Interface { pub private_key: String, pub address: Vec, @@ -48,6 +50,7 @@ pub struct Interface { pub mtu: Option, } +#[derive(Debug, Clone, Eq, PartialEq)] pub struct Config { pub peers: Vec, pub interface: Interface, // Support for multiple interfaces? diff --git a/burrow/src/wireguard/iface.rs b/burrow/src/wireguard/iface.rs index 6097082..84b5489 100755 --- a/burrow/src/wireguard/iface.rs +++ b/burrow/src/wireguard/iface.rs @@ -1,21 +1,26 @@ -use std::{net::IpAddr, sync::Arc}; -use std::ops::Deref; +use std::{net::IpAddr, ops::Deref, sync::Arc}; use anyhow::Error; use fehler::throws; use futures::future::join_all; use ip_network_table::IpNetworkTable; -use tokio::sync::{RwLock, Notify}; +use tokio::sync::{Notify, RwLock}; use tracing::{debug, error}; use tun::tokio::TunInterface; use super::{noise::Tunnel, Peer, PeerPcb}; -struct IndexedPcbs { +pub struct IndexedPcbs { pcbs: Vec>, allowed_ips: IpNetworkTable, } +impl Default for IndexedPcbs { + fn default() -> Self { + Self::new() + } +} + impl IndexedPcbs { pub fn new() -> Self { Self { @@ -49,12 +54,12 @@ impl FromIterator for IndexedPcbs { enum IfaceStatus { Running, - Idle + Idle, } pub struct Interface { - tun: Arc>>, - pcbs: Arc, + pub tun: Arc>>, + pub pcbs: Arc, status: Arc>, stop_notifier: Arc, } @@ -73,7 +78,12 @@ impl Interface { .collect::>()?; let pcbs = Arc::new(pcbs); - Self { pcbs, tun: Arc::new(RwLock::new(None)), status: Arc::new(RwLock::new(IfaceStatus::Idle)), stop_notifier: Arc::new(Notify::new()) } + Self { + pcbs, + tun: Arc::new(RwLock::new(None)), + status: Arc::new(RwLock::new(IfaceStatus::Idle)), + stop_notifier: Arc::new(Notify::new()), + } } pub async fn set_tun(&self, tun: TunInterface) { @@ -87,7 +97,7 @@ impl Interface { self.tun.clone() } - pub async fn remove_tun(&self){ + pub async fn remove_tun(&self) { let mut st = self.status.write().await; self.stop_notifier.notify_waiters(); *st = IfaceStatus::Idle; @@ -95,9 +105,7 @@ impl Interface { pub async fn run(&self) -> anyhow::Result<()> { let pcbs = self.pcbs.clone(); - let tun = self - .tun - .clone(); + let tun = self.tun.clone(); let status = self.status.clone(); let stop_notifier = self.stop_notifier.clone(); log::info!("Starting interface"); @@ -153,9 +161,7 @@ impl Interface { }; let mut tsks = vec![]; - let tun = self - .tun - .clone(); + let tun = self.tun.clone(); let outgoing = tokio::task::spawn(outgoing); tsks.push(outgoing); debug!("preparing to spawn read tasks"); diff --git a/burrow/src/wireguard/mod.rs b/burrow/src/wireguard/mod.rs index 15563fb..4c70a7f 100755 --- a/burrow/src/wireguard/mod.rs +++ b/burrow/src/wireguard/mod.rs @@ -1,4 +1,4 @@ -mod config; +pub mod config; mod iface; mod noise; mod pcb; diff --git a/tun/src/unix/apple/mod.rs b/tun/src/unix/apple/mod.rs index 6e859ca..74e93eb 100644 --- a/tun/src/unix/apple/mod.rs +++ b/tun/src/unix/apple/mod.rs @@ -1,11 +1,13 @@ -use std::{io::{Error, IoSlice}, mem, net::{Ipv4Addr, SocketAddrV4}, os::fd::{AsRawFd, FromRawFd, RawFd}, ptr}; -use std::net::{IpAddr, Ipv6Addr, SocketAddrV6}; -use std::ptr::addr_of; +use std::{ + io::{Error, IoSlice}, + mem, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddrV4}, + os::fd::{AsRawFd, FromRawFd, RawFd}, +}; use byteorder::{ByteOrder, NetworkEndian}; use fehler::throws; -use libc::{c_char, iovec, writev, AF_INET, AF_INET6, sockaddr_in6}; -use nix::sys::socket::SockaddrIn6; +use libc::{c_char, iovec, writev, AF_INET, AF_INET6}; use socket2::{Domain, SockAddr, Socket, Type}; use tracing::{self, instrument}; @@ -69,11 +71,11 @@ impl TunInterface { #[throws] fn configure(&self, options: TunOptions) { - for addr in options.address{ + for addr in options.address { if let Ok(addr) = addr.parse::() { match addr { - IpAddr::V4(addr) => {self.set_ipv4_addr(addr)?} - IpAddr::V6(addr) => {self.set_ipv6_addr(addr)?} + IpAddr::V4(addr) => self.set_ipv4_addr(addr)?, + IpAddr::V6(addr) => self.set_ipv6_addr(addr)?, } } } @@ -146,7 +148,7 @@ impl TunInterface { } #[throws] - pub fn set_ipv6_addr(&self, addr: Ipv6Addr) { + pub fn set_ipv6_addr(&self, _addr: Ipv6Addr) { // let addr = SockAddr::from(SocketAddrV6::new(addr, 0, 0, 0)); // println!("addr: {:?}", addr); // let mut iff = self.in6_ifreq()?; From bca07c33b8bf9ef44f4a57e7a1fa6fdc15ba4790 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 25 May 2024 09:06:53 -0700 Subject: [PATCH 014/102] Start authentication flow --- .gitignore | 1 + .../NetworkExtension/libburrow/build-rust.sh | 2 +- Cargo.lock | 1153 +++++++++++------ Cargo.toml | 5 + Dockerfile | 55 +- burrow/Cargo.toml | 15 +- burrow/src/auth/client.rs | 24 + burrow/src/auth/mod.rs | 2 + burrow/src/auth/server/db.rs | 89 ++ burrow/src/auth/server/mod.rs | 62 + burrow/src/auth/server/providers/mod.rs | 8 + burrow/src/auth/server/providers/slack.rs | 102 ++ burrow/src/lib.rs | 2 + burrow/src/main.rs | 12 +- tun/Cargo.toml | 9 +- 15 files changed, 1104 insertions(+), 437 deletions(-) create mode 100644 burrow/src/auth/client.rs create mode 100644 burrow/src/auth/mod.rs create mode 100644 burrow/src/auth/server/db.rs create mode 100644 burrow/src/auth/server/mod.rs create mode 100644 burrow/src/auth/server/providers/mod.rs create mode 100644 burrow/src/auth/server/providers/slack.rs diff --git a/.gitignore b/.gitignore index dc886ed..96b2507 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ xcuserdata # Rust target/ +.env .DS_STORE .idea/ diff --git a/Apple/NetworkExtension/libburrow/build-rust.sh b/Apple/NetworkExtension/libburrow/build-rust.sh index fffa0d0..e7204a5 100755 --- a/Apple/NetworkExtension/libburrow/build-rust.sh +++ b/Apple/NetworkExtension/libburrow/build-rust.sh @@ -70,7 +70,7 @@ fi # Run cargo without the various environment variables set by Xcode. # Those variables can confuse cargo and the build scripts it runs. -env -i PATH="$CARGO_PATH" CARGO_TARGET_DIR="${CONFIGURATION_TEMP_DIR}/target" cargo build "${CARGO_ARGS[@]}" +env -i PATH="$CARGO_PATH" CARGO_TARGET_DIR="${CONFIGURATION_TEMP_DIR}/target" IPHONEOS_DEPLOYMENT_TARGET="$IPHONEOS_DEPLOYMENT_TARGET" MACOSX_DEPLOYMENT_TARGET="$MACOSX_DEPLOYMENT_TARGET" cargo build "${CARGO_ARGS[@]}" mkdir -p "${BUILT_PRODUCTS_DIR}" diff --git a/Cargo.lock b/Cargo.lock index 628e996..ce263f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "addr2line" -version = "0.21.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" dependencies = [ "gimli", ] @@ -29,9 +29,9 @@ dependencies = [ [[package]] name = "aes" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", @@ -40,9 +40,9 @@ dependencies = [ [[package]] name = "ahash" -version = "0.8.9" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d713b3834d76b85304d4d525563c1276e2e30dc97cc67bfb4585a4a29fc2c89f" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if", "once_cell", @@ -52,62 +52,57 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" - [[package]] name = "anstream" -version = "0.6.11" +version = "0.6.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e1ebcb11de5c03c67de28a7df593d32191b44939c482e97702baaaa6ab6a5" +checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", + "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" -version = "1.0.4" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" +checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" [[package]] name = "anstyle-parse" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" +checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.0.2" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" +checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391" dependencies = [ "windows-sys 0.52.0", ] [[package]] name = "anstyle-wincon" -version = "3.0.2" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" +checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" dependencies = [ "anstyle", "windows-sys 0.52.0", @@ -115,18 +110,17 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.79" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "async-channel" -version = "2.1.1" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ca33f4bc4ed1babef42cad36cc1f51fa88be00420404e5b1e80ab1b18f7678c" +checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" dependencies = [ "concurrent-queue", - "event-listener", "event-listener-strategy", "futures-core", "pin-project-lite", @@ -151,25 +145,25 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.68", ] [[package]] name = "async-trait" -version = "0.1.77" +version = "0.1.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" +checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.68", ] [[package]] name = "autocfg" -version = "1.1.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "axum" @@ -178,13 +172,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" dependencies = [ "async-trait", - "axum-core", + "axum-core 0.3.4", "bitflags 1.3.2", "bytes", "futures-util", - "http", - "http-body", - "hyper", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.29", "itoa", "matchit", "memchr", @@ -193,12 +187,46 @@ dependencies = [ "pin-project-lite", "rustversion", "serde", - "sync_wrapper", + "sync_wrapper 0.1.2", "tower", "tower-layer", "tower-service", ] +[[package]] +name = "axum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf" +dependencies = [ + "async-trait", + "axum-core 0.4.3", + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "hyper 1.4.0", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.1", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-core" version = "0.3.4" @@ -208,8 +236,8 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http", - "http-body", + "http 0.2.12", + "http-body 0.4.6", "mime", "rustversion", "tower-layer", @@ -217,10 +245,31 @@ dependencies = [ ] [[package]] -name = "backtrace" -version = "0.3.69" +name = "axum-core" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +checksum = "a15c63fd72d41492dc4f497196f5da1fb04fb7529e631d73630d1b491e47a2e3" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 0.1.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backtrace" +version = "0.3.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" dependencies = [ "addr2line", "cc", @@ -237,6 +286,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.6.0" @@ -284,7 +339,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.48", + "syn 2.0.68", "which", ] @@ -296,9 +351,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.4.2" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "blake2" @@ -320,9 +375,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.14.0" +version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "burrow" @@ -331,13 +386,15 @@ dependencies = [ "aead", "anyhow", "async-channel", - "base64", + "axum 0.7.5", + "base64 0.21.7", "blake2", "caps", "chacha20poly1305", "clap", "console", "console-subscriber", + "dotenv", "fehler", "futures", "hmac", @@ -351,6 +408,7 @@ dependencies = [ "parking_lot", "rand", "rand_core", + "reqwest 0.12.5", "ring", "rusqlite", "schemars", @@ -374,9 +432,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.5.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" [[package]] name = "bzip2" @@ -411,12 +469,13 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.83" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +checksum = "74b6a57f98764a267ff415d50a25e6e166f3831a5071af4995296ea97d210490" dependencies = [ "jobserver", "libc", + "once_cell", ] [[package]] @@ -471,20 +530,20 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67523a3b4be3ce1989d607a828d036249522dd9c1c8de7f4dd2dae43a37369d1" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading 0.8.1", + "libloading 0.8.4", ] [[package]] name = "clap" -version = "4.4.18" +version = "4.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e578d6ec4194633722ccf9544794b71b1385c3c027efe0c55db226fc880865c" +checksum = "84b3edb18336f4df585bc9aa31dd99c036dfa5dc5e9a2939a722a188f3a8970d" dependencies = [ "clap_builder", "clap_derive", @@ -492,9 +551,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.4.18" +version = "4.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4df4df40ec50c46000231c914968278b1eb05098cf8f1b3a518a95030e71d1c7" +checksum = "c1c09dd5ada6c6c78075d6fd0da3f90d8080651e2d6cc8eb2f1aaa4034ced708" dependencies = [ "anstream", "anstyle", @@ -504,33 +563,33 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.4.7" +version = "4.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" +checksum = "2bac35c6dafb060fd4d275d9a4ffae97917c13a6327903a8be2153cd964f7085" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.68", ] [[package]] name = "clap_lex" -version = "0.6.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" +checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" [[package]] name = "colorchoice" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" [[package]] name = "concurrent-queue" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16048cd947b08fa32c24458a22f5dc5e835264f689f4f5653210c69fd107363" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ "crossbeam-utils", ] @@ -618,27 +677,27 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.3.2" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-channel" -version = "0.5.11" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "176dc175b78f56c0f321911d9c8eb2b77a78a4860b9c19db83835fea1a46649b" +checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.19" +version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" [[package]] name = "crypto-common" @@ -653,15 +712,14 @@ dependencies = [ [[package]] name = "curve25519-dalek" -version = "4.1.1" +version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89b8c6a2e4b1f45971ad09761aafb85514a84744b67a95e32c3cc1352d1f65c" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", "fiat-crypto", - "platforms", "rustc_version", "subtle", "zeroize", @@ -675,7 +733,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.68", ] [[package]] @@ -699,16 +757,22 @@ dependencies = [ ] [[package]] -name = "dyn-clone" -version = "1.0.16" +name = "dotenv" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "dyn-clone" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" [[package]] name = "either" -version = "1.9.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "encode_unicode" @@ -718,9 +782,9 @@ checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" [[package]] name = "encoding_rs" -version = "0.8.33" +version = "0.8.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" dependencies = [ "cfg-if", ] @@ -733,9 +797,9 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" dependencies = [ "libc", "windows-sys 0.52.0", @@ -743,9 +807,9 @@ dependencies = [ [[package]] name = "event-listener" -version = "4.0.3" +version = "5.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b215c49b2b248c855fb73579eb1f4f26c38ffdc12973e20e07b91d78d5646e" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" dependencies = [ "concurrent-queue", "parking", @@ -754,9 +818,9 @@ dependencies = [ [[package]] name = "event-listener-strategy" -version = "0.4.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958e4d70b6d5e81971bebec42271ec641e7ff4e170a6fa605f2b8a8b65cb97d3" +checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" dependencies = [ "event-listener", "pin-project-lite", @@ -776,9 +840,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" [[package]] name = "fehler" @@ -802,15 +866,15 @@ dependencies = [ [[package]] name = "fiat-crypto" -version = "0.2.5" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27573eac26f4dd11e2b1916c3fe1baa56407c83c71a773a8ba17ec0bca03b6b7" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "flate2" -version = "1.0.28" +version = "1.0.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" +checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" dependencies = [ "crc32fast", "miniz_oxide", @@ -902,7 +966,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.68", ] [[package]] @@ -947,9 +1011,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.12" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "libc", @@ -958,9 +1022,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.28.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" [[package]] name = "glob" @@ -970,17 +1034,17 @@ checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" [[package]] name = "h2" -version = "0.3.24" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" dependencies = [ "bytes", "fnv", "futures-core", "futures-sink", "futures-util", - "http", - "indexmap 2.1.0", + "http 0.2.12", + "indexmap 2.2.6", "slab", "tokio", "tokio-util", @@ -995,21 +1059,20 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" -version = "0.14.3" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", - "allocator-api2", ] [[package]] name = "hashlink" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692eaaf7f7607518dd3cef090f1474b61edc5301d8012f09579920df68b725ee" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown 0.14.3", + "hashbrown 0.14.5", ] [[package]] @@ -1018,7 +1081,7 @@ version = "7.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" dependencies = [ - "base64", + "base64 0.21.7", "byteorder", "flate2", "nom", @@ -1027,15 +1090,15 @@ dependencies = [ [[package]] name = "heck" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.3.4" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d3d0e0f38255e7fa3cf31335b3a56f05febd18025f4db5ef7a0cfb4f8da651f" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] name = "hex" @@ -1063,9 +1126,20 @@ dependencies = [ [[package]] name = "http" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" dependencies = [ "bytes", "fnv", @@ -1079,15 +1153,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" +dependencies = [ + "bytes", + "http 1.1.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", "pin-project-lite", ] [[package]] name = "httparse" -version = "1.8.0" +version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" +checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" [[package]] name = "httpdate" @@ -1103,17 +1200,17 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" -version = "0.14.28" +version = "0.14.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" +checksum = "f361cde2f109281a220d4307746cdfd5ee3f410da58a70377762396775634b33" dependencies = [ "bytes", "futures-channel", "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 0.2.12", + "http-body 0.4.6", "httparse", "httpdate", "itoa", @@ -1125,13 +1222,51 @@ dependencies = [ "want", ] +[[package]] +name = "hyper" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4fe55fb7a772d59a5ff1dfbff4fe0258d19b89fec4b233e75d35d5d2316badc" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155" +dependencies = [ + "futures-util", + "http 1.1.0", + "hyper 1.4.0", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-timeout" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" dependencies = [ - "hyper", + "hyper 0.14.29", "pin-project-lite", "tokio", "tokio-io-timeout", @@ -1144,12 +1279,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ "bytes", - "hyper", + "hyper 0.14.29", "native-tls", "tokio", "tokio-native-tls", ] +[[package]] +name = "hyper-util" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab92f4f49ee4fb4f997c784b7a2e0fa70050211e0b6a287f898c3c9785ca956" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "hyper 1.4.0", + "pin-project-lite", + "socket2", + "tokio", + "tower", + "tower-service", + "tracing", +] + [[package]] name = "idna" version = "0.5.0" @@ -1172,12 +1327,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.1.0" +version = "2.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" dependencies = [ "equivalent", - "hashbrown 0.14.3", + "hashbrown 0.14.5", ] [[package]] @@ -1191,16 +1346,15 @@ dependencies = [ [[package]] name = "insta" -version = "1.34.0" +version = "1.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d64600be34b2fcfc267740a243fa7744441bb4947a619ac4e5bb6507f35fbfc" +checksum = "810ae6042d48e2c9e9215043563a58a80b877bc863228a74cf10c49d4620a6f5" dependencies = [ "console", "lazy_static", "linked-hash-map", "serde", "similar", - "yaml-rust", ] [[package]] @@ -1232,43 +1386,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" [[package]] -name = "itertools" -version = "0.11.0" +name = "is_terminal_polyfill" +version = "1.70.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" dependencies = [ "either", ] [[package]] name = "itoa" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "jobserver" -version = "0.1.27" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c37f63953c4c63420ed5fd3d6d398c719489b9f872b9fa683262f8edd363c7d" +checksum = "d2b099aaa34a9751c5bf0878add70444e1ed2dd73f347be99003d4577277de6e" dependencies = [ "libc", ] [[package]] name = "js-sys" -version = "0.3.67" +version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a1d36f1235bc969acba30b7f5990b864423a6068a10f7c90ae8f0112e3a59d1" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" dependencies = [ "wasm-bindgen", ] [[package]] name = "lazy_static" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "lazycell" @@ -1278,9 +1438,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.152" +version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" [[package]] name = "libloading" @@ -1294,12 +1454,12 @@ dependencies = [ [[package]] name = "libloading" -version = "0.8.1" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c571b676ddfc9a8c12f1f3d3085a7b163966a8fd8098a90640953ce5f6170161" +checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d" dependencies = [ "cfg-if", - "windows-sys 0.48.0", + "windows-targets 0.52.6", ] [[package]] @@ -1339,15 +1499,15 @@ checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] name = "linux-raw-sys" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "lock_api" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", @@ -1355,9 +1515,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.20" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "matchers" @@ -1376,9 +1536,9 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "memchr" -version = "2.7.1" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "memoffset" @@ -1391,9 +1551,9 @@ dependencies = [ [[package]] name = "memoffset" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" dependencies = [ "autocfg", ] @@ -1418,7 +1578,7 @@ checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.68", ] [[package]] @@ -1435,18 +1595,18 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.7.1" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" dependencies = [ "adler", ] [[package]] name = "mio" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", "wasi", @@ -1455,11 +1615,10 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" dependencies = [ - "lazy_static", "libc", "log", "openssl", @@ -1490,10 +1649,10 @@ version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" dependencies = [ - "bitflags 2.4.2", + "bitflags 2.6.0", "cfg-if", "libc", - "memoffset 0.9.0", + "memoffset 0.9.1", ] [[package]] @@ -1517,10 +1676,16 @@ dependencies = [ ] [[package]] -name = "num-traits" -version = "0.2.17" +name = "num-conv" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] @@ -1537,9 +1702,9 @@ dependencies = [ [[package]] name = "object" -version = "0.32.2" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +checksum = "081b846d1d56ddfc18fdf1a922e4f6e07a11768ea1b92dec44e42b72712ccfce" dependencies = [ "memchr", ] @@ -1552,17 +1717,17 @@ checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "opaque-debug" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.63" +version = "0.10.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15c9d69dd87a29568d4d017cfe8ec518706046a05184e5aea92d0af890b803c8" +checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" dependencies = [ - "bitflags 2.4.2", + "bitflags 2.6.0", "cfg-if", "foreign-types", "libc", @@ -1579,7 +1744,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.68", ] [[package]] @@ -1590,9 +1755,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.99" +version = "0.9.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e1bf214306098e4832460f797824c05d25aacdf896f64a985fb0fd992454ae" +checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2" dependencies = [ "cc", "libc", @@ -1614,9 +1779,9 @@ checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" [[package]] name = "parking_lot" -version = "0.12.1" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" dependencies = [ "lock_api", "parking_lot_core", @@ -1624,15 +1789,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.9" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -1672,29 +1837,29 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0302c4a0442c456bd56f841aee5c3bfd17967563f6fadc9ceb9f9c23cf3807e0" +checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "266c042b60c9c76b8d53061e52b2e0d1116abc57cefc8c5cd671619a56ac3690" +checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.68", ] [[package]] name = "pin-project-lite" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" [[package]] name = "pin-utils" @@ -1704,15 +1869,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2900ede94e305130c13ddd391e0ab7cbaeb783945ae07a279c268cb05109c6cb" - -[[package]] -name = "platforms" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626dec3cac7cc0e1577a2ec3fc496277ec2baa084bebad95bb6fdbfae235f84c" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" [[package]] name = "poly1305" @@ -1739,28 +1898,28 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "prettyplease" -version = "0.2.16" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a41cf62165e97c7f814d2221421dbb9afcbcdb0a88068e5ea206e19951c2cbb5" +checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e" dependencies = [ "proc-macro2", - "syn 2.0.48", + "syn 2.0.68", ] [[package]] name = "proc-macro2" -version = "1.0.78" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" dependencies = [ "unicode-ident", ] [[package]] name = "prost" -version = "0.12.3" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c289cda302b98a28d40c8b3b90498d6e526dd24ac2ecea73e4e491685b94a" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" dependencies = [ "bytes", "prost-derive", @@ -1768,31 +1927,78 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.12.3" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efb6c9a1dd1def8e2124d17e83a20af56f1570d6c2d2bd9e266ccb768df3840e" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ "anyhow", "itertools", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.68", ] [[package]] name = "prost-types" -version = "0.12.3" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "193898f59edcf43c26227dcd4c8427f00d99d61e95dcde58dabd49fa291d470e" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" dependencies = [ "prost", ] [[package]] -name = "quote" -version = "1.0.35" +name = "quinn" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +checksum = "e4ceeeeabace7857413798eb1ffa1e9c905a9946a57d81fb69b4b71c4d8eb3ad" +dependencies = [ + "bytes", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "quinn-proto" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf517c03a109db8100448a4be38d498df8a210a99fe0e1b9eaf39e78c640efe" +dependencies = [ + "bytes", + "rand", + "ring", + "rustc-hash", + "rustls", + "slab", + "thiserror", + "tinyvec", + "tracing", +] + +[[package]] +name = "quinn-udp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9096629c45860fc7fb143e125eb826b5e721e10be3263160c7d60ca832cf8c46" +dependencies = [ + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" dependencies = [ "proc-macro2", ] @@ -1829,23 +2035,23 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.4.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +checksum = "c82cf8cff14456045f55ec4241383baeff27af886adb72ffb2162f99911de0fd" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.6.0", ] [[package]] name = "regex" -version = "1.10.3" +version = "1.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" +checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.5", - "regex-syntax 0.8.2", + "regex-automata 0.4.7", + "regex-syntax 0.8.4", ] [[package]] @@ -1859,13 +2065,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.5" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.2", + "regex-syntax 0.8.4", ] [[package]] @@ -1876,25 +2082,25 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" [[package]] name = "reqwest" -version = "0.11.23" +version = "0.11.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b1ae8d9ac08420c66222fb9096fc5de435c3c48542bc5336c51892cffafb41" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ - "base64", + "base64 0.21.7", "bytes", "encoding_rs", "futures-core", "futures-util", "h2", - "http", - "http-body", - "hyper", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.29", "hyper-tls", "ipnet", "js-sys", @@ -1904,9 +2110,11 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "rustls-pemfile 1.0.4", "serde", "serde_json", "serde_urlencoded", + "sync_wrapper 0.1.2", "system-configuration", "tokio", "tokio-native-tls", @@ -1915,21 +2123,64 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "winreg", + "winreg 0.50.0", +] + +[[package]] +name = "reqwest" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "hyper 1.4.0", + "hyper-rustls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pemfile 2.1.2", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.1", + "tokio", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg 0.52.0", ] [[package]] name = "ring" -version = "0.17.7" +version = "0.17.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "688c63d65483050968b2a8937f7995f443e27041a0f7700aa59b0822aedebb74" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" dependencies = [ "cc", + "cfg-if", "getrandom", "libc", "spin", "untrusted", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -1938,7 +2189,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags 2.4.2", + "bitflags 2.6.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -1948,9 +2199,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.23" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustc-hash" @@ -1969,11 +2220,11 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.30" +version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "322394588aaf33c24007e8bb3238ee3e4c5c09c084ab32bc73890b99ff326bca" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ - "bitflags 2.4.2", + "bitflags 2.6.0", "errno", "libc", "linux-raw-sys", @@ -1981,16 +2232,66 @@ dependencies = [ ] [[package]] -name = "rustversion" -version = "1.0.14" +name = "rustls" +version = "0.23.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" +checksum = "05cff451f60db80f490f3c182b77c35260baace73209e9cdbbe526bfe3a4d402" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pemfile" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" +dependencies = [ + "base64 0.22.1", + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" + +[[package]] +name = "rustls-webpki" +version = "0.102.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a6fccd794a42c2c105b513a2f62bc3fd8f3ba57a4593677ceb0bd035164d78" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" [[package]] name = "ryu" -version = "1.0.16" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "schannel" @@ -2003,9 +2304,9 @@ dependencies = [ [[package]] name = "schemars" -version = "0.8.16" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a28f4c49489add4ce10783f7911893516f15afe45d015608d41faca6bc4d29" +checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92" dependencies = [ "dyn-clone", "schemars_derive", @@ -2015,14 +2316,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "0.8.16" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c767fd6fa65d9ccf9cf026122c1b555f2ef9a4f0cea69da4d7dbc3e258d30967" +checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 1.0.109", + "syn 2.0.68", ] [[package]] @@ -2033,11 +2334,11 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "security-framework" -version = "2.9.2" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" +checksum = "c627723fd09706bacdb5cf41499e95098555af3c3c29d014dc3c458ef6be11c0" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.6.0", "core-foundation", "core-foundation-sys", "libc", @@ -2046,9 +2347,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.9.1" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" +checksum = "317936bbbd05227752583946b9e66d7ce3b489f84e11a94a510b4437fef407d7" dependencies = [ "core-foundation-sys", "libc", @@ -2056,52 +2357,62 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97ed7a9823b74f99c7742f5336af7be5ecd3eeafcb1507d1fa93347b1d589b0" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.196" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.196" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.68", ] [[package]] name = "serde_derive_internals" -version = "0.26.0" +version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85bf8229e7920a9f636479437026331ce11aa132b4dde37d121944a44d6e5f3c" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.68", ] [[package]] name = "serde_json" -version = "1.0.112" +version = "1.0.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d1bd37ce2324cf3bf85e5a25f96eb4baf0d5aa6eba43e7ae8958870c4ec48ed" +checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5" dependencies = [ "itoa", "ryu", "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2163,10 +2474,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] -name = "similar" -version = "2.4.0" +name = "signal-hook-registry" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32fea41aca09ee824cc9724996433064c89f7777e60762749a4170a14abbfa21" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "similar" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42c91313f1d05da9b26f267f931cf178d4aba455b4c4622dd7355eb80c6640" [[package]] name = "slab" @@ -2179,18 +2499,18 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "socket2" -version = "0.5.5" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -2205,7 +2525,7 @@ version = "9.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7a2b3c2bc9693bcb40870c4e9b5bf0d79f9cb46273321bf855ec513e919082" dependencies = [ - "base64", + "base64 0.21.7", "digest", "hex", "miette", @@ -2217,15 +2537,15 @@ dependencies = [ [[package]] name = "strsim" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subtle" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" @@ -2240,9 +2560,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.48" +version = "2.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +checksum = "901fa70d88b9d6c98022e23b4136f9f3e54e4662c3bc1bd1d84a42a9a0f0c1e9" dependencies = [ "proc-macro2", "quote", @@ -2255,6 +2575,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" + [[package]] name = "system-configuration" version = "0.5.1" @@ -2278,42 +2604,41 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.9.0" +version = "3.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01ce4141aa927a6d1bd34a041795abd0db1cccba5d5f24b009f694bdf3a1f3fa" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" dependencies = [ "cfg-if", "fastrand", - "redox_syscall", "rustix", "windows-sys 0.52.0", ] [[package]] name = "thiserror" -version = "1.0.56" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" +checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.56" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" +checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.68", ] [[package]] name = "thread_local" -version = "1.1.7" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" dependencies = [ "cfg-if", "once_cell", @@ -2321,11 +2646,12 @@ dependencies = [ [[package]] name = "time" -version = "0.3.31" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f657ba42c3f86e7680e53c8cd3af8abbe56b5491790b46e22e19c0d57463583e" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", + "num-conv", "powerfmt", "serde", "time-core", @@ -2339,9 +2665,9 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "tinyvec" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +checksum = "ce6b6a2fb3a985e99cebfaefa9faa3024743da73304ca1c683a36429613d3d22" dependencies = [ "tinyvec_macros", ] @@ -2354,9 +2680,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.35.1" +version = "1.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89b4efa943be685f629b149f53829423f8f5531ea21249408e8e2f8671ec104" +checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" dependencies = [ "backtrace", "bytes", @@ -2364,6 +2690,7 @@ dependencies = [ "mio", "num_cpus", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "tracing", @@ -2382,13 +2709,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.68", ] [[package]] @@ -2402,10 +2729,21 @@ dependencies = [ ] [[package]] -name = "tokio-stream" -version = "0.1.14" +name = "tokio-rustls" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" dependencies = [ "futures-core", "pin-project-lite", @@ -2414,16 +2752,15 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.10" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" +checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", - "tracing", ] [[package]] @@ -2434,13 +2771,13 @@ checksum = "d560933a0de61cf715926b9cac824d4c883c2c43142f787595e48280c40a1d0e" dependencies = [ "async-stream", "async-trait", - "axum", - "base64", + "axum 0.6.20", + "base64 0.21.7", "bytes", "h2", - "http", - "http-body", - "hyper", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.29", "hyper-timeout", "percent-encoding", "pin-project", @@ -2491,6 +2828,7 @@ version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2504,7 +2842,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.68", ] [[package]] @@ -2603,7 +2941,7 @@ dependencies = [ "libloading 0.7.4", "log", "nix 0.26.4", - "reqwest", + "reqwest 0.11.27", "schemars", "serde", "socket2", @@ -2636,18 +2974,18 @@ checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] name = "unicode-normalization" -version = "0.1.22" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" dependencies = [ "tinyvec", ] [[package]] name = "unicode-width" -version = "0.1.11" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" [[package]] name = "universal-hash" @@ -2667,9 +3005,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.0" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", "idna", @@ -2678,15 +3016,15 @@ dependencies = [ [[package]] name = "utf8parse" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.7.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00cc9702ca12d3c81455259621e676d0f7251cec66a21e98fe2e9a37db93b2a" +checksum = "5de17fd2f7da591098415cff336e12965a28061ddace43b59cb3c430179c9439" dependencies = [ "serde", ] @@ -2726,9 +3064,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.90" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1223296a201415c7fad14792dbefaace9bd52b62d33453ade1c5b5f07555406" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -2736,24 +3074,24 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.90" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcdc935b63408d58a32f8cc9738a0bffd8f05cc7c002086c6ef20b7312ad9dcd" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" dependencies = [ "bumpalo", "log", "once_cell", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.68", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.40" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde2032aeb86bdfaecc8b261eef3cba735cc426c1f3a3416d1e0791be95fc461" +checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" dependencies = [ "cfg-if", "js-sys", @@ -2763,9 +3101,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.90" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e4c238561b2d428924c49815533a8b9121c664599558a5d9ec51f8a1740a999" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2773,33 +3111,42 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.90" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae1abb6806dc1ad9e560ed242107c0f6c84335f1749dd4e8ddb012ebd5e25a7" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.68", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.90" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d91413b1c31d7539ba5ef2451af3f0b833a005eb27a631cec32bc0635a8602b" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" [[package]] name = "web-sys" -version = "0.3.67" +version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58cd2333b6e0be7a39605f0e255892fd7418a682d8da8fe042fe25128794d2ed" +checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" dependencies = [ "js-sys", "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd7c23921eeb1713a4e851530e9b9756e4fb0e89978582942612524cf09f01cd" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "4.4.2" @@ -2814,9 +3161,9 @@ dependencies = [ [[package]] name = "widestring" -version = "1.0.2" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "653f141f39ec16bba3c5abe400a0c60da7468261cc2cbf36805022876bc721a8" +checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311" [[package]] name = "winapi" @@ -2864,7 +3211,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.0", + "windows-targets 0.52.6", ] [[package]] @@ -2884,17 +3231,18 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.0", - "windows_aarch64_msvc 0.52.0", - "windows_i686_gnu 0.52.0", - "windows_i686_msvc 0.52.0", - "windows_x86_64_gnu 0.52.0", - "windows_x86_64_gnullvm 0.52.0", - "windows_x86_64_msvc 0.52.0", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "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]] @@ -2905,9 +3253,9 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" @@ -2917,9 +3265,9 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" @@ -2929,9 +3277,15 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[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_msvc" @@ -2941,9 +3295,9 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" @@ -2953,9 +3307,9 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" @@ -2965,9 +3319,9 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" @@ -2977,9 +3331,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winreg" @@ -2992,10 +3346,20 @@ dependencies = [ ] [[package]] -name = "x25519-dalek" -version = "2.0.0" +name = "winreg" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb66477291e7e8d2b0ff1bcb900bf29489a9692816d79874bea351e7a8b6de96" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", "rand_core", @@ -3005,44 +3369,35 @@ dependencies = [ [[package]] name = "xxhash-rust" -version = "0.8.8" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53be06678ed9e83edb1745eb72efc0bbcd7b5c3c35711a860906aed827a13d61" - -[[package]] -name = "yaml-rust" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" -dependencies = [ - "linked-hash-map", -] +checksum = "63658493314859b4dfdf3fb8c1defd61587839def09582db50b8a4e93afca6bb" [[package]] name = "zerocopy" -version = "0.7.32" +version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.32" +version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.68", ] [[package]] name = "zeroize" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" dependencies = [ "zeroize_derive", ] @@ -3055,7 +3410,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.68", ] [[package]] @@ -3099,9 +3454,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.0.9+zstd.1.5.5" +version = "2.0.12+zstd.1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e16efa8a874a0481a574084d34cc26fdb3b99627480f785888deb6386506656" +checksum = "0a4e40c320c3cb459d9a9ff6de98cff88f4751ee9275d140e2be94a2b74e4c13" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 44981a2..362ba2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,3 +2,8 @@ members = ["burrow", "tun"] resolver = "2" exclude = ["burrow-gtk"] + +[profile.release] +lto = true +panic = "abort" +opt-level = "z" diff --git a/Dockerfile b/Dockerfile index afd51ea..e55eb58 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM docker.io/library/rust:1.76.0-slim-bookworm AS builder +FROM docker.io/library/rust:1.77-slim-bookworm AS builder ARG TARGETPLATFORM ARG LLVM_VERSION=16 @@ -8,7 +8,7 @@ ENV KEYRINGS /etc/apt/keyrings RUN set -eux && \ mkdir -p $KEYRINGS && \ apt-get update && \ - apt-get install --no-install-recommends -y gpg curl musl-dev && \ + apt-get install --no-install-recommends -y gpg curl busybox make musl-dev && \ curl --proto '=https' --tlsv1.2 -sSf https://apt.llvm.org/llvm-snapshot.gpg.key | gpg --dearmor --output $KEYRINGS/llvm.gpg && \ echo "deb [signed-by=$KEYRINGS/llvm.gpg] http://apt.llvm.org/bookworm/ llvm-toolchain-bookworm-$LLVM_VERSION main" > /etc/apt/sources.list.d/llvm.list && \ apt-get update && \ @@ -24,30 +24,31 @@ RUN set -eux && \ apt-get remove -y --auto-remove && \ rm -rf /var/lib/apt/lists/* -ARG SQLITE_VERSION=3400100 +RUN case $TARGETPLATFORM in \ + "linux/arm64") LLVM_TARGET=aarch64-unknown-linux-musl ;; \ + "linux/amd64") LLVM_TARGET=x86_64-unknown-linux-musl ;; \ + *) exit 1 ;; \ + esac && \ + rustup target add $LLVM_TARGET + +ARG SQLITE_VERSION=3460000 RUN case $TARGETPLATFORM in \ - "linux/arm64") LLVM_TARGET=aarch64-unknown-linux-musl MUSL_TARGET=aarch64-linux-musl ;; \ - "linux/amd64") LLVM_TARGET=x86_64-unknown-linux-musl MUSL_TARGET=x86_64-linux-musl ;; \ - *) exit 1 ;; \ + "linux/arm64") LLVM_TARGET=aarch64-unknown-linux-musl MUSL_TARGET=aarch64-linux-musl ;; \ + "linux/amd64") LLVM_TARGET=x86_64-unknown-linux-musl MUSL_TARGET=x86_64-linux-musl ;; \ + *) exit 1 ;; \ esac && \ - rustup target add $LLVM_TARGET && \ - curl --proto '=https' --tlsv1.2 -sSfO https://www.sqlite.org/2022/sqlite-autoconf-$SQLITE_VERSION.tar.gz && \ + curl --proto '=https' --tlsv1.2 -sSfO https://www.sqlite.org/2024/sqlite-autoconf-$SQLITE_VERSION.tar.gz && \ tar xf sqlite-autoconf-$SQLITE_VERSION.tar.gz && \ - rm sqlite-autoconf-$SQLITE_VERSION.tar.gz && \ cd sqlite-autoconf-$SQLITE_VERSION && \ - ./configure --disable-shared \ - CC="clang-$LLVM_VERSION -target $LLVM_TARGET" \ - CFLAGS="-I/usr/local/include -I/usr/include/$MUSL_TARGET" \ - LDFLAGS="-L/usr/local/lib -L/usr/lib/$MUSL_TARGET -L/lib/$MUSL_TARGET" && \ + ./configure --disable-shared --disable-dependency-tracking \ + CC="clang-$LLVM_VERSION -target $LLVM_TARGET" \ + CFLAGS="-I/usr/local/include -I/usr/include/$MUSL_TARGET" \ + LDFLAGS="-L/usr/local/lib -L/usr/lib/$MUSL_TARGET -L/lib/$MUSL_TARGET" && \ make && \ make install && \ cd .. && \ - rm -rf sqlite-autoconf-$SQLITE_VERSION - -ENV SQLITE3_STATIC=1 \ - SQLITE3_INCLUDE_DIR=/usr/local/include \ - SQLITE3_LIB_DIR=/usr/local/lib + rm -rf sqlite-autoconf-$SQLITE_VERSION sqlite-autoconf-$SQLITE_VERSION.tar.gz ENV CC_x86_64_unknown_linux_musl=clang-$LLVM_VERSION \ AR_x86_64_unknown_linux_musl=llvm-ar-$LLVM_VERSION \ @@ -55,14 +56,17 @@ ENV CC_x86_64_unknown_linux_musl=clang-$LLVM_VERSION \ AR_aarch64_unknown_linux_musl=llvm-ar-$LLVM_VERSION \ CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS="-L/usr/lib/x86_64-linux-musl -L/lib/x86_64-linux-musl -C linker=rust-lld" \ CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS="-L/usr/lib/aarch64-linux-musl -L/lib/aarch64-linux-musl -C linker=rust-lld" \ - CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse + CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \ + SQLITE3_STATIC=1 \ + SQLITE3_INCLUDE_DIR=/usr/local/include \ + SQLITE3_LIB_DIR=/usr/local/lib COPY . . RUN case $TARGETPLATFORM in \ - "linux/arm64") LLVM_TARGET=aarch64-unknown-linux-musl ;; \ - "linux/amd64") LLVM_TARGET=x86_64-unknown-linux-musl ;; \ - *) exit 1 ;; \ + "linux/arm64") LLVM_TARGET=aarch64-unknown-linux-musl ;; \ + "linux/amd64") LLVM_TARGET=x86_64-unknown-linux-musl ;; \ + *) exit 1 ;; \ esac && \ cargo install --path burrow --target $LLVM_TARGET @@ -71,7 +75,8 @@ WORKDIR /tmp/rootfs RUN set -eux && \ mkdir -p ./bin ./etc ./tmp ./data && \ mv /usr/local/cargo/bin/burrow ./bin/burrow && \ - echo 'burrow:x:10001:10001::/tmp:/sbin/nologin' > ./etc/passwd && \ + cp /bin/busybox ./bin/busybox && \ + echo 'burrow:x:10001:10001::/tmp:/bin/busybox' > ./etc/passwd && \ echo 'burrow:x:10001:' > ./etc/group && \ chown -R 10001:10001 ./tmp ./data && \ chmod 0777 ./tmp @@ -90,4 +95,6 @@ USER 10001:10001 COPY --from=builder /tmp/rootfs / WORKDIR /data -ENTRYPOINT ["/bin/burrow"] +EXPOSE 8080 + +CMD ["/bin/burrow", "auth-server"] diff --git a/burrow/Cargo.toml b/burrow/Cargo.toml index 0c816f8..0fb63a5 100644 --- a/burrow/Cargo.toml +++ b/burrow/Cargo.toml @@ -10,12 +10,13 @@ crate-type = ["lib", "staticlib"] [dependencies] anyhow = "1.0" -tokio = { version = "1.21", features = [ +tokio = { version = "1.37", features = [ "rt", "macros", "sync", "io-util", "rt-multi-thread", + "signal", "time", "tracing", ] } @@ -24,7 +25,7 @@ clap = { version = "4.4", features = ["derive"] } tracing = "0.1" tracing-log = "0.1" tracing-oslog = { git = "https://github.com/Stormshield-robinc/tracing-oslog" } -tracing-subscriber = { version = "0.3" , features = ["std", "env-filter"] } +tracing-subscriber = { version = "0.3", features = ["std", "env-filter"] } log = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1.0" @@ -50,9 +51,13 @@ futures = "0.3.28" once_cell = "1.19" console-subscriber = { version = "0.2.0", optional = true } console = "0.15.8" - -[dependencies.rusqlite] -version = "0.31.0" +axum = "0.7.4" +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "rustls-tls", +] } +rusqlite = "0.31.0" +dotenv = "0.15.0" [target.'cfg(target_os = "linux")'.dependencies] caps = "0.5" diff --git a/burrow/src/auth/client.rs b/burrow/src/auth/client.rs new file mode 100644 index 0000000..e9721f3 --- /dev/null +++ b/burrow/src/auth/client.rs @@ -0,0 +1,24 @@ +use std::env::var; + +use anyhow::Result; +use reqwest::Url; + +pub async fn login() -> Result<()> { + let state = "vt :P"; + let nonce = "no"; + + let mut url = Url::parse("https://slack.com/openid/connect/authorize")?; + let mut q = url.query_pairs_mut(); + q.append_pair("response_type", "code"); + q.append_pair("scope", "openid profile email"); + q.append_pair("client_id", &var("CLIENT_ID")?); + q.append_pair("state", state); + q.append_pair("team", &var("SLACK_TEAM_ID")?); + q.append_pair("nonce", nonce); + q.append_pair("redirect_uri", "https://burrow.rs/callback"); + drop(q); + + println!("Continue auth in your browser:\n{}", url.as_str()); + + Ok(()) +} diff --git a/burrow/src/auth/mod.rs b/burrow/src/auth/mod.rs new file mode 100644 index 0000000..c07f47e --- /dev/null +++ b/burrow/src/auth/mod.rs @@ -0,0 +1,2 @@ +pub mod client; +pub mod server; diff --git a/burrow/src/auth/server/db.rs b/burrow/src/auth/server/db.rs new file mode 100644 index 0000000..b74f7ce --- /dev/null +++ b/burrow/src/auth/server/db.rs @@ -0,0 +1,89 @@ +use anyhow::Result; + +pub static PATH: &str = "./server.sqlite3"; + +pub fn init_db() -> Result<()> { + let conn = rusqlite::Connection::open(PATH)?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS user ( + id PRIMARY KEY, + created_at TEXT NOT NULL + )", + (), + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS user_connection ( + user_id INTEGER REFERENCES user(id) ON DELETE CASCADE, + openid_provider TEXT NOT NULL, + openid_user_id TEXT NOT NULL, + openid_user_name TEXT NOT NULL, + access_token TEXT NOT NULL, + refresh_token TEXT, + PRIMARY KEY (openid_provider, openid_user_id) + )", + (), + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS device ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + public_key TEXT NOT NULL, + apns_token TEXT UNIQUE, + user_id INT REFERENCES user(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) CHECK(created_at IS datetime(created_at)), + ipv4 TEXT NOT NULL UNIQUE, + ipv6 TEXT NOT NULL UNIQUE, + access_token TEXT NOT NULL UNIQUE, + refresh_token TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL DEFAULT (datetime('now', '+7 days')) CHECK(expires_at IS datetime(expires_at)) + )", + () + ).unwrap(); + + Ok(()) +} + +pub fn store_connection( + openid_user: super::providers::OpenIdUser, + openid_provider: &str, + access_token: &str, + refresh_token: Option<&str>, +) -> Result<()> { + log::debug!("Storing openid user {:#?}", openid_user); + let conn = rusqlite::Connection::open(PATH)?; + + conn.execute( + "INSERT OR IGNORE INTO user (id, created_at) VALUES (?, datetime('now'))", + (&openid_user.sub,), + )?; + conn.execute( + "INSERT INTO user_connection (user_id, openid_provider, openid_user_id, openid_user_name, access_token, refresh_token) VALUES ( + (SELECT id FROM user WHERE id = ?), + ?, + ?, + ?, + ?, + ? + )", + (&openid_user.sub, &openid_provider, &openid_user.sub, &openid_user.name, access_token, refresh_token), + )?; + + Ok(()) +} + +pub fn store_device( + openid_user: super::providers::OpenIdUser, + openid_provider: &str, + access_token: &str, + refresh_token: Option<&str>, +) -> Result<()> { + log::debug!("Storing openid user {:#?}", openid_user); + let conn = rusqlite::Connection::open(PATH)?; + + // TODO + + Ok(()) +} diff --git a/burrow/src/auth/server/mod.rs b/burrow/src/auth/server/mod.rs new file mode 100644 index 0000000..88b3ff3 --- /dev/null +++ b/burrow/src/auth/server/mod.rs @@ -0,0 +1,62 @@ +pub mod db; +pub mod providers; + +use anyhow::Result; +use axum::{http::StatusCode, routing::post, Router}; +use providers::slack::auth; +use tokio::signal; + +pub async fn serve() -> Result<()> { + db::init_db()?; + + let app = Router::new() + .route("/slack-auth", post(auth)) + .route("/device/new", post(device_new)); + + let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); + log::info!("Starting auth server on port 8080"); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .unwrap(); + + Ok(()) +} + +async fn device_new() -> StatusCode { + StatusCode::OK +} + +async fn shutdown_signal() { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } +} + +// mod db { +// use rusqlite::{Connection, Result}; + +// #[derive(Debug)] +// struct User { +// id: i32, +// created_at: String, +// } +// } diff --git a/burrow/src/auth/server/providers/mod.rs b/burrow/src/auth/server/providers/mod.rs new file mode 100644 index 0000000..36ff0bd --- /dev/null +++ b/burrow/src/auth/server/providers/mod.rs @@ -0,0 +1,8 @@ +pub mod slack; +pub use super::db; + +#[derive(serde::Deserialize, Default, Debug)] +pub struct OpenIdUser { + pub sub: String, + pub name: String, +} diff --git a/burrow/src/auth/server/providers/slack.rs b/burrow/src/auth/server/providers/slack.rs new file mode 100644 index 0000000..581cd1e --- /dev/null +++ b/burrow/src/auth/server/providers/slack.rs @@ -0,0 +1,102 @@ +use anyhow::Result; +use axum::{ + extract::Json, + http::StatusCode, + routing::{get, post}, +}; +use reqwest::header::AUTHORIZATION; +use serde::Deserialize; + +use super::db::store_connection; + +#[derive(Deserialize)] +pub struct SlackToken { + slack_token: String, +} +pub async fn auth(Json(payload): Json) -> (StatusCode, String) { + let slack_user = match fetch_slack_user(&payload.slack_token).await { + Ok(user) => user, + Err(e) => { + log::error!("Failed to fetch Slack user: {:?}", e); + return (StatusCode::UNAUTHORIZED, String::new()); + } + }; + + log::info!( + "Slack user {} ({}) logged in.", + slack_user.name, + slack_user.sub + ); + + let conn = match store_connection(slack_user, "slack", &payload.slack_token, None) { + Ok(user) => user, + Err(e) => { + log::error!("Failed to fetch Slack user: {:?}", e); + return (StatusCode::UNAUTHORIZED, String::new()); + } + }; + + (StatusCode::OK, String::new()) +} + +async fn fetch_slack_user(access_token: &str) -> Result { + let client = reqwest::Client::new(); + let res = client + .get("https://slack.com/api/openid.connect.userInfo") + .header(AUTHORIZATION, format!("Bearer {}", access_token)) + .send() + .await? + .json::() + .await?; + + let res_ok = res + .get("ok") + .and_then(|v| v.as_bool()) + .ok_or(anyhow::anyhow!("Slack user object not ok!"))?; + + if !res_ok { + return Err(anyhow::anyhow!("Slack user object not ok!")); + } + + Ok(serde_json::from_value(res)?) +} + +// async fn fetch_save_slack_user_data(query: Query) -> anyhow::Result<()> { +// let client = reqwest::Client::new(); +// log::trace!("Code was {}", &query.code); +// let mut url = Url::parse("https://slack.com/api/openid.connect.token")?; + +// { +// let mut q = url.query_pairs_mut(); +// q.append_pair("client_id", &var("CLIENT_ID")?); +// q.append_pair("client_secret", &var("CLIENT_SECRET")?); +// q.append_pair("code", &query.code); +// q.append_pair("grant_type", "authorization_code"); +// q.append_pair("redirect_uri", "https://burrow.rs/callback"); +// } + +// let data = client +// .post(url) +// .send() +// .await? +// .json::() +// .await?; + +// if !data.ok { +// return Err(anyhow::anyhow!("Slack code exchange response not ok!")); +// } + +// if let Some(access_token) = data.access_token { +// log::trace!("Access token is {access_token}"); +// let user = slack::fetch_slack_user(&access_token) +// .await +// .map_err(|err| anyhow::anyhow!("Failed to fetch Slack user info {:#?}", err))?; + +// db::store_user(user, access_token, String::new()) +// .map_err(|_| anyhow::anyhow!("Failed to store user in db"))?; + +// Ok(()) +// } else { +// Err(anyhow::anyhow!("Access token not found in response")) +// } +// } diff --git a/burrow/src/lib.rs b/burrow/src/lib.rs index d9ebf7e..6aae1fb 100644 --- a/burrow/src/lib.rs +++ b/burrow/src/lib.rs @@ -5,6 +5,8 @@ pub mod wireguard; mod daemon; #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub mod database; +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +mod auth; pub(crate) mod tracing; #[cfg(target_vendor = "apple")] diff --git a/burrow/src/main.rs b/burrow/src/main.rs index 295373a..ff07d4c 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -7,6 +7,9 @@ pub(crate) mod tracing; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod wireguard; +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +mod auth; + #[cfg(any(target_os = "linux", target_vendor = "apple"))] use daemon::{DaemonClient, DaemonCommand, DaemonStartOptions}; use tun::TunOptions; @@ -47,12 +50,15 @@ enum Commands { ServerConfig, /// Reload Config ReloadConfig(ReloadConfigArgs), + /// Authentication server + AuthServer, } #[derive(Args)] struct ReloadConfigArgs { #[clap(long, short)] interface_id: String, + } #[derive(Args)] @@ -133,9 +139,10 @@ async fn try_reloadconfig(interface_id: String) -> Result<()> { } #[cfg(any(target_os = "linux", target_vendor = "apple"))] -#[tokio::main(flavor = "current_thread")] +#[tokio::main] async fn main() -> Result<()> { tracing::initialize(); + dotenv::dotenv().ok(); let cli = Cli::parse(); match &cli.command { @@ -145,6 +152,7 @@ async fn main() -> Result<()> { Commands::ServerInfo => try_serverinfo().await?, Commands::ServerConfig => try_serverconfig().await?, Commands::ReloadConfig(args) => try_reloadconfig(args.interface_id.clone()).await?, + Commands::AuthServer => crate::auth::server::serve().await?, } Ok(()) @@ -152,5 +160,5 @@ async fn main() -> Result<()> { #[cfg(not(any(target_os = "linux", target_vendor = "apple")))] pub fn main() { - eprintln!("This platform is not supported currently.") + eprintln!("This platform is not supported") } diff --git a/tun/Cargo.toml b/tun/Cargo.toml index 7413f65..1b07833 100644 --- a/tun/Cargo.toml +++ b/tun/Cargo.toml @@ -8,7 +8,7 @@ libc = "0.2" fehler = "1.0" nix = { version = "0.26", features = ["ioctl"] } socket2 = "0.5" -tokio = { version = "1.28", features = [] } +tokio = { version = "1.37", default-features = false, optional = true } byteorder = "1.4" tracing = "0.1" log = "0.4" @@ -19,10 +19,7 @@ futures = { version = "0.3.28", optional = true } [features] serde = ["dep:serde", "dep:schemars"] -tokio = ["tokio/net", "dep:futures"] - -[target.'cfg(feature = "tokio")'.dev-dependencies] -tokio = { features = ["rt", "macros"] } +tokio = ["tokio/net", "dep:tokio", "dep:futures"] [target.'cfg(windows)'.dependencies] lazy_static = "1.4" @@ -37,7 +34,7 @@ windows = { version = "0.48", features = [ [target.'cfg(windows)'.build-dependencies] anyhow = "1.0" bindgen = "0.65" -reqwest = { version = "0.11", features = ["native-tls"] } +reqwest = { version = "0.11" } ssri = { version = "9.0", default-features = false } tokio = { version = "1.28", features = ["rt", "macros"] } zip = { version = "0.6", features = ["deflate"] } From 3c70bc2a5c4a012c0fc31f12f1f5e75fbde67586 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 6 Jul 2024 10:50:14 -0700 Subject: [PATCH 015/102] Remove SwiftLint from Xcode project --- Apple/Burrow.xcodeproj/project.pbxproj | 45 ---------- .../xcshareddata/swiftpm/Package.resolved | 86 ------------------- 2 files changed, 131 deletions(-) delete mode 100644 Apple/Burrow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index a3be02d..5c5e80b 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -294,7 +294,6 @@ buildRules = ( ); dependencies = ( - D082527D2B5DEB80005DA378 /* PBXTargetDependency */, ); name = Shared; productName = Shared; @@ -313,7 +312,6 @@ buildRules = ( ); dependencies = ( - D08252792B5DEB78005DA378 /* PBXTargetDependency */, D00117492B30373500D87C25 /* PBXTargetDependency */, ); name = NetworkExtension; @@ -334,7 +332,6 @@ buildRules = ( ); dependencies = ( - D082527B2B5DEB7D005DA378 /* PBXTargetDependency */, D00117472B30373100D87C25 /* PBXTargetDependency */, D020F65C29E4A697002790F6 /* PBXTargetDependency */, ); @@ -374,7 +371,6 @@ ); mainGroup = D05B9F6929E39EEC008CB1F9; packageReferences = ( - D08252772B5DEB6E005DA378 /* XCRemoteSwiftPackageReference "SwiftLint" */, ); productRefGroup = D05B9F7329E39EEC008CB1F9 /* Products */; projectDirPath = ""; @@ -513,18 +509,6 @@ target = D020F65229E4A697002790F6 /* NetworkExtension */; targetProxy = D020F65B29E4A697002790F6 /* PBXContainerItemProxy */; }; - D08252792B5DEB78005DA378 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - productRef = D08252782B5DEB78005DA378 /* SwiftLintPlugin */; - }; - D082527B2B5DEB7D005DA378 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - productRef = D082527A2B5DEB7D005DA378 /* SwiftLintPlugin */; - }; - D082527D2B5DEB80005DA378 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - productRef = D082527C2B5DEB80005DA378 /* SwiftLintPlugin */; - }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -624,35 +608,6 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ - -/* Begin XCRemoteSwiftPackageReference section */ - D08252772B5DEB6E005DA378 /* XCRemoteSwiftPackageReference "SwiftLint" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/realm/SwiftLint.git"; - requirement = { - branch = main; - kind = branch; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - D08252782B5DEB78005DA378 /* SwiftLintPlugin */ = { - isa = XCSwiftPackageProductDependency; - package = D08252772B5DEB6E005DA378 /* XCRemoteSwiftPackageReference "SwiftLint" */; - productName = "plugin:SwiftLintPlugin"; - }; - D082527A2B5DEB7D005DA378 /* SwiftLintPlugin */ = { - isa = XCSwiftPackageProductDependency; - package = D08252772B5DEB6E005DA378 /* XCRemoteSwiftPackageReference "SwiftLint" */; - productName = "plugin:SwiftLintPlugin"; - }; - D082527C2B5DEB80005DA378 /* SwiftLintPlugin */ = { - isa = XCSwiftPackageProductDependency; - package = D08252772B5DEB6E005DA378 /* XCRemoteSwiftPackageReference "SwiftLint" */; - productName = "plugin:SwiftLintPlugin"; - }; -/* End XCSwiftPackageProductDependency section */ }; rootObject = D05B9F6A29E39EEC008CB1F9 /* Project object */; } diff --git a/Apple/Burrow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Apple/Burrow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 9378372..0000000 --- a/Apple/Burrow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,86 +0,0 @@ -{ - "pins" : [ - { - "identity" : "collectionconcurrencykit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/JohnSundell/CollectionConcurrencyKit.git", - "state" : { - "revision" : "b4f23e24b5a1bff301efc5e70871083ca029ff95", - "version" : "0.2.0" - } - }, - { - "identity" : "cryptoswift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", - "state" : { - "revision" : "7892a123f7e8d0fe62f9f03728b17bbd4f94df5c", - "version" : "1.8.1" - } - }, - { - "identity" : "sourcekitten", - "kind" : "remoteSourceControl", - "location" : "https://github.com/jpsim/SourceKitten.git", - "state" : { - "revision" : "b6dc09ee51dfb0c66e042d2328c017483a1a5d56", - "version" : "0.34.1" - } - }, - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", - "state" : { - "revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531", - "version" : "1.2.3" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", - "state" : { - "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", - "version" : "509.1.1" - } - }, - { - "identity" : "swiftlint", - "kind" : "remoteSourceControl", - "location" : "https://github.com/realm/SwiftLint.git", - "state" : { - "branch" : "main", - "revision" : "7595ad3fafc1a31086dc40ba01fd898bf6b42d5f" - } - }, - { - "identity" : "swiftytexttable", - "kind" : "remoteSourceControl", - "location" : "https://github.com/scottrhoyt/SwiftyTextTable.git", - "state" : { - "revision" : "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3", - "version" : "0.9.0" - } - }, - { - "identity" : "swxmlhash", - "kind" : "remoteSourceControl", - "location" : "https://github.com/drmohundro/SWXMLHash.git", - "state" : { - "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f", - "version" : "7.0.2" - } - }, - { - "identity" : "yams", - "kind" : "remoteSourceControl", - "location" : "https://github.com/jpsim/Yams.git", - "state" : { - "revision" : "0d9ee7ea8c4ebd4a489ad7a73d5c6cad55d6fed3", - "version" : "5.0.6" - } - } - ], - "version" : 2 -} From 3dedca4de308a16f1782f3fdc30c6cce5056c8d3 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 6 Jul 2024 17:20:46 -0700 Subject: [PATCH 016/102] Update build settings --- .github/actions/notarize/action.yml | 34 +- .github/workflows/build-apple.yml | 4 +- .github/workflows/build-rpm.yml | 2 +- .github/workflows/build-rust.yml | 10 +- .github/workflows/release-apple.yml | 41 +- Apple/App/AppDelegate.swift | 3 +- Apple/App/MainMenu.xib | 4 +- Cargo.lock | 754 ++++++++++++++-------------- Dockerfile | 3 +- 9 files changed, 407 insertions(+), 448 deletions(-) diff --git a/.github/actions/notarize/action.yml b/.github/actions/notarize/action.yml index f3f98f2..efd2159 100644 --- a/.github/actions/notarize/action.yml +++ b/.github/actions/notarize/action.yml @@ -9,12 +9,6 @@ inputs: app-store-key-issuer-id: description: App Store key issuer ID required: true - archive-path: - description: Xcode archive path - required: true - export-path: - description: The path to export the archive to - required: true runs: using: composite steps: @@ -24,28 +18,8 @@ runs: run: | echo "${{ inputs.app-store-key }}" > AuthKey_${{ inputs.app-store-key-id }}.p8 - echo '{"destination":"export","method":"developer-id"}' \ - | plutil -convert xml1 -o ExportOptions.plist - + ditto -c -k --keepParent Release/Burrow.app Upload.zip + xcrun notarytool submit --wait --issuer ${{ inputs.app-store-key-issuer-id }} --key-id ${{ inputs.app-store-key-id }} --key "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" Upload.zip + xcrun stapler staple Release/Burrow.app - xcodebuild -exportArchive \ - -allowProvisioningUpdates \ - -allowProvisioningDeviceRegistration \ - -skipPackagePluginValidation \ - -skipMacroValidation \ - -onlyUsePackageVersionsFromResolvedFile \ - -authenticationKeyID ${{ inputs.app-store-key-id }} \ - -authenticationKeyIssuerID ${{ inputs.app-store-key-issuer-id }} \ - -authenticationKeyPath "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" \ - -archivePath Wallet.xcarchive \ - -exportPath Release \ - -exportOptionsPlist ExportOptions.plist - - ditto -c -k --keepParent Release/Wallet.app Upload.zip - SUBMISSION_ID=$(xcrun notarytool submit --issuer ${{ inputs.app-store-key-issuer-id }} --key-id ${{ inputs.app-store-key-id }} --key "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" Upload.zip | awk '/ id:/ { print $2; exit }') - - xcrun notarytool wait $SUBMISSION_ID --issuer ${{ inputs.app-store-key-issuer-id }} --key-id ${{ inputs.app-store-key-id }} --key "${PWD}/AuthKey_${{ inputs.app-store-key-id }}.p8" - xcrun stapler staple Release/Wallet.app - - aa archive -a lzma -b 8m -d Release -subdir Wallet.app -o Wallet.app.aar - - rm -rf Upload.zip Release AuthKey_${{ inputs.app-store-key-id }}.p8 ExportOptions.plist + rm -rf AuthKey_${{ inputs.app-store-key-id }}.p8 Release diff --git a/.github/workflows/build-apple.yml b/.github/workflows/build-apple.yml index 00b6bec..84cc03a 100644 --- a/.github/workflows/build-apple.yml +++ b/.github/workflows/build-apple.yml @@ -24,7 +24,7 @@ jobs: rust-targets: - aarch64-apple-ios - scheme: App - destination: platform=iOS Simulator,OS=17.2,name=iPhone 15 Pro + destination: platform=iOS Simulator,OS=18.0,name=iPhone 15 Pro platform: iOS Simulator sdk-name: iphonesimulator rust-targets: @@ -38,7 +38,7 @@ jobs: - x86_64-apple-darwin - aarch64-apple-darwin env: - DEVELOPER_DIR: /Applications/Xcode_15.2.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_16.0.app/Contents/Developer steps: - name: Checkout uses: actions/checkout@v3 diff --git a/.github/workflows/build-rpm.yml b/.github/workflows/build-rpm.yml index e0ce8df..029bf16 100644 --- a/.github/workflows/build-rpm.yml +++ b/.github/workflows/build-rpm.yml @@ -5,7 +5,7 @@ jobs: name: Build RPM runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: Swatinem/rust-cache@v2 - name: Install RPM run: cargo install cargo-generate-rpm diff --git a/.github/workflows/build-rust.yml b/.github/workflows/build-rust.yml index 3255fc7..76ce9f2 100644 --- a/.github/workflows/build-rust.yml +++ b/.github/workflows/build-rust.yml @@ -22,14 +22,18 @@ jobs: targets: - aarch64-unknown-linux-gnu - os: macos-12 - platform: macOS + platform: macOS (Intel) test-targets: - x86_64-apple-darwin targets: + - x86_64-apple-ios + - os: macos-14 + platform: macOS + test-targets: - aarch64-apple-darwin + targets: - aarch64-apple-ios - aarch64-apple-ios-sim - - x86_64-apple-ios - os: windows-2022 platform: Windows test-targets: @@ -38,7 +42,7 @@ jobs: - aarch64-pc-windows-msvc runs-on: ${{ matrix.os }} env: - DEVELOPER_DIR: /Applications/Xcode_14.2.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_15.4.app/Contents/Developer CARGO_INCREMENTAL: 0 CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc RUST_BACKTRACE: short diff --git a/.github/workflows/release-apple.yml b/.github/workflows/release-apple.yml index 786fb54..1883008 100644 --- a/.github/workflows/release-apple.yml +++ b/.github/workflows/release-apple.yml @@ -13,7 +13,8 @@ jobs: fail-fast: false matrix: include: - - destination: generic/platform=iOS + - + destination: generic/platform=iOS platform: iOS rust-targets: - aarch64-apple-ios @@ -23,7 +24,7 @@ jobs: - x86_64-apple-darwin - aarch64-apple-darwin env: - DEVELOPER_DIR: /Applications/Xcode_15.2.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_15.4.app/Contents/Developer steps: - name: Checkout uses: actions/checkout@v4 @@ -50,25 +51,23 @@ jobs: app-store-key-id: ${{ secrets.APPSTORE_KEY_ID }} app-store-key-issuer-id: ${{ secrets.APPSTORE_KEY_ISSUER_ID }} archive-path: Burrow.xcarchive - - name: Notarize (macOS) - if: ${{ matrix.platform == 'macOS' }} - uses: ./.github/actions/notarize - with: - app-store-key: ${{ secrets.APPSTORE_KEY }} - app-store-key-id: ${{ secrets.APPSTORE_KEY_ID }} - app-store-key-issuer-id: ${{ secrets.APPSTORE_KEY_ISSUER_ID }} - archive-path: Burrow.xcarchive - - name: Export IPA (iOS) - if: ${{ matrix.platform == 'iOS' }} + - name: Export uses: ./.github/actions/export with: - method: ad-hoc + method: ${{ matrix.platform == 'macOS' && 'developer-id' || 'ad-hoc' }} destination: export app-store-key: ${{ secrets.APPSTORE_KEY }} app-store-key-id: ${{ secrets.APPSTORE_KEY_ID }} app-store-key-issuer-id: ${{ secrets.APPSTORE_KEY_ISSUER_ID }} archive-path: Burrow.xcarchive export-path: Release + - name: Notarize + if: ${{ matrix.platform == 'macOS' }} + uses: ./.github/actions/notarize + with: + app-store-key: ${{ secrets.APPSTORE_KEY }} + app-store-key-id: ${{ secrets.APPSTORE_KEY_ID }} + app-store-key-issuer-id: ${{ secrets.APPSTORE_KEY_ISSUER_ID }} - name: Compress (iOS) if: ${{ matrix.platform == 'iOS' }} shell: bash @@ -83,27 +82,17 @@ jobs: aa archive -a lzma -b 8m -d Apple/Release -subdir Burrow.app -o Burrow.app.aar aa archive -a lzma -b 8m -d Apple -subdir Burrow.xcarchive -o Burrow-${{ matrix.platform }}.xcarchive.aar rm -rf Apple/Release - - name: Upload to GitHub (iOS) - if: ${{ matrix.platform == 'iOS' }} + - name: Upload to GitHub uses: SierraSoftworks/gh-releases@v1.0.7 with: token: ${{ secrets.GITHUB_TOKEN }} release_tag: ${{ github.ref_name }} overwrite: 'true' files: | - Burrow.ipa - Burrow-${{ matrix.platform }}.xcarchive.aar - - name: Upload to GitHub (macOS) - if: ${{ matrix.platform == 'macOS' }} - uses: SierraSoftworks/gh-releases@v1.0.7 - with: - token: ${{ secrets.GITHUB_TOKEN }} - release_tag: ${{ github.ref_name }} - overwrite: 'true' - files: | - Burrow.aap.aar + ${{ matrix.platform == 'macOS' && 'Burrow.aap.aar' || 'Burrow.ipa' }} Burrow-${{ matrix.platform }}.xcarchive.aar - name: Upload to App Store Connect + if: ${{ matrix.platform == 'iOS' }} uses: ./.github/actions/export with: method: app-store diff --git a/Apple/App/AppDelegate.swift b/Apple/App/AppDelegate.swift index 6085d85..bd76a2f 100644 --- a/Apple/App/AppDelegate.swift +++ b/Apple/App/AppDelegate.swift @@ -2,8 +2,7 @@ import AppKit import SwiftUI -@MainActor -@NSApplicationMain +@MainActor @main class AppDelegate: NSObject, NSApplicationDelegate { private let quitItem: NSMenuItem = { let quitItem = NSMenuItem( diff --git a/Apple/App/MainMenu.xib b/Apple/App/MainMenu.xib index 8933f30..587f6c4 100644 --- a/Apple/App/MainMenu.xib +++ b/Apple/App/MainMenu.xib @@ -1,7 +1,7 @@ - + - + diff --git a/Cargo.lock b/Cargo.lock index ce263f9..5ef886c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "addr2line" -version = "0.22.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" dependencies = [ "gimli", ] @@ -29,9 +29,9 @@ dependencies = [ [[package]] name = "aes" -version = "0.8.4" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2" dependencies = [ "cfg-if", "cipher", @@ -40,9 +40,9 @@ dependencies = [ [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "d713b3834d76b85304d4d525563c1276e2e30dc97cc67bfb4585a4a29fc2c89f" dependencies = [ "cfg-if", "once_cell", @@ -52,57 +52,62 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" dependencies = [ "memchr", ] [[package]] -name = "anstream" -version = "0.6.14" +name = "allocator-api2" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + +[[package]] +name = "anstream" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e1ebcb11de5c03c67de28a7df593d32191b44939c482e97702baaaa6ab6a5" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", - "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" -version = "1.0.7" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" +checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" [[package]] name = "anstyle-parse" -version = "0.2.4" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" +checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391" +checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" dependencies = [ "windows-sys 0.52.0", ] [[package]] name = "anstyle-wincon" -version = "3.0.3" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" +checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" dependencies = [ "anstyle", "windows-sys 0.52.0", @@ -110,17 +115,18 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.86" +version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" [[package]] name = "async-channel" -version = "2.3.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +checksum = "1ca33f4bc4ed1babef42cad36cc1f51fa88be00420404e5b1e80ab1b18f7678c" dependencies = [ "concurrent-queue", + "event-listener", "event-listener-strategy", "futures-core", "pin-project-lite", @@ -145,25 +151,25 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.48", ] [[package]] name = "async-trait" -version = "0.1.80" +version = "0.1.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" +checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.48", ] [[package]] name = "autocfg" -version = "1.3.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "axum" @@ -176,9 +182,9 @@ dependencies = [ "bitflags 1.3.2", "bytes", "futures-util", - "http 0.2.12", + "http 0.2.11", "http-body 0.4.6", - "hyper 0.14.29", + "hyper 0.14.28", "itoa", "matchit", "memchr", @@ -236,7 +242,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 0.2.12", + "http 0.2.11", "http-body 0.4.6", "mime", "rustversion", @@ -267,9 +273,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.73" +version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" dependencies = [ "addr2line", "cc", @@ -339,7 +345,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.68", + "syn 2.0.48", "which", ] @@ -351,9 +357,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" [[package]] name = "blake2" @@ -375,9 +381,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.16.0" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" [[package]] name = "burrow" @@ -432,9 +438,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.6.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" +checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" [[package]] name = "bzip2" @@ -469,13 +475,12 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.104" +version = "1.0.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74b6a57f98764a267ff415d50a25e6e166f3831a5071af4995296ea97d210490" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" dependencies = [ "jobserver", "libc", - "once_cell", ] [[package]] @@ -530,20 +535,20 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "67523a3b4be3ce1989d607a828d036249522dd9c1c8de7f4dd2dae43a37369d1" dependencies = [ "glob", "libc", - "libloading 0.8.4", + "libloading 0.8.1", ] [[package]] name = "clap" -version = "4.5.8" +version = "4.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b3edb18336f4df585bc9aa31dd99c036dfa5dc5e9a2939a722a188f3a8970d" +checksum = "1e578d6ec4194633722ccf9544794b71b1385c3c027efe0c55db226fc880865c" dependencies = [ "clap_builder", "clap_derive", @@ -551,9 +556,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.8" +version = "4.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1c09dd5ada6c6c78075d6fd0da3f90d8080651e2d6cc8eb2f1aaa4034ced708" +checksum = "4df4df40ec50c46000231c914968278b1eb05098cf8f1b3a518a95030e71d1c7" dependencies = [ "anstream", "anstyle", @@ -563,33 +568,33 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.8" +version = "4.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bac35c6dafb060fd4d275d9a4ffae97917c13a6327903a8be2153cd964f7085" +checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.48", ] [[package]] name = "clap_lex" -version = "0.7.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" +checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" [[package]] name = "colorchoice" -version = "1.0.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" [[package]] name = "concurrent-queue" -version = "2.5.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +checksum = "d16048cd947b08fa32c24458a22f5dc5e835264f689f4f5653210c69fd107363" dependencies = [ "crossbeam-utils", ] @@ -677,27 +682,27 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-channel" -version = "0.5.13" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" +checksum = "176dc175b78f56c0f321911d9c8eb2b77a78a4860b9c19db83835fea1a46649b" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.20" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" [[package]] name = "crypto-common" @@ -712,14 +717,15 @@ dependencies = [ [[package]] name = "curve25519-dalek" -version = "4.1.3" +version = "4.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +checksum = "e89b8c6a2e4b1f45971ad09761aafb85514a84744b67a95e32c3cc1352d1f65c" dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", "fiat-crypto", + "platforms", "rustc_version", "subtle", "zeroize", @@ -733,7 +739,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.48", ] [[package]] @@ -764,15 +770,15 @@ checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" [[package]] name = "dyn-clone" -version = "1.0.17" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" +checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d" [[package]] name = "either" -version = "1.13.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" [[package]] name = "encode_unicode" @@ -782,9 +788,9 @@ checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" [[package]] name = "encoding_rs" -version = "0.8.34" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" dependencies = [ "cfg-if", ] @@ -797,9 +803,9 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" dependencies = [ "libc", "windows-sys 0.52.0", @@ -807,9 +813,9 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.3.1" +version = "4.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +checksum = "67b215c49b2b248c855fb73579eb1f4f26c38ffdc12973e20e07b91d78d5646e" dependencies = [ "concurrent-queue", "parking", @@ -818,9 +824,9 @@ dependencies = [ [[package]] name = "event-listener-strategy" -version = "0.5.2" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +checksum = "958e4d70b6d5e81971bebec42271ec641e7ff4e170a6fa605f2b8a8b65cb97d3" dependencies = [ "event-listener", "pin-project-lite", @@ -840,9 +846,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.1.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" [[package]] name = "fehler" @@ -866,15 +872,15 @@ dependencies = [ [[package]] name = "fiat-crypto" -version = "0.2.9" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +checksum = "27573eac26f4dd11e2b1916c3fe1baa56407c83c71a773a8ba17ec0bca03b6b7" [[package]] name = "flate2" -version = "1.0.30" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" +checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" dependencies = [ "crc32fast", "miniz_oxide", @@ -966,7 +972,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.48", ] [[package]] @@ -1011,9 +1017,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" dependencies = [ "cfg-if", "libc", @@ -1022,9 +1028,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.29.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" [[package]] name = "glob" @@ -1034,17 +1040,17 @@ checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" [[package]] name = "h2" -version = "0.3.26" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9" dependencies = [ "bytes", "fnv", "futures-core", "futures-sink", "futures-util", - "http 0.2.12", - "indexmap 2.2.6", + "http 0.2.11", + "indexmap 2.1.0", "slab", "tokio", "tokio-util", @@ -1059,20 +1065,21 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" -version = "0.14.5" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" dependencies = [ "ahash", + "allocator-api2", ] [[package]] name = "hashlink" -version = "0.9.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +checksum = "692eaaf7f7607518dd3cef090f1474b61edc5301d8012f09579920df68b725ee" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.14.3", ] [[package]] @@ -1090,15 +1097,15 @@ dependencies = [ [[package]] name = "heck" -version = "0.5.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] name = "hermit-abi" -version = "0.3.9" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +checksum = "5d3d0e0f38255e7fa3cf31335b3a56f05febd18025f4db5ef7a0cfb4f8da651f" [[package]] name = "hex" @@ -1126,9 +1133,9 @@ dependencies = [ [[package]] name = "http" -version = "0.2.12" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" dependencies = [ "bytes", "fnv", @@ -1153,7 +1160,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http 0.2.12", + "http 0.2.11", "pin-project-lite", ] @@ -1182,9 +1189,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.9.4" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" [[package]] name = "httpdate" @@ -1200,16 +1207,16 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" -version = "0.14.29" +version = "0.14.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f361cde2f109281a220d4307746cdfd5ee3f410da58a70377762396775634b33" +checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" dependencies = [ "bytes", "futures-channel", "futures-core", "futures-util", "h2", - "http 0.2.12", + "http 0.2.11", "http-body 0.4.6", "httparse", "httpdate", @@ -1266,7 +1273,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" dependencies = [ - "hyper 0.14.29", + "hyper 0.14.28", "pin-project-lite", "tokio", "tokio-io-timeout", @@ -1279,7 +1286,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ "bytes", - "hyper 0.14.29", + "hyper 0.14.28", "native-tls", "tokio", "tokio-native-tls", @@ -1327,12 +1334,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.2.6" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" dependencies = [ "equivalent", - "hashbrown 0.14.5", + "hashbrown 0.14.3", ] [[package]] @@ -1346,15 +1353,16 @@ dependencies = [ [[package]] name = "insta" -version = "1.39.0" +version = "1.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "810ae6042d48e2c9e9215043563a58a80b877bc863228a74cf10c49d4620a6f5" +checksum = "5d64600be34b2fcfc267740a243fa7744441bb4947a619ac4e5bb6507f35fbfc" dependencies = [ "console", "lazy_static", "linked-hash-map", "serde", "similar", + "yaml-rust", ] [[package]] @@ -1385,50 +1393,44 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" -[[package]] -name = "is_terminal_polyfill" -version = "1.70.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" - [[package]] name = "itertools" -version = "0.12.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" dependencies = [ "either", ] [[package]] name = "itoa" -version = "1.0.11" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" [[package]] name = "jobserver" -version = "0.1.31" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2b099aaa34a9751c5bf0878add70444e1ed2dd73f347be99003d4577277de6e" +checksum = "8c37f63953c4c63420ed5fd3d6d398c719489b9f872b9fa683262f8edd363c7d" dependencies = [ "libc", ] [[package]] name = "js-sys" -version = "0.3.69" +version = "0.3.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +checksum = "9a1d36f1235bc969acba30b7f5990b864423a6068a10f7c90ae8f0112e3a59d1" dependencies = [ "wasm-bindgen", ] [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "lazycell" @@ -1438,9 +1440,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.155" +version = "0.2.152" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" [[package]] name = "libloading" @@ -1454,12 +1456,12 @@ dependencies = [ [[package]] name = "libloading" -version = "0.8.4" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d" +checksum = "c571b676ddfc9a8c12f1f3d3085a7b163966a8fd8098a90640953ce5f6170161" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-sys 0.48.0", ] [[package]] @@ -1499,15 +1501,15 @@ checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] name = "linux-raw-sys" -version = "0.4.14" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" dependencies = [ "autocfg", "scopeguard", @@ -1515,9 +1517,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.22" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] name = "matchers" @@ -1536,9 +1538,9 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "memchr" -version = "2.7.4" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" [[package]] name = "memoffset" @@ -1551,9 +1553,9 @@ dependencies = [ [[package]] name = "memoffset" -version = "0.9.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" dependencies = [ "autocfg", ] @@ -1578,7 +1580,7 @@ checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.48", ] [[package]] @@ -1595,18 +1597,18 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.7.4" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" dependencies = [ "adler", ] [[package]] name = "mio" -version = "0.8.11" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" dependencies = [ "libc", "wasi", @@ -1615,10 +1617,11 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.12" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" dependencies = [ + "lazy_static", "libc", "log", "openssl", @@ -1649,10 +1652,10 @@ version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.4.2", "cfg-if", "libc", - "memoffset 0.9.1", + "memoffset 0.9.0", ] [[package]] @@ -1675,17 +1678,11 @@ dependencies = [ "winapi", ] -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - [[package]] name = "num-traits" -version = "0.2.19" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" dependencies = [ "autocfg", ] @@ -1702,9 +1699,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "081b846d1d56ddfc18fdf1a922e4f6e07a11768ea1b92dec44e42b72712ccfce" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" dependencies = [ "memchr", ] @@ -1717,17 +1714,17 @@ checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "opaque-debug" -version = "0.3.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" [[package]] name = "openssl" -version = "0.10.64" +version = "0.10.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" +checksum = "15c9d69dd87a29568d4d017cfe8ec518706046a05184e5aea92d0af890b803c8" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.4.2", "cfg-if", "foreign-types", "libc", @@ -1744,7 +1741,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.48", ] [[package]] @@ -1755,9 +1752,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.102" +version = "0.9.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2" +checksum = "22e1bf214306098e4832460f797824c05d25aacdf896f64a985fb0fd992454ae" dependencies = [ "cc", "libc", @@ -1779,9 +1776,9 @@ checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" dependencies = [ "lock_api", "parking_lot_core", @@ -1789,15 +1786,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-targets 0.48.5", ] [[package]] @@ -1837,29 +1834,29 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project" -version = "1.1.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +checksum = "0302c4a0442c456bd56f841aee5c3bfd17967563f6fadc9ceb9f9c23cf3807e0" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +checksum = "266c042b60c9c76b8d53061e52b2e0d1116abc57cefc8c5cd671619a56ac3690" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.48", ] [[package]] name = "pin-project-lite" -version = "0.2.14" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" [[package]] name = "pin-utils" @@ -1869,9 +1866,15 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" -version = "0.3.30" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +checksum = "2900ede94e305130c13ddd391e0ab7cbaeb783945ae07a279c268cb05109c6cb" + +[[package]] +name = "platforms" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626dec3cac7cc0e1577a2ec3fc496277ec2baa084bebad95bb6fdbfae235f84c" [[package]] name = "poly1305" @@ -1898,28 +1901,28 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "prettyplease" -version = "0.2.20" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e" +checksum = "a41cf62165e97c7f814d2221421dbb9afcbcdb0a88068e5ea206e19951c2cbb5" dependencies = [ "proc-macro2", - "syn 2.0.68", + "syn 2.0.48", ] [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" dependencies = [ "unicode-ident", ] [[package]] name = "prost" -version = "0.12.6" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +checksum = "146c289cda302b98a28d40c8b3b90498d6e526dd24ac2ecea73e4e491685b94a" dependencies = [ "bytes", "prost-derive", @@ -1927,22 +1930,22 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.12.6" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +checksum = "efb6c9a1dd1def8e2124d17e83a20af56f1570d6c2d2bd9e266ccb768df3840e" dependencies = [ "anyhow", "itertools", "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.48", ] [[package]] name = "prost-types" -version = "0.12.6" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +checksum = "193898f59edcf43c26227dcd4c8427f00d99d61e95dcde58dabd49fa291d470e" dependencies = [ "prost", ] @@ -1996,9 +1999,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.36" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ "proc-macro2", ] @@ -2035,23 +2038,23 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.2" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c82cf8cff14456045f55ec4241383baeff27af886adb72ffb2162f99911de0fd" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" dependencies = [ - "bitflags 2.6.0", + "bitflags 1.3.2", ] [[package]] name = "regex" -version = "1.10.5" +version = "1.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" +checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.7", - "regex-syntax 0.8.4", + "regex-automata 0.4.5", + "regex-syntax 0.8.2", ] [[package]] @@ -2065,13 +2068,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.7" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.4", + "regex-syntax 0.8.2", ] [[package]] @@ -2082,15 +2085,15 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.4" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" [[package]] name = "reqwest" -version = "0.11.27" +version = "0.11.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +checksum = "37b1ae8d9ac08420c66222fb9096fc5de435c3c48542bc5336c51892cffafb41" dependencies = [ "base64 0.21.7", "bytes", @@ -2098,9 +2101,9 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http 0.2.12", + "http 0.2.11", "http-body 0.4.6", - "hyper 0.14.29", + "hyper 0.14.28", "hyper-tls", "ipnet", "js-sys", @@ -2110,11 +2113,9 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile 1.0.4", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 0.1.2", "system-configuration", "tokio", "tokio-native-tls", @@ -2151,7 +2152,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", - "rustls-pemfile 2.1.2", + "rustls-pemfile", "rustls-pki-types", "serde", "serde_json", @@ -2170,17 +2171,16 @@ dependencies = [ [[package]] name = "ring" -version = "0.17.8" +version = "0.17.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +checksum = "688c63d65483050968b2a8937f7995f443e27041a0f7700aa59b0822aedebb74" dependencies = [ "cc", - "cfg-if", "getrandom", "libc", "spin", "untrusted", - "windows-sys 0.52.0", + "windows-sys 0.48.0", ] [[package]] @@ -2189,7 +2189,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.4.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2199,9 +2199,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.24" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" [[package]] name = "rustc-hash" @@ -2220,11 +2220,11 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.34" +version = "0.38.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +checksum = "322394588aaf33c24007e8bb3238ee3e4c5c09c084ab32bc73890b99ff326bca" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.4.2", "errno", "libc", "linux-raw-sys", @@ -2245,15 +2245,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", -] - [[package]] name = "rustls-pemfile" version = "2.1.2" @@ -2283,15 +2274,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.17" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" [[package]] name = "ryu" -version = "1.0.18" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" [[package]] name = "schannel" @@ -2304,9 +2295,9 @@ dependencies = [ [[package]] name = "schemars" -version = "0.8.21" +version = "0.8.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92" +checksum = "45a28f4c49489add4ce10783f7911893516f15afe45d015608d41faca6bc4d29" dependencies = [ "dyn-clone", "schemars_derive", @@ -2316,14 +2307,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "0.8.21" +version = "0.8.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" +checksum = "c767fd6fa65d9ccf9cf026122c1b555f2ef9a4f0cea69da4d7dbc3e258d30967" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.68", + "syn 1.0.109", ] [[package]] @@ -2334,11 +2325,11 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "security-framework" -version = "2.11.0" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c627723fd09706bacdb5cf41499e95098555af3c3c29d014dc3c458ef6be11c0" +checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" dependencies = [ - "bitflags 2.6.0", + "bitflags 1.3.2", "core-foundation", "core-foundation-sys", "libc", @@ -2347,9 +2338,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.11.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317936bbbd05227752583946b9e66d7ce3b489f84e11a94a510b4437fef407d7" +checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" dependencies = [ "core-foundation-sys", "libc", @@ -2357,46 +2348,46 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.23" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +checksum = "b97ed7a9823b74f99c7742f5336af7be5ecd3eeafcb1507d1fa93347b1d589b0" [[package]] name = "serde" -version = "1.0.203" +version = "1.0.196" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" +checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.203" +version = "1.0.196" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" +checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.48", ] [[package]] name = "serde_derive_internals" -version = "0.29.1" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +checksum = "85bf8229e7920a9f636479437026331ce11aa132b4dde37d121944a44d6e5f3c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 1.0.109", ] [[package]] name = "serde_json" -version = "1.0.120" +version = "1.0.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5" +checksum = "4d1bd37ce2324cf3bf85e5a25f96eb4baf0d5aa6eba43e7ae8958870c4ec48ed" dependencies = [ "itoa", "ryu", @@ -2484,9 +2475,9 @@ dependencies = [ [[package]] name = "similar" -version = "2.5.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42c91313f1d05da9b26f267f931cf178d4aba455b4c4622dd7355eb80c6640" +checksum = "32fea41aca09ee824cc9724996433064c89f7777e60762749a4170a14abbfa21" [[package]] name = "slab" @@ -2499,18 +2490,18 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.13.2" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" [[package]] name = "socket2" -version = "0.5.7" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.48.0", ] [[package]] @@ -2537,15 +2528,15 @@ dependencies = [ [[package]] name = "strsim" -version = "0.11.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "subtle" -version = "2.6.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" [[package]] name = "syn" @@ -2560,9 +2551,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.68" +version = "2.0.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901fa70d88b9d6c98022e23b4136f9f3e54e4662c3bc1bd1d84a42a9a0f0c1e9" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" dependencies = [ "proc-macro2", "quote", @@ -2604,41 +2595,42 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.10.1" +version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +checksum = "01ce4141aa927a6d1bd34a041795abd0db1cccba5d5f24b009f694bdf3a1f3fa" dependencies = [ "cfg-if", "fastrand", + "redox_syscall", "rustix", "windows-sys 0.52.0", ] [[package]] name = "thiserror" -version = "1.0.61" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" +checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.61" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" +checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.48", ] [[package]] name = "thread_local" -version = "1.1.8" +version = "1.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" dependencies = [ "cfg-if", "once_cell", @@ -2646,12 +2638,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.36" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "f657ba42c3f86e7680e53c8cd3af8abbe56b5491790b46e22e19c0d57463583e" dependencies = [ "deranged", - "num-conv", "powerfmt", "serde", "time-core", @@ -2665,9 +2656,9 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "tinyvec" -version = "1.7.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6b6a2fb3a985e99cebfaefa9faa3024743da73304ca1c683a36429613d3d22" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" dependencies = [ "tinyvec_macros", ] @@ -2715,7 +2706,7 @@ checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.48", ] [[package]] @@ -2741,9 +2732,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.15" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" dependencies = [ "futures-core", "pin-project-lite", @@ -2752,15 +2743,16 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.11" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", + "tracing", ] [[package]] @@ -2775,9 +2767,9 @@ dependencies = [ "base64 0.21.7", "bytes", "h2", - "http 0.2.12", + "http 0.2.11", "http-body 0.4.6", - "hyper 0.14.29", + "hyper 0.14.28", "hyper-timeout", "percent-encoding", "pin-project", @@ -2842,7 +2834,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.48", ] [[package]] @@ -2941,7 +2933,7 @@ dependencies = [ "libloading 0.7.4", "log", "nix 0.26.4", - "reqwest 0.11.27", + "reqwest 0.11.23", "schemars", "serde", "socket2", @@ -2974,18 +2966,18 @@ checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] name = "unicode-normalization" -version = "0.1.23" +version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" dependencies = [ "tinyvec", ] [[package]] name = "unicode-width" -version = "0.1.13" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" [[package]] name = "universal-hash" @@ -3005,9 +2997,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.2" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" dependencies = [ "form_urlencoded", "idna", @@ -3016,15 +3008,15 @@ dependencies = [ [[package]] name = "utf8parse" -version = "0.2.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "uuid" -version = "1.9.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5de17fd2f7da591098415cff336e12965a28061ddace43b59cb3c430179c9439" +checksum = "f00cc9702ca12d3c81455259621e676d0f7251cec66a21e98fe2e9a37db93b2a" dependencies = [ "serde", ] @@ -3064,9 +3056,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.92" +version = "0.2.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +checksum = "b1223296a201415c7fad14792dbefaace9bd52b62d33453ade1c5b5f07555406" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -3074,24 +3066,24 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.92" +version = "0.2.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +checksum = "fcdc935b63408d58a32f8cc9738a0bffd8f05cc7c002086c6ef20b7312ad9dcd" dependencies = [ "bumpalo", "log", "once_cell", "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.48", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.42" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" +checksum = "bde2032aeb86bdfaecc8b261eef3cba735cc426c1f3a3416d1e0791be95fc461" dependencies = [ "cfg-if", "js-sys", @@ -3101,9 +3093,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.92" +version = "0.2.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +checksum = "3e4c238561b2d428924c49815533a8b9121c664599558a5d9ec51f8a1740a999" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3111,28 +3103,28 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.92" +version = "0.2.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +checksum = "bae1abb6806dc1ad9e560ed242107c0f6c84335f1749dd4e8ddb012ebd5e25a7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.48", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.92" +version = "0.2.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +checksum = "4d91413b1c31d7539ba5ef2451af3f0b833a005eb27a631cec32bc0635a8602b" [[package]] name = "web-sys" -version = "0.3.69" +version = "0.3.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" +checksum = "58cd2333b6e0be7a39605f0e255892fd7418a682d8da8fe042fe25128794d2ed" dependencies = [ "js-sys", "wasm-bindgen", @@ -3161,9 +3153,9 @@ dependencies = [ [[package]] name = "widestring" -version = "1.1.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311" +checksum = "653f141f39ec16bba3c5abe400a0c60da7468261cc2cbf36805022876bc721a8" [[package]] name = "winapi" @@ -3211,7 +3203,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets 0.52.0", ] [[package]] @@ -3231,18 +3223,17 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.6" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", - "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", + "windows_aarch64_gnullvm 0.52.0", + "windows_aarch64_msvc 0.52.0", + "windows_i686_gnu 0.52.0", + "windows_i686_msvc 0.52.0", + "windows_x86_64_gnu 0.52.0", + "windows_x86_64_gnullvm 0.52.0", + "windows_x86_64_msvc 0.52.0", ] [[package]] @@ -3253,9 +3244,9 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.6" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" [[package]] name = "windows_aarch64_msvc" @@ -3265,9 +3256,9 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.6" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" [[package]] name = "windows_i686_gnu" @@ -3277,15 +3268,9 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.6" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" [[package]] name = "windows_i686_msvc" @@ -3295,9 +3280,9 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.6" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" [[package]] name = "windows_x86_64_gnu" @@ -3307,9 +3292,9 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.6" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" [[package]] name = "windows_x86_64_gnullvm" @@ -3319,9 +3304,9 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.6" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" [[package]] name = "windows_x86_64_msvc" @@ -3331,9 +3316,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.6" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" [[package]] name = "winreg" @@ -3357,9 +3342,9 @@ dependencies = [ [[package]] name = "x25519-dalek" -version = "2.0.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +checksum = "fb66477291e7e8d2b0ff1bcb900bf29489a9692816d79874bea351e7a8b6de96" dependencies = [ "curve25519-dalek", "rand_core", @@ -3369,35 +3354,44 @@ dependencies = [ [[package]] name = "xxhash-rust" -version = "0.8.11" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63658493314859b4dfdf3fb8c1defd61587839def09582db50b8a4e93afca6bb" +checksum = "53be06678ed9e83edb1745eb72efc0bbcd7b5c3c35711a860906aed827a13d61" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] [[package]] name = "zerocopy" -version = "0.7.35" +version = "0.7.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.35" +version = "0.7.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.48", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" dependencies = [ "zeroize_derive", ] @@ -3410,7 +3404,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.48", ] [[package]] @@ -3454,9 +3448,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.0.12+zstd.1.5.6" +version = "2.0.9+zstd.1.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a4e40c320c3cb459d9a9ff6de98cff88f4751ee9275d140e2be94a2b74e4c13" +checksum = "9e16efa8a874a0481a574084d34cc26fdb3b99627480f785888deb6386506656" dependencies = [ "cc", "pkg-config", diff --git a/Dockerfile b/Dockerfile index e55eb58..8e17812 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM docker.io/library/rust:1.77-slim-bookworm AS builder +FROM docker.io/library/rust:1.79-slim-bookworm AS builder ARG TARGETPLATFORM ARG LLVM_VERSION=16 @@ -56,7 +56,6 @@ ENV CC_x86_64_unknown_linux_musl=clang-$LLVM_VERSION \ AR_aarch64_unknown_linux_musl=llvm-ar-$LLVM_VERSION \ CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS="-L/usr/lib/x86_64-linux-musl -L/lib/x86_64-linux-musl -C linker=rust-lld" \ CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS="-L/usr/lib/aarch64-linux-musl -L/lib/aarch64-linux-musl -C linker=rust-lld" \ - CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \ SQLITE3_STATIC=1 \ SQLITE3_INCLUDE_DIR=/usr/local/include \ SQLITE3_LIB_DIR=/usr/local/lib From 951b4ddae2f33e15c7d1976337de64001797d0e9 Mon Sep 17 00:00:00 2001 From: Jett Chen Date: Sat, 13 Jul 2024 18:09:09 -0700 Subject: [PATCH 017/102] add protobuf definition file --- proto/burrow.proto | 72 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 proto/burrow.proto diff --git a/proto/burrow.proto b/proto/burrow.proto new file mode 100644 index 0000000..3e15219 --- /dev/null +++ b/proto/burrow.proto @@ -0,0 +1,72 @@ +syntax = "proto3"; +package burrow; + +import "google/protobuf/timestamp.proto"; + +service Tunnel { + rpc TunnelConfiguration (Empty) returns (TunnelConfigurationResponse); + rpc TunnelStart (Empty) returns (Empty); + rpc TunnelStop (Empty) returns (Empty); + rpc TunnelStatus (Empty) returns (stream TunnelStatusResponse); +} + +service Networks { + rpc NetworkAdd (Empty) returns (Empty); + rpc NetworkList (Empty) returns (stream NetworkListResponse); + rpc NetworkReorder (NetworkReorderRequest) returns (Empty); + rpc NetworkDelete (NetworkDeleteRequest) returns (Empty); +} + +message NetworkReorderRequest { + int32 id = 1; + int32 index = 2; +} + +message WireGuardPeer { + string endpoint = 1; + repeated string subnet = 2; +} + +message WireGuardNetwork { + string address = 1; + string dns = 2; + repeated WireGuardPeer peer = 3; +} + +message NetworkDeleteRequest { + int32 id = 1; +} + +message Network { + int32 id = 1; + NetworkType type = 2; + bytes payload = 3; +} + +enum NetworkType { + WireGuard = 0; + HackClub = 1; +} + +message NetworkListResponse { + repeated Network network = 1; +} + +message Empty { + +} + +enum State { + Stopped = 0; + Running = 1; +} + +message TunnelStatusResponse { + State state = 1; + optional google.protobuf.Timestamp start = 2; +} + +message TunnelConfigurationResponse { + repeated string addresses = 1; + int32 mtu = 2; +} From aa634d03e2560dbaf500b19f584d19696abb7161 Mon Sep 17 00:00:00 2001 From: Jett Chen Date: Sat, 13 Jul 2024 18:14:00 -0700 Subject: [PATCH 018/102] update protobuf definition file --- proto/burrow.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proto/burrow.proto b/proto/burrow.proto index 3e15219..2d29c78 100644 --- a/proto/burrow.proto +++ b/proto/burrow.proto @@ -4,7 +4,7 @@ package burrow; import "google/protobuf/timestamp.proto"; service Tunnel { - rpc TunnelConfiguration (Empty) returns (TunnelConfigurationResponse); + rpc TunnelConfiguration (Empty) returns (stream TunnelConfigurationResponse); rpc TunnelStart (Empty) returns (Empty); rpc TunnelStop (Empty) returns (Empty); rpc TunnelStatus (Empty) returns (stream TunnelStatusResponse); From 62a5739d86feb8c2d23ec3d6187d2f1c6dffc9d3 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 7 Sep 2024 17:01:17 -0700 Subject: [PATCH 019/102] Update pipelines with various fixes --- .github/actions/download-profiles/action.yml | 27 ++++++++++++ .github/workflows/build-appimage.yml | 3 ++ .github/workflows/build-docker.yml | 3 ++ .github/workflows/build-rust.yml | 2 +- .github/workflows/lint-swift.yml | 2 +- .github/workflows/release-appimage.yml | 29 ------------- .github/workflows/release-apple.yml | 5 ++- .github/workflows/release-if-needed.yaml | 2 + .github/workflows/release-linux.yml | 43 +++++++++----------- 9 files changed, 59 insertions(+), 57 deletions(-) create mode 100644 .github/actions/download-profiles/action.yml delete mode 100644 .github/workflows/release-appimage.yml diff --git a/.github/actions/download-profiles/action.yml b/.github/actions/download-profiles/action.yml new file mode 100644 index 0000000..98961aa --- /dev/null +++ b/.github/actions/download-profiles/action.yml @@ -0,0 +1,27 @@ +name: Download Provisioning Profiles +inputs: + app-store-key: + description: App Store key in PEM PKCS#8 format + required: true + app-store-key-id: + description: App Store key ID + required: true + app-store-key-issuer-id: + description: App Store key issuer ID + required: true +runs: + using: composite + steps: + - shell: bash + run: | + cat << EOF > api-key.json + { + "key_id": "${{ inputs.app-store-key-id }}", + "issuer_id": "${{ inputs.app-store-key-issuer-id }}", + "key": "${{ inputs.app-store-key }}" + } + EOF + + fastlane sigh download_all --api_key_path api-key.json --download_xcode_profiles + + rm -rf api-key.json diff --git a/.github/workflows/build-appimage.yml b/.github/workflows/build-appimage.yml index bb510fb..bd29b07 100644 --- a/.github/workflows/build-appimage.yml +++ b/.github/workflows/build-appimage.yml @@ -6,6 +6,9 @@ on: pull_request: branches: - "*" +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: appimage: name: Build AppImage diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 307a93c..6a3dae1 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -6,6 +6,9 @@ on: pull_request: branches: - "*" +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: build: name: Build Docker Image diff --git a/.github/workflows/build-rust.yml b/.github/workflows/build-rust.yml index 76ce9f2..11ff60d 100644 --- a/.github/workflows/build-rust.yml +++ b/.github/workflows/build-rust.yml @@ -58,7 +58,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y ${{ join(matrix.packages, ' ') }} - - name: Install Windows Deps + - name: Configure LLVM if: matrix.os == 'windows-2022' shell: bash run: echo "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Tools\Llvm\x64\bin" >> $GITHUB_PATH diff --git a/.github/workflows/lint-swift.yml b/.github/workflows/lint-swift.yml index a2cc96a..857f575 100644 --- a/.github/workflows/lint-swift.yml +++ b/.github/workflows/lint-swift.yml @@ -13,4 +13,4 @@ jobs: - name: Checkout uses: actions/checkout@v4 - name: Lint - run: swiftlint lint --reporter github-actions-logging + run: swiftlint lint --strict --reporter github-actions-logging diff --git a/.github/workflows/release-appimage.yml b/.github/workflows/release-appimage.yml deleted file mode 100644 index e566186..0000000 --- a/.github/workflows/release-appimage.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Release (AppImage) -on: - release: - types: - - created -jobs: - appimage: - name: Build AppImage - runs-on: ubuntu-latest - container: docker - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Build - run: | - docker build -t appimage-builder . -f burrow-gtk/build-aux/Dockerfile - docker create --name temp appimage-builder - docker cp temp:/app/burrow-gtk/build-appimage/Burrow-x86_64.AppImage . - docker rm temp - - name: Upload to GitHub - uses: SierraSoftworks/gh-releases@v1.0.7 - with: - token: ${{ secrets.GITHUB_TOKEN }} - release_tag: ${{ github.ref_name }} - overwrite: 'true' - files: | - Burrow-x86_64.AppImage diff --git a/.github/workflows/release-apple.yml b/.github/workflows/release-apple.yml index 1883008..f1ee5dd 100644 --- a/.github/workflows/release-apple.yml +++ b/.github/workflows/release-apple.yml @@ -24,7 +24,7 @@ jobs: - x86_64-apple-darwin - aarch64-apple-darwin env: - DEVELOPER_DIR: /Applications/Xcode_15.4.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_16.0.app/Contents/Developer steps: - name: Checkout uses: actions/checkout@v4 @@ -40,8 +40,9 @@ jobs: with: targets: ${{ join(matrix.rust-targets, ', ') }} - name: Configure Version + id: version shell: bash - run: Tools/version.sh + run: echo "BUILD_NUMBER=$(Tools/version.sh)" >> $GITHUB_OUTPUT - name: Archive uses: ./.github/actions/archive with: diff --git a/.github/workflows/release-if-needed.yaml b/.github/workflows/release-if-needed.yaml index 0d2eb97..79f0d63 100644 --- a/.github/workflows/release-if-needed.yaml +++ b/.github/workflows/release-if-needed.yaml @@ -9,6 +9,8 @@ jobs: create: name: Create Release If Needed runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index 6709edb..7db9bcf 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -2,33 +2,28 @@ name: Release (Linux) on: release: types: - - created + - created jobs: appimage: name: Build AppImage runs-on: ubuntu-latest container: docker steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Build AppImage - run: | - docker build -t appimage-builder . -f burrow-gtk/build-aux/Dockerfile - docker create --name temp appimage-builder - docker cp temp:/app/burrow-gtk/build-appimage/Burrow-x86_64.AppImage . - docker rm temp - - name: Get Build Number - id: version - shell: bash - run: | - echo "BUILD_NUMBER=$(Tools/version.sh)" >> $GITHUB_OUTPUT - - name: Attach Artifacts - uses: SierraSoftworks/gh-releases@v1.0.7 - with: - token: ${{ secrets.GITHUB_TOKEN }} - release_tag: builds/${{ steps.version.outputs.BUILD_NUMBER }} - overwrite: "true" - files: | - Burrow-x86_64.AppImage + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Build AppImage + run: | + docker build -t appimage-builder . -f burrow-gtk/build-aux/Dockerfile + docker create --name temp appimage-builder + docker cp temp:/app/burrow-gtk/build-appimage/Burrow-x86_64.AppImage . + docker rm temp + - name: Attach Artifacts + uses: SierraSoftworks/gh-releases@v1.0.7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + release_tag: ${{ github.ref_name }} + overwrite: "true" + files: | + Burrow-x86_64.AppImage From fa1ef6fcda7acf4f9a0cf66d811baf1e626ac2a4 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 7 Sep 2024 17:08:02 -0700 Subject: [PATCH 020/102] Download provisioning profiles in release pipeline --- .github/actions/download-profiles/action.yml | 7 +++++-- .github/actions/export/action.yml | 10 +++------- .github/workflows/build-rust.yml | 2 +- .github/workflows/release-apple.yml | 21 ++++++++++++-------- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/.github/actions/download-profiles/action.yml b/.github/actions/download-profiles/action.yml index 98961aa..32b615c 100644 --- a/.github/actions/download-profiles/action.yml +++ b/.github/actions/download-profiles/action.yml @@ -13,15 +13,18 @@ runs: using: composite steps: - shell: bash + env: + FASTLANE_OPT_OUT_USAGE: 'YES' run: | + APP_STORE_KEY=$(echo "${{ inputs.app-store-key }}" | jq -sR .) cat << EOF > api-key.json { "key_id": "${{ inputs.app-store-key-id }}", "issuer_id": "${{ inputs.app-store-key-issuer-id }}", - "key": "${{ inputs.app-store-key }}" + "key": $APP_STORE_KEY } EOF - fastlane sigh download_all --api_key_path api-key.json --download_xcode_profiles + fastlane sigh download_all --api_key_path api-key.json rm -rf api-key.json diff --git a/.github/actions/export/action.yml b/.github/actions/export/action.yml index 8f891be..75b748f 100644 --- a/.github/actions/export/action.yml +++ b/.github/actions/export/action.yml @@ -12,11 +12,8 @@ inputs: archive-path: description: Xcode archive path required: true - destination: - description: The Xcode export destination. This can either be "export" or "upload" - required: true - method: - description: The Xcode export method. This can be one of app-store, validation, ad-hoc, package, enterprise, development, developer-id, or mac-application. + export-options: + description: The export options in JSON format required: true export-path: description: The path to export the archive to @@ -29,8 +26,7 @@ runs: run: | echo "${{ inputs.app-store-key }}" > AuthKey_${{ inputs.app-store-key-id }}.p8 - echo '{"destination":"${{ inputs.destination }}","method":"${{ inputs.method }}"}' \ - | plutil -convert xml1 -o ExportOptions.plist - + echo '${{ inputs.export-options }}' | plutil -convert xml1 -o ExportOptions.plist - xcodebuild \ -exportArchive \ diff --git a/.github/workflows/build-rust.yml b/.github/workflows/build-rust.yml index 11ff60d..22bf83a 100644 --- a/.github/workflows/build-rust.yml +++ b/.github/workflows/build-rust.yml @@ -42,7 +42,7 @@ jobs: - aarch64-pc-windows-msvc runs-on: ${{ matrix.os }} env: - DEVELOPER_DIR: /Applications/Xcode_15.4.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_16.0.app/Contents/Developer CARGO_INCREMENTAL: 0 CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc RUST_BACKTRACE: short diff --git a/.github/workflows/release-apple.yml b/.github/workflows/release-apple.yml index f1ee5dd..bb9c15a 100644 --- a/.github/workflows/release-apple.yml +++ b/.github/workflows/release-apple.yml @@ -13,13 +13,10 @@ jobs: fail-fast: false matrix: include: - - - destination: generic/platform=iOS - platform: iOS + - platform: iOS rust-targets: - aarch64-apple-ios - - destination: generic/platform=macOS - platform: macOS + - platform: macOS rust-targets: - x86_64-apple-darwin - aarch64-apple-darwin @@ -35,6 +32,12 @@ jobs: with: certificate: ${{ secrets.DEVELOPER_CERT }} password: ${{ secrets.DEVELOPER_CERT_PASSWORD }} + - name: Download Provisioning Profiles + uses: ./.github/actions/download-profiles + with: + app-store-key: ${{ secrets.APPSTORE_KEY }} + app-store-key-id: ${{ secrets.APPSTORE_KEY_ID }} + app-store-key-issuer-id: ${{ secrets.APPSTORE_KEY_ISSUER_ID }} - name: Install Rust uses: dtolnay/rust-toolchain@stable with: @@ -47,7 +50,7 @@ jobs: uses: ./.github/actions/archive with: scheme: App - destination: ${{ matrix.destination }} + destination: generic/platform=${{ matrix.platform }} app-store-key: ${{ secrets.APPSTORE_KEY }} app-store-key-id: ${{ secrets.APPSTORE_KEY_ID }} app-store-key-issuer-id: ${{ secrets.APPSTORE_KEY_ISSUER_ID }} @@ -61,6 +64,8 @@ jobs: app-store-key-id: ${{ secrets.APPSTORE_KEY_ID }} app-store-key-issuer-id: ${{ secrets.APPSTORE_KEY_ISSUER_ID }} archive-path: Burrow.xcarchive + export-options: | + {"teamID":"P6PV2R9443","destination":"export","method":"developer-id","provisioningProfiles":{"com.hackclub.burrow":"Burrow Developer ID","com.hackclub.burrow.network":"Burrow Network Developer ID"},"signingCertificate":"Developer ID Application","signingStyle":"manual"} export-path: Release - name: Notarize if: ${{ matrix.platform == 'macOS' }} @@ -96,10 +101,10 @@ jobs: if: ${{ matrix.platform == 'iOS' }} uses: ./.github/actions/export with: - method: app-store - destination: upload app-store-key: ${{ secrets.APPSTORE_KEY }} app-store-key-id: ${{ secrets.APPSTORE_KEY_ID }} app-store-key-issuer-id: ${{ secrets.APPSTORE_KEY_ISSUER_ID }} archive-path: Burrow.xcarchive + export-options: | + {"method": "app-store", "destination": "upload"} export-path: Release From 3fbb520a106101761ca3cff49ce62029a88408fa Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 7 Sep 2024 17:36:48 -0700 Subject: [PATCH 021/102] Fix SwiftLint errors --- .github/actions/build-for-testing/action.yml | 2 + .github/workflows/build-rust.yml | 6 ++- Apple/App/AppDelegate.swift | 3 +- Apple/App/BurrowView.swift | 1 - Apple/App/OAuth2.swift | 46 +++++++++++--------- 5 files changed, 33 insertions(+), 25 deletions(-) diff --git a/.github/actions/build-for-testing/action.yml b/.github/actions/build-for-testing/action.yml index 084ba81..185c4ab 100644 --- a/.github/actions/build-for-testing/action.yml +++ b/.github/actions/build-for-testing/action.yml @@ -27,7 +27,9 @@ runs: Apple/DerivedData key: ${{ runner.os }}-${{ inputs.scheme }}-${{ hashFiles('**/Package.resolved') }} restore-keys: | + ${{ runner.os }}-${{ inputs.scheme }}-${{ hashFiles('**/Package.resolved') }} ${{ runner.os }}-${{ inputs.scheme }}- + ${{ runner.os }}- - name: Build shell: bash working-directory: Apple diff --git a/.github/workflows/build-rust.yml b/.github/workflows/build-rust.yml index 22bf83a..84ac9d8 100644 --- a/.github/workflows/build-rust.yml +++ b/.github/workflows/build-rust.yml @@ -21,14 +21,16 @@ jobs: - x86_64-unknown-linux-gnu targets: - aarch64-unknown-linux-gnu - - os: macos-12 + - os: macos-13 platform: macOS (Intel) + xcode: /Applications/Xcode_15.2.app test-targets: - x86_64-apple-darwin targets: - x86_64-apple-ios - os: macos-14 platform: macOS + xcode: /Applications/Xcode_16.0.app test-targets: - aarch64-apple-darwin targets: @@ -42,7 +44,7 @@ jobs: - aarch64-pc-windows-msvc runs-on: ${{ matrix.os }} env: - DEVELOPER_DIR: /Applications/Xcode_16.0.app/Contents/Developer + DEVELOPER_DIR: ${{ matrix.xcode }}/Contents/Developer CARGO_INCREMENTAL: 0 CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc RUST_BACKTRACE: short diff --git a/Apple/App/AppDelegate.swift b/Apple/App/AppDelegate.swift index bd76a2f..b0c5546 100644 --- a/Apple/App/AppDelegate.swift +++ b/Apple/App/AppDelegate.swift @@ -2,7 +2,8 @@ import AppKit import SwiftUI -@MainActor @main +@main +@MainActor class AppDelegate: NSObject, NSApplicationDelegate { private let quitItem: NSMenuItem = { let quitItem = NSMenuItem( diff --git a/Apple/App/BurrowView.swift b/Apple/App/BurrowView.swift index 8447592..3a53762 100644 --- a/Apple/App/BurrowView.swift +++ b/Apple/App/BurrowView.swift @@ -42,7 +42,6 @@ struct BurrowView: View { } private func addWireGuardNetwork() { - } private func authenticateWithSlack() async throws { diff --git a/Apple/App/OAuth2.swift b/Apple/App/OAuth2.swift index dc8c62b..9a930c9 100644 --- a/Apple/App/OAuth2.swift +++ b/Apple/App/OAuth2.swift @@ -1,6 +1,6 @@ import AuthenticationServices -import SwiftUI import Foundation +import SwiftUI enum OAuth2 { enum Error: Swift.Error { @@ -35,7 +35,7 @@ enum OAuth2 { } } - public init( + init( authorizationEndpoint: URL, tokenEndpoint: URL, redirectURI: URL, @@ -125,7 +125,11 @@ enum OAuth2 { var refreshToken: String? var credential: Credential { - .init(accessToken: accessToken, refreshToken: refreshToken, expirationDate: expiresIn.map { Date.init(timeIntervalSinceNow: $0) }) + .init( + accessToken: accessToken, + refreshToken: refreshToken, + expirationDate: expiresIn.map { Date(timeIntervalSinceNow: $0) } + ) } } @@ -203,7 +207,24 @@ enum OAuth2 { } extension WebAuthenticationSession { - func start(url: URL, redirectURI: URL) async throws -> URL { +#if canImport(BrowserEngineKit) + @available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *) + fileprivate static func callback(for redirectURI: URL) throws -> ASWebAuthenticationSession.Callback { + switch redirectURI.scheme { + case "https": + guard let host = redirectURI.host else { throw OAuth2.Error.invalidRedirectURI } + return .https(host: host, path: redirectURI.path) + case "http": + throw OAuth2.Error.invalidRedirectURI + case .some(let scheme): + return .customScheme(scheme) + case .none: + throw OAuth2.Error.invalidRedirectURI + } + } +#endif + + fileprivate func start(url: URL, redirectURI: URL) async throws -> URL { #if canImport(BrowserEngineKit) if #available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *) { return try await authenticate( @@ -231,23 +252,6 @@ extension WebAuthenticationSession { return url } } - - #if canImport(BrowserEngineKit) - @available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *) - fileprivate static func callback(for redirectURI: URL) throws -> ASWebAuthenticationSession.Callback { - switch redirectURI.scheme { - case "https": - guard let host = redirectURI.host else { throw OAuth2.Error.invalidRedirectURI } - return .https(host: host, path: redirectURI.path) - case "http": - throw OAuth2.Error.invalidRedirectURI - case .some(let scheme): - return .customScheme(scheme) - case .none: - throw OAuth2.Error.invalidRedirectURI - } - } - #endif } extension View { From e4b0f1660bff2112d0e20316c228926bed47f270 Mon Sep 17 00:00:00 2001 From: Jett Chen Date: Sat, 13 Jul 2024 17:32:49 -0700 Subject: [PATCH 022/102] GRPC Server Support - Deprecates old json-rpc system - Add GRPC daemon over uds --- .github/workflows/build-apple.yml | 9 +- .github/workflows/build-rust.yml | 7 +- .gitignore | 5 + .vscode/settings.json | 9 +- .../NetworkExtension/libburrow/build-rust.sh | 2 + Cargo.lock | 341 +++++++++++++++++- Dockerfile | 2 +- Makefile | 6 + burrow/Cargo.toml | 18 +- burrow/build.rs | 4 + burrow/burrow.db | Bin 20480 -> 0 bytes burrow/src/auth/server/db.rs | 2 + burrow/src/daemon/instance.rs | 300 ++++++++++----- burrow/src/daemon/mod.rs | 72 ++-- burrow/src/daemon/net/mod.rs | 9 +- burrow/src/daemon/net/unix.rs | 4 +- burrow/src/daemon/rpc/client.rs | 31 ++ burrow/src/daemon/rpc/grpc_defs.rs | 5 + burrow/src/daemon/rpc/mod.rs | 3 + burrow/src/database.rs | 109 +++++- burrow/src/main.rs | 156 +++++++- burrow/src/wireguard/config.rs | 94 ++++- burrow/src/wireguard/iface.rs | 14 +- burrow/src/wireguard/inifield.rs | 81 +++++ burrow/src/wireguard/mod.rs | 1 + ...guard__config__tests__tst_config_toml.snap | 16 + burrow/tmp/conrd.conf | 8 + proto/burrow.proto | 2 +- 28 files changed, 1110 insertions(+), 200 deletions(-) create mode 100644 burrow/build.rs delete mode 100644 burrow/burrow.db create mode 100644 burrow/src/daemon/rpc/client.rs create mode 100644 burrow/src/daemon/rpc/grpc_defs.rs create mode 100644 burrow/src/wireguard/inifield.rs create mode 100644 burrow/src/wireguard/snapshots/burrow__wireguard__config__tests__tst_config_toml.snap create mode 100644 burrow/tmp/conrd.conf diff --git a/.github/workflows/build-apple.yml b/.github/workflows/build-apple.yml index 84cc03a..b628001 100644 --- a/.github/workflows/build-apple.yml +++ b/.github/workflows/build-apple.yml @@ -1,7 +1,7 @@ name: Build Apple Apps on: push: - branches: + branches: - main pull_request: branches: @@ -39,6 +39,7 @@ jobs: - aarch64-apple-darwin env: DEVELOPER_DIR: /Applications/Xcode_16.0.app/Contents/Developer + PROTOC_VERSION: 3.25.1 steps: - name: Checkout uses: actions/checkout@v3 @@ -54,6 +55,10 @@ jobs: uses: dtolnay/rust-toolchain@stable with: targets: ${{ join(matrix.rust-targets, ', ') }} + - name: Install protoc + uses: taiki-e/install-action@v2 + with: + tool: protoc@${{ env.PROTOC_VERSION }} - name: Build id: build uses: ./.github/actions/build-for-testing @@ -82,4 +87,4 @@ jobs: destination: ${{ matrix.destination }} test-plan: ${{ matrix.xcode-ui-test }} artifact-prefix: ui-tests-${{ matrix.sdk-name }} - check-name: Xcode UI Tests (${{ matrix.platform }}) + check-name: Xcode UI Tests (${{ matrix.platform }}) \ No newline at end of file diff --git a/.github/workflows/build-rust.yml b/.github/workflows/build-rust.yml index 84ac9d8..95fc628 100644 --- a/.github/workflows/build-rust.yml +++ b/.github/workflows/build-rust.yml @@ -48,6 +48,7 @@ jobs: CARGO_INCREMENTAL: 0 CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc RUST_BACKTRACE: short + PROTOC_VERSION: 3.25.1 steps: - name: Checkout uses: actions/checkout@v3 @@ -64,6 +65,10 @@ jobs: if: matrix.os == 'windows-2022' shell: bash run: echo "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Tools\Llvm\x64\bin" >> $GITHUB_PATH + - name: Install protoc + uses: taiki-e/install-action@v2 + with: + tool: protoc@${{ env.PROTOC_VERSION }} - name: Install Rust uses: dtolnay/rust-toolchain@stable with: @@ -77,4 +82,4 @@ jobs: run: cargo build --verbose --workspace --all-features --target ${{ join(matrix.targets, ' --target ') }} --target ${{ join(matrix.test-targets, ' --target ') }} - name: Test shell: bash - run: cargo test --verbose --workspace --all-features --target ${{ join(matrix.test-targets, ' --target ') }} + run: cargo test --verbose --workspace --all-features --target ${{ join(matrix.test-targets, ' --target ') }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 96b2507..997d4d5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,8 @@ target/ .DS_STORE .idea/ + +tmp/ + +*.db +*.sock \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index a760137..eb85504 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,5 +15,12 @@ "rust-analyzer.inlayHints.typeHints.enable": false, "rust-analyzer.linkedProjects": [ "./burrow/Cargo.toml" - ] + ], + "[yaml]": { + "editor.insertSpaces": true, + "editor.tabSize": 2, + "editor.autoIndent": "advanced", + "diffEditor.ignoreTrimWhitespace": false, + "editor.formatOnSave": false + } } diff --git a/Apple/NetworkExtension/libburrow/build-rust.sh b/Apple/NetworkExtension/libburrow/build-rust.sh index e7204a5..00c3652 100755 --- a/Apple/NetworkExtension/libburrow/build-rust.sh +++ b/Apple/NetworkExtension/libburrow/build-rust.sh @@ -68,6 +68,8 @@ else CARGO_PATH="$(dirname $(readlink -f $(which cargo))):/usr/bin" fi +CARGO_PATH="$(dirname $(readlink -f $(which protoc))):$CARGO_PATH" + # Run cargo without the various environment variables set by Xcode. # Those variables can confuse cargo and the build scripts it runs. env -i PATH="$CARGO_PATH" CARGO_TARGET_DIR="${CONFIGURATION_TEMP_DIR}/target" IPHONEOS_DEPLOYMENT_TARGET="$IPHONEOS_DEPLOYMENT_TARGET" MACOSX_DEPLOYMENT_TARGET="$MACOSX_DEPLOYMENT_TARGET" cargo build "${CARGO_ARGS[@]}" diff --git a/Cargo.lock b/Cargo.lock index 5ef886c..309fc08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -132,17 +132,38 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22068c0c19514942eefcfd4daf8976ef1aad84e61539f95cd200c35202f80af5" +dependencies = [ + "async-stream-impl 0.2.1", + "futures-core", +] + [[package]] name = "async-stream" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" dependencies = [ - "async-stream-impl", + "async-stream-impl 0.3.5", "futures-core", "pin-project-lite", ] +[[package]] +name = "async-stream-impl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f9db3b38af870bf7e5cc649167533b493928e50744e2c30ae350230b414670" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "async-stream-impl" version = "0.3.5" @@ -165,6 +186,12 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.1.0" @@ -392,6 +419,8 @@ dependencies = [ "aead", "anyhow", "async-channel", + "async-stream 0.2.1", + "async-stream 0.2.1", "axum 0.7.5", "base64 0.21.7", "blake2", @@ -404,6 +433,7 @@ dependencies = [ "fehler", "futures", "hmac", + "hyper-util", "insta", "ip_network", "ip_network_table", @@ -412,15 +442,24 @@ dependencies = [ "nix 0.27.1", "once_cell", "parking_lot", + "prost 0.13.1", + "prost-types 0.13.1", + "prost 0.13.2", + "prost-types 0.13.2", "rand", "rand_core", "reqwest 0.12.5", "ring", "rusqlite", + "rust-ini", "schemars", "serde", "serde_json", "tokio", + "tokio-stream", + "tonic 0.12.2", + "tonic-build", + "tower", "tracing", "tracing-journald", "tracing-log 0.1.4", @@ -619,9 +658,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd326812b3fd01da5bb1af7d340d0d555fd3d4b641e7f1dfcf5962a902952787" dependencies = [ "futures-core", - "prost", - "prost-types", - "tonic", + "prost 0.12.3", + "prost-types 0.12.3", + "tonic 0.10.2", "tracing-core", ] @@ -637,18 +676,38 @@ dependencies = [ "futures-task", "hdrhistogram", "humantime", - "prost-types", + "prost-types 0.12.3", "serde", "serde_json", "thread_local", "tokio", "tokio-stream", - "tonic", + "tonic 0.10.2", "tracing", "tracing-core", "tracing-subscriber", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constant_time_eq" version = "0.1.5" @@ -704,6 +763,12 @@ version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + [[package]] name = "crypto-common" version = "0.1.6" @@ -762,6 +827,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dotenv" version = "0.15.0" @@ -876,6 +950,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27573eac26f4dd11e2b1916c3fe1baa56407c83c71a773a8ba17ec0bca03b6b7" +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.0.28" @@ -1057,6 +1137,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "h2" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.1.0", + "indexmap 2.1.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1215,7 +1314,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", + "h2 0.3.24", "http 0.2.11", "http-body 0.4.6", "httparse", @@ -1238,6 +1337,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", + "h2 0.4.6", "http 1.1.0", "http-body 1.0.0", "httparse", @@ -1279,6 +1379,19 @@ dependencies = [ "tokio-io-timeout", ] +[[package]] +name = "hyper-timeout" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3203a961e5c83b6f5498933e78b6b263e208c197b63e9c6c53cc82ffd3f63793" +dependencies = [ + "hyper 1.4.0", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -1615,6 +1728,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "multimap" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" + [[package]] name = "native-tls" version = "0.2.11" @@ -1762,6 +1881,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.3", +] + [[package]] name = "overload" version = "0.1.1" @@ -1832,6 +1961,16 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.1.0", +] + [[package]] name = "pin-project" version = "1.1.4" @@ -1925,7 +2064,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c289cda302b98a28d40c8b3b90498d6e526dd24ac2ecea73e4e491685b94a" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.12.3", +] + +[[package]] +name = "prost" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2ecbe40f08db5c006b5764a2645f7f3f141ce756412ac9e1dd6087e6d32995" +dependencies = [ + "bytes", + "prost-derive 0.13.2", +] + +[[package]] +name = "prost-build" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8650aabb6c35b860610e9cff5dc1af886c9e25073b7b1712a68972af4281302" +dependencies = [ + "bytes", + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost 0.13.2", + "prost-types 0.13.2", + "regex", + "syn 2.0.48", + "tempfile", ] [[package]] @@ -1941,13 +2111,35 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "prost-derive" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acf0c195eebb4af52c752bec4f52f645da98b6e92077a04110c7f349477ae5ac" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "prost-types" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "193898f59edcf43c26227dcd4c8427f00d99d61e95dcde58dabd49fa291d470e" dependencies = [ - "prost", + "prost 0.12.3", +] + +[[package]] +name = "prost-types" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60caa6738c7369b940c3d49246a8d1749323674c65cb13010134f5c9bad5b519" +dependencies = [ + "prost 0.13.2", ] [[package]] @@ -2100,7 +2292,7 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2", + "h2 0.3.24", "http 0.2.11", "http-body 0.4.6", "hyper 0.14.28", @@ -2197,6 +2389,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rust-ini" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d625ed57d8f49af6cfa514c42e1a71fadcff60eb0b1c517ff82fe41aa025b41" +dependencies = [ + "cfg-if", + "ordered-multimap", + "trim-in-place", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -2404,6 +2607,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2654,6 +2866,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -2755,25 +2976,59 @@ dependencies = [ "tracing", ] +[[package]] +name = "toml" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2caab0bf757388c6c0ae23b3293fdb463fee59434529014f85e3263b995c28" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "278f3d518e152219c994ce877758516bca5e118eaed6996192a774fb9fbf0788" +dependencies = [ + "indexmap 2.1.0", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tonic" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d560933a0de61cf715926b9cac824d4c883c2c43142f787595e48280c40a1d0e" dependencies = [ - "async-stream", + "async-stream 0.3.5", "async-trait", "axum 0.6.20", "base64 0.21.7", "bytes", - "h2", + "h2 0.3.24", "http 0.2.11", "http-body 0.4.6", "hyper 0.14.28", - "hyper-timeout", + "hyper-timeout 0.4.1", "percent-encoding", "pin-project", - "prost", + "prost 0.12.3", "tokio", "tokio-stream", "tower", @@ -2782,6 +3037,49 @@ dependencies = [ "tracing", ] +[[package]] +name = "tonic" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f6ba989e4b2c58ae83d862d3a3e27690b6e3ae630d0deb59f3697f32aa88ad" +dependencies = [ + "async-stream 0.3.5", + "async-trait", + "axum 0.7.5", + "base64 0.22.1", + "bytes", + "h2 0.4.6", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "hyper 1.4.0", + "hyper-timeout 0.5.1", + "hyper-util", + "percent-encoding", + "pin-project", + "prost 0.13.2", + "socket2", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4ee8877250136bd7e3d2331632810a4df4ea5e004656990d8d66d2f5ee8a67" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "quote", + "syn 2.0.48", +] + [[package]] name = "tower" version = "0.4.13" @@ -2913,6 +3211,12 @@ dependencies = [ "tracing-log 0.2.0", ] +[[package]] +name = "trim-in-place" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc" + [[package]] name = "try-lock" version = "0.2.5" @@ -3320,6 +3624,15 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" +[[package]] +name = "winnow" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "557404e450152cd6795bb558bca69e43c585055f4606e3bcae5894fc6dac9ba0" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" diff --git a/Dockerfile b/Dockerfile index 8e17812..404179b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN set -eux && \ curl --proto '=https' --tlsv1.2 -sSf https://apt.llvm.org/llvm-snapshot.gpg.key | gpg --dearmor --output $KEYRINGS/llvm.gpg && \ echo "deb [signed-by=$KEYRINGS/llvm.gpg] http://apt.llvm.org/bookworm/ llvm-toolchain-bookworm-$LLVM_VERSION main" > /etc/apt/sources.list.d/llvm.list && \ apt-get update && \ - apt-get install --no-install-recommends -y clang-$LLVM_VERSION llvm-$LLVM_VERSION lld-$LLVM_VERSION build-essential sqlite3 libsqlite3-dev musl musl-tools musl-dev && \ + apt-get install --no-install-recommends -y clang-$LLVM_VERSION llvm-$LLVM_VERSION lld-$LLVM_VERSION build-essential sqlite3 libsqlite3-dev musl musl-tools musl-dev protobuf-compiler libprotobuf-dev && \ ln -s clang-$LLVM_VERSION /usr/bin/clang && \ ln -s clang /usr/bin/clang++ && \ ln -s lld-$LLVM_VERSION /usr/bin/ld.lld && \ diff --git a/Makefile b/Makefile index d0c9bd9..6563ab1 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,12 @@ start: stop: @$(cargo_norm) stop +status: + @$(cargo_norm) server-status + +tunnel-config: + @$(cargo_norm) tunnel-config + test-dns: @sudo route delete 8.8.8.8 @sudo route add 8.8.8.8 -interface $(tun) diff --git a/burrow/Cargo.toml b/burrow/Cargo.toml index 0fb63a5..d5e56c1 100644 --- a/burrow/Cargo.toml +++ b/burrow/Cargo.toml @@ -19,6 +19,7 @@ tokio = { version = "1.37", features = [ "signal", "time", "tracing", + "fs", ] } tun = { version = "0.1", path = "../tun", features = ["serde", "tokio"] } clap = { version = "4.4", features = ["derive"] } @@ -56,8 +57,17 @@ reqwest = { version = "0.12", default-features = false, features = [ "json", "rustls-tls", ] } -rusqlite = "0.31.0" +rusqlite = { version = "0.31.0", features = ["blob"] } dotenv = "0.15.0" +tonic = "0.12.0" +prost = "0.13.1" +prost-types = "0.13.1" +tokio-stream = "0.1" +async-stream = "0.2" +tower = "0.4.13" +hyper-util = "0.1.6" +toml = "0.8.15" +rust-ini = "0.21.0" [target.'cfg(target_os = "linux")'.dependencies] caps = "0.5" @@ -66,7 +76,7 @@ tracing-journald = "0.3" [target.'cfg(target_vendor = "apple")'.dependencies] nix = { version = "0.27" } -rusqlite = { version = "0.31.0", features = ["bundled"] } +rusqlite = { version = "0.31.0", features = ["bundled", "blob"] } [dev-dependencies] insta = { version = "1.32", features = ["yaml"] } @@ -83,3 +93,7 @@ pre_uninstall_script = "../package/rpm/pre_uninstall" [features] tokio-console = ["dep:console-subscriber"] bundled = ["rusqlite/bundled"] + + +[build-dependencies] +tonic-build = "0.12.0" diff --git a/burrow/build.rs b/burrow/build.rs new file mode 100644 index 0000000..8eea5dc --- /dev/null +++ b/burrow/build.rs @@ -0,0 +1,4 @@ +fn main() -> Result<(), Box> { + tonic_build::compile_protos("../proto/burrow.proto")?; + Ok(()) +} diff --git a/burrow/burrow.db b/burrow/burrow.db deleted file mode 100644 index c5b6e2c614ecb4db4c264f50691b6f2cea99772a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20480 zcmWFz^vNtqRY=P(%1ta$FlG>7U}R))P*7lCU=U|uU|?lH0A>aT1{MUDff0#~iz&{a zSJuG`(#R{q!1sc08t)Wd5nPH##YaP6Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONfZicc z$HFcyEzQ^%T#}fSlbV-WQl4Lw4W(F}gIpa$TopnboqSvspn?h-TnbQ-nOBlpl$MyB z8lRb>;OQ5l5ajCS8szHd>>8|4o*oaE*2qlJRPgsx2n}!n8RzU6?Cj{`3N}Wwv7Q<1 zfaXxJ1Ip9m3sO^ypcD&=1E7LbbAS%m1t7nq=A{(mXXceCgt$h8DERq@DENi?_#os9 zN|SOjljE~fD{-kv%*n|wPfdx>EGWjMq@XCZI3uwrH3e=C*nZ6bCN^HWN82kl9QqrXkB9 z2QfHiUEN)S6as=geI0`$6}(*|6&yoD{5}1ggIs-G{X!4{1#$w|{|KR+%;J*Ny!e9r zq7qOV0hxr5%q=O!6f7vpEK4j&g$EOs2uVyyDM~HI8Pq9xXi|`n2KCLkcwHFyck!3- z>+!wdTf`T`C&qh$w~N<>-uZ6SzR?gE4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R80;b7 z!o|VBz|4@U&dYEr$KN#|EG0iT)hDDP#M7y)%s3>n*efVK)xe{`($6khIH_U^2USdAr-~_TR567W$rN|LLQhA3=b(zL9R1|XAY71JH1mFA{7sYRJyiIoQ0`5rzQLB0iH+K#3bj)4Xl&R*Ju=HcZQ zhK~MaAtqSUX|Z`ug??_jc2R1Wt8borUSVowq<>&`m5YU0o{@HXWL|}#uVrO=rh!Ga zZ6hlS#2qXH?G9#$JD3OB9ZV2+Fb%LfSQyzjLFL%MIs?@IXAq!ac|B_MXb6mkz-S1J ihQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2B~hX4R^(?&G_ diff --git a/burrow/src/auth/server/db.rs b/burrow/src/auth/server/db.rs index b74f7ce..995e64b 100644 --- a/burrow/src/auth/server/db.rs +++ b/burrow/src/auth/server/db.rs @@ -1,5 +1,7 @@ use anyhow::Result; +use crate::daemon::rpc::grpc_defs::{Network, NetworkType}; + pub static PATH: &str = "./server.sqlite3"; pub fn init_db() -> Result<()> { diff --git a/burrow/src/daemon/instance.rs b/burrow/src/daemon/instance.rs index bc506bd..ce96fa5 100644 --- a/burrow/src/daemon/instance.rs +++ b/burrow/src/daemon/instance.rs @@ -1,13 +1,30 @@ use std::{ + ops::Deref, path::{Path, PathBuf}, sync::Arc, + time::Duration, }; use anyhow::Result; -use tokio::{sync::RwLock, task::JoinHandle}; +use rusqlite::Connection; +use tokio::sync::{mpsc, watch, Notify, RwLock}; +use tokio_stream::wrappers::ReceiverStream; +use tonic::{Request, Response, Status as RspStatus}; use tracing::{debug, info, warn}; -use tun::tokio::TunInterface; +use tun::{tokio::TunInterface, TunOptions}; +use super::rpc::grpc_defs::{ + networks_server::Networks, + tunnel_server::Tunnel, + Empty, + Network, + NetworkDeleteRequest, + NetworkListResponse, + NetworkReorderRequest, + State as RPCTunnelState, + TunnelConfigurationResponse, + TunnelStatusResponse, +}; use crate::{ daemon::rpc::{ DaemonCommand, @@ -17,114 +34,223 @@ use crate::{ ServerConfig, ServerInfo, }, - database::{get_connection, load_interface}, + database::{ + add_network, + delete_network, + get_connection, + list_networks, + load_interface, + reorder_network, + }, wireguard::{Config, Interface}, }; +#[derive(Debug, Clone)] enum RunState { - Running(JoinHandle>), + Running, Idle, } -pub struct DaemonInstance { - rx: async_channel::Receiver, - sx: async_channel::Sender, - subx: async_channel::Sender, +impl RunState { + pub fn to_rpc(&self) -> RPCTunnelState { + match self { + RunState::Running => RPCTunnelState::Running, + RunState::Idle => RPCTunnelState::Stopped, + } + } +} + +#[derive(Clone)] +pub struct DaemonRPCServer { tun_interface: Arc>>, wg_interface: Arc>, config: Arc>, db_path: Option, - wg_state: RunState, + wg_state_chan: (watch::Sender, watch::Receiver), + network_update_chan: (watch::Sender<()>, watch::Receiver<()>), } -impl DaemonInstance { +impl DaemonRPCServer { pub fn new( - rx: async_channel::Receiver, - sx: async_channel::Sender, - subx: async_channel::Sender, wg_interface: Arc>, config: Arc>, db_path: Option<&Path>, - ) -> Self { - Self { - rx, - sx, - subx, - wg_interface, + ) -> Result { + Ok(Self { tun_interface: Arc::new(RwLock::new(None)), + wg_interface, config, db_path: db_path.map(|p| p.to_owned()), - wg_state: RunState::Idle, - } + wg_state_chan: watch::channel(RunState::Idle), + network_update_chan: watch::channel(()), + }) } - async fn proc_command(&mut self, command: DaemonCommand) -> Result { - info!("Daemon got command: {:?}", command); - match command { - DaemonCommand::Start(st) => { - match self.wg_state { - RunState::Running(_) => { - warn!("Got start, but tun interface already up."); - } - RunState::Idle => { - let tun_if = st.tun.open()?; - debug!("Setting tun on wg_interface"); - self.wg_interface.read().await.set_tun(tun_if).await; - debug!("tun set on wg_interface"); - - debug!("Setting tun_interface"); - self.tun_interface = self.wg_interface.read().await.get_tun(); - debug!("tun_interface set: {:?}", self.tun_interface); - - debug!("Cloning wg_interface"); - let tmp_wg = self.wg_interface.clone(); - let run_task = tokio::spawn(async move { - let twlock = tmp_wg.read().await; - twlock.run().await - }); - self.wg_state = RunState::Running(run_task); - info!("Daemon started tun interface"); - } - } - Ok(DaemonResponseData::None) - } - DaemonCommand::ServerInfo => match &self.tun_interface.read().await.as_ref() { - None => Ok(DaemonResponseData::None), - Some(ti) => { - info!("{:?}", ti); - Ok(DaemonResponseData::ServerInfo(ServerInfo::try_from( - ti.inner.get_ref(), - )?)) - } - }, - DaemonCommand::Stop => { - self.wg_interface.read().await.remove_tun().await; - self.wg_state = RunState::Idle; - Ok(DaemonResponseData::None) - } - DaemonCommand::ServerConfig => { - Ok(DaemonResponseData::ServerConfig(ServerConfig::default())) - } - DaemonCommand::ReloadConfig(interface_id) => { - let conn = get_connection(self.db_path.as_deref())?; - let cfig = load_interface(&conn, &interface_id)?; - *self.config.write().await = cfig; - self.subx - .send(DaemonNotification::ConfigChange(ServerConfig::try_from( - &self.config.read().await.to_owned(), - )?)) - .await?; - Ok(DaemonResponseData::None) - } - } + pub fn get_connection(&self) -> Result { + get_connection(self.db_path.as_deref()).map_err(proc_err) } - pub async fn run(&mut self) -> Result<()> { - while let Ok(command) = self.rx.recv().await { - let response = self.proc_command(command).await; - info!("Daemon response: {:?}", response); - self.sx.send(DaemonResponse::new(response)).await?; - } - Ok(()) + async fn set_wg_state(&self, state: RunState) -> Result<(), RspStatus> { + self.wg_state_chan.0.send(state).map_err(proc_err) + } + + async fn get_wg_state(&self) -> RunState { + self.wg_state_chan.1.borrow().to_owned() + } + + async fn notify_network_update(&self) -> Result<(), RspStatus> { + self.network_update_chan.0.send(()).map_err(proc_err) + } +} + +#[tonic::async_trait] +impl Tunnel for DaemonRPCServer { + type TunnelConfigurationStream = ReceiverStream>; + type TunnelStatusStream = ReceiverStream>; + + async fn tunnel_configuration( + &self, + _request: Request, + ) -> Result, RspStatus> { + let (tx, rx) = mpsc::channel(10); + tokio::spawn(async move { + let serv_config = ServerConfig::default(); + tx.send(Ok(TunnelConfigurationResponse { + mtu: serv_config.mtu.unwrap_or(1000), + addresses: serv_config.address, + })) + .await + }); + Ok(Response::new(ReceiverStream::new(rx))) + } + + async fn tunnel_start(&self, _request: Request) -> Result, RspStatus> { + let wg_state = self.get_wg_state().await; + match wg_state { + RunState::Idle => { + let tun_if = TunOptions::new().open()?; + debug!("Setting tun on wg_interface"); + self.tun_interface.write().await.replace(tun_if); + self.wg_interface + .write() + .await + .set_tun_ref(self.tun_interface.clone()) + .await; + debug!("tun set on wg_interface"); + + debug!("Setting tun_interface"); + debug!("tun_interface set: {:?}", self.tun_interface); + + debug!("Cloning wg_interface"); + let tmp_wg = self.wg_interface.clone(); + let run_task = tokio::spawn(async move { + let twlock = tmp_wg.read().await; + twlock.run().await + }); + self.set_wg_state(RunState::Running).await?; + } + + RunState::Running => { + warn!("Got start, but tun interface already up."); + } + } + + return Ok(Response::new(Empty {})); + } + + async fn tunnel_stop(&self, _request: Request) -> Result, RspStatus> { + self.wg_interface.write().await.remove_tun().await; + self.set_wg_state(RunState::Idle).await?; + return Ok(Response::new(Empty {})); + } + + async fn tunnel_status( + &self, + _request: Request, + ) -> Result, RspStatus> { + let (tx, rx) = mpsc::channel(10); + let mut state_rx = self.wg_state_chan.1.clone(); + tokio::spawn(async move { + let cur = state_rx.borrow_and_update().to_owned(); + tx.send(Ok(status_rsp(cur))).await; + loop { + state_rx.changed().await.unwrap(); + let cur = state_rx.borrow().to_owned(); + let res = tx.send(Ok(status_rsp(cur))).await; + if res.is_err() { + eprintln!("Tunnel status channel closed"); + break; + } + } + }); + Ok(Response::new(ReceiverStream::new(rx))) + } +} + +#[tonic::async_trait] +impl Networks for DaemonRPCServer { + type NetworkListStream = ReceiverStream>; + + async fn network_add(&self, request: Request) -> Result, RspStatus> { + let conn = self.get_connection()?; + let network = request.into_inner(); + add_network(&conn, &network).map_err(proc_err)?; + self.notify_network_update().await?; + Ok(Response::new(Empty {})) + } + + async fn network_list( + &self, + _request: Request, + ) -> Result, RspStatus> { + debug!("Mock network_list called"); + let (tx, rx) = mpsc::channel(10); + let conn = self.get_connection()?; + let mut sub = self.network_update_chan.1.clone(); + tokio::spawn(async move { + loop { + let networks = list_networks(&conn) + .map(|res| NetworkListResponse { network: res }) + .map_err(proc_err); + let res = tx.send(networks).await; + if res.is_err() { + eprintln!("Network list channel closed"); + break; + } + sub.changed().await.unwrap(); + } + }); + Ok(Response::new(ReceiverStream::new(rx))) + } + + async fn network_reorder( + &self, + request: Request, + ) -> Result, RspStatus> { + let conn = self.get_connection()?; + reorder_network(&conn, request.into_inner()).map_err(proc_err)?; + self.notify_network_update().await?; + Ok(Response::new(Empty {})) + } + + async fn network_delete( + &self, + request: Request, + ) -> Result, RspStatus> { + let conn = self.get_connection()?; + delete_network(&conn, request.into_inner()).map_err(proc_err)?; + self.notify_network_update().await?; + Ok(Response::new(Empty {})) + } +} + +fn proc_err(err: impl ToString) -> RspStatus { + RspStatus::internal(err.to_string()) +} + +fn status_rsp(state: RunState) -> TunnelStatusResponse { + TunnelStatusResponse { + state: state.to_rpc().into(), + start: None, // TODO: Add timestamp } } diff --git a/burrow/src/daemon/mod.rs b/burrow/src/daemon/mod.rs index 4469e90..f6b973f 100644 --- a/burrow/src/daemon/mod.rs +++ b/burrow/src/daemon/mod.rs @@ -5,14 +5,20 @@ mod instance; mod net; pub mod rpc; -use anyhow::Result; -use instance::DaemonInstance; -pub use net::{DaemonClient, Listener}; +use anyhow::{Error as AhError, Result}; +use instance::DaemonRPCServer; +pub use net::{get_socket_path, DaemonClient}; pub use rpc::{DaemonCommand, DaemonResponseData, DaemonStartOptions}; -use tokio::sync::{Notify, RwLock}; +use tokio::{ + net::UnixListener, + sync::{Notify, RwLock}, +}; +use tokio_stream::wrappers::UnixListenerStream; +use tonic::transport::Server; use tracing::{error, info}; use crate::{ + daemon::rpc::grpc_defs::{networks_server::NetworksServer, tunnel_server::TunnelServer}, database::{get_connection, load_interface}, wireguard::Interface, }; @@ -22,52 +28,36 @@ pub async fn daemon_main( db_path: Option<&Path>, notify_ready: Option>, ) -> Result<()> { - let (commands_tx, commands_rx) = async_channel::unbounded(); - let (response_tx, response_rx) = async_channel::unbounded(); - let (subscribe_tx, subscribe_rx) = async_channel::unbounded(); - - let listener = if let Some(path) = socket_path { - info!("Creating listener... {:?}", path); - Listener::new_with_path(commands_tx, response_rx, subscribe_rx, path) - } else { - info!("Creating listener..."); - Listener::new(commands_tx, response_rx, subscribe_rx) - }; if let Some(n) = notify_ready { n.notify_one() } - let listener = listener?; let conn = get_connection(db_path)?; let config = load_interface(&conn, "1")?; - let iface: Interface = config.clone().try_into()?; - let mut instance = DaemonInstance::new( - commands_rx, - response_tx, - subscribe_tx, - Arc::new(RwLock::new(iface)), + let burrow_server = DaemonRPCServer::new( + Arc::new(RwLock::new(config.clone().try_into()?)), Arc::new(RwLock::new(config)), - db_path, - ); + db_path.clone(), + )?; + let spp = socket_path.clone(); + let tmp = get_socket_path(); + let sock_path = spp.unwrap_or(Path::new(tmp.as_str())); + if sock_path.exists() { + std::fs::remove_file(sock_path)?; + } + let uds = UnixListener::bind(sock_path)?; + let serve_job = tokio::spawn(async move { + let uds_stream = UnixListenerStream::new(uds); + let _srv = Server::builder() + .add_service(TunnelServer::new(burrow_server.clone())) + .add_service(NetworksServer::new(burrow_server)) + .serve_with_incoming(uds_stream) + .await?; + Ok::<(), AhError>(()) + }); info!("Starting daemon..."); - let main_job = tokio::spawn(async move { - let result = instance.run().await; - if let Err(e) = result.as_ref() { - error!("Instance exited: {}", e); - } - result - }); - - let listener_job = tokio::spawn(async move { - let result = listener.run().await; - if let Err(e) = result.as_ref() { - error!("Listener exited: {}", e); - } - result - }); - - tokio::try_join!(main_job, listener_job) + tokio::try_join!(serve_job) .map(|_| ()) .map_err(|e| e.into()) } diff --git a/burrow/src/daemon/net/mod.rs b/burrow/src/daemon/net/mod.rs index 242f479..eb45335 100644 --- a/burrow/src/daemon/net/mod.rs +++ b/burrow/src/daemon/net/mod.rs @@ -1,18 +1,11 @@ - - - - - #[cfg(target_family = "unix")] mod unix; #[cfg(target_family = "unix")] -pub use unix::{DaemonClient, Listener}; +pub use unix::{get_socket_path, DaemonClient, Listener}; #[cfg(target_os = "windows")] mod windows; #[cfg(target_os = "windows")] pub use windows::{DaemonClient, Listener}; - - diff --git a/burrow/src/daemon/net/unix.rs b/burrow/src/daemon/net/unix.rs index 70c4207..975c470 100644 --- a/burrow/src/daemon/net/unix.rs +++ b/burrow/src/daemon/net/unix.rs @@ -25,7 +25,7 @@ const UNIX_SOCKET_PATH: &str = "/run/burrow.sock"; #[cfg(target_vendor = "apple")] const UNIX_SOCKET_PATH: &str = "burrow.sock"; -fn get_socket_path() -> String { +pub fn get_socket_path() -> String { if std::env::var("BURROW_SOCKET_PATH").is_ok() { return std::env::var("BURROW_SOCKET_PATH").unwrap(); } @@ -36,7 +36,7 @@ pub struct Listener { cmd_tx: async_channel::Sender, rsp_rx: async_channel::Receiver, sub_chan: async_channel::Receiver, - inner: UnixListener, + pub inner: UnixListener, } impl Listener { diff --git a/burrow/src/daemon/rpc/client.rs b/burrow/src/daemon/rpc/client.rs new file mode 100644 index 0000000..862e34c --- /dev/null +++ b/burrow/src/daemon/rpc/client.rs @@ -0,0 +1,31 @@ +use anyhow::Result; +use hyper_util::rt::TokioIo; +use tokio::net::UnixStream; +use tonic::transport::{Endpoint, Uri}; +use tower::service_fn; + +use super::grpc_defs::{networks_client::NetworksClient, tunnel_client::TunnelClient}; +use crate::daemon::get_socket_path; + +pub struct BurrowClient { + pub networks_client: NetworksClient, + pub tunnel_client: TunnelClient, +} + +impl BurrowClient { + #[cfg(any(target_os = "linux", target_vendor = "apple"))] + pub async fn from_uds() -> Result { + let channel = Endpoint::try_from("http://[::]:50051")? // NOTE: this is a hack(?) + .connect_with_connector(service_fn(|_: Uri| async { + let sock_path = get_socket_path(); + Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(sock_path).await?)) + })) + .await?; + let nw_client = NetworksClient::new(channel.clone()); + let tun_client = TunnelClient::new(channel.clone()); + Ok(BurrowClient { + networks_client: nw_client, + tunnel_client: tun_client, + }) + } +} diff --git a/burrow/src/daemon/rpc/grpc_defs.rs b/burrow/src/daemon/rpc/grpc_defs.rs new file mode 100644 index 0000000..f3085ee --- /dev/null +++ b/burrow/src/daemon/rpc/grpc_defs.rs @@ -0,0 +1,5 @@ +pub use burrowgrpc::*; + +mod burrowgrpc { + tonic::include_proto!("burrow"); +} diff --git a/burrow/src/daemon/rpc/mod.rs b/burrow/src/daemon/rpc/mod.rs index 4146e71..512662c 100644 --- a/burrow/src/daemon/rpc/mod.rs +++ b/burrow/src/daemon/rpc/mod.rs @@ -1,7 +1,10 @@ +pub mod client; +pub mod grpc_defs; pub mod notification; pub mod request; pub mod response; +pub use client::BurrowClient; pub use notification::DaemonNotification; pub use request::{DaemonCommand, DaemonRequest, DaemonStartOptions}; pub use response::{DaemonResponse, DaemonResponseData, ServerConfig, ServerInfo}; diff --git a/burrow/src/database.rs b/burrow/src/database.rs index 0047b01..9a9aac3 100644 --- a/burrow/src/database.rs +++ b/burrow/src/database.rs @@ -3,7 +3,15 @@ use std::path::Path; use anyhow::Result; use rusqlite::{params, Connection}; -use crate::wireguard::config::{Config, Interface, Peer}; +use crate::{ + daemon::rpc::grpc_defs::{ + Network as RPCNetwork, + NetworkDeleteRequest, + NetworkReorderRequest, + NetworkType, + }, + wireguard::config::{Config, Interface, Peer}, +}; #[cfg(target_vendor = "apple")] const DB_PATH: &str = "burrow.db"; @@ -30,8 +38,20 @@ const CREATE_WG_PEER_TABLE: &str = "CREATE TABLE IF NOT EXISTS wg_peer ( )"; const CREATE_NETWORK_TABLE: &str = "CREATE TABLE IF NOT EXISTS network ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + payload BLOB, + idx INTEGER, interface_id INT REFERENCES wg_interface(id) ON UPDATE CASCADE -)"; +); +CREATE TRIGGER IF NOT EXISTS increment_network_idx +AFTER INSERT ON network +BEGIN + UPDATE network + SET idx = (SELECT COALESCE(MAX(idx), 0) + 1 FROM network) + WHERE id = NEW.id; +END; +"; pub fn initialize_tables(conn: &Connection) -> Result<()> { conn.execute(CREATE_WG_INTERFACE_TABLE, [])?; @@ -40,20 +60,6 @@ pub fn initialize_tables(conn: &Connection) -> Result<()> { Ok(()) } -fn parse_lst(s: &str) -> Vec { - if s.is_empty() { - return vec![]; - } - s.split(',').map(|s| s.to_string()).collect() -} - -fn to_lst(v: &Vec) -> String { - v.iter() - .map(|s| s.to_string()) - .collect::>() - .join(",") -} - pub fn load_interface(conn: &Connection, interface_id: &str) -> Result { let iface = conn.query_row( "SELECT private_key, dns, address, listen_port, mtu FROM wg_interface WHERE id = ?", @@ -99,7 +105,7 @@ pub fn dump_interface(conn: &Connection, config: &Config) -> Result<()> { cif.private_key, to_lst(&cif.dns), to_lst(&cif.address), - cif.listen_port, + cif.listen_port.unwrap_or(51820), cif.mtu ])?; let interface_id = conn.last_insert_rowid(); @@ -127,10 +133,75 @@ pub fn get_connection(path: Option<&Path>) -> Result { Ok(Connection::open(p)?) } +pub fn add_network(conn: &Connection, network: &RPCNetwork) -> Result<()> { + let mut stmt = conn.prepare("INSERT INTO network (id, type, payload) VALUES (?, ?, ?)")?; + stmt.execute(params![ + network.id, + network.r#type().as_str_name(), + &network.payload + ])?; + if network.r#type() == NetworkType::WireGuard { + let payload_str = String::from_utf8(network.payload.clone())?; + let wg_config = Config::from_content_fmt(&payload_str, "ini")?; + dump_interface(conn, &wg_config)?; + } + Ok(()) +} + +pub fn list_networks(conn: &Connection) -> Result> { + let mut stmt = conn.prepare("SELECT id, type, payload FROM network ORDER BY idx")?; + let networks: Vec = stmt + .query_map([], |row| { + println!("row: {:?}", row); + let network_id: i32 = row.get(0)?; + let network_type: String = row.get(1)?; + let network_type = NetworkType::from_str_name(network_type.as_str()) + .ok_or(rusqlite::Error::InvalidQuery)?; + let payload: Vec = row.get(2)?; + Ok(RPCNetwork { + id: network_id, + r#type: network_type.into(), + payload: payload.into(), + }) + })? + .collect::, rusqlite::Error>>()?; + Ok(networks) +} + +pub fn reorder_network(conn: &Connection, req: NetworkReorderRequest) -> Result<()> { + let mut stmt = conn.prepare("UPDATE network SET idx = ? WHERE id = ?")?; + let res = stmt.execute(params![req.index, req.id])?; + if res == 0 { + return Err(anyhow::anyhow!("No such network exists")); + } + Ok(()) +} + +pub fn delete_network(conn: &Connection, req: NetworkDeleteRequest) -> Result<()> { + let mut stmt = conn.prepare("DELETE FROM network WHERE id = ?")?; + let res = stmt.execute(params![req.id])?; + if res == 0 { + return Err(anyhow::anyhow!("No such network exists")); + } + Ok(()) +} + +fn parse_lst(s: &str) -> Vec { + if s.is_empty() { + return vec![]; + } + s.split(',').map(|s| s.to_string()).collect() +} + +fn to_lst(v: &Vec) -> String { + v.iter() + .map(|s| s.to_string()) + .collect::>() + .join(",") +} + #[cfg(test)] mod tests { - use std::path::Path; - use super::*; #[test] diff --git a/burrow/src/main.rs b/burrow/src/main.rs index ff07d4c..e87b4c9 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -11,8 +11,7 @@ mod wireguard; mod auth; #[cfg(any(target_os = "linux", target_vendor = "apple"))] -use daemon::{DaemonClient, DaemonCommand, DaemonStartOptions}; -use tun::TunOptions; +use daemon::{DaemonClient, DaemonCommand}; #[cfg(any(target_os = "linux", target_vendor = "apple"))] use crate::daemon::DaemonResponseData; @@ -20,6 +19,9 @@ use crate::daemon::DaemonResponseData; #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub mod database; +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +use crate::daemon::rpc::{grpc_defs::Empty, BurrowClient}; + #[derive(Parser)] #[command(name = "Burrow")] #[command(author = "Hack Club ")] @@ -52,13 +54,24 @@ enum Commands { ReloadConfig(ReloadConfigArgs), /// Authentication server AuthServer, + /// Server Status + ServerStatus, + /// Tunnel Config + TunnelConfig, + /// Add Network + NetworkAdd(NetworkAddArgs), + /// List Networks + NetworkList, + /// Reorder Network + NetworkReorder(NetworkReorderArgs), + /// Delete Network + NetworkDelete(NetworkDeleteArgs), } #[derive(Args)] struct ReloadConfigArgs { #[clap(long, short)] interface_id: String, - } #[derive(Args)] @@ -67,21 +80,132 @@ struct StartArgs {} #[derive(Args)] struct DaemonArgs {} +#[derive(Args)] +struct NetworkAddArgs { + id: i32, + network_type: i32, + payload_path: String, +} + +#[derive(Args)] +struct NetworkReorderArgs { + id: i32, + index: i32, +} + +#[derive(Args)] +struct NetworkDeleteArgs { + id: i32, +} + #[cfg(any(target_os = "linux", target_vendor = "apple"))] async fn try_start() -> Result<()> { - let mut client = DaemonClient::new().await?; - client - .send_command(DaemonCommand::Start(DaemonStartOptions { - tun: TunOptions::new().address(vec!["10.13.13.2", "::2"]), - })) - .await - .map(|_| ()) + let mut client = BurrowClient::from_uds().await?; + let res = client.tunnel_client.tunnel_start(Empty {}).await?; + println!("Got results! {:?}", res); + Ok(()) } #[cfg(any(target_os = "linux", target_vendor = "apple"))] async fn try_stop() -> Result<()> { - let mut client = DaemonClient::new().await?; - client.send_command(DaemonCommand::Stop).await?; + let mut client = BurrowClient::from_uds().await?; + let res = client.tunnel_client.tunnel_stop(Empty {}).await?; + println!("Got results! {:?}", res); + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_serverstatus() -> Result<()> { + let mut client = BurrowClient::from_uds().await?; + let mut res = client + .tunnel_client + .tunnel_status(Empty {}) + .await? + .into_inner(); + if let Some(st) = res.message().await? { + println!("Server Status: {:?}", st); + } else { + println!("Server Status is None"); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_tun_config() -> Result<()> { + let mut client = BurrowClient::from_uds().await?; + let mut res = client + .tunnel_client + .tunnel_configuration(Empty {}) + .await? + .into_inner(); + if let Some(config) = res.message().await? { + println!("Tunnel Config: {:?}", config); + } else { + println!("Tunnel Config is None"); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_network_add(id: i32, network_type: i32, payload_path: &str) -> Result<()> { + use tokio::{fs::File, io::AsyncReadExt}; + + use crate::daemon::rpc::grpc_defs::Network; + + let mut file = File::open(payload_path).await?; + let mut payload = Vec::new(); + file.read_to_end(&mut payload).await?; + + let mut client = BurrowClient::from_uds().await?; + let network = Network { + id, + r#type: network_type, + payload, + }; + let res = client.networks_client.network_add(network).await?; + println!("Network Add Response: {:?}", res); + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_network_list() -> Result<()> { + let mut client = BurrowClient::from_uds().await?; + let mut res = client + .networks_client + .network_list(Empty {}) + .await? + .into_inner(); + while let Some(network_list) = res.message().await? { + println!("Network List: {:?}", network_list); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_network_reorder(id: i32, index: i32) -> Result<()> { + use crate::daemon::rpc::grpc_defs::NetworkReorderRequest; + + let mut client = BurrowClient::from_uds().await?; + let reorder_request = NetworkReorderRequest { id, index }; + let res = client + .networks_client + .network_reorder(reorder_request) + .await?; + println!("Network Reorder Response: {:?}", res); + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_network_delete(id: i32) -> Result<()> { + use crate::daemon::rpc::grpc_defs::NetworkDeleteRequest; + + let mut client = BurrowClient::from_uds().await?; + let delete_request = NetworkDeleteRequest { id }; + let res = client + .networks_client + .network_delete(delete_request) + .await?; + println!("Network Delete Response: {:?}", res); Ok(()) } @@ -153,6 +277,14 @@ async fn main() -> Result<()> { Commands::ServerConfig => try_serverconfig().await?, Commands::ReloadConfig(args) => try_reloadconfig(args.interface_id.clone()).await?, Commands::AuthServer => crate::auth::server::serve().await?, + Commands::ServerStatus => try_serverstatus().await?, + Commands::TunnelConfig => try_tun_config().await?, + Commands::NetworkAdd(args) => { + try_network_add(args.id, args.network_type, &args.payload_path).await? + } + Commands::NetworkList => try_network_list().await?, + Commands::NetworkReorder(args) => try_network_reorder(args.id, args.index).await?, + Commands::NetworkDelete(args) => try_network_delete(args.id).await?, } Ok(()) diff --git a/burrow/src/wireguard/config.rs b/burrow/src/wireguard/config.rs index bd86a9f..5766675 100644 --- a/burrow/src/wireguard/config.rs +++ b/burrow/src/wireguard/config.rs @@ -3,9 +3,12 @@ use std::{net::ToSocketAddrs, str::FromStr}; use anyhow::{anyhow, Error, Result}; use base64::{engine::general_purpose, Engine}; use fehler::throws; +use ini::{Ini, Properties}; use ip_network::IpNetwork; +use serde::{Deserialize, Serialize}; use x25519_dalek::{PublicKey, StaticSecret}; +use super::inifield::IniField; use crate::wireguard::{Interface as WgInterface, Peer as WgPeer}; #[throws] @@ -31,7 +34,7 @@ fn parse_public_key(string: &str) -> PublicKey { /// A raw version of Peer Config that can be used later to reflect configuration files. /// This should be later converted to a `WgPeer`. /// Refers to https://github.com/pirate/wireguard-docs?tab=readme-ov-file#overview -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct Peer { pub public_key: String, pub preshared_key: Option, @@ -41,17 +44,18 @@ pub struct Peer { pub name: Option, } -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct Interface { pub private_key: String, pub address: Vec, - pub listen_port: u32, + pub listen_port: Option, pub dns: Vec, pub mtu: Option, } -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct Config { + #[serde(rename = "Peer")] pub peers: Vec, pub interface: Interface, // Support for multiple interfaces? } @@ -98,7 +102,7 @@ impl Default for Config { interface: Interface { private_key: "OEPVdomeLTxTIBvv3TYsJRge0Hp9NMiY0sIrhT8OWG8=".into(), address: vec!["10.13.13.2/24".into()], - listen_port: 51820, + listen_port: Some(51820), dns: Default::default(), mtu: Default::default(), }, @@ -113,3 +117,83 @@ impl Default for Config { } } } + +fn props_get(props: &Properties, key: &str) -> Result +where + T: TryFrom, +{ + IniField::try_from(props.get(key))?.try_into() +} + +impl TryFrom<&Properties> for Interface { + type Error = anyhow::Error; + + fn try_from(props: &Properties) -> Result { + Ok(Self { + private_key: props_get(props, "PrivateKey")?, + address: props_get(props, "Address")?, + listen_port: props_get(props, "ListenPort")?, + dns: props_get(props, "DNS")?, + mtu: props_get(props, "MTU")?, + }) + } +} + +impl TryFrom<&Properties> for Peer { + type Error = anyhow::Error; + + fn try_from(props: &Properties) -> Result { + Ok(Self { + public_key: props_get(props, "PublicKey")?, + preshared_key: props_get(props, "PresharedKey")?, + allowed_ips: props_get(props, "AllowedIPs")?, + endpoint: props_get(props, "Endpoint")?, + persistent_keepalive: props_get(props, "PersistentKeepalive")?, + name: props_get(props, "Name")?, + }) + } +} + +impl Config { + pub fn from_toml(toml: &str) -> Result { + toml::from_str(toml).map_err(Into::into) + } + + pub fn from_ini(ini: &str) -> Result { + let ini = Ini::load_from_str(ini)?; + let interface = ini + .section(Some("Interface")) + .ok_or(anyhow!("Interface section not found"))?; + let peers = ini.section_all(Some("Peer")); + Ok(Self { + interface: Interface::try_from(interface)?, + peers: peers + .into_iter() + .map(|v| Peer::try_from(v)) + .collect::>>()?, + }) + } + + pub fn from_content_fmt(content: &str, fmt: &str) -> Result { + match fmt { + "toml" => Self::from_toml(content), + "ini" | "conf" => Self::from_ini(content), + _ => Err(anyhow::anyhow!("Unsupported format: {}", fmt)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tst_config_toml() { + let cfig = Config::default(); + let toml = toml::to_string(&cfig).unwrap(); + println!("{}", &toml); + insta::assert_snapshot!(toml); + let cfig2: Config = toml::from_str(&toml).unwrap(); + assert_eq!(cfig, cfig2); + } +} diff --git a/burrow/src/wireguard/iface.rs b/burrow/src/wireguard/iface.rs index 84b5489..321801b 100755 --- a/burrow/src/wireguard/iface.rs +++ b/burrow/src/wireguard/iface.rs @@ -93,6 +93,12 @@ impl Interface { *st = IfaceStatus::Running; } + pub async fn set_tun_ref(&mut self, tun: Arc>>) { + self.tun = tun; + let mut st = self.status.write().await; + *st = IfaceStatus::Running; + } + pub fn get_tun(&self) -> Arc>> { self.tun.clone() } @@ -135,7 +141,7 @@ impl Interface { Some(addr) => addr, None => { debug!("No destination found"); - continue + continue; } }; @@ -154,7 +160,7 @@ impl Interface { } Err(e) => { log::error!("Failed to send packet {}", e); - continue + continue; } }; } @@ -175,7 +181,7 @@ impl Interface { let main_tsk = async move { if let Err(e) = pcb.open_if_closed().await { log::error!("failed to open pcb: {}", e); - return + return; } let r2 = pcb.run(tun).await; if let Err(e) = r2 { @@ -195,7 +201,7 @@ impl Interface { Ok(..) => (), Err(e) => { error!("Failed to update timers: {}", e); - return + return; } } } diff --git a/burrow/src/wireguard/inifield.rs b/burrow/src/wireguard/inifield.rs new file mode 100644 index 0000000..946868d --- /dev/null +++ b/burrow/src/wireguard/inifield.rs @@ -0,0 +1,81 @@ +use std::str::FromStr; + +use anyhow::{Error, Result}; + +pub struct IniField(String); + +impl FromStr for IniField { + type Err = Error; + + fn from_str(s: &str) -> Result { + Ok(Self(s.to_string())) + } +} + +impl TryFrom for Vec { + type Error = Error; + + fn try_from(field: IniField) -> Result { + Ok(field.0.split(',').map(|s| s.trim().to_string()).collect()) + } +} + +impl TryFrom for u32 { + type Error = Error; + + fn try_from(value: IniField) -> Result { + value.0.parse().map_err(Error::from) + } +} + +impl TryFrom for Option { + type Error = Error; + + fn try_from(value: IniField) -> Result { + if value.0.is_empty() { + Ok(None) + } else { + value.0.parse().map(Some).map_err(Error::from) + } + } +} + +impl TryFrom for String { + type Error = Error; + + fn try_from(value: IniField) -> Result { + Ok(value.0) + } +} + +impl TryFrom for Option { + type Error = Error; + + fn try_from(value: IniField) -> Result { + if value.0.is_empty() { + Ok(None) + } else { + Ok(Some(value.0)) + } + } +} + +impl TryFrom> for IniField +where + T: ToString, +{ + type Error = Error; + + fn try_from(value: Option) -> Result { + Ok(match value { + Some(v) => Self(v.to_string()), + None => Self(String::new()), + }) + } +} + +impl IniField { + fn new(value: &str) -> Self { + Self(value.to_string()) + } +} diff --git a/burrow/src/wireguard/mod.rs b/burrow/src/wireguard/mod.rs index 4c70a7f..cfb4585 100755 --- a/burrow/src/wireguard/mod.rs +++ b/burrow/src/wireguard/mod.rs @@ -1,5 +1,6 @@ pub mod config; mod iface; +mod inifield; mod noise; mod pcb; mod peer; diff --git a/burrow/src/wireguard/snapshots/burrow__wireguard__config__tests__tst_config_toml.snap b/burrow/src/wireguard/snapshots/burrow__wireguard__config__tests__tst_config_toml.snap new file mode 100644 index 0000000..3800647 --- /dev/null +++ b/burrow/src/wireguard/snapshots/burrow__wireguard__config__tests__tst_config_toml.snap @@ -0,0 +1,16 @@ +--- +source: burrow/src/wireguard/config.rs +expression: toml +--- +[[Peer]] +public_key = "8GaFjVO6c4luCHG4ONO+1bFG8tO+Zz5/Gy+Geht1USM=" +preshared_key = "ha7j4BjD49sIzyF9SNlbueK0AMHghlj6+u0G3bzC698=" +allowed_ips = ["8.8.8.8/32", "0.0.0.0/0"] +endpoint = "wg.burrow.rs:51820" + +[interface] +private_key = "OEPVdomeLTxTIBvv3TYsJRge0Hp9NMiY0sIrhT8OWG8=" +address = ["10.13.13.2/24"] +listen_port = 51820 +dns = [] + diff --git a/burrow/tmp/conrd.conf b/burrow/tmp/conrd.conf new file mode 100644 index 0000000..52572d1 --- /dev/null +++ b/burrow/tmp/conrd.conf @@ -0,0 +1,8 @@ +[Interface] +PrivateKey = gAaK0KFGOpxY7geGo59XXDufcxeoSNXXNC12mCQmlVs= +Address = 10.1.11.2/32 +DNS = 10.1.11.1 +[Peer] +PublicKey = Ab6V2mgPHiCXaAZfQrNts8ha8RkEzC49VnmMQfe5Yg4= +AllowedIPs = 10.1.11.1/32,10.1.11.2/32,0.0.0.0/0 +Endpoint = 172.251.163.175:51820 \ No newline at end of file diff --git a/proto/burrow.proto b/proto/burrow.proto index 2d29c78..2355b8d 100644 --- a/proto/burrow.proto +++ b/proto/burrow.proto @@ -11,7 +11,7 @@ service Tunnel { } service Networks { - rpc NetworkAdd (Empty) returns (Empty); + rpc NetworkAdd (Network) returns (Empty); rpc NetworkList (Empty) returns (stream NetworkListResponse); rpc NetworkReorder (NetworkReorderRequest) returns (Empty); rpc NetworkDelete (NetworkDeleteRequest) returns (Empty); From 25a0f7c42158831ceb5f6bbe7defe3c067eb586c Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 7 Sep 2024 20:35:28 -0700 Subject: [PATCH 023/102] Add Developer ID Profiles to build --- .github/workflows/release-apple.yml | 5 +++++ .../Burrow_Developer_ID.provisionprofile | Bin 0 -> 13091 bytes ...Burrow_Network_Developer_ID.provisionprofile | Bin 0 -> 13027 bytes 3 files changed, 5 insertions(+) create mode 100644 Apple/Profiles/Burrow_Developer_ID.provisionprofile create mode 100644 Apple/Profiles/Burrow_Network_Developer_ID.provisionprofile diff --git a/.github/workflows/release-apple.yml b/.github/workflows/release-apple.yml index bb9c15a..c0a34a9 100644 --- a/.github/workflows/release-apple.yml +++ b/.github/workflows/release-apple.yml @@ -38,6 +38,11 @@ jobs: app-store-key: ${{ secrets.APPSTORE_KEY }} app-store-key-id: ${{ secrets.APPSTORE_KEY_ID }} app-store-key-issuer-id: ${{ secrets.APPSTORE_KEY_ISSUER_ID }} + - name: Install Provisioning Profiles + shell: bash + run: | + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles/ + cp -f Apple/Profiles/* ~/Library/MobileDevice/Provisioning\ Profiles/ - name: Install Rust uses: dtolnay/rust-toolchain@stable with: diff --git a/Apple/Profiles/Burrow_Developer_ID.provisionprofile b/Apple/Profiles/Burrow_Developer_ID.provisionprofile new file mode 100644 index 0000000000000000000000000000000000000000..3ecd831fe2614bb8b5aea636ca5c080a48d99a98 GIT binary patch literal 13091 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 Uf<>W=OI4qBvCm4;3e_kF0D*cnOaK4? literal 0 HcmV?d00001 From 85640ffce18eac6ac1b6fa85ff278a457c955198 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 13 Jul 2024 18:08:43 -0700 Subject: [PATCH 024/102] Switch to gRPC client in Swift app --- .github/workflows/build-apple.yml | 9 +- .github/workflows/release-apple.yml | 4 + .gitignore | 3 + .swiftlint.yml | 1 - Apple/App/AppDelegate.swift | 1 + Apple/App/BurrowApp.swift | 3 +- Apple/App/MainMenu.xib | 4 +- Apple/App/Networks/Network.swift | 10 - Apple/App/Tunnel.swift | 50 - Apple/Burrow.xcodeproj/project.pbxproj | 814 +++++++++---- .../xcshareddata/swiftpm/Package.resolved | 123 ++ .../xcshareddata/xcschemes/App.xcscheme | 5 +- .../xcschemes/NetworkExtension.xcscheme | 6 +- Apple/Configuration/App.xcconfig | 6 +- Apple/Configuration/Compiler.xcconfig | 41 +- .../Configuration.xcconfig} | 5 +- .../Constants/Constants.h | 0 .../Constants}/Constants.swift | 31 +- .../Constants/module.modulemap | 2 +- Apple/Configuration/Debug.xcconfig | 26 + Apple/Configuration/Extension.xcconfig | 6 +- Apple/Configuration/Framework.xcconfig | 14 + Apple/Core/Client.swift | 32 + Apple/Core/Client/burrow.proto | 1 + Apple/Core/Client/grpc-swift-config.json | 11 + Apple/Core/Client/swift-protobuf-config.json | 10 + Apple/{Shared => Core}/Logging.swift | 2 +- .../PacketTunnelProvider.swift | 108 +- .../NetworkExtension/libburrow/build-rust.sh | 5 +- Apple/NetworkExtension/libburrow/libburrow.h | 2 +- Apple/Shared/Client.swift | 106 -- Apple/Shared/DataTypes.swift | 139 --- Apple/Shared/NWConnection+Async.swift | 32 - Apple/Shared/NewlineProtocolFramer.swift | 54 - .../AccentColor.colorset/Contents.json | 0 .../AppIcon.appiconset/100.png | Bin .../AppIcon.appiconset/1024.png | Bin .../AppIcon.appiconset/114.png | Bin .../AppIcon.appiconset/120.png | Bin .../AppIcon.appiconset/128.png | Bin .../AppIcon.appiconset/144.png | Bin .../AppIcon.appiconset/152.png | Bin .../Assets.xcassets/AppIcon.appiconset/16.png | Bin .../AppIcon.appiconset/167.png | Bin .../AppIcon.appiconset/172.png | Bin .../AppIcon.appiconset/180.png | Bin .../AppIcon.appiconset/196.png | Bin .../Assets.xcassets/AppIcon.appiconset/20.png | Bin .../AppIcon.appiconset/216.png | Bin .../AppIcon.appiconset/256.png | Bin .../Assets.xcassets/AppIcon.appiconset/29.png | Bin .../Assets.xcassets/AppIcon.appiconset/32.png | Bin .../Assets.xcassets/AppIcon.appiconset/40.png | Bin .../Assets.xcassets/AppIcon.appiconset/48.png | Bin .../Assets.xcassets/AppIcon.appiconset/50.png | Bin .../AppIcon.appiconset/512.png | Bin .../Assets.xcassets/AppIcon.appiconset/55.png | Bin .../Assets.xcassets/AppIcon.appiconset/57.png | Bin .../Assets.xcassets/AppIcon.appiconset/58.png | Bin .../Assets.xcassets/AppIcon.appiconset/60.png | Bin .../Assets.xcassets/AppIcon.appiconset/64.png | Bin .../Assets.xcassets/AppIcon.appiconset/72.png | Bin .../Assets.xcassets/AppIcon.appiconset/76.png | Bin .../Assets.xcassets/AppIcon.appiconset/80.png | Bin .../Assets.xcassets/AppIcon.appiconset/87.png | Bin .../Assets.xcassets/AppIcon.appiconset/88.png | Bin .../AppIcon.appiconset/Contents.json | 0 .../{App => UI}/Assets.xcassets/Contents.json | 0 .../HackClub.colorset/Contents.json | 0 .../HackClub.imageset/Contents.json | 0 .../flag-standalone-wtransparent.pdf | Bin .../WireGuard.colorset/Contents.json | 0 .../WireGuard.imageset/Contents.json | 0 .../WireGuard.imageset/WireGuard.svg | 0 .../WireGuardTitle.imageset/Contents.json | 0 .../WireGuardTitle.svg | 0 Apple/{App => UI}/BurrowView.swift | 7 +- Apple/{App => UI}/FloatingButtonStyle.swift | 0 Apple/{App => UI}/MenuItemToggleView.swift | 11 +- Apple/{App => UI}/NetworkCarouselView.swift | 8 +- .../{App => UI}/NetworkExtension+Async.swift | 6 +- .../{App => UI}/NetworkExtensionTunnel.swift | 72 +- Apple/{App => UI}/NetworkView.swift | 0 Apple/{App => UI}/Networks/HackClub.swift | 8 +- Apple/UI/Networks/Network.swift | 36 + Apple/{App => UI}/Networks/WireGuard.swift | 8 +- Apple/{App => UI}/OAuth2.swift | 21 +- Apple/UI/Tunnel.swift | 61 + Apple/{App => UI}/TunnelButton.swift | 2 +- Apple/{App => UI}/TunnelStatusView.swift | 2 +- Apple/UI/UI.xcconfig | 3 + Cargo.lock | 1080 +++++++++-------- burrow-gtk/build-aux/Dockerfile | 2 +- 93 files changed, 1666 insertions(+), 1327 deletions(-) delete mode 100644 Apple/App/Networks/Network.swift delete mode 100644 Apple/App/Tunnel.swift create mode 100644 Apple/Burrow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved rename Apple/{Shared/Shared.xcconfig => Configuration/Configuration.xcconfig} (65%) rename Apple/{Shared => Configuration}/Constants/Constants.h (100%) rename Apple/{Shared => Configuration/Constants}/Constants.swift (61%) rename Apple/{Shared => Configuration}/Constants/module.modulemap (66%) create mode 100644 Apple/Configuration/Debug.xcconfig create mode 100644 Apple/Configuration/Framework.xcconfig create mode 100644 Apple/Core/Client.swift create mode 120000 Apple/Core/Client/burrow.proto create mode 100644 Apple/Core/Client/grpc-swift-config.json create mode 100644 Apple/Core/Client/swift-protobuf-config.json rename Apple/{Shared => Core}/Logging.swift (88%) delete mode 100644 Apple/Shared/Client.swift delete mode 100644 Apple/Shared/DataTypes.swift delete mode 100644 Apple/Shared/NWConnection+Async.swift delete mode 100644 Apple/Shared/NewlineProtocolFramer.swift rename Apple/{App => UI}/Assets.xcassets/AccentColor.colorset/Contents.json (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/100.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/1024.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/114.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/120.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/128.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/144.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/152.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/16.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/167.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/172.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/180.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/196.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/20.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/216.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/256.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/29.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/32.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/40.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/48.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/50.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/512.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/55.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/57.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/58.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/60.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/64.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/72.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/76.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/80.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/87.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/88.png (100%) rename Apple/{App => UI}/Assets.xcassets/AppIcon.appiconset/Contents.json (100%) rename Apple/{App => UI}/Assets.xcassets/Contents.json (100%) rename Apple/{App => UI}/Assets.xcassets/HackClub.colorset/Contents.json (100%) rename Apple/{App => UI}/Assets.xcassets/HackClub.imageset/Contents.json (100%) rename Apple/{App => UI}/Assets.xcassets/HackClub.imageset/flag-standalone-wtransparent.pdf (100%) rename Apple/{App => UI}/Assets.xcassets/WireGuard.colorset/Contents.json (100%) rename Apple/{App => UI}/Assets.xcassets/WireGuard.imageset/Contents.json (100%) rename Apple/{App => UI}/Assets.xcassets/WireGuard.imageset/WireGuard.svg (100%) rename Apple/{App => UI}/Assets.xcassets/WireGuardTitle.imageset/Contents.json (100%) rename Apple/{App => UI}/Assets.xcassets/WireGuardTitle.imageset/WireGuardTitle.svg (100%) rename Apple/{App => UI}/BurrowView.swift (95%) rename Apple/{App => UI}/FloatingButtonStyle.swift (100%) rename Apple/{App => UI}/MenuItemToggleView.swift (87%) rename Apple/{App => UI}/NetworkCarouselView.swift (90%) rename Apple/{App => UI}/NetworkExtension+Async.swift (82%) rename Apple/{App => UI}/NetworkExtensionTunnel.swift (67%) rename Apple/{App => UI}/NetworkView.swift (100%) rename Apple/{App => UI}/Networks/HackClub.swift (76%) create mode 100644 Apple/UI/Networks/Network.swift rename Apple/{App => UI}/Networks/WireGuard.swift (82%) rename Apple/{App => UI}/OAuth2.swift (94%) create mode 100644 Apple/UI/Tunnel.swift rename Apple/{App => UI}/TunnelButton.swift (95%) rename Apple/{App => UI}/TunnelStatusView.swift (95%) create mode 100644 Apple/UI/UI.xcconfig diff --git a/.github/workflows/build-apple.yml b/.github/workflows/build-apple.yml index b628001..7ae8c4c 100644 --- a/.github/workflows/build-apple.yml +++ b/.github/workflows/build-apple.yml @@ -39,7 +39,7 @@ jobs: - aarch64-apple-darwin env: DEVELOPER_DIR: /Applications/Xcode_16.0.app/Contents/Developer - PROTOC_VERSION: 3.25.1 + PROTOC_PATH: /opt/homebrew/bin/protoc steps: - name: Checkout uses: actions/checkout@v3 @@ -55,10 +55,9 @@ jobs: uses: dtolnay/rust-toolchain@stable with: targets: ${{ join(matrix.rust-targets, ', ') }} - - name: Install protoc - uses: taiki-e/install-action@v2 - with: - tool: protoc@${{ env.PROTOC_VERSION }} + - name: Install Protobuf + shell: bash + run: brew install protobuf - name: Build id: build uses: ./.github/actions/build-for-testing diff --git a/.github/workflows/release-apple.yml b/.github/workflows/release-apple.yml index c0a34a9..c869d6a 100644 --- a/.github/workflows/release-apple.yml +++ b/.github/workflows/release-apple.yml @@ -22,6 +22,7 @@ jobs: - aarch64-apple-darwin env: DEVELOPER_DIR: /Applications/Xcode_16.0.app/Contents/Developer + PROTOC_PATH: /opt/homebrew/bin/protoc steps: - name: Checkout uses: actions/checkout@v4 @@ -47,6 +48,9 @@ jobs: uses: dtolnay/rust-toolchain@stable with: targets: ${{ join(matrix.rust-targets, ', ') }} + - name: Install Protobuf + shell: bash + run: brew install protobuf - name: Configure Version id: version shell: bash diff --git a/.gitignore b/.gitignore index 997d4d5..1b300b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Xcode xcuserdata +# Swift +Apple/Package/.swiftpm/ + # Rust target/ .env diff --git a/.swiftlint.yml b/.swiftlint.yml index 22ef035..8efc85e 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -30,7 +30,6 @@ opt_in_rules: - function_default_parameter_at_end - ibinspectable_in_extension - identical_operands -- implicitly_unwrapped_optional - indentation_width - joined_default_parameter - last_where diff --git a/Apple/App/AppDelegate.swift b/Apple/App/AppDelegate.swift index b0c5546..0ea93f4 100644 --- a/Apple/App/AppDelegate.swift +++ b/Apple/App/AppDelegate.swift @@ -1,5 +1,6 @@ #if os(macOS) import AppKit +import BurrowUI import SwiftUI @main diff --git a/Apple/App/BurrowApp.swift b/Apple/App/BurrowApp.swift index 21ebf84..838ef54 100644 --- a/Apple/App/BurrowApp.swift +++ b/Apple/App/BurrowApp.swift @@ -1,6 +1,7 @@ +#if !os(macOS) +import BurrowUI import SwiftUI -#if !os(macOS) @MainActor @main struct BurrowApp: App { diff --git a/Apple/App/MainMenu.xib b/Apple/App/MainMenu.xib index 587f6c4..50ba431 100644 --- a/Apple/App/MainMenu.xib +++ b/Apple/App/MainMenu.xib @@ -1,7 +1,7 @@ - + - + diff --git a/Apple/App/Networks/Network.swift b/Apple/App/Networks/Network.swift deleted file mode 100644 index d441d24..0000000 --- a/Apple/App/Networks/Network.swift +++ /dev/null @@ -1,10 +0,0 @@ -import SwiftUI - -protocol Network { - associatedtype Label: View - - var id: String { get } - var backgroundColor: Color { get } - - var label: Label { get } -} diff --git a/Apple/App/Tunnel.swift b/Apple/App/Tunnel.swift deleted file mode 100644 index 8db366f..0000000 --- a/Apple/App/Tunnel.swift +++ /dev/null @@ -1,50 +0,0 @@ -import SwiftUI - -protocol Tunnel { - var status: TunnelStatus { get } - - func start() - func stop() - func enable() -} - -enum TunnelStatus: Equatable, Hashable { - case unknown - case permissionRequired - case disabled - case connecting - case connected(Date) - case disconnecting - case disconnected - case reasserting - case invalid - case configurationReadWriteFailed -} - -struct TunnelKey: EnvironmentKey { - static let defaultValue: any Tunnel = NetworkExtensionTunnel() -} - -extension EnvironmentValues { - var tunnel: any Tunnel { - get { self[TunnelKey.self] } - set { self[TunnelKey.self] = newValue } - } -} - -#if DEBUG -@Observable -class PreviewTunnel: Tunnel { - var status: TunnelStatus = .permissionRequired - - func start() { - status = .connected(.now) - } - func stop() { - status = .disconnected - } - func enable() { - status = .disconnected - } -} -#endif diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index 5c5e80b..617b88f 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -7,52 +7,50 @@ objects = { /* Begin PBXBuildFile section */ - 0BA6D73B2BA638D900BD4B55 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B46E8DF2AC918CA00BA2A3C /* Client.swift */; }; - 0BA6D73C2BA6393200BD4B55 /* NWConnection+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00117302B2FFFC900D87C25 /* NWConnection+Async.swift */; }; - 0BA6D73D2BA6393B00BD4B55 /* NewlineProtocolFramer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00117322B3001A400D87C25 /* NewlineProtocolFramer.swift */; }; - 0BA6D73E2BA6394B00BD4B55 /* DataTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B28F1552ABF463A000D44B0 /* DataTypes.swift */; }; - 43AA26D82A10004900F14CE6 /* MenuItemToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AA26D72A10004900F14CE6 /* MenuItemToggleView.swift */; }; - D000363D2BB8928E00E582EC /* NetworkCarouselView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D000363C2BB8928E00E582EC /* NetworkCarouselView.swift */; }; - D000363F2BB895FB00E582EC /* OAuth2.swift in Sources */ = {isa = PBXBuildFile; fileRef = D000363E2BB895FB00E582EC /* OAuth2.swift */; }; - D001173B2B30341C00D87C25 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D001173A2B30341C00D87C25 /* Logging.swift */; }; - D00117442B30372900D87C25 /* libBurrowShared.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D00117382B30341C00D87C25 /* libBurrowShared.a */; }; - D00117452B30372C00D87C25 /* libBurrowShared.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D00117382B30341C00D87C25 /* libBurrowShared.a */; }; D00AA8972A4669BC005C8102 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00AA8962A4669BC005C8102 /* AppDelegate.swift */; }; - D01A79312B81630D0024EC91 /* NetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01A79302B81630D0024EC91 /* NetworkView.swift */; }; D020F65829E4A697002790F6 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F65729E4A697002790F6 /* PacketTunnelProvider.swift */; }; D020F65D29E4A697002790F6 /* BurrowNetworkExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - D032E6522B8A79C20006B8AD /* HackClub.swift in Sources */ = {isa = PBXBuildFile; fileRef = D032E6512B8A79C20006B8AD /* HackClub.swift */; }; - D032E6542B8A79DA0006B8AD /* WireGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D032E6532B8A79DA0006B8AD /* WireGuard.swift */; }; + D03383AD2C8E67E300F7C44E /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = D078F7E22C8DA375008A8CEC /* SwiftProtobuf */; }; + D03383AE2C8E67E300F7C44E /* NIO in Frameworks */ = {isa = PBXBuildFile; productRef = D044EE902C8DAB2000778185 /* NIO */; }; + D03383AF2C8E67E300F7C44E /* NIOConcurrencyHelpers in Frameworks */ = {isa = PBXBuildFile; productRef = D044EE922C8DAB2000778185 /* NIOConcurrencyHelpers */; }; + D03383B02C8E67E300F7C44E /* NIOTransportServices in Frameworks */ = {isa = PBXBuildFile; productRef = D044EE952C8DAB2800778185 /* NIOTransportServices */; }; D05B9F7629E39EEC008CB1F9 /* BurrowApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05B9F7529E39EEC008CB1F9 /* BurrowApp.swift */; }; - D05B9F7829E39EEC008CB1F9 /* BurrowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05B9F7729E39EEC008CB1F9 /* BurrowView.swift */; }; - D05B9F7A29E39EED008CB1F9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D05B9F7929E39EED008CB1F9 /* Assets.xcassets */; }; - D05EF8C82B81818D0017AB4F /* FloatingButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05EF8C72B81818D0017AB4F /* FloatingButtonStyle.swift */; }; - D08252762B5C9FC4005DA378 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08252752B5C9FC4005DA378 /* Constants.swift */; }; - D09150422B9D2AF700BE3CB0 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = D09150412B9D2AF700BE3CB0 /* MainMenu.xib */; }; - D0BCC5FD2A086D4700AD070D /* NetworkExtension+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BCC5FC2A086D4700AD070D /* NetworkExtension+Async.swift */; }; - D0BCC6082A0981FE00AD070D /* Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B98FC629FDC5B5004E7149 /* Tunnel.swift */; }; + D09150422B9D2AF700BE3CB0 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = D09150412B9D2AF700BE3CB0 /* MainMenu.xib */; platformFilters = (macos, ); }; + D0B1D1102C436152004B7823 /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = D0B1D10F2C436152004B7823 /* AsyncAlgorithms */; }; D0BCC6092A09A03E00AD070D /* libburrow.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D0BCC6032A09535900AD070D /* libburrow.a */; }; - D0FAB5922B818A5900F6A84B /* NetworkExtensionTunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FAB5912B818A5900F6A84B /* NetworkExtensionTunnel.swift */; }; - D0FAB5962B818B2900F6A84B /* TunnelButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FAB5952B818B2900F6A84B /* TunnelButton.swift */; }; - D0FAB5982B818B8200F6A84B /* TunnelStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FAB5972B818B8200F6A84B /* TunnelStatusView.swift */; }; - D0FAB59A2B818B9600F6A84B /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FAB5992B818B9600F6A84B /* Network.swift */; }; + D0BF09522C8E66F6000D8DEC /* BurrowConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */; }; + D0BF09552C8E66FD000D8DEC /* BurrowConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */; }; + D0D4E53A2C8D996F007F820A /* BurrowCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D0D4E56B2C8D9C2F007F820A /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49A2C8D921A007F820A /* Logging.swift */; }; + D0D4E5702C8D9C62007F820A /* BurrowCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; }; + D0D4E5712C8D9C6F007F820A /* HackClub.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49D2C8D921A007F820A /* HackClub.swift */; }; + D0D4E5722C8D9C6F007F820A /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49E2C8D921A007F820A /* Network.swift */; }; + D0D4E5732C8D9C6F007F820A /* WireGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49F2C8D921A007F820A /* WireGuard.swift */; }; + D0D4E5742C8D9C6F007F820A /* BurrowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A22C8D921A007F820A /* BurrowView.swift */; }; + D0D4E5752C8D9C6F007F820A /* FloatingButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A32C8D921A007F820A /* FloatingButtonStyle.swift */; }; + D0D4E5762C8D9C6F007F820A /* MenuItemToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A42C8D921A007F820A /* MenuItemToggleView.swift */; }; + D0D4E5772C8D9C6F007F820A /* NetworkCarouselView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A52C8D921A007F820A /* NetworkCarouselView.swift */; }; + D0D4E5782C8D9C6F007F820A /* NetworkExtension+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */; }; + D0D4E5792C8D9C6F007F820A /* NetworkExtensionTunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */; }; + D0D4E57A2C8D9C6F007F820A /* NetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A82C8D921A007F820A /* NetworkView.swift */; }; + D0D4E57B2C8D9C6F007F820A /* OAuth2.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A92C8D921A007F820A /* OAuth2.swift */; }; + D0D4E57C2C8D9C6F007F820A /* Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AA2C8D921A007F820A /* Tunnel.swift */; }; + D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */; }; + D0D4E57E2C8D9C6F007F820A /* TunnelStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */; }; + D0D4E5892C8D9C94007F820A /* BurrowUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5582C8D9BF2007F820A /* BurrowUI.framework */; }; + D0D4E58A2C8D9C9E007F820A /* BurrowUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5582C8D9BF2007F820A /* BurrowUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D0D4E58B2C8D9CA4007F820A /* BurrowConfiguration.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D0D4E5922C8D9D15007F820A /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E58F2C8D9D0A007F820A /* Constants.swift */; }; + D0D4E5A62C8D9E65007F820A /* BurrowCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; }; + D0F4FAD32C8DC79C0068730A /* BurrowCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; }; + D0F7594E2C8DAB6B00126CF3 /* GRPC in Frameworks */ = {isa = PBXBuildFile; productRef = D078F7E02C8DA375008A8CEC /* GRPC */; }; + D0F759612C8DB24B00126CF3 /* grpc-swift-config.json in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4962C8D921A007F820A /* grpc-swift-config.json */; }; + D0F759622C8DB24B00126CF3 /* swift-protobuf-config.json in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4972C8D921A007F820A /* swift-protobuf-config.json */; }; + D0F7597E2C8DB30500126CF3 /* CGRPCZlib in Frameworks */ = {isa = PBXBuildFile; productRef = D0F7597D2C8DB30500126CF3 /* CGRPCZlib */; }; + D0F7598D2C8DB3DA00126CF3 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4992C8D921A007F820A /* Client.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - D00117462B30373100D87C25 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; - proxyType = 1; - remoteGlobalIDString = D00117372B30341C00D87C25; - remoteInfo = Shared; - }; - D00117482B30373500D87C25 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; - proxyType = 1; - remoteGlobalIDString = D00117372B30341C00D87C25; - remoteInfo = Shared; - }; D020F65B29E4A697002790F6 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; @@ -60,6 +58,48 @@ remoteGlobalIDString = D020F65229E4A697002790F6; remoteInfo = BurrowNetworkExtension; }; + D0BF09502C8E66F1000D8DEC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0D4E55A2C8D9BF4007F820A; + remoteInfo = Configuration; + }; + D0BF09532C8E66FA000D8DEC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0D4E55A2C8D9BF4007F820A; + remoteInfo = Configuration; + }; + D0D4E56E2C8D9C5D007F820A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0D4E5302C8D996F007F820A; + remoteInfo = Core; + }; + D0D4E57F2C8D9C78007F820A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0D4E5302C8D996F007F820A; + remoteInfo = Core; + }; + D0D4E5872C8D9C88007F820A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0D4E5502C8D9BF2007F820A; + remoteInfo = UI; + }; + D0F4FAD12C8DC7960068730A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0D4E5302C8D996F007F820A; + remoteInfo = Core; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -74,22 +114,24 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + D0D4E53F2C8D996F007F820A /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + D0D4E58B2C8D9CA4007F820A /* BurrowConfiguration.framework in Embed Frameworks */, + D0D4E58A2C8D9C9E007F820A /* BurrowUI.framework in Embed Frameworks */, + D0D4E53A2C8D996F007F820A /* BurrowCore.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 0B28F1552ABF463A000D44B0 /* DataTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataTypes.swift; sourceTree = ""; }; - 0B46E8DF2AC918CA00BA2A3C /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = ""; }; - 43AA26D72A10004900F14CE6 /* MenuItemToggleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemToggleView.swift; sourceTree = ""; }; - D000363C2BB8928E00E582EC /* NetworkCarouselView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkCarouselView.swift; sourceTree = ""; }; - D000363E2BB895FB00E582EC /* OAuth2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuth2.swift; sourceTree = ""; }; - D00117302B2FFFC900D87C25 /* NWConnection+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NWConnection+Async.swift"; sourceTree = ""; }; - D00117322B3001A400D87C25 /* NewlineProtocolFramer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewlineProtocolFramer.swift; sourceTree = ""; }; - D00117382B30341C00D87C25 /* libBurrowShared.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libBurrowShared.a; sourceTree = BUILT_PRODUCTS_DIR; }; - D001173A2B30341C00D87C25 /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; }; - D00117412B30347800D87C25 /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = ""; }; - D00117422B30348D00D87C25 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = ""; }; + D00117422B30348D00D87C25 /* Configuration.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Configuration.xcconfig; sourceTree = ""; }; D00AA8962A4669BC005C8102 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - D01A79302B81630D0024EC91 /* NetworkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkView.swift; sourceTree = ""; }; D020F63D29E4A1FF002790F6 /* Identity.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Identity.xcconfig; sourceTree = ""; }; D020F64029E4A1FF002790F6 /* Compiler.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Compiler.xcconfig; sourceTree = ""; }; D020F64229E4A1FF002790F6 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -104,43 +146,54 @@ D020F66729E4A95D002790F6 /* NetworkExtension-iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "NetworkExtension-iOS.entitlements"; sourceTree = ""; }; D020F66829E4AA74002790F6 /* App-iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "App-iOS.entitlements"; sourceTree = ""; }; D020F66929E4AA74002790F6 /* App-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "App-macOS.entitlements"; sourceTree = ""; }; - D032E6512B8A79C20006B8AD /* HackClub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HackClub.swift; sourceTree = ""; }; - D032E6532B8A79DA0006B8AD /* WireGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireGuard.swift; sourceTree = ""; }; D04A3E1D2BAF465F0043EC85 /* Version.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = ""; }; D05B9F7229E39EEC008CB1F9 /* Burrow.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Burrow.app; sourceTree = BUILT_PRODUCTS_DIR; }; D05B9F7529E39EEC008CB1F9 /* BurrowApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurrowApp.swift; sourceTree = ""; }; - D05B9F7729E39EEC008CB1F9 /* BurrowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurrowView.swift; sourceTree = ""; }; - D05B9F7929E39EED008CB1F9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - D05EF8C72B81818D0017AB4F /* FloatingButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FloatingButtonStyle.swift; sourceTree = ""; }; - D08252742B5C9DEB005DA378 /* Constants.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Constants.h; sourceTree = ""; }; - D08252752B5C9FC4005DA378 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; D09150412B9D2AF700BE3CB0 /* MainMenu.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; }; D0B98FBF29FD8072004E7149 /* build-rust.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-rust.sh"; sourceTree = ""; }; - D0B98FC629FDC5B5004E7149 /* Tunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tunnel.swift; sourceTree = ""; }; D0B98FD829FDDB6F004E7149 /* libburrow.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = libburrow.h; sourceTree = ""; }; D0B98FDC29FDDDCF004E7149 /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = ""; }; - D0BCC5FC2A086D4700AD070D /* NetworkExtension+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetworkExtension+Async.swift"; sourceTree = ""; }; D0BCC6032A09535900AD070D /* libburrow.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libburrow.a; sourceTree = BUILT_PRODUCTS_DIR; }; - D0FAB5912B818A5900F6A84B /* NetworkExtensionTunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkExtensionTunnel.swift; sourceTree = ""; }; - D0FAB5952B818B2900F6A84B /* TunnelButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelButton.swift; sourceTree = ""; }; - D0FAB5972B818B8200F6A84B /* TunnelStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelStatusView.swift; sourceTree = ""; }; - D0FAB5992B818B9600F6A84B /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = ""; }; + D0BF09582C8E6789000D8DEC /* UI.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = UI.xcconfig; sourceTree = ""; }; + D0D4E4952C8D921A007F820A /* burrow.proto */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.protobuf; path = burrow.proto; sourceTree = ""; }; + D0D4E4962C8D921A007F820A /* grpc-swift-config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "grpc-swift-config.json"; sourceTree = ""; }; + D0D4E4972C8D921A007F820A /* swift-protobuf-config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "swift-protobuf-config.json"; sourceTree = ""; }; + D0D4E4992C8D921A007F820A /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = ""; }; + D0D4E49A2C8D921A007F820A /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; }; + D0D4E49D2C8D921A007F820A /* HackClub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HackClub.swift; sourceTree = ""; }; + D0D4E49E2C8D921A007F820A /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = ""; }; + D0D4E49F2C8D921A007F820A /* WireGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireGuard.swift; sourceTree = ""; }; + D0D4E4A12C8D921A007F820A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + D0D4E4A22C8D921A007F820A /* BurrowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurrowView.swift; sourceTree = ""; }; + D0D4E4A32C8D921A007F820A /* FloatingButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingButtonStyle.swift; sourceTree = ""; }; + D0D4E4A42C8D921A007F820A /* MenuItemToggleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemToggleView.swift; sourceTree = ""; }; + D0D4E4A52C8D921A007F820A /* NetworkCarouselView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkCarouselView.swift; sourceTree = ""; }; + D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetworkExtension+Async.swift"; sourceTree = ""; }; + D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkExtensionTunnel.swift; sourceTree = ""; }; + D0D4E4A82C8D921A007F820A /* NetworkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkView.swift; sourceTree = ""; }; + D0D4E4A92C8D921A007F820A /* OAuth2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuth2.swift; sourceTree = ""; }; + D0D4E4AA2C8D921A007F820A /* Tunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tunnel.swift; sourceTree = ""; }; + D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelButton.swift; sourceTree = ""; }; + D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelStatusView.swift; sourceTree = ""; }; + D0D4E4F62C8D932D007F820A /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + D0D4E4F72C8D941D007F820A /* Framework.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Framework.xcconfig; sourceTree = ""; }; + D0D4E5312C8D996F007F820A /* BurrowCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = BurrowCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D0D4E5582C8D9BF2007F820A /* BurrowUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = BurrowUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = BurrowConfiguration.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D0D4E58E2C8D9D0A007F820A /* Constants.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Constants.h; sourceTree = ""; }; + D0D4E58F2C8D9D0A007F820A /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + D0D4E5902C8D9D0A007F820A /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - D00117352B30341C00D87C25 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; D020F65029E4A697002790F6 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - D00117442B30372900D87C25 /* libBurrowShared.a in Frameworks */, + D0BF09522C8E66F6000D8DEC /* BurrowConfiguration.framework in Frameworks */, + D0D4E5A62C8D9E65007F820A /* BurrowCore.framework in Frameworks */, D0BCC6092A09A03E00AD070D /* libburrow.a in Frameworks */, + D0B1D1102C436152004B7823 /* AsyncAlgorithms in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -148,37 +201,36 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - D00117452B30372C00D87C25 /* libBurrowShared.a in Frameworks */, + D0BF09552C8E66FD000D8DEC /* BurrowConfiguration.framework in Frameworks */, + D0F4FAD32C8DC79C0068730A /* BurrowCore.framework in Frameworks */, + D0D4E5892C8D9C94007F820A /* BurrowUI.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D078F7CF2C8DA213008A8CEC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D03383B02C8E67E300F7C44E /* NIOTransportServices in Frameworks */, + D03383AF2C8E67E300F7C44E /* NIOConcurrencyHelpers in Frameworks */, + D03383AE2C8E67E300F7C44E /* NIO in Frameworks */, + D03383AD2C8E67E300F7C44E /* SwiftProtobuf in Frameworks */, + D0F7594E2C8DAB6B00126CF3 /* GRPC in Frameworks */, + D0F7597E2C8DB30500126CF3 /* CGRPCZlib in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0D4E5532C8D9BF2007F820A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D0D4E5702C8D9C62007F820A /* BurrowCore.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - D00117392B30341C00D87C25 /* Shared */ = { - isa = PBXGroup; - children = ( - 0B28F1552ABF463A000D44B0 /* DataTypes.swift */, - D00117322B3001A400D87C25 /* NewlineProtocolFramer.swift */, - D00117302B2FFFC900D87C25 /* NWConnection+Async.swift */, - 0B46E8DF2AC918CA00BA2A3C /* Client.swift */, - D001173A2B30341C00D87C25 /* Logging.swift */, - D08252752B5C9FC4005DA378 /* Constants.swift */, - D00117422B30348D00D87C25 /* Shared.xcconfig */, - D001173F2B30347800D87C25 /* Constants */, - ); - path = Shared; - sourceTree = ""; - }; - D001173F2B30347800D87C25 /* Constants */ = { - isa = PBXGroup; - children = ( - D08252742B5C9DEB005DA378 /* Constants.h */, - D00117412B30347800D87C25 /* module.modulemap */, - ); - path = Constants; - sourceTree = ""; - }; D00117432B30372900D87C25 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -192,9 +244,13 @@ D020F63D29E4A1FF002790F6 /* Identity.xcconfig */, D020F64A29E4A452002790F6 /* App.xcconfig */, D020F66329E4A703002790F6 /* Extension.xcconfig */, + D0D4E4F72C8D941D007F820A /* Framework.xcconfig */, D020F64029E4A1FF002790F6 /* Compiler.xcconfig */, + D0D4E4F62C8D932D007F820A /* Debug.xcconfig */, D04A3E1D2BAF465F0043EC85 /* Version.xcconfig */, D020F64229E4A1FF002790F6 /* Info.plist */, + D0D4E5912C8D9D0A007F820A /* Constants */, + D00117422B30348D00D87C25 /* Configuration.xcconfig */, ); path = Configuration; sourceTree = ""; @@ -212,22 +268,13 @@ path = NetworkExtension; sourceTree = ""; }; - D032E64D2B8A69C90006B8AD /* Networks */ = { - isa = PBXGroup; - children = ( - D0FAB5992B818B9600F6A84B /* Network.swift */, - D032E6512B8A79C20006B8AD /* HackClub.swift */, - D032E6532B8A79DA0006B8AD /* WireGuard.swift */, - ); - path = Networks; - sourceTree = ""; - }; D05B9F6929E39EEC008CB1F9 = { isa = PBXGroup; children = ( D05B9F7429E39EEC008CB1F9 /* App */, D020F65629E4A697002790F6 /* NetworkExtension */, - D00117392B30341C00D87C25 /* Shared */, + D0D4E49C2C8D921A007F820A /* Core */, + D0D4E4AD2C8D921A007F820A /* UI */, D020F63C29E4A1FF002790F6 /* Configuration */, D05B9F7329E39EEC008CB1F9 /* Products */, D00117432B30372900D87C25 /* Frameworks */, @@ -239,7 +286,10 @@ children = ( D05B9F7229E39EEC008CB1F9 /* Burrow.app */, D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */, - D00117382B30341C00D87C25 /* libBurrowShared.a */, + D0BCC6032A09535900AD070D /* libburrow.a */, + D0D4E5312C8D996F007F820A /* BurrowCore.framework */, + D0D4E5582C8D9BF2007F820A /* BurrowUI.framework */, + D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */, ); name = Products; sourceTree = ""; @@ -249,19 +299,6 @@ children = ( D05B9F7529E39EEC008CB1F9 /* BurrowApp.swift */, D00AA8962A4669BC005C8102 /* AppDelegate.swift */, - 43AA26D72A10004900F14CE6 /* MenuItemToggleView.swift */, - D05B9F7729E39EEC008CB1F9 /* BurrowView.swift */, - D000363C2BB8928E00E582EC /* NetworkCarouselView.swift */, - D01A79302B81630D0024EC91 /* NetworkView.swift */, - D000363E2BB895FB00E582EC /* OAuth2.swift */, - D032E64D2B8A69C90006B8AD /* Networks */, - D0FAB5972B818B8200F6A84B /* TunnelStatusView.swift */, - D0FAB5952B818B2900F6A84B /* TunnelButton.swift */, - D0B98FC629FDC5B5004E7149 /* Tunnel.swift */, - D0FAB5912B818A5900F6A84B /* NetworkExtensionTunnel.swift */, - D0BCC5FC2A086D4700AD070D /* NetworkExtension+Async.swift */, - D05EF8C72B81818D0017AB4F /* FloatingButtonStyle.swift */, - D05B9F7929E39EED008CB1F9 /* Assets.xcassets */, D09150412B9D2AF700BE3CB0 /* MainMenu.xib */, D020F66829E4AA74002790F6 /* App-iOS.entitlements */, D020F66929E4AA74002790F6 /* App-macOS.entitlements */, @@ -276,30 +313,74 @@ D0B98FBF29FD8072004E7149 /* build-rust.sh */, D0B98FDC29FDDDCF004E7149 /* module.modulemap */, D0B98FD829FDDB6F004E7149 /* libburrow.h */, - D0BCC6032A09535900AD070D /* libburrow.a */, ); path = libburrow; sourceTree = ""; }; + D0D4E4982C8D921A007F820A /* Client */ = { + isa = PBXGroup; + children = ( + D0D4E4952C8D921A007F820A /* burrow.proto */, + D0D4E4962C8D921A007F820A /* grpc-swift-config.json */, + D0D4E4972C8D921A007F820A /* swift-protobuf-config.json */, + ); + path = Client; + sourceTree = ""; + }; + D0D4E49C2C8D921A007F820A /* Core */ = { + isa = PBXGroup; + children = ( + D0D4E49A2C8D921A007F820A /* Logging.swift */, + D0D4E4992C8D921A007F820A /* Client.swift */, + D0D4E4982C8D921A007F820A /* Client */, + ); + path = Core; + sourceTree = ""; + }; + D0D4E4A02C8D921A007F820A /* Networks */ = { + isa = PBXGroup; + children = ( + D0D4E49D2C8D921A007F820A /* HackClub.swift */, + D0D4E49E2C8D921A007F820A /* Network.swift */, + D0D4E49F2C8D921A007F820A /* WireGuard.swift */, + ); + path = Networks; + sourceTree = ""; + }; + D0D4E4AD2C8D921A007F820A /* UI */ = { + isa = PBXGroup; + children = ( + D0D4E4A22C8D921A007F820A /* BurrowView.swift */, + D0D4E4A02C8D921A007F820A /* Networks */, + D0D4E4A32C8D921A007F820A /* FloatingButtonStyle.swift */, + D0D4E4A42C8D921A007F820A /* MenuItemToggleView.swift */, + D0D4E4A52C8D921A007F820A /* NetworkCarouselView.swift */, + D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */, + D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */, + D0D4E4A82C8D921A007F820A /* NetworkView.swift */, + D0D4E4A92C8D921A007F820A /* OAuth2.swift */, + D0D4E4AA2C8D921A007F820A /* Tunnel.swift */, + D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */, + D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */, + D0D4E4A12C8D921A007F820A /* Assets.xcassets */, + D0BF09582C8E6789000D8DEC /* UI.xcconfig */, + ); + path = UI; + sourceTree = ""; + }; + D0D4E5912C8D9D0A007F820A /* Constants */ = { + isa = PBXGroup; + children = ( + D0D4E58E2C8D9D0A007F820A /* Constants.h */, + D0D4E58F2C8D9D0A007F820A /* Constants.swift */, + D0D4E5902C8D9D0A007F820A /* module.modulemap */, + ); + path = Constants; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - D00117372B30341C00D87C25 /* Shared */ = { - isa = PBXNativeTarget; - buildConfigurationList = D001173C2B30341C00D87C25 /* Build configuration list for PBXNativeTarget "Shared" */; - buildPhases = ( - D00117342B30341C00D87C25 /* Sources */, - D00117352B30341C00D87C25 /* Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Shared; - productName = Shared; - productReference = D00117382B30341C00D87C25 /* libBurrowShared.a */; - productType = "com.apple.product-type.library.static"; - }; D020F65229E4A697002790F6 /* NetworkExtension */ = { isa = PBXNativeTarget; buildConfigurationList = D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */; @@ -307,12 +388,12 @@ D0BCC60B2A09A0C100AD070D /* Compile Rust */, D020F64F29E4A697002790F6 /* Sources */, D020F65029E4A697002790F6 /* Frameworks */, - D020F65129E4A697002790F6 /* Resources */, ); buildRules = ( ); dependencies = ( - D00117492B30373500D87C25 /* PBXTargetDependency */, + D0BF09512C8E66F1000D8DEC /* PBXTargetDependency */, + D0D4E5802C8D9C78007F820A /* PBXTargetDependency */, ); name = NetworkExtension; productName = BurrowNetworkExtension; @@ -323,16 +404,18 @@ isa = PBXNativeTarget; buildConfigurationList = D05B9F8129E39EED008CB1F9 /* Build configuration list for PBXNativeTarget "App" */; buildPhases = ( - D04A3E232BAF4AE50043EC85 /* Update Build Number */, D05B9F6E29E39EEC008CB1F9 /* Sources */, D05B9F6F29E39EEC008CB1F9 /* Frameworks */, D05B9F7029E39EEC008CB1F9 /* Resources */, + D0D4E53F2C8D996F007F820A /* Embed Frameworks */, D020F66129E4A697002790F6 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( - D00117472B30373100D87C25 /* PBXTargetDependency */, + D0BF09542C8E66FA000D8DEC /* PBXTargetDependency */, + D0F4FAD22C8DC7960068730A /* PBXTargetDependency */, + D0D4E5882C8D9C88007F820A /* PBXTargetDependency */, D020F65C29E4A697002790F6 /* PBXTargetDependency */, ); name = App; @@ -340,6 +423,71 @@ productReference = D05B9F7229E39EEC008CB1F9 /* Burrow.app */; productType = "com.apple.product-type.application"; }; + D0D4E5302C8D996F007F820A /* Core */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0D4E53C2C8D996F007F820A /* Build configuration list for PBXNativeTarget "Core" */; + buildPhases = ( + D0D4E52D2C8D996F007F820A /* Sources */, + D078F7CF2C8DA213008A8CEC /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + D0F7598A2C8DB34200126CF3 /* PBXTargetDependency */, + D0F7595E2C8DB24400126CF3 /* PBXTargetDependency */, + D0F759602C8DB24400126CF3 /* PBXTargetDependency */, + ); + name = Core; + packageProductDependencies = ( + D078F7E02C8DA375008A8CEC /* GRPC */, + D078F7E22C8DA375008A8CEC /* SwiftProtobuf */, + D044EE902C8DAB2000778185 /* NIO */, + D044EE922C8DAB2000778185 /* NIOConcurrencyHelpers */, + D044EE952C8DAB2800778185 /* NIOTransportServices */, + D0F7597D2C8DB30500126CF3 /* CGRPCZlib */, + ); + productName = Core; + productReference = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; + productType = "com.apple.product-type.framework"; + }; + D0D4E5502C8D9BF2007F820A /* UI */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0D4E5552C8D9BF2007F820A /* Build configuration list for PBXNativeTarget "UI" */; + buildPhases = ( + D0D4E5522C8D9BF2007F820A /* Sources */, + D0D4E5532C8D9BF2007F820A /* Frameworks */, + D0D4E5542C8D9BF2007F820A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + D0D4E56F2C8D9C5D007F820A /* PBXTargetDependency */, + ); + name = UI; + packageProductDependencies = ( + ); + productName = Core; + productReference = D0D4E5582C8D9BF2007F820A /* BurrowUI.framework */; + productType = "com.apple.product-type.framework"; + }; + D0D4E55A2C8D9BF4007F820A /* Configuration */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0D4E55F2C8D9BF4007F820A /* Build configuration list for PBXNativeTarget "Configuration" */; + buildPhases = ( + D0F759912C8DB49E00126CF3 /* Configure Version */, + D0D4E55C2C8D9BF4007F820A /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Configuration; + packageProductDependencies = ( + ); + productName = Core; + productReference = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */; + productType = "com.apple.product-type.framework"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -347,18 +495,18 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1510; + LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1520; TargetAttributes = { - D00117372B30341C00D87C25 = { - CreatedOnToolsVersion = 15.1; - }; D020F65229E4A697002790F6 = { CreatedOnToolsVersion = 14.3; }; D05B9F7129E39EEC008CB1F9 = { CreatedOnToolsVersion = 14.3; }; + D0D4E5302C8D996F007F820A = { + CreatedOnToolsVersion = 16.0; + }; }; }; buildConfigurationList = D05B9F6D29E39EEC008CB1F9 /* Build configuration list for PBXProject "Burrow" */; @@ -371,6 +519,11 @@ ); mainGroup = D05B9F6929E39EEC008CB1F9; packageReferences = ( + D0B1D10E2C436152004B7823 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, + D0D4E4822C8D8EF6007F820A /* XCRemoteSwiftPackageReference "grpc-swift" */, + D0D4E4852C8D8F29007F820A /* XCRemoteSwiftPackageReference "swift-protobuf" */, + D044EE8F2C8DAB2000778185 /* XCRemoteSwiftPackageReference "swift-nio" */, + D044EE942C8DAB2800778185 /* XCRemoteSwiftPackageReference "swift-nio-transport-services" */, ); productRefGroup = D05B9F7329E39EEC008CB1F9 /* Products */; projectDirPath = ""; @@ -378,52 +531,32 @@ targets = ( D05B9F7129E39EEC008CB1F9 /* App */, D020F65229E4A697002790F6 /* NetworkExtension */, - D00117372B30341C00D87C25 /* Shared */, + D0D4E5502C8D9BF2007F820A /* UI */, + D0D4E5302C8D996F007F820A /* Core */, + D0D4E55A2C8D9BF4007F820A /* Configuration */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - D020F65129E4A697002790F6 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; D05B9F7029E39EEC008CB1F9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - D05B9F7A29E39EED008CB1F9 /* Assets.xcassets in Resources */, D09150422B9D2AF700BE3CB0 /* MainMenu.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; + D0D4E5542C8D9BF2007F820A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - D04A3E232BAF4AE50043EC85 /* Update Build Number */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "$(PROJECT_DIR)/../Tools/version.sh", - "$(PROJECT_DIR)/../.git", - ); - name = "Update Build Number"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(PROJECT_DIR)/Configuration/Version.xcconfig", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"$PROJECT_DIR/../Tools/version.sh\"\n"; - }; D0BCC60B2A09A0C100AD070D /* Compile Rust */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -444,22 +577,31 @@ shellScript = "\"${PROJECT_DIR}/NetworkExtension/libburrow/build-rust.sh\"\n"; showEnvVarsInLog = 0; }; + D0F759912C8DB49E00126CF3 /* Configure Version */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(PROJECT_DIR)/../Tools/version.sh", + "$(PROJECT_DIR)/../.git", + ); + name = "Configure Version"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(PROJECT_DIR)/Configuration/Version.xcconfig", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$PROJECT_DIR/../Tools/version.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - D00117342B30341C00D87C25 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - D001173B2B30341C00D87C25 /* Logging.swift in Sources */, - 0BA6D73C2BA6393200BD4B55 /* NWConnection+Async.swift in Sources */, - D08252762B5C9FC4005DA378 /* Constants.swift in Sources */, - 0BA6D73E2BA6394B00BD4B55 /* DataTypes.swift in Sources */, - 0BA6D73B2BA638D900BD4B55 /* Client.swift in Sources */, - 0BA6D73D2BA6393B00BD4B55 /* NewlineProtocolFramer.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; D020F64F29E4A697002790F6 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -472,60 +614,104 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D0FAB59A2B818B9600F6A84B /* Network.swift in Sources */, - D0BCC6082A0981FE00AD070D /* Tunnel.swift in Sources */, - D0FAB5982B818B8200F6A84B /* TunnelStatusView.swift in Sources */, - 43AA26D82A10004900F14CE6 /* MenuItemToggleView.swift in Sources */, - D05B9F7829E39EEC008CB1F9 /* BurrowView.swift in Sources */, - D0FAB5922B818A5900F6A84B /* NetworkExtensionTunnel.swift in Sources */, - D000363F2BB895FB00E582EC /* OAuth2.swift in Sources */, - D0FAB5962B818B2900F6A84B /* TunnelButton.swift in Sources */, D00AA8972A4669BC005C8102 /* AppDelegate.swift in Sources */, - D05EF8C82B81818D0017AB4F /* FloatingButtonStyle.swift in Sources */, - D032E6522B8A79C20006B8AD /* HackClub.swift in Sources */, D05B9F7629E39EEC008CB1F9 /* BurrowApp.swift in Sources */, - D01A79312B81630D0024EC91 /* NetworkView.swift in Sources */, - D032E6542B8A79DA0006B8AD /* WireGuard.swift in Sources */, - D0BCC5FD2A086D4700AD070D /* NetworkExtension+Async.swift in Sources */, - D000363D2BB8928E00E582EC /* NetworkCarouselView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0D4E52D2C8D996F007F820A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D0F759612C8DB24B00126CF3 /* grpc-swift-config.json in Sources */, + D0F759622C8DB24B00126CF3 /* swift-protobuf-config.json in Sources */, + D0F7598D2C8DB3DA00126CF3 /* Client.swift in Sources */, + D0D4E56B2C8D9C2F007F820A /* Logging.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0D4E5522C8D9BF2007F820A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D0D4E5712C8D9C6F007F820A /* HackClub.swift in Sources */, + D0D4E5722C8D9C6F007F820A /* Network.swift in Sources */, + D0D4E5732C8D9C6F007F820A /* WireGuard.swift in Sources */, + D0D4E5742C8D9C6F007F820A /* BurrowView.swift in Sources */, + D0D4E5752C8D9C6F007F820A /* FloatingButtonStyle.swift in Sources */, + D0D4E5762C8D9C6F007F820A /* MenuItemToggleView.swift in Sources */, + D0D4E5772C8D9C6F007F820A /* NetworkCarouselView.swift in Sources */, + D0D4E5782C8D9C6F007F820A /* NetworkExtension+Async.swift in Sources */, + D0D4E5792C8D9C6F007F820A /* NetworkExtensionTunnel.swift in Sources */, + D0D4E57A2C8D9C6F007F820A /* NetworkView.swift in Sources */, + D0D4E57B2C8D9C6F007F820A /* OAuth2.swift in Sources */, + D0D4E57C2C8D9C6F007F820A /* Tunnel.swift in Sources */, + D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */, + D0D4E57E2C8D9C6F007F820A /* TunnelStatusView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0D4E55C2C8D9BF4007F820A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D0D4E5922C8D9D15007F820A /* Constants.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - D00117472B30373100D87C25 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = D00117372B30341C00D87C25 /* Shared */; - targetProxy = D00117462B30373100D87C25 /* PBXContainerItemProxy */; - }; - D00117492B30373500D87C25 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = D00117372B30341C00D87C25 /* Shared */; - targetProxy = D00117482B30373500D87C25 /* PBXContainerItemProxy */; - }; D020F65C29E4A697002790F6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = D020F65229E4A697002790F6 /* NetworkExtension */; targetProxy = D020F65B29E4A697002790F6 /* PBXContainerItemProxy */; }; + D0BF09512C8E66F1000D8DEC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0D4E55A2C8D9BF4007F820A /* Configuration */; + targetProxy = D0BF09502C8E66F1000D8DEC /* PBXContainerItemProxy */; + }; + D0BF09542C8E66FA000D8DEC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0D4E55A2C8D9BF4007F820A /* Configuration */; + targetProxy = D0BF09532C8E66FA000D8DEC /* PBXContainerItemProxy */; + }; + D0D4E56F2C8D9C5D007F820A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0D4E5302C8D996F007F820A /* Core */; + targetProxy = D0D4E56E2C8D9C5D007F820A /* PBXContainerItemProxy */; + }; + D0D4E5802C8D9C78007F820A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0D4E5302C8D996F007F820A /* Core */; + targetProxy = D0D4E57F2C8D9C78007F820A /* PBXContainerItemProxy */; + }; + D0D4E5882C8D9C88007F820A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0D4E5502C8D9BF2007F820A /* UI */; + targetProxy = D0D4E5872C8D9C88007F820A /* PBXContainerItemProxy */; + }; + D0F4FAD22C8DC7960068730A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0D4E5302C8D996F007F820A /* Core */; + targetProxy = D0F4FAD12C8DC7960068730A /* PBXContainerItemProxy */; + }; + D0F7595E2C8DB24400126CF3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = D0F7595D2C8DB24400126CF3 /* GRPCSwiftPlugin */; + }; + D0F759602C8DB24400126CF3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = D0F7595F2C8DB24400126CF3 /* SwiftProtobufPlugin */; + }; + D0F7598A2C8DB34200126CF3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = D0F759892C8DB34200126CF3 /* GRPC */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - D001173D2B30341C00D87C25 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = D00117422B30348D00D87C25 /* Shared.xcconfig */; - buildSettings = { - }; - name = Debug; - }; - D001173E2B30341C00D87C25 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = D00117422B30348D00D87C25 /* Shared.xcconfig */; - buildSettings = { - }; - name = Release; - }; D020F65F29E4A697002790F6 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = D020F66229E4A6E5002790F6 /* NetworkExtension.xcconfig */; @@ -568,18 +754,51 @@ }; name = Release; }; + D0D4E53D2C8D996F007F820A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D0D4E4F72C8D941D007F820A /* Framework.xcconfig */; + buildSettings = { + }; + name = Debug; + }; + D0D4E53E2C8D996F007F820A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D0D4E4F72C8D941D007F820A /* Framework.xcconfig */; + buildSettings = { + }; + name = Release; + }; + D0D4E5562C8D9BF2007F820A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D0BF09582C8E6789000D8DEC /* UI.xcconfig */; + buildSettings = { + }; + name = Debug; + }; + D0D4E5572C8D9BF2007F820A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D0BF09582C8E6789000D8DEC /* UI.xcconfig */; + buildSettings = { + }; + name = Release; + }; + D0D4E5602C8D9BF4007F820A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D00117422B30348D00D87C25 /* Configuration.xcconfig */; + buildSettings = { + }; + name = Debug; + }; + D0D4E5612C8D9BF4007F820A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D00117422B30348D00D87C25 /* Configuration.xcconfig */; + buildSettings = { + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - D001173C2B30341C00D87C25 /* Build configuration list for PBXNativeTarget "Shared" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - D001173D2B30341C00D87C25 /* Debug */, - D001173E2B30341C00D87C25 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -607,7 +826,130 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + D0D4E53C2C8D996F007F820A /* Build configuration list for PBXNativeTarget "Core" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0D4E53D2C8D996F007F820A /* Debug */, + D0D4E53E2C8D996F007F820A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D0D4E5552C8D9BF2007F820A /* Build configuration list for PBXNativeTarget "UI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0D4E5562C8D9BF2007F820A /* Debug */, + D0D4E5572C8D9BF2007F820A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D0D4E55F2C8D9BF4007F820A /* Build configuration list for PBXNativeTarget "Configuration" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0D4E5602C8D9BF4007F820A /* Debug */, + D0D4E5612C8D9BF4007F820A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + D044EE8F2C8DAB2000778185 /* XCRemoteSwiftPackageReference "swift-nio" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-nio.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.72.0; + }; + }; + D044EE942C8DAB2800778185 /* XCRemoteSwiftPackageReference "swift-nio-transport-services" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-nio-transport-services.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.21.0; + }; + }; + D0B1D10E2C436152004B7823 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-async-algorithms.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.1; + }; + }; + D0D4E4822C8D8EF6007F820A /* XCRemoteSwiftPackageReference "grpc-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/grpc/grpc-swift.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.23.0; + }; + }; + D0D4E4852C8D8F29007F820A /* XCRemoteSwiftPackageReference "swift-protobuf" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-protobuf.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.28.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + D044EE902C8DAB2000778185 /* NIO */ = { + isa = XCSwiftPackageProductDependency; + package = D044EE8F2C8DAB2000778185 /* XCRemoteSwiftPackageReference "swift-nio" */; + productName = NIO; + }; + D044EE922C8DAB2000778185 /* NIOConcurrencyHelpers */ = { + isa = XCSwiftPackageProductDependency; + package = D044EE8F2C8DAB2000778185 /* XCRemoteSwiftPackageReference "swift-nio" */; + productName = NIOConcurrencyHelpers; + }; + D044EE952C8DAB2800778185 /* NIOTransportServices */ = { + isa = XCSwiftPackageProductDependency; + package = D044EE942C8DAB2800778185 /* XCRemoteSwiftPackageReference "swift-nio-transport-services" */; + productName = NIOTransportServices; + }; + D078F7E02C8DA375008A8CEC /* GRPC */ = { + isa = XCSwiftPackageProductDependency; + package = D0D4E4822C8D8EF6007F820A /* XCRemoteSwiftPackageReference "grpc-swift" */; + productName = GRPC; + }; + D078F7E22C8DA375008A8CEC /* SwiftProtobuf */ = { + isa = XCSwiftPackageProductDependency; + package = D0D4E4852C8D8F29007F820A /* XCRemoteSwiftPackageReference "swift-protobuf" */; + productName = SwiftProtobuf; + }; + D0B1D10F2C436152004B7823 /* AsyncAlgorithms */ = { + isa = XCSwiftPackageProductDependency; + package = D0B1D10E2C436152004B7823 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */; + productName = AsyncAlgorithms; + }; + D0F7595D2C8DB24400126CF3 /* GRPCSwiftPlugin */ = { + isa = XCSwiftPackageProductDependency; + package = D0D4E4822C8D8EF6007F820A /* XCRemoteSwiftPackageReference "grpc-swift" */; + productName = "plugin:GRPCSwiftPlugin"; + }; + D0F7595F2C8DB24400126CF3 /* SwiftProtobufPlugin */ = { + isa = XCSwiftPackageProductDependency; + package = D0D4E4852C8D8F29007F820A /* XCRemoteSwiftPackageReference "swift-protobuf" */; + productName = "plugin:SwiftProtobufPlugin"; + }; + D0F7597D2C8DB30500126CF3 /* CGRPCZlib */ = { + isa = XCSwiftPackageProductDependency; + package = D0D4E4822C8D8EF6007F820A /* XCRemoteSwiftPackageReference "grpc-swift" */; + productName = CGRPCZlib; + }; + D0F759892C8DB34200126CF3 /* GRPC */ = { + isa = XCSwiftPackageProductDependency; + package = D0D4E4822C8D8EF6007F820A /* XCRemoteSwiftPackageReference "grpc-swift" */; + productName = GRPC; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = D05B9F6A29E39EEC008CB1F9 /* Project object */; } diff --git a/Apple/Burrow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Apple/Burrow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..739b77c --- /dev/null +++ b/Apple/Burrow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,123 @@ +{ + "originHash" : "fa512b990383b7e309c5854a5279817052294a8191a6d3c55c49cfb38e88c0c3", + "pins" : [ + { + "identity" : "grpc-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift.git", + "state" : { + "revision" : "6a90b7e77e29f9bda6c2b3a4165a40d6c02cfda1", + "version" : "1.23.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "6ae9a051f76b81cc668305ceed5b0e0a7fd93d20", + "version" : "1.0.1" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "cd142fd2f64be2100422d658e7411e39489da985", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", + "version" : "1.1.3" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types", + "state" : { + "revision" : "ae67c8178eb46944fd85e4dc6dd970e1f3ed6ccd", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", + "version" : "1.6.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "9746cf80e29edfef2a39924a66731249223f42a3", + "version" : "2.72.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "d1ead62745cc3269e482f1c51f27608057174379", + "version" : "1.24.0" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "b5f7062b60e4add1e8c343ba4eb8da2e324b3a94", + "version" : "1.34.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "7b84abbdcef69cc3be6573ac12440220789dcd69", + "version" : "2.27.2" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "38ac8221dd20674682148d6451367f89c2652980", + "version" : "1.21.0" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "edb6ed4919f7756157fe02f2552b7e3850a538e5", + "version" : "1.28.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "d2ba781702a1d8285419c15ee62fd734a9437ff5", + "version" : "1.3.2" + } + } + ], + "version" : 3 +} diff --git a/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme index 670823d..a524e87 100644 --- a/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme +++ b/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -1,10 +1,11 @@ + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> = { - guard let groupContainerURL = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) else { - return .failure(.invalidAppGroupIdentifier) - } - return .success(groupContainerURL) - }() public static var socketURL: URL { get throws { try groupContainerURL.appending(component: "burrow.sock", directoryHint: .notDirectory) } } - public static var dbURL: URL { + public static var databaseURL: URL { get throws { try groupContainerURL.appending(component: "burrow.db", directoryHint: .notDirectory) } } + + private static var groupContainerURL: URL { + get throws { try _groupContainerURL.get() } + } + private static let _groupContainerURL: Result = { + switch FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) { + case .some(let url): .success(url) + case .none: .failure(.invalidAppGroupIdentifier) + } + }() +} + +extension Logger { + @_dynamicReplacement(for: subsystem) + public static var subsystem: String { Constants.bundleIdentifier } } diff --git a/Apple/Shared/Constants/module.modulemap b/Apple/Configuration/Constants/module.modulemap similarity index 66% rename from Apple/Shared/Constants/module.modulemap rename to Apple/Configuration/Constants/module.modulemap index 7ee21fc..0e60f32 100644 --- a/Apple/Shared/Constants/module.modulemap +++ b/Apple/Configuration/Constants/module.modulemap @@ -1,4 +1,4 @@ -module Constants { +module CConstants { header "Constants.h" export * } diff --git a/Apple/Configuration/Debug.xcconfig b/Apple/Configuration/Debug.xcconfig new file mode 100644 index 0000000..9529dbd --- /dev/null +++ b/Apple/Configuration/Debug.xcconfig @@ -0,0 +1,26 @@ +// Release +DEBUG_INFORMATION_FORMAT = dwarf-with-dsym +SWIFT_COMPILATION_MODE = wholemodule +SWIFT_OPTIMIZATION_LEVEL = -Osize +LLVM_LTO = YES +DEAD_CODE_STRIPPING = YES +STRIP_INSTALLED_PRODUCT = YES +STRIP_SWIFT_SYMBOLS = YES +COPY_PHASE_STRIP = NO +VALIDATE_PRODUCT = YES +ENABLE_MODULE_VERIFIER = YES + +// Debug +ONLY_ACTIVE_ARCH[config=Debug] = YES +DEBUG_INFORMATION_FORMAT[config=Debug] = dwarf +ENABLE_TESTABILITY[config=Debug] = YES +GCC_PREPROCESSOR_DEFINITIONS[config=Debug] = DEBUG=1 $(inherited) +SWIFT_OPTIMIZATION_LEVEL[config=Debug] = -Onone +SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug] = DEBUG +SWIFT_COMPILATION_MODE[config=Debug] = singlefile +LLVM_LTO[config=Debug] = NO +DEAD_CODE_STRIPPING[config=Debug] = NO +VALIDATE_PRODUCT[config=Debug] = NO +STRIP_INSTALLED_PRODUCT[config=Debug] = NO +STRIP_SWIFT_SYMBOLS[config=Debug] = NO +ENABLE_MODULE_VERIFIER[config=Debug] = NO diff --git a/Apple/Configuration/Extension.xcconfig b/Apple/Configuration/Extension.xcconfig index f8d90a3..5885c31 100644 --- a/Apple/Configuration/Extension.xcconfig +++ b/Apple/Configuration/Extension.xcconfig @@ -1,4 +1,6 @@ -MERGED_BINARY_TYPE = manual +LD_EXPORT_SYMBOLS = NO + +OTHER_SWIFT_FLAGS = $(inherited) -Xfrontend -disable-autolink-framework -Xfrontend UIKit -Xfrontend -disable-autolink-framework -Xfrontend AppKit -Xfrontend -disable-autolink-framework -Xfrontend SwiftUI LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks @executable_path/../../Frameworks -LD_RUNPATH_SEARCH_PATHS[sdk=macos*] = $(inherited) @executable_path/../Frameworks @executable_path/../../../../Frameworks +LD_RUNPATH_SEARCH_PATHS[sdk=macosx*] = $(inherited) @executable_path/../Frameworks @executable_path/../../../../Frameworks diff --git a/Apple/Configuration/Framework.xcconfig b/Apple/Configuration/Framework.xcconfig new file mode 100644 index 0000000..6fa4f19 --- /dev/null +++ b/Apple/Configuration/Framework.xcconfig @@ -0,0 +1,14 @@ +PRODUCT_NAME = Burrow$(TARGET_NAME:c99extidentifier) +PRODUCT_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).$(TARGET_NAME:c99extidentifier) +APPLICATION_EXTENSION_API_ONLY = YES +SWIFT_INSTALL_OBJC_HEADER = NO +SWIFT_SKIP_AUTOLINKING_FRAMEWORKS = YES +SWIFT_SKIP_AUTOLINKING_LIBRARIES = YES + +LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks @loader_path/Frameworks +LD_RUNPATH_SEARCH_PATHS[sdk=macosx*] = $(inherited) @executable_path/../Frameworks @loader_path/Frameworks + +DYLIB_INSTALL_NAME_BASE = @rpath +DYLIB_COMPATIBILITY_VERSION = 1 +DYLIB_CURRENT_VERSION = 1 +VERSIONING_SYSTEM = diff --git a/Apple/Core/Client.swift b/Apple/Core/Client.swift new file mode 100644 index 0000000..8874e3b --- /dev/null +++ b/Apple/Core/Client.swift @@ -0,0 +1,32 @@ +import GRPC +import NIOTransportServices + +public typealias TunnelClient = Burrow_TunnelAsyncClient +public typealias NetworksClient = Burrow_NetworksAsyncClient + +public protocol Client { + init(channel: GRPCChannel) +} + +extension Client { + public static func unix(socketURL: URL) -> Self { + let group = NIOTSEventLoopGroup() + let configuration = ClientConnection.Configuration.default( + target: .unixDomainSocket(socketURL.path), + eventLoopGroup: group + ) + return Self(channel: ClientConnection(configuration: configuration)) + } +} + +extension TunnelClient: Client { + public init(channel: any GRPCChannel) { + self.init(channel: channel, defaultCallOptions: .init(), interceptors: .none) + } +} + +extension NetworksClient: Client { + public init(channel: any GRPCChannel) { + self.init(channel: channel, defaultCallOptions: .init(), interceptors: .none) + } +} diff --git a/Apple/Core/Client/burrow.proto b/Apple/Core/Client/burrow.proto new file mode 120000 index 0000000..03e86a5 --- /dev/null +++ b/Apple/Core/Client/burrow.proto @@ -0,0 +1 @@ +../../../proto/burrow.proto \ No newline at end of file diff --git a/Apple/Core/Client/grpc-swift-config.json b/Apple/Core/Client/grpc-swift-config.json new file mode 100644 index 0000000..2d89698 --- /dev/null +++ b/Apple/Core/Client/grpc-swift-config.json @@ -0,0 +1,11 @@ +{ + "invocations": [ + { + "protoFiles": [ + "burrow.proto", + ], + "server": false, + "visibility": "public" + } + ] +} diff --git a/Apple/Core/Client/swift-protobuf-config.json b/Apple/Core/Client/swift-protobuf-config.json new file mode 100644 index 0000000..87aaec3 --- /dev/null +++ b/Apple/Core/Client/swift-protobuf-config.json @@ -0,0 +1,10 @@ +{ + "invocations": [ + { + "protoFiles": [ + "burrow.proto", + ], + "visibility": "public" + } + ] +} diff --git a/Apple/Shared/Logging.swift b/Apple/Core/Logging.swift similarity index 88% rename from Apple/Shared/Logging.swift rename to Apple/Core/Logging.swift index 36f024c..ba40888 100644 --- a/Apple/Shared/Logging.swift +++ b/Apple/Core/Logging.swift @@ -4,7 +4,7 @@ import os extension Logger { private static let loggers: OSAllocatedUnfairLock<[String: Logger]> = OSAllocatedUnfairLock(initialState: [:]) - public static let subsystem = Constants.bundleIdentifier + public dynamic static var subsystem: String { "com.hackclub.burrow" } public static func logger(for type: Any.Type) -> Logger { let category = String(describing: type) diff --git a/Apple/NetworkExtension/PacketTunnelProvider.swift b/Apple/NetworkExtension/PacketTunnelProvider.swift index 89e0de6..a8e42e0 100644 --- a/Apple/NetworkExtension/PacketTunnelProvider.swift +++ b/Apple/NetworkExtension/PacketTunnelProvider.swift @@ -1,92 +1,74 @@ -import BurrowShared +import AsyncAlgorithms +import BurrowConfiguration +import BurrowCore import libburrow import NetworkExtension import os class PacketTunnelProvider: NEPacketTunnelProvider { + enum Error: Swift.Error { + case missingTunnelConfiguration + } + private let logger = Logger.logger(for: PacketTunnelProvider.self) - private var client: Client? + + private var client: TunnelClient { + get throws { try _client.get() } + } + private let _client: Result = Result { + try TunnelClient.unix(socketURL: Constants.socketURL) + } override init() { do { libburrow.spawnInProcess( socketPath: try Constants.socketURL.path(percentEncoded: false), - dbPath: try Constants.dbURL.path(percentEncoded: false) + databasePath: try Constants.databaseURL.path(percentEncoded: false) ) } catch { - logger.error("Failed to spawn: \(error)") + logger.error("Failed to spawn networking thread: \(error)") } } override func startTunnel(options: [String: NSObject]? = nil) async throws { do { - let client = try Client() - self.client = client - register_events(client) - - _ = try await self.loadTunSettings() - let startRequest = Start( - tun: Start.TunOptions( - name: nil, no_pi: false, tun_excl: false, tun_retrieve: true, address: [] - ) - ) - let response = try await client.request(startRequest, type: BurrowResult.self) - self.logger.log("Received start server response: \(String(describing: response))") + let configuration = try await Array(client.tunnelConfiguration(.init()).prefix(1)).first + guard let settings = configuration?.settings else { + throw Error.missingTunnelConfiguration + } + try await setTunnelNetworkSettings(settings) + _ = try await client.tunnelStart(.init()) + logger.log("Started tunnel with network settings: \(settings)") } catch { - self.logger.error("Failed to start tunnel: \(error)") + logger.error("Failed to start tunnel: \(error)") throw error } } override func stopTunnel(with reason: NEProviderStopReason) async { do { - let client = try Client() - _ = try await client.single_request("Stop", type: BurrowResult.self) - self.logger.log("Stopped client.") + _ = try await client.tunnelStop(.init()) + logger.log("Stopped client") } catch { - self.logger.error("Failed to stop tunnel: \(error)") - } - } - func loadTunSettings() async throws -> ServerConfig { - guard let client = self.client else { - throw BurrowError.noClient - } - let srvConfig = try await client.single_request("ServerConfig", type: BurrowResult.self) - guard let serverconfig = srvConfig.Ok else { - throw BurrowError.resultIsError - } - guard let tunNs = generateTunSettings(from: serverconfig) else { - throw BurrowError.addrDoesntExist - } - try await self.setTunnelNetworkSettings(tunNs) - self.logger.info("Set remote tunnel address to \(tunNs.tunnelRemoteAddress)") - return serverconfig - } - private func generateTunSettings(from: ServerConfig) -> NETunnelNetworkSettings? { - // Using a makeshift remote tunnel address - let nst = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "1.1.1.1") - var v4Addresses = [String]() - var v6Addresses = [String]() - for addr in from.address { - if IPv4Address(addr) != nil { - v6Addresses.append(addr) - } - if IPv6Address(addr) != nil { - v4Addresses.append(addr) - } - } - nst.ipv4Settings = NEIPv4Settings(addresses: v4Addresses, subnetMasks: v4Addresses.map { _ in - "255.255.255.0" - }) - nst.ipv6Settings = NEIPv6Settings(addresses: v6Addresses, networkPrefixLengths: v6Addresses.map { _ in 64 }) - logger.log("Initialized ipv4 settings: \(nst.ipv4Settings)") - return nst - } - func register_events(_ client: Client) { - client.on_event(.ConfigChange) { (cfig: ServerConfig) in - self.logger.info("Config Change Notification: \(String(describing: cfig))") - self.setTunnelNetworkSettings(self.generateTunSettings(from: cfig)) - self.logger.info("Updated Tunnel Network Settings.") + logger.error("Failed to stop tunnel: \(error)") } } } + +extension Burrow_TunnelConfigurationResponse { + fileprivate var settings: NEPacketTunnelNetworkSettings { + let ipv6Addresses = addresses.filter { IPv6Address($0) != nil } + + let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "1.1.1.1") + settings.mtu = NSNumber(value: mtu) + settings.ipv4Settings = NEIPv4Settings( + addresses: addresses.filter { IPv4Address($0) != nil }, + subnetMasks: ["255.255.255.0"] + ) + settings.ipv6Settings = NEIPv6Settings( + addresses: ipv6Addresses, + networkPrefixLengths: ipv6Addresses.map { _ in 64 } + ) + return settings + } +} diff --git a/Apple/NetworkExtension/libburrow/build-rust.sh b/Apple/NetworkExtension/libburrow/build-rust.sh index 00c3652..6f455a9 100755 --- a/Apple/NetworkExtension/libburrow/build-rust.sh +++ b/Apple/NetworkExtension/libburrow/build-rust.sh @@ -68,11 +68,12 @@ else CARGO_PATH="$(dirname $(readlink -f $(which cargo))):/usr/bin" fi -CARGO_PATH="$(dirname $(readlink -f $(which protoc))):$CARGO_PATH" +PROTOC=$(readlink -f $(which protoc)) +CARGO_PATH="$(dirname $PROTOC):$CARGO_PATH" # Run cargo without the various environment variables set by Xcode. # Those variables can confuse cargo and the build scripts it runs. -env -i PATH="$CARGO_PATH" CARGO_TARGET_DIR="${CONFIGURATION_TEMP_DIR}/target" IPHONEOS_DEPLOYMENT_TARGET="$IPHONEOS_DEPLOYMENT_TARGET" MACOSX_DEPLOYMENT_TARGET="$MACOSX_DEPLOYMENT_TARGET" cargo build "${CARGO_ARGS[@]}" +env -i PATH="$CARGO_PATH" PROTOC="$PROTOC" CARGO_TARGET_DIR="${CONFIGURATION_TEMP_DIR}/target" IPHONEOS_DEPLOYMENT_TARGET="$IPHONEOS_DEPLOYMENT_TARGET" MACOSX_DEPLOYMENT_TARGET="$MACOSX_DEPLOYMENT_TARGET" cargo build "${CARGO_ARGS[@]}" mkdir -p "${BUILT_PRODUCTS_DIR}" diff --git a/Apple/NetworkExtension/libburrow/libburrow.h b/Apple/NetworkExtension/libburrow/libburrow.h index 2b578ab..59b4734 100644 --- a/Apple/NetworkExtension/libburrow/libburrow.h +++ b/Apple/NetworkExtension/libburrow/libburrow.h @@ -1,2 +1,2 @@ -__attribute__((__swift_name__("spawnInProcess(socketPath:dbPath:)"))) +__attribute__((__swift_name__("spawnInProcess(socketPath:databasePath:)"))) extern void spawn_in_process(const char * __nullable socket_path, const char * __nullable db_path); diff --git a/Apple/Shared/Client.swift b/Apple/Shared/Client.swift deleted file mode 100644 index f643c6c..0000000 --- a/Apple/Shared/Client.swift +++ /dev/null @@ -1,106 +0,0 @@ -import Foundation -import Network - -public final class Client { - let connection: NWConnection - - private let logger = Logger.logger(for: Client.self) - private var generator = SystemRandomNumberGenerator() - private var continuations: [UInt: UnsafeContinuation] = [:] - private var eventMap: [NotificationType: [(Data) throws -> Void]] = [:] - private var task: Task? - - public convenience init() throws { - self.init(url: try Constants.socketURL) - } - - public init(url: URL) { - let endpoint: NWEndpoint - if url.isFileURL { - endpoint = .unix(path: url.path(percentEncoded: false)) - } else { - endpoint = .url(url) - } - - let parameters = NWParameters.tcp - parameters.defaultProtocolStack - .applicationProtocols - .insert(NWProtocolFramer.Options(definition: NewlineProtocolFramer.definition), at: 0) - let connection = NWConnection(to: endpoint, using: parameters) - connection.start(queue: .global()) - self.connection = connection - self.task = Task { [weak self] in - while true { - let (data, _, _) = try await connection.receiveMessage() - let peek = try JSONDecoder().decode(MessagePeek.self, from: data) - switch peek.type { - case .Response: - let response = try JSONDecoder().decode(ResponsePeek.self, from: data) - self?.logger.info("Received response for \(response.id)") - guard let continuations = self?.continuations else {return} - self?.logger.debug("All keys in continuation table: \(continuations.keys)") - guard let continuation = self?.continuations[response.id] else { return } - self?.logger.debug("Got matching continuation") - continuation.resume(returning: data) - case .Notification: - let peek = try JSONDecoder().decode(NotificationPeek.self, from: data) - guard let handlers = self?.eventMap[peek.method] else { continue } - _ = try handlers.map { try $0(data) } - default: - continue - } - } - } - } - private func send(_ request: T) async throws -> U { - let data: Data = try await withUnsafeThrowingContinuation { continuation in - continuations[request.id] = continuation - do { - let data = try JSONEncoder().encode(request) - let completion: NWConnection.SendCompletion = .contentProcessed { error in - guard let error = error else { - return - } - continuation.resume(throwing: error) - } - connection.send(content: data, completion: completion) - } catch { - continuation.resume(throwing: error) - return - } - } - self.logger.debug("Got response data: \(String(describing: data.base64EncodedString()))") - let res = try JSONDecoder().decode(Response.self, from: data) - self.logger.debug("Got response data decoded: \(String(describing: res))") - return res.result - } - public func request(_ request: T, type: U.Type = U.self) async throws -> U { - let req = BurrowRequest( - id: generator.next(upperBound: UInt.max), - command: request - ) - return try await send(req) - } - public func single_request(_ request: String, type: U.Type = U.self) async throws -> U { - let req = BurrowSimpleRequest( - id: generator.next(upperBound: UInt.max), - command: request - ) - return try await send(req) - } - public func on_event(_ event: NotificationType, callable: @escaping (T) throws -> Void) { - let action = { data in - let decoded = try JSONDecoder().decode(Notification.self, from: data) - try callable(decoded.params) - } - if eventMap[event] != nil { - eventMap[event]?.append(action) - } else { - eventMap[event] = [action] - } - } - - deinit { - connection.cancel() - } -} diff --git a/Apple/Shared/DataTypes.swift b/Apple/Shared/DataTypes.swift deleted file mode 100644 index ac49abc..0000000 --- a/Apple/Shared/DataTypes.swift +++ /dev/null @@ -1,139 +0,0 @@ -import Foundation - -// swiftlint:disable identifier_name raw_value_for_camel_cased_codable_enum -public enum BurrowError: Error { - case addrDoesntExist - case resultIsError - case cantParseResult - case resultIsNone - case noClient -} - -public protocol Request: Codable where Params: Codable { - associatedtype Params - - var id: UInt { get set } - var method: String { get set } - var params: Params? { get set } -} - -public enum MessageType: String, Codable { - case Request - case Response - case Notification -} - -public struct MessagePeek: Codable { - public var type: MessageType - public init(type: MessageType) { - self.type = type - } -} - -public struct BurrowSimpleRequest: Request { - public var id: UInt - public var method: String - public var params: String? - public init(id: UInt, command: String, params: String? = nil) { - self.id = id - self.method = command - self.params = params - } -} - -public struct BurrowRequest: Request where T: Codable { - public var id: UInt - public var method: String - public var params: T? - public init(id: UInt, command: T) { - self.id = id - self.method = "\(T.self)" - self.params = command - } -} - -public struct Response: Decodable where T: Decodable { - public var id: UInt - public var result: T - public init(id: UInt, result: T) { - self.id = id - self.result = result - } -} - -public struct ResponsePeek: Codable { - public var id: UInt - public init(id: UInt) { - self.id = id - } -} - -public enum NotificationType: String, Codable { - case ConfigChange -} - -public struct Notification: Codable where T: Codable { - public var method: NotificationType - public var params: T - public init(method: NotificationType, params: T) { - self.method = method - self.params = params - } -} - -public struct NotificationPeek: Codable { - public var method: NotificationType - public init(method: NotificationType) { - self.method = method - } -} - -public struct AnyResponseData: Codable { - public var type: String - public init(type: String) { - self.type = type - } -} - -public struct BurrowResult: Codable where T: Codable { - public var Ok: T? - public var Err: String? - public init(Ok: T, Err: String? = nil) { - self.Ok = Ok - self.Err = Err - } -} - -public struct ServerConfig: Codable { - public let address: [String] - public let name: String? - public let mtu: Int32? - public init(address: [String], name: String?, mtu: Int32?) { - self.address = address - self.name = name - self.mtu = mtu - } -} - -public struct Start: Codable { - public struct TunOptions: Codable { - public let name: String? - public let no_pi: Bool - public let tun_excl: Bool - public let tun_retrieve: Bool - public let address: [String] - public init(name: String?, no_pi: Bool, tun_excl: Bool, tun_retrieve: Bool, address: [String]) { - self.name = name - self.no_pi = no_pi - self.tun_excl = tun_excl - self.tun_retrieve = tun_retrieve - self.address = address - } - } - public let tun: TunOptions - public init(tun: TunOptions) { - self.tun = tun - } -} - -// swiftlint:enable identifier_name raw_value_for_camel_cased_codable_enum diff --git a/Apple/Shared/NWConnection+Async.swift b/Apple/Shared/NWConnection+Async.swift deleted file mode 100644 index c21fdc0..0000000 --- a/Apple/Shared/NWConnection+Async.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation -import Network - -extension NWConnection { - // swiftlint:disable:next large_tuple - func receiveMessage() async throws -> (Data, NWConnection.ContentContext?, Bool) { - try await withUnsafeThrowingContinuation { continuation in - receiveMessage { completeContent, contentContext, isComplete, error in - if let error { - continuation.resume(throwing: error) - } else { - guard let completeContent = completeContent else { - fatalError("Both error and completeContent were nil") - } - continuation.resume(returning: (completeContent, contentContext, isComplete)) - } - } - } - } - - func send(content: Data) async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - send(content: content, completion: .contentProcessed { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume(returning: ()) - } - }) - } - } -} diff --git a/Apple/Shared/NewlineProtocolFramer.swift b/Apple/Shared/NewlineProtocolFramer.swift deleted file mode 100644 index d2f71e5..0000000 --- a/Apple/Shared/NewlineProtocolFramer.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation -import Network - -final class NewlineProtocolFramer: NWProtocolFramerImplementation { - private static let delimeter: UInt8 = 10 // `\n` - - static let definition = NWProtocolFramer.Definition(implementation: NewlineProtocolFramer.self) - static let label = "Lines" - - init(framer: NWProtocolFramer.Instance) { } - - func start(framer: NWProtocolFramer.Instance) -> NWProtocolFramer.StartResult { .ready } - func stop(framer: NWProtocolFramer.Instance) -> Bool { true } - - func wakeup(framer: NWProtocolFramer.Instance) { } - func cleanup(framer: NWProtocolFramer.Instance) { } - - func handleInput(framer: NWProtocolFramer.Instance) -> Int { - while true { - var result: [Data] = [] - let parsed = framer.parseInput(minimumIncompleteLength: 1, maximumLength: 16_000) { buffer, _ in - guard let buffer else { return 0 } - var lines = buffer - .split(separator: Self.delimeter, omittingEmptySubsequences: false) - .map { Data($0) } - guard lines.count > 1 else { return 0 } - _ = lines.popLast() - - result = lines - return lines.reduce(lines.count) { $0 + $1.count } - } - - guard parsed && !result.isEmpty else { break } - - for line in result { - framer.deliverInput(data: line, message: .init(instance: framer), isComplete: true) - } - } - return 0 - } - - func handleOutput( - framer: NWProtocolFramer.Instance, - message: NWProtocolFramer.Message, - messageLength: Int, - isComplete: Bool - ) { - do { - try framer.writeOutputNoCopy(length: messageLength) - framer.writeOutput(data: [Self.delimeter]) - } catch { - } - } -} diff --git a/Apple/App/Assets.xcassets/AccentColor.colorset/Contents.json b/Apple/UI/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Apple/App/Assets.xcassets/AccentColor.colorset/Contents.json rename to Apple/UI/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/100.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/100.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/100.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/100.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/1024.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/1024.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/1024.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/1024.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/114.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/114.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/114.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/114.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/120.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/120.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/120.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/120.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/128.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/128.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/128.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/128.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/144.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/144.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/144.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/144.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/152.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/152.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/152.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/152.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/16.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/16.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/16.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/16.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/167.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/167.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/167.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/167.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/172.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/172.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/172.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/172.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/180.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/180.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/180.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/180.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/196.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/196.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/196.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/196.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/20.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/20.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/20.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/20.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/216.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/216.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/216.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/216.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/256.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/256.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/256.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/256.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/29.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/29.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/29.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/29.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/32.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/32.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/32.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/32.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/40.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/40.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/40.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/40.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/48.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/48.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/48.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/48.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/50.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/50.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/50.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/50.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/512.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/512.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/512.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/512.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/55.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/55.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/55.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/55.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/57.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/57.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/57.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/57.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/58.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/58.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/58.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/58.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/60.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/60.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/60.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/60.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/64.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/64.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/64.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/64.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/72.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/72.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/72.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/72.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/76.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/76.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/76.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/76.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/80.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/80.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/80.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/80.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/87.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/87.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/87.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/87.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/88.png b/Apple/UI/Assets.xcassets/AppIcon.appiconset/88.png similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/88.png rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/88.png diff --git a/Apple/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/Apple/UI/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from Apple/App/Assets.xcassets/AppIcon.appiconset/Contents.json rename to Apple/UI/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/Apple/App/Assets.xcassets/Contents.json b/Apple/UI/Assets.xcassets/Contents.json similarity index 100% rename from Apple/App/Assets.xcassets/Contents.json rename to Apple/UI/Assets.xcassets/Contents.json diff --git a/Apple/App/Assets.xcassets/HackClub.colorset/Contents.json b/Apple/UI/Assets.xcassets/HackClub.colorset/Contents.json similarity index 100% rename from Apple/App/Assets.xcassets/HackClub.colorset/Contents.json rename to Apple/UI/Assets.xcassets/HackClub.colorset/Contents.json diff --git a/Apple/App/Assets.xcassets/HackClub.imageset/Contents.json b/Apple/UI/Assets.xcassets/HackClub.imageset/Contents.json similarity index 100% rename from Apple/App/Assets.xcassets/HackClub.imageset/Contents.json rename to Apple/UI/Assets.xcassets/HackClub.imageset/Contents.json diff --git a/Apple/App/Assets.xcassets/HackClub.imageset/flag-standalone-wtransparent.pdf b/Apple/UI/Assets.xcassets/HackClub.imageset/flag-standalone-wtransparent.pdf similarity index 100% rename from Apple/App/Assets.xcassets/HackClub.imageset/flag-standalone-wtransparent.pdf rename to Apple/UI/Assets.xcassets/HackClub.imageset/flag-standalone-wtransparent.pdf diff --git a/Apple/App/Assets.xcassets/WireGuard.colorset/Contents.json b/Apple/UI/Assets.xcassets/WireGuard.colorset/Contents.json similarity index 100% rename from Apple/App/Assets.xcassets/WireGuard.colorset/Contents.json rename to Apple/UI/Assets.xcassets/WireGuard.colorset/Contents.json diff --git a/Apple/App/Assets.xcassets/WireGuard.imageset/Contents.json b/Apple/UI/Assets.xcassets/WireGuard.imageset/Contents.json similarity index 100% rename from Apple/App/Assets.xcassets/WireGuard.imageset/Contents.json rename to Apple/UI/Assets.xcassets/WireGuard.imageset/Contents.json diff --git a/Apple/App/Assets.xcassets/WireGuard.imageset/WireGuard.svg b/Apple/UI/Assets.xcassets/WireGuard.imageset/WireGuard.svg similarity index 100% rename from Apple/App/Assets.xcassets/WireGuard.imageset/WireGuard.svg rename to Apple/UI/Assets.xcassets/WireGuard.imageset/WireGuard.svg diff --git a/Apple/App/Assets.xcassets/WireGuardTitle.imageset/Contents.json b/Apple/UI/Assets.xcassets/WireGuardTitle.imageset/Contents.json similarity index 100% rename from Apple/App/Assets.xcassets/WireGuardTitle.imageset/Contents.json rename to Apple/UI/Assets.xcassets/WireGuardTitle.imageset/Contents.json diff --git a/Apple/App/Assets.xcassets/WireGuardTitle.imageset/WireGuardTitle.svg b/Apple/UI/Assets.xcassets/WireGuardTitle.imageset/WireGuardTitle.svg similarity index 100% rename from Apple/App/Assets.xcassets/WireGuardTitle.imageset/WireGuardTitle.svg rename to Apple/UI/Assets.xcassets/WireGuardTitle.imageset/WireGuardTitle.svg diff --git a/Apple/App/BurrowView.swift b/Apple/UI/BurrowView.swift similarity index 95% rename from Apple/App/BurrowView.swift rename to Apple/UI/BurrowView.swift index 3a53762..96467c7 100644 --- a/Apple/App/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -2,11 +2,11 @@ import AuthenticationServices import SwiftUI #if !os(macOS) -struct BurrowView: View { +public struct BurrowView: View { @Environment(\.webAuthenticationSession) private var webAuthenticationSession - var body: some View { + public var body: some View { NavigationStack { VStack { HStack { @@ -35,6 +35,9 @@ struct BurrowView: View { } } + public init() { + } + private func addHackClubNetwork() { Task { try await authenticateWithSlack() diff --git a/Apple/App/FloatingButtonStyle.swift b/Apple/UI/FloatingButtonStyle.swift similarity index 100% rename from Apple/App/FloatingButtonStyle.swift rename to Apple/UI/FloatingButtonStyle.swift diff --git a/Apple/App/MenuItemToggleView.swift b/Apple/UI/MenuItemToggleView.swift similarity index 87% rename from Apple/App/MenuItemToggleView.swift rename to Apple/UI/MenuItemToggleView.swift index 07db51d..ef5e8ee 100644 --- a/Apple/App/MenuItemToggleView.swift +++ b/Apple/UI/MenuItemToggleView.swift @@ -7,11 +7,11 @@ import SwiftUI -struct MenuItemToggleView: View { +public struct MenuItemToggleView: View { @Environment(\.tunnel) var tunnel: Tunnel - var body: some View { + public var body: some View { HStack { VStack(alignment: .leading) { Text("Burrow") @@ -30,10 +30,13 @@ struct MenuItemToggleView: View { .padding(10) .frame(minWidth: 300, minHeight: 32, maxHeight: 32) } + + public init() { + } } extension Tunnel { - fileprivate var toggleDisabled: Bool { + @MainActor fileprivate var toggleDisabled: Bool { switch status { case .disconnected, .permissionRequired, .connected, .disconnecting: false @@ -42,7 +45,7 @@ extension Tunnel { } } - var toggleIsOn: Binding { + @MainActor var toggleIsOn: Binding { Binding { switch status { case .connecting, .reasserting, .connected: diff --git a/Apple/App/NetworkCarouselView.swift b/Apple/UI/NetworkCarouselView.swift similarity index 90% rename from Apple/App/NetworkCarouselView.swift rename to Apple/UI/NetworkCarouselView.swift index b120c60..f969356 100644 --- a/Apple/App/NetworkCarouselView.swift +++ b/Apple/UI/NetworkCarouselView.swift @@ -2,10 +2,10 @@ import SwiftUI struct NetworkCarouselView: View { var networks: [any Network] = [ - HackClub(id: "1"), - HackClub(id: "2"), - WireGuard(id: "4"), - HackClub(id: "5"), + HackClub(id: 1), + HackClub(id: 2), + WireGuard(id: 4), + HackClub(id: 5) ] var body: some View { diff --git a/Apple/App/NetworkExtension+Async.swift b/Apple/UI/NetworkExtension+Async.swift similarity index 82% rename from Apple/App/NetworkExtension+Async.swift rename to Apple/UI/NetworkExtension+Async.swift index 4833efb..5820e7f 100644 --- a/Apple/App/NetworkExtension+Async.swift +++ b/Apple/UI/NetworkExtension+Async.swift @@ -1,6 +1,6 @@ import NetworkExtension -extension NEVPNManager { +extension NEVPNManager: @unchecked @retroactive Sendable { func remove() async throws { _ = try await withUnsafeThrowingContinuation { continuation in removeFromPreferences(completionHandler: completion(continuation)) @@ -14,7 +14,7 @@ extension NEVPNManager { } } -extension NETunnelProviderManager { +extension NETunnelProviderManager: @unchecked @retroactive Sendable { class var managers: [NETunnelProviderManager] { get async throws { try await withUnsafeThrowingContinuation { continuation in @@ -34,7 +34,7 @@ private func completion(_ continuation: UnsafeContinuation) -> (Err } } -private func completion(_ continuation: UnsafeContinuation) -> (T?, Error?) -> Void { +private func completion(_ continuation: UnsafeContinuation) -> (T?, Error?) -> Void { return { value, error in if let error { continuation.resume(throwing: error) diff --git a/Apple/App/NetworkExtensionTunnel.swift b/Apple/UI/NetworkExtensionTunnel.swift similarity index 67% rename from Apple/App/NetworkExtensionTunnel.swift rename to Apple/UI/NetworkExtensionTunnel.swift index 08002de..7aaa3b1 100644 --- a/Apple/App/NetworkExtensionTunnel.swift +++ b/Apple/UI/NetworkExtensionTunnel.swift @@ -1,22 +1,23 @@ -import BurrowShared +import BurrowCore import NetworkExtension @Observable -class NetworkExtensionTunnel: Tunnel { - @MainActor private(set) var status: TunnelStatus = .unknown - private var error: NEVPNError? +public final class NetworkExtensionTunnel: Tunnel { + @MainActor public private(set) var status: TunnelStatus = .unknown + @MainActor private var error: NEVPNError? private let logger = Logger.logger(for: Tunnel.self) private let bundleIdentifier: String - private var tasks: [Task] = [] + private let configurationChanged: Task + private let statusChanged: Task // Each manager corresponds to one entry in the Settings app. // Our goal is to maintain a single manager, so we create one if none exist and delete any extra. - private var managers: [NEVPNManager]? { + @MainActor private var managers: [NEVPNManager]? { didSet { Task { await updateStatus() } } } - private var currentStatus: TunnelStatus { + @MainActor private var currentStatus: TunnelStatus { guard let managers = managers else { guard let error = error else { return .unknown @@ -41,35 +42,40 @@ class NetworkExtensionTunnel: Tunnel { return manager.connection.tunnelStatus } - convenience init() { - self.init(Constants.networkExtensionBundleIdentifier) - } - - init(_ bundleIdentifier: String) { + public init(bundleIdentifier: String) { self.bundleIdentifier = bundleIdentifier let center = NotificationCenter.default - let configurationChanged = Task { [weak self] in - for try await _ in center.notifications(named: .NEVPNConfigurationChange).map({ _ in () }) { - await self?.update() + let tunnel: OSAllocatedUnfairLock = .init(initialState: .none) + configurationChanged = Task { + for try await _ in center.notifications(named: .NEVPNConfigurationChange) { + try Task.checkCancellation() + await tunnel.withLock { $0 }?.update() } } - let statusChanged = Task { [weak self] in - for try await _ in center.notifications(named: .NEVPNStatusDidChange).map({ _ in () }) { - await self?.updateStatus() + statusChanged = Task { + for try await _ in center.notifications(named: .NEVPNStatusDidChange) { + try Task.checkCancellation() + await tunnel.withLock { $0 }?.updateStatus() } } - tasks = [configurationChanged, statusChanged] + tunnel.withLock { $0 = self } Task { await update() } } private func update() async { do { - managers = try await NETunnelProviderManager.managers + let result = try await NETunnelProviderManager.managers + await MainActor.run { + managers = result + status = currentStatus + } await self.updateStatus() } catch let vpnError as NEVPNError { - error = vpnError + await MainActor.run { + error = vpnError + } } catch { logger.error("Failed to update VPN configurations: \(error)") } @@ -82,12 +88,7 @@ class NetworkExtensionTunnel: Tunnel { } func configure() async throws { - if managers == nil { - await update() - } - - guard let managers = managers else { return } - + let managers = try await NETunnelProviderManager.managers if managers.count > 1 { try await withThrowingTaskGroup(of: Void.self, returning: Void.self) { group in for manager in managers.suffix(from: 1) { @@ -110,9 +111,9 @@ class NetworkExtensionTunnel: Tunnel { try await manager.save() } - func start() { - guard let manager = managers?.first else { return } + public func start() { Task { + guard let manager = try await NETunnelProviderManager.managers.first else { return } do { if !manager.isEnabled { manager.isEnabled = true @@ -125,12 +126,14 @@ class NetworkExtensionTunnel: Tunnel { } } - func stop() { - guard let manager = managers?.first else { return } - manager.connection.stopVPNTunnel() + public func stop() { + Task { + guard let manager = try await NETunnelProviderManager.managers.first else { return } + manager.connection.stopVPNTunnel() + } } - func enable() { + public func enable() { Task { do { try await configure() @@ -141,7 +144,8 @@ class NetworkExtensionTunnel: Tunnel { } deinit { - tasks.forEach { $0.cancel() } + configurationChanged.cancel() + statusChanged.cancel() } } diff --git a/Apple/App/NetworkView.swift b/Apple/UI/NetworkView.swift similarity index 100% rename from Apple/App/NetworkView.swift rename to Apple/UI/NetworkView.swift diff --git a/Apple/App/Networks/HackClub.swift b/Apple/UI/Networks/HackClub.swift similarity index 76% rename from Apple/App/Networks/HackClub.swift rename to Apple/UI/Networks/HackClub.swift index f7df674..b1c2023 100644 --- a/Apple/App/Networks/HackClub.swift +++ b/Apple/UI/Networks/HackClub.swift @@ -1,10 +1,14 @@ +import BurrowCore import SwiftUI struct HackClub: Network { - var id: String + typealias NetworkType = Burrow_WireGuardNetwork + static let type: Burrow_NetworkType = .hackClub + + var id: Int32 var backgroundColor: Color { .init("HackClub") } - var label: some View { + @MainActor var label: some View { GeometryReader { reader in VStack(alignment: .leading) { Image("HackClub") diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift new file mode 100644 index 0000000..c6d5fba --- /dev/null +++ b/Apple/UI/Networks/Network.swift @@ -0,0 +1,36 @@ +import Atomics +import BurrowCore +import SwiftProtobuf +import SwiftUI + +protocol Network { + associatedtype NetworkType: Message + associatedtype Label: View + + static var type: Burrow_NetworkType { get } + + var id: Int32 { get } + var backgroundColor: Color { get } + + @MainActor var label: Label { get } +} + +@Observable +@MainActor +final class NetworkViewModel: Sendable { + private(set) var networks: [Burrow_Network] = [] + + private var task: Task! + + init(socketURL: URL) { + task = Task { [weak self] in + let client = NetworksClient.unix(socketURL: socketURL) + for try await networks in client.networkList(.init()) { + guard let viewModel = self else { continue } + Task { @MainActor in + viewModel.networks = networks.network + } + } + } + } +} diff --git a/Apple/App/Networks/WireGuard.swift b/Apple/UI/Networks/WireGuard.swift similarity index 82% rename from Apple/App/Networks/WireGuard.swift rename to Apple/UI/Networks/WireGuard.swift index 499288a..cba67ef 100644 --- a/Apple/App/Networks/WireGuard.swift +++ b/Apple/UI/Networks/WireGuard.swift @@ -1,10 +1,14 @@ +import BurrowCore import SwiftUI struct WireGuard: Network { - var id: String + typealias NetworkType = Burrow_WireGuardNetwork + static let type: BurrowCore.Burrow_NetworkType = .wireGuard + + var id: Int32 var backgroundColor: Color { .init("WireGuard") } - var label: some View { + @MainActor var label: some View { GeometryReader { reader in VStack(alignment: .leading) { HStack { diff --git a/Apple/App/OAuth2.swift b/Apple/UI/OAuth2.swift similarity index 94% rename from Apple/App/OAuth2.swift rename to Apple/UI/OAuth2.swift index 9a930c9..0fafc8d 100644 --- a/Apple/App/OAuth2.swift +++ b/Apple/UI/OAuth2.swift @@ -1,5 +1,6 @@ import AuthenticationServices import Foundation +import os import SwiftUI enum OAuth2 { @@ -25,11 +26,16 @@ enum OAuth2 { var clientID: String var clientSecret: String - fileprivate static var queue: [Int: CheckedContinuation] = [:] + fileprivate static let queue: OSAllocatedUnfairLock<[Int: CheckedContinuation]> = { + .init(initialState: [:]) + }() fileprivate static func handle(url: URL) { - let continuations = queue - queue.removeAll() + let continuations = queue.withLock { continuations in + let copy = continuations + continuations.removeAll() + return copy + } for (_, continuation) in continuations { continuation.resume(returning: url) } @@ -56,7 +62,7 @@ enum OAuth2 { var queryItems: [URLQueryItem] = [ .init(name: "client_id", value: clientID), .init(name: "response_type", value: responseType.rawValue), - .init(name: "redirect_uri", value: redirectURI.absoluteString), + .init(name: "redirect_uri", value: redirectURI.absoluteString) ] if !scopes.isEmpty { queryItems.append(.init(name: "scope", value: scopes.joined(separator: ","))) @@ -206,6 +212,9 @@ enum OAuth2 { } } +extension WebAuthenticationSession: @unchecked @retroactive Sendable { +} + extension WebAuthenticationSession { #if canImport(BrowserEngineKit) @available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *) @@ -243,12 +252,12 @@ extension WebAuthenticationSession { let id = Int.random(in: 0.. Date: Tue, 23 Sep 2025 19:56:39 -0700 Subject: [PATCH 025/102] wip --- Cargo.lock | 1853 +++++++++++------ burrow-gtk/Cargo.lock | 1220 +++++++++-- burrow/src/daemon/rpc/response.rs | 9 + ...c__response__response_serialization-2.snap | 4 +- ...n__response__response_serialization-2.snap | 4 +- proto/google/protobuf/duration.proto | 115 + proto/google/protobuf/timestamp.proto | 144 ++ tun/src/unix/apple/mod.rs | 46 + tun/src/unix/linux/mod.rs | 45 +- tun/src/unix/linux/sys.rs | 2 +- 10 files changed, 2625 insertions(+), 817 deletions(-) create mode 100644 proto/google/protobuf/duration.proto create mode 100644 proto/google/protobuf/timestamp.proto diff --git a/Cargo.lock b/Cargo.lock index a5554fb..22a3bf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,27 +1,21 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" -version = "0.22.0" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ "gimli", ] -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aead" @@ -46,9 +40,9 @@ dependencies = [ [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "once_cell", @@ -67,9 +61,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.15" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -82,49 +76,50 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.8" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" [[package]] name = "anstyle-parse" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.1" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.4" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", - "windows-sys 0.52.0", + "once_cell_polyfill", + "windows-sys 0.60.2", ] [[package]] name = "anyhow" -version = "1.0.87" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10f00e1f6e58a40e807377c75c6a7f97bf9044fab57816f2414e6f5f4499d7b8" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "async-channel" -version = "2.3.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ "concurrent-queue", "event-listener-strategy", @@ -144,11 +139,11 @@ dependencies = [ [[package]] name = "async-stream" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" dependencies = [ - "async-stream-impl 0.3.5", + "async-stream-impl 0.3.6", "futures-core", "pin-project-lite", ] @@ -166,24 +161,24 @@ dependencies = [ [[package]] name = "async-stream-impl" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", ] [[package]] name = "async-trait" -version = "0.1.82" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a27b8a3a6e1a44fa4c8baf1f653e4172e81486d4941f2237e20dc2d0cf4ddff1" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", ] [[package]] @@ -194,9 +189,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.3.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" @@ -211,7 +206,7 @@ dependencies = [ "futures-util", "http 0.2.12", "http-body 0.4.6", - "hyper 0.14.30", + "hyper 0.14.32", "itoa", "matchit", "memchr", @@ -221,25 +216,25 @@ dependencies = [ "rustversion", "serde", "sync_wrapper 0.1.2", - "tower", + "tower 0.4.13", "tower-layer", "tower-service", ] [[package]] name = "axum" -version = "0.7.5" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", - "axum-core 0.4.3", + "axum-core 0.4.5", "bytes", "futures-util", - "http 1.1.0", + "http 1.3.1", "http-body 1.0.1", "http-body-util", - "hyper 1.4.1", + "hyper 1.7.0", "hyper-util", "itoa", "matchit", @@ -252,9 +247,9 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sync_wrapper 1.0.1", + "sync_wrapper 1.0.2", "tokio", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", "tracing", @@ -279,20 +274,20 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.3" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15c63fd72d41492dc4f497196f5da1fb04fb7529e631d73630d1b491e47a2e3" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.1.0", + "http 1.3.1", "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", "rustversion", - "sync_wrapper 0.1.2", + "sync_wrapper 1.0.2", "tower-layer", "tower-service", "tracing", @@ -300,17 +295,17 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.73" +version = "0.3.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" dependencies = [ "addr2line", - "cc", "cfg-if", "libc", - "miniz_oxide 0.7.4", + "miniz_oxide", "object", "rustc-demangle", + "windows-targets 0.52.6", ] [[package]] @@ -327,9 +322,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.6.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "bindgen" @@ -372,7 +367,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.77", + "syn 2.0.106", "which", ] @@ -384,9 +379,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" [[package]] name = "blake2" @@ -408,9 +403,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.16.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "burrow" @@ -420,7 +415,7 @@ dependencies = [ "anyhow", "async-channel", "async-stream 0.2.1", - "axum 0.7.5", + "axum 0.7.9", "base64 0.21.7", "blake2", "caps", @@ -441,11 +436,11 @@ dependencies = [ "nix 0.27.1", "once_cell", "parking_lot", - "prost 0.13.2", - "prost-types 0.13.2", - "rand", - "rand_core", - "reqwest 0.12.7", + "prost 0.13.5", + "prost-types 0.13.5", + "rand 0.8.5", + "rand_core 0.6.4", + "reqwest 0.12.23", "ring", "rusqlite", "rust-ini", @@ -455,9 +450,9 @@ dependencies = [ "tokio", "tokio-stream", "toml", - "tonic 0.12.2", + "tonic 0.12.3", "tonic-build", - "tower", + "tower 0.4.13", "tracing", "tracing-journald", "tracing-log 0.1.4", @@ -475,9 +470,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.7.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "bzip2" @@ -491,12 +486,11 @@ dependencies = [ [[package]] name = "bzip2-sys" -version = "0.1.11+1.0.8" +version = "0.1.13+1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" dependencies = [ "cc", - "libc", "pkg-config", ] @@ -507,15 +501,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "190baaad529bcfbde9e1a19022c42781bdb6ff9de25721abdb8fd98c0807730b" dependencies = [ "libc", - "thiserror", + "thiserror 1.0.69", ] [[package]] name = "cc" -version = "1.1.18" +version = "1.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62ac837cdb5cb22e10a256099b4fc502b1dfe560cb282963a974d7abd80e476" +checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -527,14 +522,20 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" @@ -579,14 +580,14 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading 0.8.5", + "libloading 0.8.9", ] [[package]] name = "clap" -version = "4.5.17" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e5a21b8495e732f1b3c364c9949b201ca7bae518c502c80256c96ad79eaf6ac" +checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" dependencies = [ "clap_builder", "clap_derive", @@ -594,9 +595,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.17" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf2dd12af7a047ad9d6da2b6b249759a22a7abc0f474c1dae1777afa4b21a73" +checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" dependencies = [ "anstream", "anstyle", @@ -606,27 +607,27 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.13" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", ] [[package]] name = "clap_lex" -version = "0.7.2" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "colorchoice" -version = "1.0.2" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "concurrent-queue" @@ -639,15 +640,15 @@ dependencies = [ [[package]] name = "console" -version = "0.15.8" +version = "0.15.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" dependencies = [ "encode_unicode", - "lazy_static", "libc", - "unicode-width", - "windows-sys 0.52.0", + "once_cell", + "unicode-width 0.2.1", + "windows-sys 0.59.0", ] [[package]] @@ -702,7 +703,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom", + "getrandom 0.2.16", "once_cell", "tiny-keccak", ] @@ -731,42 +732,42 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.14" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-channel" -version = "0.5.13" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.20" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" @@ -775,7 +776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -802,14 +803,14 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", ] [[package]] name = "deranged" -version = "0.3.11" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" dependencies = [ "powerfmt", ] @@ -825,6 +826,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "dlv-list" version = "0.5.2" @@ -842,52 +854,52 @@ checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" [[package]] name = "dyn-clone" -version = "1.0.17" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.13.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "encode_unicode" -version = "0.3.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "encoding_rs" -version = "0.8.34" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ "cfg-if", ] [[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] name = "event-listener" -version = "5.3.1" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -896,9 +908,9 @@ dependencies = [ [[package]] name = "event-listener-strategy" -version = "0.5.2" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ "event-listener", "pin-project-lite", @@ -918,9 +930,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fehler" @@ -949,19 +961,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] -name = "fixedbitset" -version = "0.4.2" +name = "find-msvc-tools" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.0.33" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", - "miniz_oxide 0.8.0", + "miniz_oxide", ] [[package]] @@ -987,18 +1005,18 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] [[package]] name = "futures" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -1011,9 +1029,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -1021,15 +1039,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -1038,38 +1056,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", ] [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -1095,32 +1113,48 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.7+wasi-0.2.4", + "wasm-bindgen", ] [[package]] name = "gimli" -version = "0.29.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "glob" -version = "0.3.1" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "h2" -version = "0.3.26" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" dependencies = [ "bytes", "fnv", @@ -1128,7 +1162,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.5.0", + "indexmap 2.11.4", "slab", "tokio", "tokio-util", @@ -1137,17 +1171,17 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.6" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.1.0", - "indexmap 2.5.0", + "http 1.3.1", + "indexmap 2.11.4", "slab", "tokio", "tokio-util", @@ -1169,6 +1203,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + [[package]] name = "hashlink" version = "0.9.1" @@ -1187,7 +1227,7 @@ dependencies = [ "base64 0.21.7", "byteorder", "flate2", - "nom", + "nom 7.1.3", "num-traits", ] @@ -1197,12 +1237,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - [[package]] name = "hex" version = "0.4.3" @@ -1220,11 +1254,11 @@ dependencies = [ [[package]] name = "home" -version = "0.5.9" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1240,9 +1274,9 @@ dependencies = [ [[package]] name = "http" -version = "1.1.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ "bytes", "fnv", @@ -1267,27 +1301,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.1.0", + "http 1.3.1", ] [[package]] name = "http-body-util" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", - "futures-util", - "http 1.1.0", + "futures-core", + "http 1.3.1", "http-body 1.0.1", "pin-project-lite", ] [[package]] name = "httparse" -version = "1.9.4" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpdate" @@ -1297,28 +1331,28 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.1.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" -version = "0.14.30" +version = "0.14.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a152ddd61dfaec7273fe8419ab357f33aee0d914c5f4efbf0d96fa749eea5ec9" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" dependencies = [ "bytes", "futures-channel", "futures-core", "futures-util", - "h2 0.3.26", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "httparse", "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1327,20 +1361,22 @@ dependencies = [ [[package]] name = "hyper" -version = "1.4.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", - "h2 0.4.6", - "http 1.1.0", + "futures-core", + "h2 0.4.12", + "http 1.3.1", "http-body 1.0.1", "httparse", "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -1348,13 +1384,12 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.3" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "futures-util", - "http 1.1.0", - "hyper 1.4.1", + "http 1.3.1", + "hyper 1.7.0", "hyper-util", "rustls", "rustls-pki-types", @@ -1370,7 +1405,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" dependencies = [ - "hyper 0.14.30", + "hyper 0.14.32", "pin-project-lite", "tokio", "tokio-io-timeout", @@ -1378,11 +1413,11 @@ dependencies = [ [[package]] name = "hyper-timeout" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3203a961e5c83b6f5498933e78b6b263e208c197b63e9c6c53cc82ffd3f63793" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper 1.4.1", + "hyper 1.7.0", "hyper-util", "pin-project-lite", "tokio", @@ -1396,7 +1431,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ "bytes", - "hyper 0.14.30", + "hyper 0.14.32", "native-tls", "tokio", "tokio-native-tls", @@ -1404,32 +1439,133 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.7" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", + "futures-core", "futures-util", - "http 1.1.0", + "http 1.3.1", "http-body 1.0.1", - "hyper 1.4.1", + "hyper 1.7.0", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.0", "tokio", - "tower", "tower-service", "tracing", ] [[package]] -name = "idna" -version = "0.5.0" +name = "icu_collections" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", ] [[package]] @@ -1444,36 +1580,46 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.5.0" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.14.5", + "hashbrown 0.16.0", ] [[package]] name = "inout" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ "generic-array", ] [[package]] name = "insta" -version = "1.40.0" +version = "1.43.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6593a41c7a73841868772495db7dc1e8ecab43bb5c0b6da2059246c4b506ab60" +checksum = "46fdb647ebde000f43b5b53f773c30cf9b0cb4300453208713fa38b2c70935a0" dependencies = [ "console", - "lazy_static", - "linked-hash-map", + "once_cell", "serde", "similar", ] +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "libc", +] + [[package]] name = "ip_network" version = "0.4.1" @@ -1498,9 +1644,19 @@ checksum = "8e537132deb99c0eb4b752f0346b6a836200eaaa3516dd7e5514b63930a09e5d" [[package]] name = "ipnet" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] [[package]] name = "is_terminal_polyfill" @@ -1519,34 +1675,36 @@ dependencies = [ [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] [[package]] name = "itoa" -version = "1.0.11" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jobserver" -version = "0.1.32" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ + "getrandom 0.3.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.70" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" +checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" dependencies = [ + "once_cell", "wasm-bindgen", ] @@ -1564,9 +1722,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.158" +version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" [[package]] name = "libloading" @@ -1580,12 +1738,12 @@ dependencies = [ [[package]] name = "libloading" -version = "0.8.5" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-link 0.2.0", ] [[package]] @@ -1601,39 +1759,45 @@ dependencies = [ [[package]] name = "libsystemd" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c592dc396b464005f78a5853555b9f240bc5378bf5221acc4e129910b2678869" +checksum = "19c97a761fc86953c5b885422b22c891dbf5bcb9dcc99d0110d6ce4c052759f0" dependencies = [ "hmac", "libc", "log", - "nix 0.27.1", - "nom", + "nix 0.29.0", + "nom 8.0.0", "once_cell", "serde", "sha2", - "thiserror", + "thiserror 2.0.16", "uuid", ] [[package]] -name = "linked-hash-map" -version = "0.5.6" +name = "linux-raw-sys" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.4.14" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" dependencies = [ "autocfg", "scopeguard", @@ -1641,17 +1805,23 @@ dependencies = [ [[package]] name = "log" -version = "0.4.22" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -1662,9 +1832,9 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "memchr" -version = "2.7.4" +version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] name = "memoffset" @@ -1692,8 +1862,8 @@ checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" dependencies = [ "miette-derive", "once_cell", - "thiserror", - "unicode-width", + "thiserror 1.0.69", + "unicode-width 0.1.14", ] [[package]] @@ -1704,7 +1874,7 @@ checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", ] [[package]] @@ -1721,45 +1891,35 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.7.4" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" -dependencies = [ - "adler", -] - -[[package]] -name = "miniz_oxide" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", ] [[package]] name = "mio" -version = "1.0.2" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ - "hermit-abi", "libc", - "wasi", - "windows-sys 0.52.0", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", ] [[package]] name = "multimap" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] name = "native-tls" -version = "0.2.12" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" dependencies = [ "libc", "log", @@ -1791,9 +1951,21 @@ version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.4", "cfg-if", "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "cfg_aliases", + "libc", "memoffset 0.9.1", ] @@ -1808,13 +1980,21 @@ dependencies = [ ] [[package]] -name = "nu-ansi-term" -version = "0.46.0" +name = "nom" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" dependencies = [ - "overload", - "winapi", + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +dependencies = [ + "windows-sys 0.52.0", ] [[package]] @@ -1834,18 +2014,24 @@ dependencies = [ [[package]] name = "object" -version = "0.36.4" +version = "0.36.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" dependencies = [ "memchr", ] [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" [[package]] name = "opaque-debug" @@ -1855,11 +2041,11 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.66" +version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.4", "cfg-if", "foreign-types", "libc", @@ -1876,20 +2062,20 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", ] [[package]] name = "openssl-probe" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.103" +version = "0.9.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" dependencies = [ "cc", "libc", @@ -1907,12 +2093,6 @@ dependencies = [ "hashbrown 0.14.5", ] -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "parking" version = "2.2.1" @@ -1921,9 +2101,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ "lock_api", "parking_lot_core", @@ -1931,9 +2111,9 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", @@ -1949,7 +2129,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1973,45 +2153,45 @@ checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "petgraph" -version = "0.6.5" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap 2.5.0", + "indexmap 2.11.4", ] [[package]] name = "pin-project" -version = "1.1.5" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.5" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", ] [[package]] name = "pin-project-lite" -version = "0.2.14" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" @@ -2021,9 +2201,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "poly1305" @@ -2036,6 +2216,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2044,28 +2233,28 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" -version = "0.2.20" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ "zerocopy", ] [[package]] name = "prettyplease" -version = "0.2.22" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479cf940fbbb3426c32c5d5176f62ad57549a0bb84773423ba8be9d089f5faba" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.77", + "syn 2.0.106", ] [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -2082,32 +2271,31 @@ dependencies = [ [[package]] name = "prost" -version = "0.13.2" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2ecbe40f08db5c006b5764a2645f7f3f141ce756412ac9e1dd6087e6d32995" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", - "prost-derive 0.13.2", + "prost-derive 0.13.5", ] [[package]] name = "prost-build" -version = "0.13.2" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8650aabb6c35b860610e9cff5dc1af886c9e25073b7b1712a68972af4281302" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ - "bytes", "heck", - "itertools 0.13.0", + "itertools 0.14.0", "log", "multimap", "once_cell", "petgraph", "prettyplease", - "prost 0.13.2", - "prost-types 0.13.2", + "prost 0.13.5", + "prost-types 0.13.5", "regex", - "syn 2.0.77", + "syn 2.0.106", "tempfile", ] @@ -2121,20 +2309,20 @@ dependencies = [ "itertools 0.12.1", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", ] [[package]] name = "prost-derive" -version = "0.13.2" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acf0c195eebb4af52c752bec4f52f645da98b6e92077a04110c7f349477ae5ac" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", ] [[package]] @@ -2148,70 +2336,83 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.13.2" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60caa6738c7369b940c3d49246a8d1749323674c65cb13010134f5c9bad5b519" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" dependencies = [ - "prost 0.13.2", + "prost 0.13.5", ] [[package]] name = "quinn" -version = "0.11.5" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7c5fdde3cdae7203427dc4f0a68fe0ed09833edc525a03456b153b79828684" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", + "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.0.0", + "rustc-hash 2.1.1", "rustls", - "socket2", - "thiserror", + "socket2 0.6.0", + "thiserror 2.0.16", "tokio", "tracing", + "web-time", ] [[package]] name = "quinn-proto" -version = "0.11.8" +version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fadfaed2cd7f389d0161bb73eeb07b7b78f8691047a6f3e73caaeae55310a4a6" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ "bytes", - "rand", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.2", "ring", - "rustc-hash 2.0.0", + "rustc-hash 2.1.1", "rustls", + "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.16", "tinyvec", "tracing", + "web-time", ] [[package]] name = "quinn-udp" -version = "0.5.5" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fe68c2e9e1a1234e218683dbdf9f9dfcb094113c5ac2b938dfcb9bab4c4140b" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ + "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.0", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.37" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.5" @@ -2219,8 +2420,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -2230,7 +2441,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "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.3", ] [[package]] @@ -2239,61 +2460,55 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", ] [[package]] name = "redox_syscall" -version = "0.5.3" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.4", ] [[package]] name = "regex" -version = "1.10.6" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.7", - "regex-syntax 0.8.4", + "regex-automata", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.1.10" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", -] - -[[package]] -name = "regex-automata" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.4", + "regex-syntax", ] [[package]] name = "regex-syntax" -version = "0.6.29" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - -[[package]] -name = "regex-syntax" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "reqwest" @@ -2306,10 +2521,10 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.3.26", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", - "hyper 0.14.30", + "hyper 0.14.32", "hyper-tls", "ipnet", "js-sys", @@ -2319,7 +2534,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile 1.0.4", + "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", @@ -2337,57 +2552,52 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.7" +version = "0.12.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8f4955649ef5c38cc7f9e8aa41761d48fb9677197daea9984dc54f56aad5e63" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64 0.22.1", "bytes", "futures-core", - "futures-util", - "http 1.1.0", + "http 1.3.1", "http-body 1.0.1", "http-body-util", - "hyper 1.4.1", + "hyper 1.7.0", "hyper-rustls", "hyper-util", - "ipnet", "js-sys", "log", - "mime", - "once_cell", "percent-encoding", "pin-project-lite", "quinn", "rustls", - "rustls-pemfile 2.1.3", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.1", + "sync_wrapper 1.0.2", "tokio", "tokio-rustls", + "tower 0.5.2", + "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", "webpki-roots", - "windows-registry", ] [[package]] name = "ring" -version = "0.17.8" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.16", "libc", - "spin", "untrusted", "windows-sys 0.52.0", ] @@ -2398,7 +2608,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.4", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2408,20 +2618,19 @@ dependencies = [ [[package]] name = "rust-ini" -version = "0.21.1" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e310ef0e1b6eeb79169a1171daf9abcb87a2e17c03bee2c4bb100b55c75409f" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" dependencies = [ "cfg-if", "ordered-multimap", - "trim-in-place", ] [[package]] name = "rustc-demangle" -version = "0.1.24" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -2431,9 +2640,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustc_version" @@ -2446,22 +2655,35 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.36" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f55e80d50763938498dd5ebb18647174e0c76dc38c5505294bb224624f30f36" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.4", "errno", "libc", - "linux-raw-sys", - "windows-sys 0.52.0", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.9.4", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.0", ] [[package]] name = "rustls" -version = "0.23.12" +version = "0.23.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044" +checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" dependencies = [ "once_cell", "ring", @@ -2481,26 +2703,20 @@ dependencies = [ ] [[package]] -name = "rustls-pemfile" -version = "2.1.3" +name = "rustls-pki-types" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "196fe16b00e106300d3e45ecfcb764fa292a535d7326a29a5875c579c7417425" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ - "base64 0.22.1", - "rustls-pki-types", + "web-time", + "zeroize", ] -[[package]] -name = "rustls-pki-types" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" - [[package]] name = "rustls-webpki" -version = "0.102.7" +version = "0.103.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84678086bd54edf2b415183ed7a94d0efb049f1b646a33e22a36f3794be6ae56" +checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" dependencies = [ "ring", "rustls-pki-types", @@ -2509,30 +2725,30 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.17" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.18" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "schannel" -version = "0.1.24" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9aaafd5a2b6e3d657ff009d82fbd630b6bd54dd4eb06f21693925cdf80f9b8b" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] name = "schemars" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "schemars_derive", @@ -2542,14 +2758,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.77", + "syn 2.0.106", ] [[package]] @@ -2564,7 +2780,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.4", "core-foundation", "core-foundation-sys", "libc", @@ -2573,9 +2789,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.11.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75da29fe9b9b08fe9d6b22b5b4bcbc75d8db3aa31e639aa56bb62e9d46bfceaf" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ "core-foundation-sys", "libc", @@ -2583,28 +2799,38 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.23" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.210" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.226" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.210" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", ] [[package]] @@ -2615,36 +2841,38 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", ] [[package]] name = "serde_json" -version = "1.0.128" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] name = "serde_path_to_error" -version = "0.1.16" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ "itoa", "serde", + "serde_core", ] [[package]] name = "serde_spanned" -version = "0.6.7" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb5b1b31579f3811bf615c144393417496f152e12ac8b7663bf664f4a815306d" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ "serde", ] @@ -2685,9 +2913,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", @@ -2711,49 +2939,50 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ "libc", ] [[package]] name = "similar" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "slab" -version = "0.4.9" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" -version = "1.13.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.5.7" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", "windows-sys 0.52.0", ] [[package]] -name = "spin" -version = "0.9.8" +name = "socket2" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] [[package]] name = "ssri" @@ -2767,10 +2996,16 @@ dependencies = [ "miette", "sha-1", "sha2", - "thiserror", + "thiserror 1.0.69", "xxhash-rust", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "strsim" version = "0.11.1" @@ -2796,9 +3031,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.77" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -2813,13 +3048,24 @@ checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" [[package]] name = "sync_wrapper" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +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 2.0.106", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -2843,52 +3089,71 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.12.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ - "cfg-if", "fastrand", + "getrandom 0.3.3", "once_cell", - "rustix", - "windows-sys 0.59.0", + "rustix 1.1.2", + "windows-sys 0.61.0", ] [[package]] name = "thiserror" -version = "1.0.63" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl 2.0.16", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] name = "thread_local" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", - "once_cell", ] [[package]] name = "time" -version = "0.3.36" +version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", "num-conv", @@ -2899,9 +3164,9 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "tiny-keccak" @@ -2913,10 +3178,20 @@ dependencies = [ ] [[package]] -name = "tinyvec" -version = "1.8.0" +name = "tinystr" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] @@ -2929,27 +3204,29 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.40.0" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", "pin-project-lite", "signal-hook-registry", - "socket2", + "slab", + "socket2 0.6.0", "tokio-macros", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "tokio-io-timeout" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b74022ada614a1b4834de765f9bb43877f910cc8ce4be40e89042c9223a8bf" +checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" dependencies = [ "pin-project-lite", "tokio", @@ -2957,13 +3234,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", ] [[package]] @@ -2978,20 +3255,19 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.0" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" dependencies = [ "rustls", - "rustls-pki-types", "tokio", ] [[package]] name = "tokio-stream" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4e6ce100d0eb49a2734f8c0812bcd324cf357d21810932c5df6b96ef2b86f1" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" dependencies = [ "futures-core", "pin-project-lite", @@ -3000,9 +3276,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.12" +version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes", "futures-core", @@ -3013,9 +3289,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.19" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", @@ -3025,48 +3301,55 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.22.20" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583c44c02ad26b0c3f3066fe629275e50627026c51ac2e595cca4c230ce1ce1d" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.5.0", + "indexmap 2.11.4", "serde", "serde_spanned", "toml_datetime", + "toml_write", "winnow", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tonic" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d560933a0de61cf715926b9cac824d4c883c2c43142f787595e48280c40a1d0e" dependencies = [ - "async-stream 0.3.5", + "async-stream 0.3.6", "async-trait", "axum 0.6.20", "base64 0.21.7", "bytes", - "h2 0.3.26", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", - "hyper 0.14.30", + "hyper 0.14.32", "hyper-timeout 0.4.1", "percent-encoding", "pin-project", "prost 0.12.6", "tokio", "tokio-stream", - "tower", + "tower 0.4.13", "tower-layer", "tower-service", "tracing", @@ -3074,29 +3357,29 @@ dependencies = [ [[package]] name = "tonic" -version = "0.12.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f6ba989e4b2c58ae83d862d3a3e27690b6e3ae630d0deb59f3697f32aa88ad" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" dependencies = [ - "async-stream 0.3.5", + "async-stream 0.3.6", "async-trait", - "axum 0.7.5", + "axum 0.7.9", "base64 0.22.1", "bytes", - "h2 0.4.6", - "http 1.1.0", + "h2 0.4.12", + "http 1.3.1", "http-body 1.0.1", "http-body-util", - "hyper 1.4.1", - "hyper-timeout 0.5.1", + "hyper 1.7.0", + "hyper-timeout 0.5.2", "hyper-util", "percent-encoding", "pin-project", - "prost 0.13.2", - "socket2", + "prost 0.13.5", + "socket2 0.5.10", "tokio", "tokio-stream", - "tower", + "tower 0.4.13", "tower-layer", "tower-service", "tracing", @@ -3104,15 +3387,16 @@ dependencies = [ [[package]] name = "tonic-build" -version = "0.12.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4ee8877250136bd7e3d2331632810a4df4ea5e004656990d8d66d2f5ee8a67" +checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" dependencies = [ "prettyplease", "proc-macro2", "prost-build", + "prost-types 0.13.5", "quote", - "syn 2.0.77", + "syn 2.0.106", ] [[package]] @@ -3126,7 +3410,7 @@ dependencies = [ "indexmap 1.9.3", "pin-project", "pin-project-lite", - "rand", + "rand 0.8.5", "slab", "tokio", "tokio-util", @@ -3135,6 +3419,40 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.9.4", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower 0.5.2", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -3149,9 +3467,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "log", "pin-project-lite", @@ -3161,20 +3479,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", ] [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", "valuable", @@ -3182,9 +3500,9 @@ dependencies = [ [[package]] name = "tracing-journald" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba316a74e8fc3c3896a850dba2375928a9fa171b085ecddfc7c054d39970f3fd" +checksum = "fc0b4143302cf1022dac868d521e36e8b27691f72c84b3311750d5188ebba657" dependencies = [ "libc", "tracing-core", @@ -3230,14 +3548,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.18" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -3246,12 +3564,6 @@ dependencies = [ "tracing-log 0.2.0", ] -[[package]] -name = "trim-in-place" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc" - [[package]] name = "try-lock" version = "0.2.5" @@ -3275,7 +3587,7 @@ dependencies = [ "reqwest 0.11.27", "schemars", "serde", - "socket2", + "socket2 0.5.10", "ssri", "tempfile", "tokio", @@ -3287,36 +3599,27 @@ dependencies = [ [[package]] name = "typenum" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" - -[[package]] -name = "unicode-bidi" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" - -[[package]] -name = "unicode-normalization" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" -dependencies = [ - "tinyvec", -] +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-width" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" [[package]] name = "universal-hash" @@ -3336,15 +3639,22 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.2" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] +[[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" @@ -3353,18 +3663,20 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.10.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ + "js-sys", "serde", + "wasm-bindgen", ] [[package]] name = "valuable" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "vcpkg" @@ -3389,53 +3701,73 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] [[package]] name = "wasm-bindgen" -version = "0.2.93" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" dependencies = [ "cfg-if", "once_cell", + "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.93" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" dependencies = [ "bumpalo", "log", - "once_cell", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.43" +version = "0.4.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed" +checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" dependencies = [ "cfg-if", "js-sys", + "once_cell", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.93" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3443,28 +3775,41 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.93" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.93" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" +checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" +dependencies = [ + "unicode-ident", +] [[package]] name = "web-sys" -version = "0.3.70" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0" +checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" +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", @@ -3472,9 +3817,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.5" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bd24728e5af82c6c4ec1b66ac4844bdf8156257fccda846ec58b42cd0cdbe6a" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" dependencies = [ "rustls-pki-types", ] @@ -3488,14 +3833,14 @@ dependencies = [ "either", "home", "once_cell", - "rustix", + "rustix 0.38.44", ] [[package]] name = "widestring" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311" +checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" [[package]] name = "winapi" @@ -3529,34 +3874,16 @@ dependencies = [ ] [[package]] -name = "windows-registry" -version = "0.2.0" +name = "windows-link" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" -dependencies = [ - "windows-result", - "windows-strings", - "windows-targets 0.52.6", -] +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] -name = "windows-result" +name = "windows-link" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-strings" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" -dependencies = [ - "windows-result", - "windows-targets 0.52.6", -] +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" [[package]] name = "windows-sys" @@ -3585,6 +3912,24 @@ 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.3", +] + +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link 0.2.0", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -3609,13 +3954,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link 0.1.3", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3628,6 +3990,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -3640,6 +4008,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -3652,12 +4026,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + [[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -3670,6 +4056,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -3682,6 +4074,12 @@ 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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -3694,6 +4092,12 @@ 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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -3707,10 +4111,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "winnow" -version = "0.6.18" +name = "windows_x86_64_msvc" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] @@ -3725,6 +4135,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -3732,36 +4154,80 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", "serde", "zeroize", ] [[package]] name = "xxhash-rust" -version = "0.8.12" +version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a5cbf750400958819fb6178eaa83bee5cd9c29a26a40cc241df8c70fdd46984" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] [[package]] name = "zerocopy" -version = "0.7.35" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ - "byteorder", "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.35" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", ] [[package]] @@ -3781,7 +4247,40 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.106", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] @@ -3825,9 +4324,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.0.13+zstd.1.5.6" +version = "2.0.16+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38ff0f21cfee8f97d94cef41359e0c89aa6113028ab0291aa8ca0038995a95aa" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" dependencies = [ "cc", "pkg-config", diff --git a/burrow-gtk/Cargo.lock b/burrow-gtk/Cargo.lock index 6721318..a1a9ebd 100644 --- a/burrow-gtk/Cargo.lock +++ b/burrow-gtk/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -38,6 +38,18 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.2" @@ -114,6 +126,49 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22068c0c19514942eefcfd4daf8976ef1aad84e61539f95cd200c35202f80af5" +dependencies = [ + "async-stream-impl 0.2.1", + "futures-core", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl 0.3.6", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f9db3b38af870bf7e5cc649167533b493928e50744e2c30ae350230b414670" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "async-trait" version = "0.1.77" @@ -122,15 +177,76 @@ checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.106", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "axum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.6.0", + "hyper-util", + "itoa", + "matchit", + "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-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.3.1", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.69" @@ -152,6 +268,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.6.0" @@ -174,7 +296,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 1.1.0", "shlex", "syn 1.0.109", "which", @@ -197,9 +319,9 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 1.1.0", "shlex", - "syn 2.0.48", + "syn 2.0.106", "which", ] @@ -252,15 +374,19 @@ dependencies = [ "aead", "anyhow", "async-channel", - "base64", + "async-stream 0.2.1", + "axum", + "base64 0.21.7", "blake2", "caps", "chacha20poly1305", "clap", "console", + "dotenv", "fehler", "futures", "hmac", + "hyper-util", "ip_network", "ip_network_table", "libsystemd", @@ -268,13 +394,23 @@ dependencies = [ "nix 0.27.1", "once_cell", "parking_lot", - "rand", - "rand_core", + "prost", + "prost-types", + "rand 0.8.5", + "rand_core 0.6.4", + "reqwest 0.12.5", "ring", + "rusqlite", + "rust-ini", "schemars", "serde", "serde_json", "tokio", + "tokio-stream", + "toml", + "tonic", + "tonic-build", + "tower", "tracing", "tracing-journald", "tracing-log 0.1.4", @@ -304,9 +440,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.5.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "bzip2" @@ -340,7 +476,7 @@ dependencies = [ "glib", "libc", "once_cell", - "thiserror", + "thiserror 1.0.56", ] [[package]] @@ -361,7 +497,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "190baaad529bcfbde9e1a19022c42781bdb6ff9de25721abdb8fd98c0807730b" dependencies = [ "libc", - "thiserror", + "thiserror 1.0.56", ] [[package]] @@ -399,6 +535,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -476,7 +618,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.106", ] [[package]] @@ -513,6 +655,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.12", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constant_time_eq" version = "0.1.5" @@ -559,6 +721,12 @@ version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.6" @@ -566,7 +734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -594,7 +762,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.106", ] [[package]] @@ -617,6 +785,21 @@ dependencies = [ "subtle", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + [[package]] name = "dyn-clone" version = "1.0.16" @@ -681,6 +864,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.0.1" @@ -723,6 +918,12 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.0.28" @@ -838,7 +1039,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.106", ] [[package]] @@ -950,7 +1151,21 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.7+wasi-0.2.4", "wasm-bindgen", ] @@ -997,7 +1212,7 @@ dependencies = [ "once_cell", "pin-project-lite", "smallvec", - "thiserror", + "thiserror 1.0.56", ] [[package]] @@ -1033,7 +1248,7 @@ dependencies = [ "memchr", "once_cell", "smallvec", - "thiserror", + "thiserror 1.0.56", ] [[package]] @@ -1206,19 +1421,62 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", - "indexmap", + "http 0.2.11", + "indexmap 2.11.4", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.3.1", + "indexmap 2.11.4", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.3", +] [[package]] name = "heck" @@ -1226,12 +1484,6 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" -[[package]] -name = "hermit-abi" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d3d0e0f38255e7fa3cf31335b3a56f05febd18025f4db5ef7a0cfb4f8da651f" - [[package]] name = "hex" version = "0.4.3" @@ -1267,6 +1519,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http-body" version = "0.4.6" @@ -1274,15 +1537,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http", + "http 0.2.11", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.3.1", +] + +[[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.3.1", + "http-body 1.0.1", "pin-project-lite", ] [[package]] name = "httparse" -version = "1.8.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpdate" @@ -1300,20 +1586,71 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.3.24", + "http 0.2.11", + "http-body 0.4.6", "httparse", "httpdate", "itoa", "pin-project-lite", - "socket2 0.5.5", + "socket2 0.4.10", "tokio", "tower-service", "tracing", "want", ] +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2 0.4.12", + "http 1.3.1", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.3.1", + "hyper 1.6.0", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.2", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper 1.6.0", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -1321,12 +1658,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ "bytes", - "hyper", + "hyper 0.14.28", "native-tls", "tokio", "tokio-native-tls", ] +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "hyper 1.6.0", + "libc", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "idna" version = "0.5.0" @@ -1339,12 +1697,22 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.1.0" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.0", ] [[package]] @@ -1356,6 +1724,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags 2.4.2", + "cfg-if", + "libc", +] + [[package]] name = "ip_network" version = "0.4.1" @@ -1384,6 +1763,15 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +[[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.10" @@ -1401,10 +1789,11 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.67" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a1d36f1235bc969acba30b7f5990b864423a6068a10f7c90ae8f0112e3a59d1" +checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" dependencies = [ + "once_cell", "wasm-bindgen", ] @@ -1455,9 +1844,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.152" +version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" [[package]] name = "libloading" @@ -1479,6 +1868,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libsystemd" version = "0.7.0" @@ -1493,7 +1893,7 @@ dependencies = [ "once_cell", "serde", "sha2", - "thiserror", + "thiserror 1.0.56", "uuid", ] @@ -1532,6 +1932,12 @@ version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "malloc_buf" version = "0.0.6" @@ -1550,6 +1956,12 @@ dependencies = [ "regex-automata 0.1.10", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.7.1" @@ -1582,7 +1994,7 @@ checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" dependencies = [ "miette-derive", "once_cell", - "thiserror", + "thiserror 1.0.56", "unicode-width", ] @@ -1594,7 +2006,7 @@ checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.106", ] [[package]] @@ -1620,22 +2032,28 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.10" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", - "wasi", - "windows-sys 0.48.0", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "nanorand" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" dependencies = [ - "getrandom", + "getrandom 0.2.12", ] [[package]] @@ -1701,16 +2119,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi", - "libc", -] - [[package]] name = "objc" version = "0.2.7" @@ -1784,7 +2192,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.106", ] [[package]] @@ -1805,6 +2213,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.3", +] + [[package]] name = "overload" version = "0.1.1" @@ -1873,7 +2291,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1901,6 +2319,16 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.11.4", +] + [[package]] name = "pin-project" version = "1.1.4" @@ -1918,7 +2346,7 @@ checksum = "266c042b60c9c76b8d53061e52b2e0d1116abc57cefc8c5cd671619a56ac3690" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.106", ] [[package]] @@ -1975,7 +2403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a41cf62165e97c7f814d2221421dbb9afcbcdb0a88068e5ea206e19951c2cbb5" dependencies = [ "proc-macro2", - "syn 2.0.48", + "syn 2.0.106", ] [[package]] @@ -2014,13 +2442,120 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.78" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.106", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[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 2.1.1", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.16", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.16", + "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 0.5.10", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.35" @@ -2030,6 +2565,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.5" @@ -2037,8 +2578,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -2048,7 +2599,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "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.3", ] [[package]] @@ -2057,7 +2618,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.12", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", ] [[package]] @@ -2139,7 +2709,7 @@ checksum = "9340e2553c0a184a80a0bfa1dcf73c47f3d48933aa6be90724b202f9fbd24735" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.106", ] [[package]] @@ -2148,15 +2718,15 @@ version = "0.11.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b1ae8d9ac08420c66222fb9096fc5de435c3c48542bc5336c51892cffafb41" dependencies = [ - "base64", + "base64 0.21.7", "bytes", "encoding_rs", "futures-core", "futures-util", - "h2", - "http", - "http-body", - "hyper", + "h2 0.3.24", + "http 0.2.11", + "http-body 0.4.6", + "hyper 0.14.28", "hyper-tls", "ipnet", "js-sys", @@ -2177,7 +2747,49 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "winreg", + "winreg 0.50.0", +] + +[[package]] +name = "reqwest" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.6.0", + "hyper-rustls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 0.26.11", + "winreg 0.52.0", ] [[package]] @@ -2187,13 +2799,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "688c63d65483050968b2a8937f7995f443e27041a0f7700aa59b0822aedebb74" dependencies = [ "cc", - "getrandom", + "getrandom 0.2.12", "libc", "spin", "untrusted", "windows-sys 0.48.0", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.4.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -2206,6 +2842,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.0" @@ -2228,6 +2870,56 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.23.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[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.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" +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.16" @@ -2304,22 +2996,32 @@ checksum = "b97ed7a9823b74f99c7742f5336af7be5ecd3eeafcb1507d1fa93347b1d589b0" [[package]] name = "serde" -version = "1.0.195" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02" +checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.226" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.195" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c" +checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.106", ] [[package]] @@ -2345,10 +3047,21 @@ dependencies = [ ] [[package]] -name = "serde_spanned" -version = "0.6.5" +name = "serde_path_to_error" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ "serde", ] @@ -2413,6 +3126,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + [[package]] name = "slab" version = "0.4.9" @@ -2440,12 +3162,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.5" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -2463,13 +3185,13 @@ version = "9.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7a2b3c2bc9693bcb40870c4e9b5bf0d79f9cb46273321bf855ec513e919082" dependencies = [ - "base64", + "base64 0.21.7", "digest", "hex", "miette", "sha-1", "sha2", - "thiserror", + "thiserror 1.0.56", "xxhash-rust", ] @@ -2498,15 +3220,21 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.48" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "system-configuration" version = "0.5.1" @@ -2572,7 +3300,16 @@ version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.56", +] + +[[package]] +name = "thiserror" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl 2.0.16", ] [[package]] @@ -2583,7 +3320,18 @@ checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.106", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] @@ -2614,6 +3362,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -2631,31 +3388,33 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.35.1" +version = "1.46.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89b4efa943be685f629b149f53829423f8f5531ea21249408e8e2f8671ec104" +checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", - "num_cpus", "pin-project-lite", - "socket2 0.5.5", + "signal-hook-registry", + "slab", + "socket2 0.5.10", "tokio-macros", "tracing", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "tokio-macros" -version = "2.2.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.106", ] [[package]] @@ -2668,6 +3427,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.10" @@ -2684,21 +3464,21 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.2" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", "toml_datetime", - "toml_edit 0.20.2", + "toml_edit 0.22.27", ] [[package]] name = "toml_datetime" -version = "0.6.3" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] @@ -2709,24 +3489,101 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap", + "indexmap 2.11.4", "toml_datetime", - "winnow", + "winnow 0.5.34", ] [[package]] name = "toml_edit" -version = "0.20.2" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.11.4", "serde", "serde_spanned", "toml_datetime", - "winnow", + "toml_write", + "winnow 0.7.13", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream 0.3.6", + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2 0.4.12", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.6.0", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "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.2" @@ -2739,6 +3596,7 @@ version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2752,7 +3610,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.106", ] [[package]] @@ -2851,10 +3709,10 @@ dependencies = [ "libloading 0.7.4", "log", "nix 0.26.4", - "reqwest", + "reqwest 0.11.23", "schemars", "serde", - "socket2 0.4.10", + "socket2 0.5.10", "ssri", "tempfile", "tokio", @@ -2979,27 +3837,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] -name = "wasm-bindgen" -version = "0.2.90" +name = "wasi" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1223296a201415c7fad14792dbefaace9bd52b62d33453ade1c5b5f07555406" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" dependencies = [ "cfg-if", + "once_cell", + "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.90" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcdc935b63408d58a32f8cc9738a0bffd8f05cc7c002086c6ef20b7312ad9dcd" +checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" dependencies = [ "bumpalo", "log", - "once_cell", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.106", "wasm-bindgen-shared", ] @@ -3017,9 +3895,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.90" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e4c238561b2d428924c49815533a8b9121c664599558a5d9ec51f8a1740a999" +checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3027,22 +3905,25 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.90" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae1abb6806dc1ad9e560ed242107c0f6c84335f1749dd4e8ddb012ebd5e25a7" +checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.90" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d91413b1c31d7539ba5ef2451af3f0b833a005eb27a631cec32bc0635a8602b" +checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" +dependencies = [ + "unicode-ident", +] [[package]] name = "web-sys" @@ -3054,6 +3935,34 @@ dependencies = [ "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.2", +] + +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "4.4.2" @@ -3244,6 +4153,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" @@ -3254,6 +4172,22 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "x25519-dalek" version = "2.0.0" @@ -3261,7 +4195,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb66477291e7e8d2b0ff1bcb900bf29489a9692816d79874bea351e7a8b6de96" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", "serde", "zeroize", ] @@ -3272,6 +4206,26 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53be06678ed9e83edb1745eb72efc0bbcd7b5c3c35711a860906aed827a13d61" +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "zeroize" version = "1.7.0" @@ -3289,7 +4243,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.106", ] [[package]] diff --git a/burrow/src/daemon/rpc/response.rs b/burrow/src/daemon/rpc/response.rs index 61c9c50..8948ca4 100644 --- a/burrow/src/daemon/rpc/response.rs +++ b/burrow/src/daemon/rpc/response.rs @@ -36,6 +36,8 @@ impl DaemonResponse { pub struct ServerInfo { pub name: Option, pub ip: Option, + #[serde(default)] + pub ipv6: Vec, pub mtu: Option, } @@ -47,6 +49,12 @@ impl TryFrom<&TunInterface> for ServerInfo { Ok(ServerInfo { name: server.name().ok(), ip: server.ipv4_addr().ok().map(|ip| ip.to_string()), + ipv6: server + .ipv6_addrs() + .unwrap_or_default() + .into_iter() + .map(|ip| ip.to_string()) + .collect(), mtu: server.mtu().ok(), }) } @@ -109,6 +117,7 @@ fn test_response_serialization() -> anyhow::Result<()> { DaemonResponseData::ServerInfo(ServerInfo { name: Some("burrow".to_string()), ip: None, + ipv6: Vec::new(), mtu: Some(1500) }) )))?); diff --git a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-2.snap b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-2.snap index d7bd712..76aa944 100644 --- a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-2.snap +++ b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-2.snap @@ -1,5 +1,5 @@ --- source: burrow/src/daemon/rpc/response.rs -expression: "serde_json::to_string(&DaemonResponse::new(Ok::(DaemonResponseData::ServerInfo(ServerInfo {\n name: Some(\"burrow\".to_string()),\n ip: None,\n mtu: Some(1500),\n }))))?" +expression: "serde_json::to_string(&DaemonResponse::new(Ok::(DaemonResponseData::ServerInfo(ServerInfo {\n name: Some(\"burrow\".to_string()),\n ip: None,\n ipv6: Vec::new(),\n mtu: Some(1500),\n }))))?" --- -{"result":{"Ok":{"type":"ServerInfo","name":"burrow","ip":null,"mtu":1500}},"id":0} +{"result":{"Ok":{"type":"ServerInfo","name":"burrow","ip":null,"ipv6":[],"mtu":1500}},"id":0} diff --git a/burrow/src/daemon/snapshots/burrow__daemon__response__response_serialization-2.snap b/burrow/src/daemon/snapshots/burrow__daemon__response__response_serialization-2.snap index 3787cd1..20988bf 100644 --- a/burrow/src/daemon/snapshots/burrow__daemon__response__response_serialization-2.snap +++ b/burrow/src/daemon/snapshots/burrow__daemon__response__response_serialization-2.snap @@ -1,5 +1,5 @@ --- source: burrow/src/daemon/response.rs -expression: "serde_json::to_string(&DaemonResponse::new(Ok::(DaemonResponseData::ServerInfo(ServerInfo {\n name: Some(\"burrow\".to_string()),\n ip: None,\n mtu: Some(1500),\n }))))?" +expression: "serde_json::to_string(&DaemonResponse::new(Ok::(DaemonResponseData::ServerInfo(ServerInfo {\n name: Some(\"burrow\".to_string()),\n ip: None,\n ipv6: Vec::new(),\n mtu: Some(1500),\n }))))?" --- -{"result":{"Ok":{"ServerInfo":{"name":"burrow","ip":null,"mtu":1500}}},"id":0} +{"result":{"Ok":{"ServerInfo":{"name":"burrow","ip":null,"ipv6":[],"mtu":1500}}},"id":0} diff --git a/proto/google/protobuf/duration.proto b/proto/google/protobuf/duration.proto new file mode 100644 index 0000000..41f40c2 --- /dev/null +++ b/proto/google/protobuf/duration.proto @@ -0,0 +1,115 @@ +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +syntax = "proto3"; + +package google.protobuf; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/durationpb"; +option java_package = "com.google.protobuf"; +option java_outer_classname = "DurationProto"; +option java_multiple_files = true; +option objc_class_prefix = "GPB"; +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; + +// A Duration represents a signed, fixed-length span of time represented +// as a count of seconds and fractions of seconds at nanosecond +// resolution. It is independent of any calendar and concepts like "day" +// or "month". It is related to Timestamp in that the difference between +// two Timestamp values is a Duration and it can be added or subtracted +// from a Timestamp. Range is approximately +-10,000 years. +// +// # Examples +// +// Example 1: Compute Duration from two Timestamps in pseudo code. +// +// Timestamp start = ...; +// Timestamp end = ...; +// Duration duration = ...; +// +// duration.seconds = end.seconds - start.seconds; +// duration.nanos = end.nanos - start.nanos; +// +// if (duration.seconds < 0 && duration.nanos > 0) { +// duration.seconds += 1; +// duration.nanos -= 1000000000; +// } else if (duration.seconds > 0 && duration.nanos < 0) { +// duration.seconds -= 1; +// duration.nanos += 1000000000; +// } +// +// Example 2: Compute Timestamp from Timestamp + Duration in pseudo code. +// +// Timestamp start = ...; +// Duration duration = ...; +// Timestamp end = ...; +// +// end.seconds = start.seconds + duration.seconds; +// end.nanos = start.nanos + duration.nanos; +// +// if (end.nanos < 0) { +// end.seconds -= 1; +// end.nanos += 1000000000; +// } else if (end.nanos >= 1000000000) { +// end.seconds += 1; +// end.nanos -= 1000000000; +// } +// +// Example 3: Compute Duration from datetime.timedelta in Python. +// +// td = datetime.timedelta(days=3, minutes=10) +// duration = Duration() +// duration.FromTimedelta(td) +// +// # JSON Mapping +// +// In JSON format, the Duration type is encoded as a string rather than an +// object, where the string ends in the suffix "s" (indicating seconds) and +// is preceded by the number of seconds, with nanoseconds expressed as +// fractional seconds. For example, 3 seconds with 0 nanoseconds should be +// encoded in JSON format as "3s", while 3 seconds and 1 nanosecond should +// be expressed in JSON format as "3.000000001s", and 3 seconds and 1 +// microsecond should be expressed in JSON format as "3.000001s". +// +message Duration { + // Signed seconds of the span of time. Must be from -315,576,000,000 + // to +315,576,000,000 inclusive. Note: these bounds are computed from: + // 60 sec/min * 60 min/hr * 24 hr/day * 365.25 days/year * 10000 years + int64 seconds = 1; + + // Signed fractions of a second at nanosecond resolution of the span + // of time. Durations less than one second are represented with a 0 + // `seconds` field and a positive or negative `nanos` field. For durations + // of one second or more, a non-zero value for the `nanos` field must be + // of the same sign as the `seconds` field. Must be from -999,999,999 + // to +999,999,999 inclusive. + int32 nanos = 2; +} diff --git a/proto/google/protobuf/timestamp.proto b/proto/google/protobuf/timestamp.proto new file mode 100644 index 0000000..fd0bc07 --- /dev/null +++ b/proto/google/protobuf/timestamp.proto @@ -0,0 +1,144 @@ +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +syntax = "proto3"; + +package google.protobuf; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/timestamppb"; +option java_package = "com.google.protobuf"; +option java_outer_classname = "TimestampProto"; +option java_multiple_files = true; +option objc_class_prefix = "GPB"; +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; + +// A Timestamp represents a point in time independent of any time zone or local +// calendar, encoded as a count of seconds and fractions of seconds at +// nanosecond resolution. The count is relative to an epoch at UTC midnight on +// January 1, 1970, in the proleptic Gregorian calendar which extends the +// Gregorian calendar backwards to year one. +// +// All minutes are 60 seconds long. Leap seconds are "smeared" so that no leap +// second table is needed for interpretation, using a [24-hour linear +// smear](https://developers.google.com/time/smear). +// +// The range is from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. By +// restricting to that range, we ensure that we can convert to and from [RFC +// 3339](https://www.ietf.org/rfc/rfc3339.txt) date strings. +// +// # Examples +// +// Example 1: Compute Timestamp from POSIX `time()`. +// +// Timestamp timestamp; +// timestamp.set_seconds(time(NULL)); +// timestamp.set_nanos(0); +// +// Example 2: Compute Timestamp from POSIX `gettimeofday()`. +// +// struct timeval tv; +// gettimeofday(&tv, NULL); +// +// Timestamp timestamp; +// timestamp.set_seconds(tv.tv_sec); +// timestamp.set_nanos(tv.tv_usec * 1000); +// +// Example 3: Compute Timestamp from Win32 `GetSystemTimeAsFileTime()`. +// +// FILETIME ft; +// GetSystemTimeAsFileTime(&ft); +// UINT64 ticks = (((UINT64)ft.dwHighDateTime) << 32) | ft.dwLowDateTime; +// +// // A Windows tick is 100 nanoseconds. Windows epoch 1601-01-01T00:00:00Z +// // is 11644473600 seconds before Unix epoch 1970-01-01T00:00:00Z. +// Timestamp timestamp; +// timestamp.set_seconds((INT64) ((ticks / 10000000) - 11644473600LL)); +// timestamp.set_nanos((INT32) ((ticks % 10000000) * 100)); +// +// Example 4: Compute Timestamp from Java `System.currentTimeMillis()`. +// +// long millis = System.currentTimeMillis(); +// +// Timestamp timestamp = Timestamp.newBuilder().setSeconds(millis / 1000) +// .setNanos((int) ((millis % 1000) * 1000000)).build(); +// +// Example 5: Compute Timestamp from Java `Instant.now()`. +// +// Instant now = Instant.now(); +// +// Timestamp timestamp = +// Timestamp.newBuilder().setSeconds(now.getEpochSecond()) +// .setNanos(now.getNano()).build(); +// +// Example 6: Compute Timestamp from current time in Python. +// +// timestamp = Timestamp() +// timestamp.GetCurrentTime() +// +// # JSON Mapping +// +// In JSON format, the Timestamp type is encoded as a string in the +// [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. That is, the +// format is "{year}-{month}-{day}T{hour}:{min}:{sec}[.{frac_sec}]Z" +// where {year} is always expressed using four digits while {month}, {day}, +// {hour}, {min}, and {sec} are zero-padded to two digits each. The fractional +// seconds, which can go up to 9 digits (i.e. up to 1 nanosecond resolution), +// are optional. The "Z" suffix indicates the timezone ("UTC"); the timezone +// is required. A proto3 JSON serializer should always use UTC (as indicated by +// "Z") when printing the Timestamp type and a proto3 JSON parser should be +// able to accept both UTC and other timezones (as indicated by an offset). +// +// For example, "2017-01-15T01:30:15.01Z" encodes 15.01 seconds past +// 01:30 UTC on January 15, 2017. +// +// In JavaScript, one can convert a Date object to this format using the +// standard +// [toISOString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString) +// method. In Python, a standard `datetime.datetime` object can be converted +// to this format using +// [`strftime`](https://docs.python.org/2/library/time.html#time.strftime) with +// the time format spec '%Y-%m-%dT%H:%M:%S.%fZ'. Likewise, in Java, one can use +// the Joda Time's [`ISODateTimeFormat.dateTime()`]( +// http://joda-time.sourceforge.net/apidocs/org/joda/time/format/ISODateTimeFormat.html#dateTime() +// ) to obtain a formatter capable of generating timestamps in this format. +// +message Timestamp { + // Represents seconds of UTC time since Unix epoch + // 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to + // 9999-12-31T23:59:59Z inclusive. + int64 seconds = 1; + + // Non-negative fractions of a second at nanosecond resolution. Negative + // second values with fractions must still have non-negative nanos values + // that count forward in time. Must be from 0 to 999,999,999 + // inclusive. + int32 nanos = 2; +} diff --git a/tun/src/unix/apple/mod.rs b/tun/src/unix/apple/mod.rs index 74e93eb..0d60aa7 100644 --- a/tun/src/unix/apple/mod.rs +++ b/tun/src/unix/apple/mod.rs @@ -1,4 +1,5 @@ use std::{ + ffi::CStr, io::{Error, IoSlice}, mem, net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddrV4}, @@ -162,6 +163,46 @@ impl TunInterface { tracing::warn!("Setting IPV6 address on MacOS CLI mode is not supported yet."); } + #[throws] + #[instrument] + pub fn ipv6_addrs(&self) -> Vec { + struct IfAddrs(*mut libc::ifaddrs); + + impl Drop for IfAddrs { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { libc::freeifaddrs(self.0) }; + } + } + } + + let mut ifaddrs = std::ptr::null_mut(); + unsafe { + if libc::getifaddrs(&mut ifaddrs) != 0 { + Err(Error::last_os_error())?; + } + } + + let guard = IfAddrs(ifaddrs); + let interface_name = self.name()?; + let mut cursor = guard.0; + let mut result = Vec::new(); + + while let Some(ifa) = unsafe { cursor.as_ref() } { + if !ifa.ifa_addr.is_null() + && unsafe { (*ifa.ifa_addr).sa_family as i32 } == AF_INET6 + && unsafe { CStr::from_ptr(ifa.ifa_name) }.to_string_lossy() == interface_name + { + let sockaddr = unsafe { *(ifa.ifa_addr as *const libc::sockaddr_in6) }; + result.push(Ipv6Addr::from(in6_addr_octets(sockaddr.sin6_addr))); + } + + cursor = ifa.ifa_next; + } + + result + } + #[throws] fn perform(&self, perform: impl FnOnce(RawFd) -> Result) -> R { let span = tracing::info_span!("perform", fd = self.as_raw_fd()); @@ -250,3 +291,8 @@ impl TunInterface { .map_err(|_| Error::new(ErrorKind::Other, "Conversion error"))? } } + +#[inline] +fn in6_addr_octets(addr: libc::in6_addr) -> [u8; 16] { + unsafe { addr.__u6_addr.__u6_addr8 } +} diff --git a/tun/src/unix/linux/mod.rs b/tun/src/unix/linux/mod.rs index 60d6341..829c875 100644 --- a/tun/src/unix/linux/mod.rs +++ b/tun/src/unix/linux/mod.rs @@ -1,6 +1,7 @@ use std::{ + ffi::CStr, fs::OpenOptions, - io::{Error, Write}, + io::Error, mem, net::{Ipv4Addr, Ipv6Addr, SocketAddrV4}, os::{ @@ -10,7 +11,7 @@ use std::{ }; use fehler::throws; -use libc::in6_ifreq; +use libc::{in6_ifreq, AF_INET6}; use socket2::{Domain, SockAddr, Socket, Type}; use tracing::{info, instrument}; @@ -147,6 +148,46 @@ impl TunInterface { info!("ipv6_addr_set: {:?} (fd: {:?})", addr, self.as_raw_fd()) } + #[throws] + #[instrument] + pub fn ipv6_addrs(&self) -> Vec { + struct IfAddrs(*mut libc::ifaddrs); + + impl Drop for IfAddrs { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { libc::freeifaddrs(self.0) }; + } + } + } + + let mut ifaddrs = std::ptr::null_mut(); + unsafe { + if libc::getifaddrs(&mut ifaddrs) != 0 { + Err(Error::last_os_error())?; + } + } + + let guard = IfAddrs(ifaddrs); + let interface_name = self.name()?; + let mut cursor = guard.0; + let mut result = Vec::new(); + + while let Some(ifa) = unsafe { cursor.as_ref() } { + if !ifa.ifa_addr.is_null() + && unsafe { (*ifa.ifa_addr).sa_family as i32 } == AF_INET6 + && unsafe { CStr::from_ptr(ifa.ifa_name) }.to_string_lossy() == interface_name + { + let sockaddr = unsafe { *(ifa.ifa_addr as *const libc::sockaddr_in6) }; + result.push(Ipv6Addr::from(sockaddr.sin6_addr.s6_addr)); + } + + cursor = ifa.ifa_next; + } + + result + } + #[throws] #[instrument] pub fn set_mtu(&self, mtu: i32) { diff --git a/tun/src/unix/linux/sys.rs b/tun/src/unix/linux/sys.rs index e12c8ec..25839dc 100644 --- a/tun/src/unix/linux/sys.rs +++ b/tun/src/unix/linux/sys.rs @@ -1,6 +1,6 @@ use std::mem::size_of; -pub use libc::{ifreq, sockaddr, sockaddr_in, sockaddr_in6}; +pub use libc::{ifreq, sockaddr_in}; use nix::{ioctl_read_bad, ioctl_write_ptr_bad, request_code_read, request_code_write}; ioctl_write_ptr_bad!( From 3fb0269d7c6ef6882396e4e8f7dc090d82ceec00 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Wed, 29 Oct 2025 19:08:32 -0700 Subject: [PATCH 026/102] Add IPv6 prefix handling to unix tun interface --- tun/src/options.rs | 2 + tun/src/unix/address.rs | 120 ++++++++++++++++++++++++++++++++++++++ tun/src/unix/apple/mod.rs | 69 ++++++++++++++++------ tun/src/unix/apple/sys.rs | 34 ++++++----- tun/src/unix/linux/mod.rs | 32 +++++++++- tun/src/unix/linux/sys.rs | 3 +- tun/src/unix/mod.rs | 1 + tun/tests/configure.rs | 2 +- tun/tests/packets.rs | 6 +- tun/tests/tokio.rs | 1 + 10 files changed, 229 insertions(+), 41 deletions(-) create mode 100644 tun/src/unix/address.rs diff --git a/tun/src/options.rs b/tun/src/options.rs index e21bf5f..bb364e5 100644 --- a/tun/src/options.rs +++ b/tun/src/options.rs @@ -1,5 +1,7 @@ +#[cfg(all(any(target_os = "linux", target_vendor = "apple"), feature = "tokio"))] use std::io::Error; +#[cfg(all(any(target_os = "linux", target_vendor = "apple"), feature = "tokio"))] use fehler::throws; #[cfg(any(target_os = "linux", target_vendor = "apple"))] diff --git a/tun/src/unix/address.rs b/tun/src/unix/address.rs new file mode 100644 index 0000000..dc84e96 --- /dev/null +++ b/tun/src/unix/address.rs @@ -0,0 +1,120 @@ +use std::io::{Error, ErrorKind}; +use std::net::IpAddr; + +use fehler::throws; + +#[throws] +pub(crate) fn ensure_valid_ipv6_prefix(prefix_len: u8) { + if prefix_len > 128 { + Err(Error::new( + ErrorKind::InvalidInput, + "IPv6 prefix length must be between 0 and 128", + ))?; + } +} + +#[cfg_attr(not(any(test, target_vendor = "apple")), allow(dead_code))] +#[throws] +pub(crate) fn ipv6_prefix_octets(prefix_len: u8) -> [u8; 16] { + ensure_valid_ipv6_prefix(prefix_len)?; + + let mut octets = [0u8; 16]; + for bit in 0..prefix_len { + let idx = (bit / 8) as usize; + let offset = (bit % 8) as u8; + octets[idx] |= 0x80 >> offset; + } + + octets +} + +#[cfg_attr(not(any(test, target_vendor = "apple")), allow(dead_code))] +pub(crate) fn parse_addr_spec(spec: &str) -> Result)>, Error> { + let (addr_str, prefix) = match spec.split_once('/') { + Some((addr, prefix)) => (addr, Some(prefix)), + None => (spec, None), + }; + + let addr: IpAddr = match addr_str.parse() { + Ok(addr) => addr, + Err(_) => return Ok(None), + }; + + let prefix_len = if let Some(prefix) = prefix { + let parsed = prefix + .parse::() + .map_err(|_| Error::new(ErrorKind::InvalidInput, "Invalid prefix length"))?; + ensure_valid_ipv6_prefix(parsed)?; + Some(parsed) + } else { + None + }; + + Ok(Some((addr, prefix_len))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + #[test] + fn parse_ipv4_without_prefix() { + let parsed = parse_addr_spec("192.0.2.1").expect("parse succeeds"); + assert_eq!( + parsed, + Some((IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)), None)) + ); + } + + #[test] + fn parse_ipv6_with_prefix() { + let parsed = parse_addr_spec("2001:db8::1/64").expect("parse succeeds"); + assert_eq!( + parsed, + Some(( + IpAddr::V6("2001:db8::1".parse::().unwrap()), + Some(64), + )) + ); + } + + #[test] + fn parse_invalid_addr_returns_none() { + assert_eq!(parse_addr_spec("not-an-ip").unwrap(), None); + } + + #[test] + fn parse_invalid_prefix_string_errors() { + assert!(parse_addr_spec("::1/not-a-number").is_err()); + } + + #[test] + fn parse_prefix_out_of_range_errors() { + assert!(parse_addr_spec("::1/129").is_err()); + } + + #[test] + fn ensure_valid_ipv6_prefix_accepts_bounds() { + ensure_valid_ipv6_prefix(0).expect("zero prefix is allowed"); + ensure_valid_ipv6_prefix(128).expect("max prefix is allowed"); + } + + #[test] + fn ensure_valid_ipv6_prefix_rejects_invalid() { + assert!(ensure_valid_ipv6_prefix(129).is_err()); + } + + #[test] + fn ipv6_prefix_octets_zero_prefix() { + assert_eq!(ipv6_prefix_octets(0).unwrap(), [0u8; 16]); + } + + #[test] + fn ipv6_prefix_octets_sets_bits_correctly() { + let mask = ipv6_prefix_octets(65).unwrap(); + assert_eq!(mask[0..8], [0xFF; 8]); + assert_eq!(mask[8], 0x80); + assert_eq!(mask[9..], [0u8; 7]); + } +} diff --git a/tun/src/unix/apple/mod.rs b/tun/src/unix/apple/mod.rs index 0d60aa7..0fc701e 100644 --- a/tun/src/unix/apple/mod.rs +++ b/tun/src/unix/apple/mod.rs @@ -1,8 +1,8 @@ use std::{ ffi::CStr, - io::{Error, IoSlice}, + io::{Error, ErrorKind, IoSlice}, mem, - net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddrV4}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6}, os::fd::{AsRawFd, FromRawFd, RawFd}, }; @@ -17,6 +17,7 @@ pub mod sys; use kern_control::SysControlSocket; +use super::address::{ensure_valid_ipv6_prefix, ipv6_prefix_octets, parse_addr_spec}; use super::{ifname_to_string, string_to_ifname}; use crate::TunOptions; @@ -72,11 +73,11 @@ impl TunInterface { #[throws] fn configure(&self, options: TunOptions) { - for addr in options.address { - if let Ok(addr) = addr.parse::() { + for spec in options.address { + if let Some((addr, prefix_len)) = parse_addr_spec(&spec)? { match addr { IpAddr::V4(addr) => self.set_ipv4_addr(addr)?, - IpAddr::V6(addr) => self.set_ipv6_addr(addr)?, + IpAddr::V6(addr) => self.add_ipv6_addr(addr, prefix_len.unwrap_or(128))?, } } } @@ -149,18 +150,38 @@ impl TunInterface { } #[throws] - pub fn set_ipv6_addr(&self, _addr: Ipv6Addr) { - // let addr = SockAddr::from(SocketAddrV6::new(addr, 0, 0, 0)); - // println!("addr: {:?}", addr); - // let mut iff = self.in6_ifreq()?; - // let sto = addr.as_storage(); - // let ifadddr_ptr: *const sockaddr_in6 = addr_of!(sto).cast(); - // iff.ifr_ifru.ifru_addr = unsafe { *ifadddr_ptr }; - // println!("ifru addr set"); - // println!("{:?}", sys::SIOCSIFADDR_IN6); - // self.perform6(|fd| unsafe { sys::if_set_addr6(fd, &iff) })?; - // tracing::info!("ipv6_addr_set"); - tracing::warn!("Setting IPV6 address on MacOS CLI mode is not supported yet."); + #[instrument] + pub fn add_ipv6_addr(&self, addr: Ipv6Addr, prefix_len: u8) { + ensure_valid_ipv6_prefix(prefix_len)?; + + let mut req: sys::in6_aliasreq = unsafe { mem::zeroed() }; + req.ifra_name = string_to_ifname(&self.name()?); + req.ifra_addr = ipv6_to_sockaddr(addr); + req.ifra_prefixmask = ipv6_prefix_mask(prefix_len)?; + self.perform6(|fd| unsafe { sys::if_add_addr6(fd, &req) })?; + tracing::info!( + "ipv6_addr_added: {:?}/{} (fd: {:?})", + addr, + prefix_len, + self.as_raw_fd() + ); + } + + #[throws] + #[instrument] + pub fn remove_ipv6_addr(&self, addr: Ipv6Addr, prefix_len: u8) { + ensure_valid_ipv6_prefix(prefix_len)?; + + let mut iff = self.in6_ifreq()?; + iff.ifr_ifru.ifru_addr = ipv6_to_sockaddr(addr); + iff.ifr_ifru.ifru_prefixmask = ipv6_prefix_mask(prefix_len)?; + self.perform6(|fd| unsafe { sys::if_del_addr6(fd, &iff) })?; + tracing::info!( + "ipv6_addr_removed: {:?}/{} (fd: {:?})", + addr, + prefix_len, + self.as_raw_fd() + ); } #[throws] @@ -269,7 +290,6 @@ impl TunInterface { #[throws] #[instrument] pub fn send(&self, buf: &[u8]) -> usize { - use std::io::ErrorKind; let proto = match buf[0] >> 4 { 6 => Ok(AF_INET6), 4 => Ok(AF_INET), @@ -294,5 +314,16 @@ impl TunInterface { #[inline] fn in6_addr_octets(addr: libc::in6_addr) -> [u8; 16] { - unsafe { addr.__u6_addr.__u6_addr8 } + addr.s6_addr +} + +fn ipv6_to_sockaddr(addr: Ipv6Addr) -> libc::sockaddr_in6 { + let sockaddr = SockAddr::from(SocketAddrV6::new(addr, 0, 0, 0)); + unsafe { *(sockaddr.as_ptr() as *const libc::sockaddr_in6) } +} + +#[throws] +fn ipv6_prefix_mask(prefix_len: u8) -> libc::sockaddr_in6 { + let octets = ipv6_prefix_octets(prefix_len)?; + ipv6_to_sockaddr(Ipv6Addr::from(octets)) } diff --git a/tun/src/unix/apple/sys.rs b/tun/src/unix/apple/sys.rs index d48d6ee..282ee34 100644 --- a/tun/src/unix/apple/sys.rs +++ b/tun/src/unix/apple/sys.rs @@ -2,20 +2,11 @@ use std::mem; use libc::{c_char, c_int, c_short, c_uint, c_ulong, sockaddr, sockaddr_in6, time_t}; pub use libc::{ - c_void, - sockaddr_ctl, - sockaddr_in, - socklen_t, - AF_SYSTEM, - AF_SYS_CONTROL, - IFNAMSIZ, + c_void, sockaddr_ctl, sockaddr_in, socklen_t, AF_SYSTEM, AF_SYS_CONTROL, IFNAMSIZ, SYSPROTO_CONTROL, }; use nix::{ - ioctl_read_bad, - ioctl_readwrite, - ioctl_write_ptr_bad, - request_code_readwrite, + ioctl_read_bad, ioctl_readwrite, ioctl_write_ptr_bad, request_code_readwrite, request_code_write, }; @@ -77,7 +68,7 @@ pub struct ifreq { #[repr(C)] #[derive(Copy, Clone, Debug)] -pub struct in6_addrlifetime{ +pub struct in6_addrlifetime { pub ia6t_expire: time_t, pub ia6t_preferred: time_t, pub ia6t_vltime: u32, @@ -157,6 +148,7 @@ pub struct icmp6_ifstat { pub union ifr_ifru6 { pub ifru_addr: sockaddr_in6, pub ifru_dstaddr: sockaddr_in6, + pub ifru_prefixmask: sockaddr_in6, pub ifru_flags: c_int, pub ifru_flags6: c_int, pub ifru_metric: c_int, @@ -165,7 +157,7 @@ pub union ifr_ifru6 { pub ifru_lifetime: in6_addrlifetime, // ifru_lifetime pub ifru_stat: in6_ifstat, pub ifru_icmp6stat: icmp6_ifstat, - pub ifru_scope_id: [u32; SCOPE6_ID_MAX] + pub ifru_scope_id: [u32; SCOPE6_ID_MAX], } #[repr(C)] @@ -174,8 +166,21 @@ pub struct in6_ifreq { pub ifr_ifru: ifr_ifru6, } +#[repr(C)] +#[derive(Copy, Clone, Debug)] +pub struct in6_aliasreq { + pub ifra_name: [c_char; IFNAMSIZ], + pub ifra_addr: sockaddr_in6, + pub ifra_dstaddr: sockaddr_in6, + pub ifra_prefixmask: sockaddr_in6, + pub ifra_lifetime: in6_addrlifetime, + pub ifra_flags: c_int, +} + pub const SIOCSIFADDR: c_ulong = request_code_write!(b'i', 12, mem::size_of::()); pub const SIOCSIFADDR_IN6: c_ulong = request_code_write!(b'i', 12, mem::size_of::()); +pub const SIOCAIFADDR_IN6: c_ulong = request_code_write!(b'i', 30, mem::size_of::()); +pub const SIOCDIFADDR_IN6: c_ulong = request_code_write!(b'i', 25, mem::size_of::()); pub const SIOCGIFMTU: c_ulong = request_code_readwrite!(b'i', 51, mem::size_of::()); pub const SIOCSIFMTU: c_ulong = request_code_write!(b'i', 52, mem::size_of::()); pub const SIOCGIFNETMASK: c_ulong = request_code_readwrite!(b'i', 37, mem::size_of::()); @@ -198,6 +203,7 @@ ioctl_read_bad!(if_get_addr, libc::SIOCGIFADDR, ifreq); ioctl_read_bad!(if_get_mtu, SIOCGIFMTU, ifreq); ioctl_read_bad!(if_get_netmask, SIOCGIFNETMASK, ifreq); ioctl_write_ptr_bad!(if_set_addr, SIOCSIFADDR, ifreq); -ioctl_write_ptr_bad!(if_set_addr6, SIOCSIFADDR_IN6, in6_ifreq); +ioctl_write_ptr_bad!(if_add_addr6, SIOCAIFADDR_IN6, in6_aliasreq); +ioctl_write_ptr_bad!(if_del_addr6, SIOCDIFADDR_IN6, in6_ifreq); ioctl_write_ptr_bad!(if_set_mtu, SIOCSIFMTU, ifreq); ioctl_write_ptr_bad!(if_set_netmask, SIOCSIFNETMASK, ifreq); diff --git a/tun/src/unix/linux/mod.rs b/tun/src/unix/linux/mod.rs index 829c875..03b6f09 100644 --- a/tun/src/unix/linux/mod.rs +++ b/tun/src/unix/linux/mod.rs @@ -15,6 +15,7 @@ use libc::{in6_ifreq, AF_INET6}; use socket2::{Domain, SockAddr, Socket, Type}; use tracing::{info, instrument}; +use super::address::ensure_valid_ipv6_prefix; use super::{ifname_to_string, string_to_ifname}; use crate::TunOptions; @@ -141,11 +142,36 @@ impl TunInterface { #[throws] #[instrument] - pub fn set_ipv6_addr(&self, addr: Ipv6Addr) { + pub fn add_ipv6_addr(&self, addr: Ipv6Addr, prefix_len: u8) { + ensure_valid_ipv6_prefix(prefix_len)?; + let mut iff = self.in6_ifreq()?; iff.ifr6_addr.s6_addr = addr.octets(); - self.perform6(|fd| unsafe { sys::if_set_addr6(fd, &iff) })?; - info!("ipv6_addr_set: {:?} (fd: {:?})", addr, self.as_raw_fd()) + iff.ifr6_prefixlen = prefix_len.into(); + self.perform6(|fd| unsafe { sys::if_add_addr6(fd, &iff) })?; + info!( + "ipv6_addr_added: {:?}/{} (fd: {:?})", + addr, + prefix_len, + self.as_raw_fd() + ) + } + + #[throws] + #[instrument] + pub fn remove_ipv6_addr(&self, addr: Ipv6Addr, prefix_len: u8) { + ensure_valid_ipv6_prefix(prefix_len)?; + + let mut iff = self.in6_ifreq()?; + iff.ifr6_addr.s6_addr = addr.octets(); + iff.ifr6_prefixlen = prefix_len.into(); + self.perform6(|fd| unsafe { sys::if_del_addr6(fd, &iff) })?; + info!( + "ipv6_addr_removed: {:?}/{} (fd: {:?})", + addr, + prefix_len, + self.as_raw_fd() + ) } #[throws] diff --git a/tun/src/unix/linux/sys.rs b/tun/src/unix/linux/sys.rs index 25839dc..cba5554 100644 --- a/tun/src/unix/linux/sys.rs +++ b/tun/src/unix/linux/sys.rs @@ -20,7 +20,8 @@ ioctl_read_bad!(if_get_mtu, libc::SIOCGIFMTU, libc::ifreq); ioctl_read_bad!(if_get_netmask, libc::SIOCGIFNETMASK, libc::ifreq); ioctl_write_ptr_bad!(if_set_addr, libc::SIOCSIFADDR, libc::ifreq); -ioctl_write_ptr_bad!(if_set_addr6, libc::SIOCSIFADDR, libc::in6_ifreq); +ioctl_write_ptr_bad!(if_add_addr6, libc::SIOCSIFADDR, libc::in6_ifreq); +ioctl_write_ptr_bad!(if_del_addr6, libc::SIOCDIFADDR, libc::in6_ifreq); ioctl_write_ptr_bad!(if_set_brdaddr, libc::SIOCSIFBRDADDR, libc::ifreq); ioctl_write_ptr_bad!(if_set_mtu, libc::SIOCSIFMTU, libc::ifreq); ioctl_write_ptr_bad!(if_set_netmask, libc::SIOCSIFNETMASK, libc::ifreq); diff --git a/tun/src/unix/mod.rs b/tun/src/unix/mod.rs index ae0b77a..f1d7da1 100644 --- a/tun/src/unix/mod.rs +++ b/tun/src/unix/mod.rs @@ -6,6 +6,7 @@ use std::{ use tracing::instrument; +mod address; mod queue; #[cfg(target_vendor = "apple")] diff --git a/tun/tests/configure.rs b/tun/tests/configure.rs index e7e2c6d..7c05959 100644 --- a/tun/tests/configure.rs +++ b/tun/tests/configure.rs @@ -46,7 +46,7 @@ fn test_set_get_ipv6() { let tun = TunInterface::new()?; let addr = Ipv6Addr::new(1, 1, 1, 1, 1, 1, 1, 1); - tun.set_ipv6_addr(addr)?; + tun.add_ipv6_addr(addr, 128)?; // let result = tun.ipv6_addr()?; // assert_eq!(addr, result); diff --git a/tun/tests/packets.rs b/tun/tests/packets.rs index 80c078b..b9607b3 100644 --- a/tun/tests/packets.rs +++ b/tun/tests/packets.rs @@ -1,5 +1,5 @@ -use std::{io::Error, net::Ipv4Addr}; use std::net::Ipv6Addr; +use std::{io::Error, net::Ipv4Addr}; use fehler::throws; use tun::TunInterface; @@ -44,5 +44,5 @@ fn set_ipv6() { println!("tun name: {:?}", tun.name()?); let targ_addr: Ipv6Addr = "::1".parse().unwrap(); println!("v6 addr: {:?}", targ_addr); - tun.set_ipv6_addr(targ_addr)?; -} \ No newline at end of file + tun.add_ipv6_addr(targ_addr, 128)?; +} diff --git a/tun/tests/tokio.rs b/tun/tests/tokio.rs index f7cb273..097387c 100644 --- a/tun/tests/tokio.rs +++ b/tun/tests/tokio.rs @@ -1,3 +1,4 @@ +#[cfg(all(feature = "tokio", not(target_os = "windows")))] use std::net::Ipv4Addr; #[tokio::test] From 450e9c6fcdd90cea0c9690ba9d2027d1e2640271 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Mon, 30 Mar 2026 19:01:58 -0700 Subject: [PATCH 027/102] Drive daemon tunnels from stored networks --- burrow/src/daemon/instance.rs | 240 ++++++++++++++++++++-------------- burrow/src/daemon/mod.rs | 227 +++++++++++++++++++++++++++++--- burrow/src/daemon/runtime.rs | 180 +++++++++++++++++++++++++ burrow/src/database.rs | 218 ++++++++++++++++++++++++++---- 4 files changed, 726 insertions(+), 139 deletions(-) create mode 100644 burrow/src/daemon/runtime.rs diff --git a/burrow/src/daemon/instance.rs b/burrow/src/daemon/instance.rs index ce96fa5..fdcd95f 100644 --- a/burrow/src/daemon/instance.rs +++ b/burrow/src/daemon/instance.rs @@ -1,48 +1,27 @@ use std::{ - ops::Deref, path::{Path, PathBuf}, sync::Arc, - time::Duration, }; use anyhow::Result; use rusqlite::Connection; -use tokio::sync::{mpsc, watch, Notify, RwLock}; +use tokio::sync::{mpsc, watch, RwLock}; use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status as RspStatus}; -use tracing::{debug, info, warn}; -use tun::{tokio::TunInterface, TunOptions}; +use tracing::warn; +use tun::tokio::TunInterface; -use super::rpc::grpc_defs::{ - networks_server::Networks, - tunnel_server::Tunnel, - Empty, - Network, - NetworkDeleteRequest, - NetworkListResponse, - NetworkReorderRequest, - State as RPCTunnelState, - TunnelConfigurationResponse, - TunnelStatusResponse, +use super::{ + rpc::grpc_defs::{ + networks_server::Networks, tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest, + NetworkListResponse, NetworkReorderRequest, State as RPCTunnelState, + TunnelConfigurationResponse, TunnelStatusResponse, + }, + runtime::{ActiveTunnel, ResolvedTunnel}, }; use crate::{ - daemon::rpc::{ - DaemonCommand, - DaemonNotification, - DaemonResponse, - DaemonResponseData, - ServerConfig, - ServerInfo, - }, - database::{ - add_network, - delete_network, - get_connection, - list_networks, - load_interface, - reorder_network, - }, - wireguard::{Config, Interface}, + daemon::rpc::ServerConfig, + database::{add_network, delete_network, get_connection, list_networks, reorder_network}, }; #[derive(Debug, Clone)] @@ -52,10 +31,10 @@ enum RunState { } impl RunState { - pub fn to_rpc(&self) -> RPCTunnelState { + fn to_rpc(&self) -> RPCTunnelState { match self { - RunState::Running => RPCTunnelState::Running, - RunState::Idle => RPCTunnelState::Stopped, + Self::Running => RPCTunnelState::Running, + Self::Idle => RPCTunnelState::Stopped, } } } @@ -63,30 +42,24 @@ impl RunState { #[derive(Clone)] pub struct DaemonRPCServer { tun_interface: Arc>>, - wg_interface: Arc>, - config: Arc>, db_path: Option, wg_state_chan: (watch::Sender, watch::Receiver), network_update_chan: (watch::Sender<()>, watch::Receiver<()>), + active_tunnel: Arc>>, } impl DaemonRPCServer { - pub fn new( - wg_interface: Arc>, - config: Arc>, - db_path: Option<&Path>, - ) -> Result { + pub fn new(db_path: Option<&Path>) -> Result { Ok(Self { tun_interface: Arc::new(RwLock::new(None)), - wg_interface, - config, - db_path: db_path.map(|p| p.to_owned()), + db_path: db_path.map(Path::to_owned), wg_state_chan: watch::channel(RunState::Idle), network_update_chan: watch::channel(()), + active_tunnel: Arc::new(RwLock::new(None)), }) } - pub fn get_connection(&self) -> Result { + fn get_connection(&self) -> Result { get_connection(self.db_path.as_deref()).map_err(proc_err) } @@ -94,13 +67,71 @@ impl DaemonRPCServer { self.wg_state_chan.0.send(state).map_err(proc_err) } - async fn get_wg_state(&self) -> RunState { - self.wg_state_chan.1.borrow().to_owned() - } - async fn notify_network_update(&self) -> Result<(), RspStatus> { self.network_update_chan.0.send(()).map_err(proc_err) } + + async fn resolve_tunnel(&self) -> Result, RspStatus> { + let conn = self.get_connection()?; + let networks = list_networks(&conn).map_err(proc_err)?; + ResolvedTunnel::from_networks(&networks).map_err(proc_err) + } + + async fn current_tunnel_configuration(&self) -> Result { + match self.resolve_tunnel().await? { + Some(config) => { + let config = config.server_config().map_err(proc_err)?; + Ok(configuration_rsp(config)) + } + None => Ok(empty_configuration_rsp()), + } + } + + async fn stop_active_tunnel(&self) -> Result { + let current = { self.active_tunnel.write().await.take() }; + let Some(current) = current else { + return Ok(false); + }; + + current + .shutdown(&self.tun_interface) + .await + .map_err(proc_err)?; + self.set_wg_state(RunState::Idle).await?; + Ok(true) + } + + async fn replace_active_tunnel(&self, desired: ResolvedTunnel) -> Result<(), RspStatus> { + let _ = self.stop_active_tunnel().await?; + let active = desired + .start(self.tun_interface.clone()) + .await + .map_err(proc_err)?; + self.active_tunnel.write().await.replace(active); + self.set_wg_state(RunState::Running).await?; + Ok(()) + } + + async fn reconcile_runtime(&self) -> Result<(), RspStatus> { + let desired = self.resolve_tunnel().await?; + let Some(desired) = desired else { + let _ = self.stop_active_tunnel().await?; + return Ok(()); + }; + let needs_restart = { + let guard = self.active_tunnel.read().await; + guard + .as_ref() + .map(|active| active.identity() != desired.identity()) + .unwrap_or(false) + }; + + if needs_restart { + self.replace_active_tunnel(desired).await?; + } + + Ok(()) + } } #[tonic::async_trait] @@ -113,55 +144,49 @@ impl Tunnel for DaemonRPCServer { _request: Request, ) -> Result, RspStatus> { let (tx, rx) = mpsc::channel(10); + let server = self.clone(); + let mut sub = self.network_update_chan.1.clone(); + tokio::spawn(async move { - let serv_config = ServerConfig::default(); - tx.send(Ok(TunnelConfigurationResponse { - mtu: serv_config.mtu.unwrap_or(1000), - addresses: serv_config.address, - })) - .await + loop { + let response = server.current_tunnel_configuration().await; + if tx.send(response).await.is_err() { + break; + } + if sub.changed().await.is_err() { + break; + } + } }); + Ok(Response::new(ReceiverStream::new(rx))) } async fn tunnel_start(&self, _request: Request) -> Result, RspStatus> { - let wg_state = self.get_wg_state().await; - match wg_state { - RunState::Idle => { - let tun_if = TunOptions::new().open()?; - debug!("Setting tun on wg_interface"); - self.tun_interface.write().await.replace(tun_if); - self.wg_interface - .write() - .await - .set_tun_ref(self.tun_interface.clone()) - .await; - debug!("tun set on wg_interface"); + let desired = self + .resolve_tunnel() + .await? + .ok_or_else(|| RspStatus::failed_precondition("no stored network configured"))?; + let already_running = { + let guard = self.active_tunnel.read().await; + guard + .as_ref() + .map(|active| active.identity() == desired.identity()) + .unwrap_or(false) + }; - debug!("Setting tun_interface"); - debug!("tun_interface set: {:?}", self.tun_interface); - - debug!("Cloning wg_interface"); - let tmp_wg = self.wg_interface.clone(); - let run_task = tokio::spawn(async move { - let twlock = tmp_wg.read().await; - twlock.run().await - }); - self.set_wg_state(RunState::Running).await?; - } - - RunState::Running => { - warn!("Got start, but tun interface already up."); - } + if already_running { + warn!("Got start, but active tunnel already matches desired network."); + return Ok(Response::new(Empty {})); } - return Ok(Response::new(Empty {})); + self.replace_active_tunnel(desired).await?; + Ok(Response::new(Empty {})) } async fn tunnel_stop(&self, _request: Request) -> Result, RspStatus> { - self.wg_interface.write().await.remove_tun().await; - self.set_wg_state(RunState::Idle).await?; - return Ok(Response::new(Empty {})); + let _ = self.stop_active_tunnel().await?; + Ok(Response::new(Empty {})) } async fn tunnel_status( @@ -172,13 +197,16 @@ impl Tunnel for DaemonRPCServer { let mut state_rx = self.wg_state_chan.1.clone(); tokio::spawn(async move { let cur = state_rx.borrow_and_update().to_owned(); - tx.send(Ok(status_rsp(cur))).await; + if tx.send(Ok(status_rsp(cur))).await.is_err() { + return; + } + loop { - state_rx.changed().await.unwrap(); + if state_rx.changed().await.is_err() { + break; + } let cur = state_rx.borrow().to_owned(); - let res = tx.send(Ok(status_rsp(cur))).await; - if res.is_err() { - eprintln!("Tunnel status channel closed"); + if tx.send(Ok(status_rsp(cur))).await.is_err() { break; } } @@ -196,6 +224,7 @@ impl Networks for DaemonRPCServer { let network = request.into_inner(); add_network(&conn, &network).map_err(proc_err)?; self.notify_network_update().await?; + self.reconcile_runtime().await?; Ok(Response::new(Empty {})) } @@ -203,7 +232,6 @@ impl Networks for DaemonRPCServer { &self, _request: Request, ) -> Result, RspStatus> { - debug!("Mock network_list called"); let (tx, rx) = mpsc::channel(10); let conn = self.get_connection()?; let mut sub = self.network_update_chan.1.clone(); @@ -212,12 +240,12 @@ impl Networks for DaemonRPCServer { let networks = list_networks(&conn) .map(|res| NetworkListResponse { network: res }) .map_err(proc_err); - let res = tx.send(networks).await; - if res.is_err() { - eprintln!("Network list channel closed"); + if tx.send(networks).await.is_err() { + break; + } + if sub.changed().await.is_err() { break; } - sub.changed().await.unwrap(); } }); Ok(Response::new(ReceiverStream::new(rx))) @@ -230,6 +258,7 @@ impl Networks for DaemonRPCServer { let conn = self.get_connection()?; reorder_network(&conn, request.into_inner()).map_err(proc_err)?; self.notify_network_update().await?; + self.reconcile_runtime().await?; Ok(Response::new(Empty {})) } @@ -240,6 +269,7 @@ impl Networks for DaemonRPCServer { let conn = self.get_connection()?; delete_network(&conn, request.into_inner()).map_err(proc_err)?; self.notify_network_update().await?; + self.reconcile_runtime().await?; Ok(Response::new(Empty {})) } } @@ -248,6 +278,20 @@ fn proc_err(err: impl ToString) -> RspStatus { RspStatus::internal(err.to_string()) } +fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse { + TunnelConfigurationResponse { + mtu: config.mtu.unwrap_or(1000), + addresses: config.address, + } +} + +fn empty_configuration_rsp() -> TunnelConfigurationResponse { + TunnelConfigurationResponse { + mtu: 1500, + addresses: Vec::new(), + } +} + fn status_rsp(state: RunState) -> TunnelStatusResponse { TunnelStatusResponse { state: state.to_rpc().into(), diff --git a/burrow/src/daemon/mod.rs b/burrow/src/daemon/mod.rs index f6b973f..f5ad7d3 100644 --- a/burrow/src/daemon/mod.rs +++ b/burrow/src/daemon/mod.rs @@ -4,23 +4,20 @@ pub mod apple; mod instance; mod net; pub mod rpc; +mod runtime; use anyhow::{Error as AhError, Result}; use instance::DaemonRPCServer; pub use net::{get_socket_path, DaemonClient}; pub use rpc::{DaemonCommand, DaemonResponseData, DaemonStartOptions}; -use tokio::{ - net::UnixListener, - sync::{Notify, RwLock}, -}; +use tokio::{net::UnixListener, sync::Notify}; use tokio_stream::wrappers::UnixListenerStream; use tonic::transport::Server; -use tracing::{error, info}; +use tracing::info; use crate::{ daemon::rpc::grpc_defs::{networks_server::NetworksServer, tunnel_server::TunnelServer}, - database::{get_connection, load_interface}, - wireguard::Interface, + database::get_connection, }; pub async fn daemon_main( @@ -28,16 +25,8 @@ pub async fn daemon_main( db_path: Option<&Path>, notify_ready: Option>, ) -> Result<()> { - if let Some(n) = notify_ready { - n.notify_one() - } - let conn = get_connection(db_path)?; - let config = load_interface(&conn, "1")?; - let burrow_server = DaemonRPCServer::new( - Arc::new(RwLock::new(config.clone().try_into()?)), - Arc::new(RwLock::new(config)), - db_path.clone(), - )?; + let _conn = get_connection(db_path)?; + let burrow_server = DaemonRPCServer::new(db_path)?; let spp = socket_path.clone(); let tmp = get_socket_path(); let sock_path = spp.unwrap_or(Path::new(tmp.as_str())); @@ -55,9 +44,213 @@ pub async fn daemon_main( Ok::<(), AhError>(()) }); + if let Some(n) = notify_ready { + n.notify_one(); + } + info!("Starting daemon..."); tokio::try_join!(serve_job) .map(|_| ()) .map_err(|e| e.into()) } + +#[cfg(test)] +mod tests { + use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use anyhow::{anyhow, Result}; + use iroh::PublicKey; + use serde_json::json; + use tokio::time::{timeout, Duration}; + + use super::*; + use crate::daemon::rpc::{ + client::BurrowClient, + grpc_defs::{ + Empty, Network, NetworkListResponse, NetworkReorderRequest, NetworkType, + TunnelConfigurationResponse, + }, + }; + + #[tokio::test] + async fn daemon_tracks_network_priority_via_grpc() -> Result<()> { + let socket_path = temp_path("sock"); + let db_path = temp_path("sqlite3"); + let ready = Arc::new(Notify::new()); + + let daemon_ready = ready.clone(); + let daemon_socket_path = socket_path.clone(); + let daemon_db_path = db_path.clone(); + let daemon_task = tokio::spawn(async move { + daemon_main( + Some(daemon_socket_path.as_path()), + Some(daemon_db_path.as_path()), + Some(daemon_ready), + ) + .await + }); + + timeout(Duration::from_secs(5), ready.notified()).await?; + + let mut client = timeout( + Duration::from_secs(5), + BurrowClient::from_uds_path(&socket_path), + ) + .await??; + let mut config_stream = client + .tunnel_client + .tunnel_configuration(Empty {}) + .await? + .into_inner(); + let mut network_stream = client + .networks_client + .network_list(Empty {}) + .await? + .into_inner(); + + let initial_config = next_configuration(&mut config_stream).await?; + assert!(initial_config.addresses.is_empty()); + assert_eq!(initial_config.mtu, 1500); + + let initial_networks = next_networks(&mut network_stream).await?; + assert!(initial_networks.network.is_empty()); + + let start_err = client + .tunnel_client + .tunnel_start(Empty {}) + .await + .expect_err("starting without a stored network should fail"); + assert_eq!(start_err.code(), tonic::Code::FailedPrecondition); + + client + .networks_client + .network_add(Network { + id: 1, + r#type: NetworkType::WireGuard.into(), + payload: sample_wireguard_payload(), + }) + .await?; + + let networks_after_wg = next_networks(&mut network_stream).await?; + assert_eq!( + network_ids(&networks_after_wg), + vec![(1, NetworkType::WireGuard)] + ); + + let wireguard_config = next_configuration(&mut config_stream).await?; + assert_eq!( + wireguard_config.addresses, + vec!["10.8.0.2/32", "fd00::2/128"] + ); + assert_eq!(wireguard_config.mtu, 1420); + + client + .networks_client + .network_add(Network { + id: 2, + r#type: NetworkType::HackClub.into(), + payload: sample_hackclub_payload(), + }) + .await?; + + let networks_after_mesh_add = next_networks(&mut network_stream).await?; + assert_eq!( + network_ids(&networks_after_mesh_add), + vec![(1, NetworkType::WireGuard), (2, NetworkType::HackClub)] + ); + + let still_wireguard = next_configuration(&mut config_stream).await?; + assert_eq!(still_wireguard.addresses, wireguard_config.addresses); + + client + .networks_client + .network_reorder(NetworkReorderRequest { id: 2, index: 0 }) + .await?; + + let networks_after_reorder = next_networks(&mut network_stream).await?; + assert_eq!( + network_ids(&networks_after_reorder), + vec![(2, NetworkType::HackClub), (1, NetworkType::WireGuard)] + ); + + let mesh_config = next_configuration(&mut config_stream).await?; + assert_eq!(mesh_config.addresses, vec!["10.77.0.2/32"]); + assert_eq!(mesh_config.mtu, 1380); + + daemon_task.abort(); + let _ = daemon_task.await; + cleanup_path(&socket_path); + cleanup_path(&db_path); + + Ok(()) + } + + fn temp_path(ext: &str) -> PathBuf { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time is after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("burrow-daemon-test-{now}.{ext}")) + } + + fn cleanup_path(path: &Path) { + let _ = std::fs::remove_file(path); + } + + fn sample_wireguard_payload() -> Vec { + br#"[Interface] +PrivateKey = OEPVdomeLTxTIBvv3TYsJRge0Hp9NMiY0sIrhT8OWG8= +Address = 10.8.0.2/32, fd00::2/128 +ListenPort = 51820 +MTU = 1420 + +[Peer] +PublicKey = 8GaFjVO6c4luCHG4ONO+1bFG8tO+Zz5/Gy+Geht1USM= +PresharedKey = ha7j4BjD49sIzyF9SNlbueK0AMHghlj6+u0G3bzC698= +AllowedIPs = 0.0.0.0/0, ::/0 +Endpoint = wg.burrow.rs:51820 +"# + .to_vec() + } + + fn sample_hackclub_payload() -> Vec { + let endpoint_id = PublicKey::from_bytes(&[0; 32]).unwrap().to_string(); + json!({ + "endpoint_id": endpoint_id, + "addresses": ["127.0.0.1:7777"], + "local_addresses": ["10.77.0.2/32"], + "mtu": 1380, + "tun_name": "burrow-test-mesh", + }) + .to_string() + .into_bytes() + } + + async fn next_configuration( + stream: &mut tonic::Streaming, + ) -> Result { + timeout(Duration::from_secs(5), stream.message()) + .await?? + .ok_or_else(|| anyhow!("configuration stream ended unexpectedly")) + } + + async fn next_networks( + stream: &mut tonic::Streaming, + ) -> Result { + timeout(Duration::from_secs(5), stream.message()) + .await?? + .ok_or_else(|| anyhow!("network stream ended unexpectedly")) + } + + fn network_ids(response: &NetworkListResponse) -> Vec<(i32, NetworkType)> { + response + .network + .iter() + .map(|network| (network.id, network.r#type())) + .collect() + } +} diff --git a/burrow/src/daemon/runtime.rs b/burrow/src/daemon/runtime.rs new file mode 100644 index 0000000..31c0b0a --- /dev/null +++ b/burrow/src/daemon/runtime.rs @@ -0,0 +1,180 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use tokio::{sync::RwLock, task::JoinHandle}; +use tun::{tokio::TunInterface, TunOptions}; + +use super::rpc::{ + grpc_defs::{Network, NetworkType}, + ServerConfig, +}; +use crate::{ + mesh::iroh::{self as mesh_iroh, HackClubNetworkConfig, MeshHandle}, + wireguard::{Config, Interface as WireGuardInterface}, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RuntimeIdentity { + Network { + id: i32, + network_type: NetworkType, + payload: Vec, + }, +} + +#[derive(Clone, Debug)] +pub enum ResolvedTunnel { + WireGuard { + identity: RuntimeIdentity, + config: Config, + }, + HackClub { + identity: RuntimeIdentity, + config: HackClubNetworkConfig, + }, +} + +impl ResolvedTunnel { + pub fn from_networks(networks: &[Network]) -> Result> { + let Some(network) = networks.first() else { + return Ok(None); + }; + + let identity = RuntimeIdentity::Network { + id: network.id, + network_type: network.r#type(), + payload: network.payload.clone(), + }; + + match network.r#type() { + NetworkType::WireGuard => { + let payload = String::from_utf8(network.payload.clone()) + .context("wireguard payload must be valid UTF-8")?; + let config = Config::from_content_fmt(&payload, "ini")?; + Ok(Some(Self::WireGuard { identity, config })) + } + NetworkType::HackClub => { + let config = HackClubNetworkConfig::from_payload(&network.payload)?; + Ok(Some(Self::HackClub { identity, config })) + } + } + } + + pub fn identity(&self) -> &RuntimeIdentity { + match self { + Self::WireGuard { identity, .. } | Self::HackClub { identity, .. } => identity, + } + } + + pub fn server_config(&self) -> Result { + match self { + Self::WireGuard { config, .. } => ServerConfig::try_from(config), + Self::HackClub { config, .. } => Ok(ServerConfig { + address: config.local_addresses.clone(), + name: config.tun_name.clone(), + mtu: config.mtu.map(i32::from), + }), + } + } + + pub async fn start( + self, + tun_interface: Arc>>, + ) -> Result { + match self { + Self::WireGuard { identity, config } => { + let tun = TunOptions::new().open()?; + tun_interface.write().await.replace(tun); + + match start_wireguard_runtime(config, tun_interface.clone()).await { + Ok((interface, task)) => { + Ok(ActiveTunnel::WireGuard { identity, interface, task }) + } + Err(err) => { + tun_interface.write().await.take(); + Err(err) + } + } + } + Self::HackClub { identity, config } => { + let mut tun_opts = TunOptions::new(); + if let Some(name) = config.tun_name.as_deref() { + tun_opts = tun_opts.name(name); + } + + let tun = tun_opts.open()?; + tun_interface.write().await.replace(tun); + + match mesh_iroh::spawn_hackclub_tunnel(config, tun_interface.clone()).await { + Ok(handle) => Ok(ActiveTunnel::HackClub { identity, handle }), + Err(err) => { + tun_interface.write().await.take(); + Err(err) + } + } + } + } + } +} + +pub enum ActiveTunnel { + WireGuard { + identity: RuntimeIdentity, + interface: Arc>, + task: JoinHandle>, + }, + HackClub { + identity: RuntimeIdentity, + handle: MeshHandle, + }, +} + +impl ActiveTunnel { + pub fn identity(&self) -> &RuntimeIdentity { + match self { + Self::WireGuard { identity, .. } | Self::HackClub { identity, .. } => identity, + } + } + + pub async fn shutdown(self, tun_interface: &Arc>>) -> Result<()> { + match self { + Self::WireGuard { interface, task, .. } => { + interface.read().await.remove_tun().await; + let task_result = task.await; + tun_interface.write().await.take(); + task_result??; + Ok(()) + } + Self::HackClub { handle, .. } => { + let result = handle.shutdown().await; + tun_interface.write().await.take(); + result + } + } + } +} + +async fn start_wireguard_runtime( + config: Config, + tun_interface: Arc>>, +) -> Result<(Arc>, JoinHandle>)> { + let mut interface: WireGuardInterface = config.try_into()?; + interface.set_tun_ref(tun_interface).await; + let interface = Arc::new(RwLock::new(interface)); + let run_interface = interface.clone(); + let task = tokio::spawn(async move { + let guard = run_interface.read().await; + guard.run().await + }); + Ok((interface, task)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_networks_resolves_to_no_tunnel() { + assert!(ResolvedTunnel::from_networks(&[]).unwrap().is_none()); + } +} diff --git a/burrow/src/database.rs b/burrow/src/database.rs index 9a9aac3..c03048c 100644 --- a/burrow/src/database.rs +++ b/burrow/src/database.rs @@ -5,11 +5,9 @@ use rusqlite::{params, Connection}; use crate::{ daemon::rpc::grpc_defs::{ - Network as RPCNetwork, - NetworkDeleteRequest, - NetworkReorderRequest, - NetworkType, + Network as RPCNetwork, NetworkDeleteRequest, NetworkReorderRequest, NetworkType, }, + mesh::iroh::HackClubNetworkConfig, wireguard::config::{Config, Interface, Peer}, }; @@ -124,35 +122,26 @@ pub fn dump_interface(conn: &Connection, config: &Config) -> Result<()> { pub fn get_connection(path: Option<&Path>) -> Result { let p = path.unwrap_or_else(|| std::path::Path::new(DB_PATH)); - if !p.exists() { - let conn = Connection::open(p)?; - initialize_tables(&conn)?; - dump_interface(&conn, &Config::default())?; - return Ok(conn); - } - Ok(Connection::open(p)?) + let conn = Connection::open(p)?; + initialize_tables(&conn)?; + Ok(conn) } pub fn add_network(conn: &Connection, network: &RPCNetwork) -> Result<()> { + validate_network_payload(network)?; let mut stmt = conn.prepare("INSERT INTO network (id, type, payload) VALUES (?, ?, ?)")?; stmt.execute(params![ network.id, network.r#type().as_str_name(), &network.payload ])?; - if network.r#type() == NetworkType::WireGuard { - let payload_str = String::from_utf8(network.payload.clone())?; - let wg_config = Config::from_content_fmt(&payload_str, "ini")?; - dump_interface(conn, &wg_config)?; - } Ok(()) } pub fn list_networks(conn: &Connection) -> Result> { - let mut stmt = conn.prepare("SELECT id, type, payload FROM network ORDER BY idx")?; + let mut stmt = conn.prepare("SELECT id, type, payload FROM network ORDER BY idx, id")?; let networks: Vec = stmt .query_map([], |row| { - println!("row: {:?}", row); let network_id: i32 = row.get(0)?; let network_type: String = row.get(1)?; let network_type = NetworkType::from_str_name(network_type.as_str()) @@ -169,12 +158,19 @@ pub fn list_networks(conn: &Connection) -> Result> { } pub fn reorder_network(conn: &Connection, req: NetworkReorderRequest) -> Result<()> { - let mut stmt = conn.prepare("UPDATE network SET idx = ? WHERE id = ?")?; - let res = stmt.execute(params![req.index, req.id])?; - if res == 0 { + let mut ordered_ids = ordered_network_ids(conn)?; + let Some(current_idx) = ordered_ids.iter().position(|id| *id == req.id) else { return Err(anyhow::anyhow!("No such network exists")); - } - Ok(()) + }; + + let target_idx = usize::try_from(req.index) + .map_err(|_| anyhow::anyhow!("Network index must be non-negative"))?; + + let moved_id = ordered_ids.remove(current_idx); + let target_idx = target_idx.min(ordered_ids.len()); + ordered_ids.insert(target_idx, moved_id); + + renumber_networks(conn, &ordered_ids) } pub fn delete_network(conn: &Connection, req: NetworkDeleteRequest) -> Result<()> { @@ -183,7 +179,8 @@ pub fn delete_network(conn: &Connection, req: NetworkDeleteRequest) -> Result<() if res == 0 { return Err(anyhow::anyhow!("No such network exists")); } - Ok(()) + let ordered_ids = ordered_network_ids(conn)?; + renumber_networks(conn, &ordered_ids) } fn parse_lst(s: &str) -> Vec { @@ -200,9 +197,83 @@ fn to_lst(v: &Vec) -> String { .join(",") } +fn validate_network_payload(network: &RPCNetwork) -> Result<()> { + match network.r#type() { + NetworkType::WireGuard => { + let payload_str = String::from_utf8(network.payload.clone())?; + Config::from_content_fmt(&payload_str, "ini")?; + } + NetworkType::HackClub => { + HackClubNetworkConfig::from_payload(&network.payload)?; + } + } + Ok(()) +} + +fn ordered_network_ids(conn: &Connection) -> Result> { + let mut stmt = conn.prepare("SELECT id FROM network ORDER BY idx, id")?; + let ids = stmt + .query_map([], |row| row.get::<_, i32>(0))? + .collect::>>()?; + Ok(ids) +} + +fn renumber_networks(conn: &Connection, ordered_ids: &[i32]) -> Result<()> { + conn.execute_batch("BEGIN IMMEDIATE")?; + let result = (|| -> Result<()> { + let mut stmt = conn.prepare("UPDATE network SET idx = ? WHERE id = ?")?; + for (idx, id) in ordered_ids.iter().enumerate() { + stmt.execute(params![idx as i32, id])?; + } + Ok(()) + })(); + + match result { + Ok(()) => { + conn.execute_batch("COMMIT")?; + Ok(()) + } + Err(err) => { + let _ = conn.execute_batch("ROLLBACK"); + Err(err) + } + } +} + #[cfg(test)] mod tests { use super::*; + use iroh::PublicKey; + use serde_json::json; + use tempfile::tempdir; + + fn sample_wireguard_payload() -> Vec { + br#"[Interface] +PrivateKey = OEPVdomeLTxTIBvv3TYsJRge0Hp9NMiY0sIrhT8OWG8= +Address = 10.13.13.2/24 +ListenPort = 51820 + +[Peer] +PublicKey = 8GaFjVO6c4luCHG4ONO+1bFG8tO+Zz5/Gy+Geht1USM= +PresharedKey = ha7j4BjD49sIzyF9SNlbueK0AMHghlj6+u0G3bzC698= +AllowedIPs = 0.0.0.0/0, 8.8.8.8/32 +Endpoint = wg.burrow.rs:51820 +"# + .to_vec() + } + + fn sample_hackclub_payload(name: &str, address: &str) -> Vec { + let endpoint_id = PublicKey::from_bytes(&[0; 32]).unwrap().to_string(); + json!({ + "endpoint_id": endpoint_id, + "addresses": ["127.0.0.1:7777"], + "local_addresses": [address], + "mtu": 1380, + "tun_name": name, + }) + .to_string() + .into_bytes() + } #[test] fn test_db() { @@ -213,4 +284,103 @@ mod tests { let loaded = load_interface(&conn, "1").unwrap(); assert_eq!(config, loaded); } + + #[test] + fn add_network_validates_payloads() { + let conn = Connection::open_in_memory().unwrap(); + initialize_tables(&conn).unwrap(); + + add_network( + &conn, + &RPCNetwork { + id: 1, + r#type: NetworkType::WireGuard.into(), + payload: sample_wireguard_payload(), + }, + ) + .unwrap(); + + add_network( + &conn, + &RPCNetwork { + id: 2, + r#type: NetworkType::HackClub.into(), + payload: sample_hackclub_payload("burrow-test-0", "10.42.0.2/32"), + }, + ) + .unwrap(); + + assert!(add_network( + &conn, + &RPCNetwork { + id: 3, + r#type: NetworkType::WireGuard.into(), + payload: b"not-a-config".to_vec(), + }, + ) + .is_err()); + + let ids: Vec = list_networks(&conn) + .unwrap() + .into_iter() + .map(|n| n.id) + .collect(); + assert_eq!(ids, vec![1, 2]); + } + + #[test] + fn reorder_and_delete_networks_keep_priority_stable() { + let conn = Connection::open_in_memory().unwrap(); + initialize_tables(&conn).unwrap(); + + for (id, name, address) in [ + (1, "burrow-test-1", "10.42.0.2/32"), + (2, "burrow-test-2", "10.42.0.3/32"), + (3, "burrow-test-3", "10.42.0.4/32"), + ] { + add_network( + &conn, + &RPCNetwork { + id, + r#type: NetworkType::HackClub.into(), + payload: sample_hackclub_payload(name, address), + }, + ) + .unwrap(); + } + + reorder_network(&conn, NetworkReorderRequest { id: 3, index: 0 }).unwrap(); + let ids: Vec = list_networks(&conn) + .unwrap() + .into_iter() + .map(|n| n.id) + .collect(); + assert_eq!(ids, vec![3, 1, 2]); + + delete_network(&conn, NetworkDeleteRequest { id: 1 }).unwrap(); + let ids: Vec = list_networks(&conn) + .unwrap() + .into_iter() + .map(|n| n.id) + .collect(); + assert_eq!(ids, vec![3, 2]); + } + + #[test] + fn get_connection_does_not_seed_a_default_interface() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("burrow.sqlite3"); + + let conn = get_connection(Some(db_path.as_path())).unwrap(); + + let interface_count: i64 = conn + .query_row("SELECT COUNT(*) FROM wg_interface", [], |row| row.get(0)) + .unwrap(); + let network_count: i64 = conn + .query_row("SELECT COUNT(*) FROM network", [], |row| row.get(0)) + .unwrap(); + + assert_eq!(interface_count, 0); + assert_eq!(network_count, 0); + } } From 7ade60646bcb17716297140f5d36218b2ccf5ba2 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Mon, 30 Mar 2026 19:30:22 -0700 Subject: [PATCH 028/102] Allow no-tunnel passthrough mode --- burrow/src/daemon/instance.rs | 31 +++++++------------------- burrow/src/daemon/mod.rs | 42 +++++++++++++++++++++++++++++------ burrow/src/daemon/runtime.rs | 41 +++++++++++++++++++++++++++------- 3 files changed, 76 insertions(+), 38 deletions(-) diff --git a/burrow/src/daemon/instance.rs b/burrow/src/daemon/instance.rs index fdcd95f..1eb0629 100644 --- a/burrow/src/daemon/instance.rs +++ b/burrow/src/daemon/instance.rs @@ -71,20 +71,19 @@ impl DaemonRPCServer { self.network_update_chan.0.send(()).map_err(proc_err) } - async fn resolve_tunnel(&self) -> Result, RspStatus> { + async fn resolve_tunnel(&self) -> Result { let conn = self.get_connection()?; let networks = list_networks(&conn).map_err(proc_err)?; ResolvedTunnel::from_networks(&networks).map_err(proc_err) } async fn current_tunnel_configuration(&self) -> Result { - match self.resolve_tunnel().await? { - Some(config) => { - let config = config.server_config().map_err(proc_err)?; - Ok(configuration_rsp(config)) - } - None => Ok(empty_configuration_rsp()), - } + let config = self + .resolve_tunnel() + .await? + .server_config() + .map_err(proc_err)?; + Ok(configuration_rsp(config)) } async fn stop_active_tunnel(&self) -> Result { @@ -114,10 +113,6 @@ impl DaemonRPCServer { async fn reconcile_runtime(&self) -> Result<(), RspStatus> { let desired = self.resolve_tunnel().await?; - let Some(desired) = desired else { - let _ = self.stop_active_tunnel().await?; - return Ok(()); - }; let needs_restart = { let guard = self.active_tunnel.read().await; guard @@ -163,10 +158,7 @@ impl Tunnel for DaemonRPCServer { } async fn tunnel_start(&self, _request: Request) -> Result, RspStatus> { - let desired = self - .resolve_tunnel() - .await? - .ok_or_else(|| RspStatus::failed_precondition("no stored network configured"))?; + let desired = self.resolve_tunnel().await?; let already_running = { let guard = self.active_tunnel.read().await; guard @@ -285,13 +277,6 @@ fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse { } } -fn empty_configuration_rsp() -> TunnelConfigurationResponse { - TunnelConfigurationResponse { - mtu: 1500, - addresses: Vec::new(), - } -} - fn status_rsp(state: RunState) -> TunnelStatusResponse { TunnelStatusResponse { state: state.to_rpc().into(), diff --git a/burrow/src/daemon/mod.rs b/burrow/src/daemon/mod.rs index f5ad7d3..8fe3d41 100644 --- a/burrow/src/daemon/mod.rs +++ b/burrow/src/daemon/mod.rs @@ -72,7 +72,7 @@ mod tests { client::BurrowClient, grpc_defs::{ Empty, Network, NetworkListResponse, NetworkReorderRequest, NetworkType, - TunnelConfigurationResponse, + TunnelConfigurationResponse, TunnelStatusResponse, }, }; @@ -111,6 +111,11 @@ mod tests { .network_list(Empty {}) .await? .into_inner(); + let mut status_stream = client + .tunnel_client + .tunnel_status(Empty {}) + .await? + .into_inner(); let initial_config = next_configuration(&mut config_stream).await?; assert!(initial_config.addresses.is_empty()); @@ -119,12 +124,27 @@ mod tests { let initial_networks = next_networks(&mut network_stream).await?; assert!(initial_networks.network.is_empty()); - let start_err = client - .tunnel_client - .tunnel_start(Empty {}) - .await - .expect_err("starting without a stored network should fail"); - assert_eq!(start_err.code(), tonic::Code::FailedPrecondition); + let initial_status = next_status(&mut status_stream).await?; + assert_eq!( + initial_status.state(), + crate::daemon::rpc::grpc_defs::State::Stopped + ); + + client.tunnel_client.tunnel_start(Empty {}).await?; + + let passthrough_status = next_status(&mut status_stream).await?; + assert_eq!( + passthrough_status.state(), + crate::daemon::rpc::grpc_defs::State::Running + ); + + client.tunnel_client.tunnel_stop(Empty {}).await?; + + let stopped_status = next_status(&mut status_stream).await?; + assert_eq!( + stopped_status.state(), + crate::daemon::rpc::grpc_defs::State::Stopped + ); client .networks_client @@ -246,6 +266,14 @@ Endpoint = wg.burrow.rs:51820 .ok_or_else(|| anyhow!("network stream ended unexpectedly")) } + async fn next_status( + stream: &mut tonic::Streaming, + ) -> Result { + timeout(Duration::from_secs(5), stream.message()) + .await?? + .ok_or_else(|| anyhow!("status stream ended unexpectedly")) + } + fn network_ids(response: &NetworkListResponse) -> Vec<(i32, NetworkType)> { response .network diff --git a/burrow/src/daemon/runtime.rs b/burrow/src/daemon/runtime.rs index 31c0b0a..7fea964 100644 --- a/burrow/src/daemon/runtime.rs +++ b/burrow/src/daemon/runtime.rs @@ -15,6 +15,7 @@ use crate::{ #[derive(Clone, Debug, PartialEq, Eq)] pub enum RuntimeIdentity { + Passthrough, Network { id: i32, network_type: NetworkType, @@ -24,6 +25,9 @@ pub enum RuntimeIdentity { #[derive(Clone, Debug)] pub enum ResolvedTunnel { + Passthrough { + identity: RuntimeIdentity, + }, WireGuard { identity: RuntimeIdentity, config: Config, @@ -35,9 +39,11 @@ pub enum ResolvedTunnel { } impl ResolvedTunnel { - pub fn from_networks(networks: &[Network]) -> Result> { + pub fn from_networks(networks: &[Network]) -> Result { let Some(network) = networks.first() else { - return Ok(None); + return Ok(Self::Passthrough { + identity: RuntimeIdentity::Passthrough, + }); }; let identity = RuntimeIdentity::Network { @@ -51,23 +57,30 @@ impl ResolvedTunnel { let payload = String::from_utf8(network.payload.clone()) .context("wireguard payload must be valid UTF-8")?; let config = Config::from_content_fmt(&payload, "ini")?; - Ok(Some(Self::WireGuard { identity, config })) + Ok(Self::WireGuard { identity, config }) } NetworkType::HackClub => { let config = HackClubNetworkConfig::from_payload(&network.payload)?; - Ok(Some(Self::HackClub { identity, config })) + Ok(Self::HackClub { identity, config }) } } } pub fn identity(&self) -> &RuntimeIdentity { match self { - Self::WireGuard { identity, .. } | Self::HackClub { identity, .. } => identity, + Self::Passthrough { identity } + | Self::WireGuard { identity, .. } + | Self::HackClub { identity, .. } => identity, } } pub fn server_config(&self) -> Result { match self { + Self::Passthrough { .. } => Ok(ServerConfig { + address: Vec::new(), + name: None, + mtu: Some(1500), + }), Self::WireGuard { config, .. } => ServerConfig::try_from(config), Self::HackClub { config, .. } => Ok(ServerConfig { address: config.local_addresses.clone(), @@ -82,6 +95,7 @@ impl ResolvedTunnel { tun_interface: Arc>>, ) -> Result { match self { + Self::Passthrough { identity } => Ok(ActiveTunnel::Passthrough { identity }), Self::WireGuard { identity, config } => { let tun = TunOptions::new().open()?; tun_interface.write().await.replace(tun); @@ -118,6 +132,9 @@ impl ResolvedTunnel { } pub enum ActiveTunnel { + Passthrough { + identity: RuntimeIdentity, + }, WireGuard { identity: RuntimeIdentity, interface: Arc>, @@ -132,12 +149,15 @@ pub enum ActiveTunnel { impl ActiveTunnel { pub fn identity(&self) -> &RuntimeIdentity { match self { - Self::WireGuard { identity, .. } | Self::HackClub { identity, .. } => identity, + Self::Passthrough { identity } + | Self::WireGuard { identity, .. } + | Self::HackClub { identity, .. } => identity, } } pub async fn shutdown(self, tun_interface: &Arc>>) -> Result<()> { match self { + Self::Passthrough { .. } => Ok(()), Self::WireGuard { interface, task, .. } => { interface.read().await.remove_tun().await; let task_result = task.await; @@ -174,7 +194,12 @@ mod tests { use super::*; #[test] - fn no_networks_resolves_to_no_tunnel() { - assert!(ResolvedTunnel::from_networks(&[]).unwrap().is_none()); + fn no_networks_resolve_to_passthrough() { + let resolved = ResolvedTunnel::from_networks(&[]).unwrap(); + assert_eq!(resolved.identity(), &RuntimeIdentity::Passthrough); + assert_eq!( + resolved.server_config().unwrap().address, + Vec::::new() + ); } } From cdf8d2205547d8cb571056232a14b03f6ed669fc Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Mon, 30 Mar 2026 20:01:55 -0700 Subject: [PATCH 029/102] Add Linux tor-exec namespace runtime --- Cargo.lock | 5521 ++++++++++++++++++++++++++++++++++++- burrow/Cargo.toml | 17 +- burrow/src/database.rs | 2 +- burrow/src/lib.rs | 15 +- burrow/src/main.rs | 31 + burrow/src/tor/config.rs | 187 ++ burrow/src/tor/dns.rs | 178 ++ burrow/src/tor/exec.rs | 439 +++ burrow/src/tor/mod.rs | 9 + burrow/src/tor/runtime.rs | 129 + burrow/src/tor/system.rs | 140 + rust-toolchain.toml | 4 + 12 files changed, 6514 insertions(+), 158 deletions(-) create mode 100644 burrow/src/tor/config.rs create mode 100644 burrow/src/tor/dns.rs create mode 100644 burrow/src/tor/exec.rs create mode 100644 burrow/src/tor/mod.rs create mode 100644 burrow/src/tor/runtime.rs create mode 100644 burrow/src/tor/system.rs create mode 100644 rust-toolchain.toml diff --git a/Cargo.lock b/Cargo.lock index 22a3bf3..b5a929f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,10 +23,21 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "generic-array", ] +[[package]] +name = "aead" +version = "0.6.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac8202ab55fcbf46ca829833f347a82a2a4ce0596f0304ac322c2d100030cd56" +dependencies = [ + "bytes", + "crypto-common 0.2.0-rc.4", + "inout 0.2.1", +] + [[package]] name = "aes" version = "0.8.4" @@ -34,20 +45,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", + "zeroize", ] [[package]] @@ -59,6 +59,82 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "amplify" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f7fb4ac7c881e54a8e7015e399b6112a2a5bc958b6c89ac510840ff20273b31" +dependencies = [ + "amplify_derive", + "amplify_num", + "ascii", + "getrandom 0.2.16", + "getrandom 0.3.3", + "wasm-bindgen", +] + +[[package]] +name = "amplify_derive" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a6309e6b8d89b36b9f959b7a8fa093583b94922a0f6438a24fb08936de4d428" +dependencies = [ + "amplify_syn", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "amplify_num" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99bcb75a2982047f733547042fc3968c0f460dfcf7d90b90dea3b2744580e9ad" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "amplify_syn" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7736fb8d473c0d83098b5bac44df6a561e20470375cd8bcae30516dc889fd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[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 = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.20" @@ -114,6 +190,132 @@ name = "anyhow" version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +dependencies = [ + "backtrace", +] + +[[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 0.5.0", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "arti-client" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89842cae6e3bda0fd128a5c66eb3392ed412065dc698c77d9fcc4b77e4159f2" +dependencies = [ + "async-trait", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more 2.0.1", + "educe", + "fs-mistrust", + "futures", + "hostname-validator", + "humantime", + "humantime-serde", + "libc", + "once_cell", + "postage", + "rand 0.9.2", + "safelog", + "serde", + "thiserror 2.0.16", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-chanmgr", + "tor-circmgr", + "tor-config", + "tor-config-path", + "tor-dircommon", + "tor-dirmgr", + "tor-error", + "tor-guardmgr", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-rtcompat", + "tracing", + "void", +] + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[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 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.16", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" [[package]] name = "async-channel" @@ -127,6 +329,44 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-compression" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93c1f86859c1af3d514fa19e8323147ff10ea98684e6c7b307912509f50e67b2" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-native-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9343dc5acf07e79ff82d0c37899f079db3534d99f189a1837c8e549c99405bec" +dependencies = [ + "futures-util", + "native-tls", + "thiserror 1.0.69", + "url", +] + [[package]] name = "async-stream" version = "0.2.1" @@ -181,12 +421,87 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "async_executors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a982d2f86de6137cc05c9db9a915a19886c97911f9790d04f174cede74be01a5" +dependencies = [ + "blanket", + "futures-core", + "futures-task", + "futures-util", + "pin-project", + "rustc_version", + "tokio", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + +[[package]] +name = "asynchronous-codec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http 1.3.1", + "log", + "url", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -293,6 +608,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + [[package]] name = "backtrace" version = "0.3.75" @@ -308,6 +634,24 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base16ct" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b59d472eab27ade8d770dcb11da7201c11234bef9f82ce7aa517be028d462b" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + [[package]] name = "base64" version = "0.21.7" @@ -326,6 +670,16 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "serde", + "unty", +] + [[package]] name = "bindgen" version = "0.64.0" @@ -383,13 +737,49 @@ version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "blake2" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "blake3" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq 0.3.1", +] + +[[package]] +name = "blanket" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b121a9fe0df916e362fb3271088d071159cdf11db0e4182d02152850756eff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] @@ -401,6 +791,33 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.11.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9ef36a6fcdb072aa548f3da057640ec10859eb4e91ddf526ee648d50c76a949" +dependencies = [ + "hybrid-array", + "zeroize", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "btparse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "387e80962b798815a2b5c4bcfdb6bf626fa922ffe9f74e373103b858738e9f31" + [[package]] name = "bumpalo" version = "3.19.0" @@ -411,13 +828,16 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" name = "burrow" version = "0.1.0" dependencies = [ - "aead", + "aead 0.5.2", "anyhow", + "argon2", + "arti-client", "async-channel", "async-stream 0.2.1", "axum 0.7.9", "base64 0.21.7", "blake2", + "bytes", "caps", "chacha20poly1305", "clap", @@ -426,11 +846,15 @@ dependencies = [ "dotenv", "fehler", "futures", + "hickory-proto", "hmac", "hyper-util", "insta", "ip_network", "ip_network_table", + "ipnetwork", + "iroh", + "libc", "libsystemd", "log", "nix 0.27.1", @@ -444,14 +868,18 @@ dependencies = [ "ring", "rusqlite", "rust-ini", - "schemars", + "schemars 0.8.22", "serde", "serde_json", + "subtle", + "tempfile", "tokio", "tokio-stream", - "toml", + "tokio-util", + "toml 0.8.23", "tonic 0.12.3", "tonic-build", + "tor-rtcompat", "tower 0.4.13", "tracing", "tracing-journald", @@ -462,6 +890,18 @@ dependencies = [ "x25519-dalek", ] +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" @@ -504,6 +944,18 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "caret" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beae2cb9f60bc3f21effaaf9c64e51f6627edd54eedc9199ba07f519ef2a2101" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.38" @@ -516,6 +968,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cexpr" version = "0.6.0" @@ -544,31 +1002,94 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures", ] +[[package]] +name = "chacha20" +version = "0.10.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd162f2b8af3e0639d83f28a637e4e55657b7a74508dba5a9bf4da523d5c9e9" +dependencies = [ + "cfg-if", + "cipher 0.5.0-rc.1", + "cpufeatures", + "zeroize", +] + [[package]] name = "chacha20poly1305" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ - "aead", - "chacha20", - "cipher", - "poly1305", + "aead 0.5.2", + "chacha20 0.9.1", + "cipher 0.4.4", + "poly1305 0.8.0", "zeroize", ] +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.6", + "inout 0.1.4", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.5.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e12a13eb01ded5d32ee9658d94f553a19e804204f2dc811df69ab4d9e0cb8c7" +dependencies = [ + "block-buffer 0.11.0-rc.5", + "crypto-common 0.2.0-rc.4", + "inout 0.2.1", "zeroize", ] @@ -602,7 +1123,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -623,12 +1144,72 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "coarsetime" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2" +dependencies = [ + "libc", + "wasix", + "wasm-bindgen", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.16", +] + +[[package]] +name = "color-backtrace" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308329d5d62e877ba02943db3a8e8c052de9fde7ab48283395ba0e6494efbabd" +dependencies = [ + "backtrace", + "btparse", + "termcolor", +] + [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680dc087785c5230f8e8843e2e57ac7c1c90488b6a91b88caa265410568f441b" +dependencies = [ + "compression-core", + "flate2", + "liblzma", + "zstd 0.13.3", + "zstd-safe 7.2.4", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -688,6 +1269,18 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dabb6555f92fb9ee4140454eb5dcd14c7960e1225c6d1a6cc361f032947713e" + [[package]] name = "const-random" version = "0.1.18" @@ -714,6 +1307,40 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[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-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -724,6 +1351,16 @@ dependencies = [ "libc", ] +[[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" @@ -748,6 +1385,57 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools 0.13.0", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-cycles-per-byte" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5396de42a52e9e5d8f67ef0702dae30451f310a9ba1c3094dcf228f0be0e54bc" +dependencies = [ + "cfg-if", + "criterion", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools 0.13.0", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -757,6 +1445,34 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -769,6 +1485,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -780,6 +1508,57 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8235645834fbc6832939736ce2f2d08192652269e11010a6240f61b908a1c6" +dependencies = [ + "hybrid-array", + "rand_core 0.9.3", +] + +[[package]] +name = "crypto_box" +version = "0.10.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bda4de3e070830cf3a27a394de135b6709aefcc54d1e16f2f029271254a6ed9" +dependencies = [ + "aead 0.6.0-rc.2", + "chacha20 0.10.0-rc.2", + "crypto_secretbox", + "curve25519-dalek 5.0.0-pre.1", + "salsa20", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto_secretbox" +version = "0.2.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54532aae6546084a52cef855593daf9555945719eeeda9974150e0def854873e" +dependencies = [ + "aead 0.6.0-rc.2", + "chacha20 0.10.0-rc.2", + "cipher 0.5.0-rc.1", + "hybrid-array", + "poly1305 0.9.0-rc.2", + "salsa20", + "subtle", + "zeroize", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -789,12 +1568,31 @@ dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", - "fiat-crypto", + "digest 0.10.7", + "fiat-crypto 0.2.9", "rustc_version", "subtle", "zeroize", ] +[[package]] +name = "curve25519-dalek" +version = "5.0.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f9200d1d13637f15a6acb71e758f64624048d85b31a5fdbfd8eca1e2687d0b7" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.11.0-rc.3", + "fiat-crypto 0.3.0", + "rand_core 0.9.3", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek-derive" version = "0.1.1" @@ -806,6 +1604,151 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d8dd2f26c86b27a2a8ea2767ec7f9df7a89516e4794e54ac01ee618dda3aa4" +dependencies = [ + "const-oid 0.10.1", + "pem-rfc7468 1.0.0-rc.3", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "cookie-factory", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.3" @@ -813,19 +1756,170 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" dependencies = [ "powerfmt", + "serde", ] +[[package]] +name = "derive-deftly" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284db66a66f03c3dafbe17360d959eb76b83f77cfe191677e2a7899c0da291f3" +dependencies = [ + "derive-deftly-macros", + "heck", +] + +[[package]] +name = "derive-deftly-macros" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caef6056a5788d05d173cdc3c562ac28ae093828f851f69378b74e4e3d578e41" +dependencies = [ + "heck", + "indexmap 2.11.4", + "itertools 0.14.0", + "proc-macro-crate", + "proc-macro2", + "quote", + "sha3", + "strum", + "syn 2.0.106", + "void", +] + +[[package]] +name = "derive_builder_core_fork_arti" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24c1b715c79be6328caa9a5e1a387a196ea503740f0722ec3dd8f67a9e72314d" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_fork_arti" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3eae24d595f4d0ecc90a9a5a6d11c2bd8dafe2375ec4a1ec63250e5ade7d228" +dependencies = [ + "derive_builder_macro_fork_arti", +] + +[[package]] +name = "derive_builder_macro_fork_arti" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69887769a2489cd946bf782eb2b1bb2cb7bc88551440c94a765d4f040c08ebf3" +dependencies = [ + "derive_builder_core_fork_arti", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", +] + +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl 2.0.1", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "unicode-xid", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 2.0.106", + "unicode-xid", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "digest" +version = "0.11.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dac89f8a64533a9b0eaa73a68e424db0fb1fd6271c74cc0125336a05f090568d" +dependencies = [ + "block-buffer 0.11.0-rc.5", + "const-oid 0.10.1", + "crypto-common 0.2.0-rc.4", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[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.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -837,6 +1931,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "dlopen2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b4f5f101177ff01b8ec4ecc81eead416a8aa42819a2869311b3420fa114ffa" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + [[package]] name = "dlv-list" version = "0.5.2" @@ -846,24 +1951,149 @@ dependencies = [ "const-random", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenv" version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef49c0b20c0ad088893ad2a790a29c06a012b3f05bcfc66661fd22a94b32129" +dependencies = [ + "pkcs8 0.11.0-rc.7", + "serde", + "signature 3.0.0-rc.4", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "merlin", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad207ed88a133091f83224265eac21109930db09bedcad05d5252f2af2de20a1" +dependencies = [ + "curve25519-dalek 5.0.0-pre.1", + "ed25519 3.0.0-rc.1", + "rand_core 0.9.3", + "serde", + "sha2 0.11.0-rc.2", + "signature 3.0.0-rc.4", + "subtle", + "zeroize", +] + +[[package]] +name = "educe" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0042ff8246a363dbe77d2ceedb073339e85a804b9a47636c6e016a9a32c05f" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -879,6 +2109,64 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "enum-ordinalize" +version = "3.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf1fa3f06bbff1ea5b1a9c7b14aa992a39657db60a2759457328d7e058f49ee" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "enumset" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -954,12 +2242,52 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic 0.6.1", + "serde", + "toml 0.8.23", + "uncased", + "version_check", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.2" @@ -982,12 +2310,30 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluid-let" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "749cff877dc1af878a0b31a41dd221a753634401ea0ef2f87b62d3171522485a" + [[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 = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1012,6 +2358,37 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-mistrust" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f5ac9f88fd18733e0f9ce1f4a95c40eb1d4f83131bf1472e81d1f128fefb7c2" +dependencies = [ + "derive_builder_fork_arti", + "dirs", + "libc", + "pwd-grp", + "serde", + "thiserror 2.0.16", + "walkdir", +] + +[[package]] +name = "fslock" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.31" @@ -1027,6 +2404,19 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-buffered" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e0e1f38ec07ba4abbde21eed377082f17ccb988be9d988a5adbf4bafc118fd" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.0", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -1060,6 +2450,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -1101,6 +2504,20 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows 0.61.3", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1109,6 +2526,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1133,11 +2551,36 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasi 0.14.7+wasi-0.2.4", "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 = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "gimli" version = "0.31.1" @@ -1150,6 +2593,35 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "glob-match" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.3.27" @@ -1188,6 +2660,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1199,23 +2691,34 @@ 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 = [ - "ahash", + "foldhash 0.1.5", ] [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" -version = "0.9.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.16.1", ] [[package]] @@ -1231,6 +2734,20 @@ dependencies = [ "num-traits", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.8", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -1243,13 +2760,75 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "h2 0.4.12", + "http 1.3.1", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "rustls", + "thiserror 2.0.16", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "rustls", + "smallvec", + "thiserror 2.0.16", + "tokio", + "tokio-rustls", + "tracing", +] + +[[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", + "digest 0.10.7", ] [[package]] @@ -1261,6 +2840,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "hostname-validator" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" + [[package]] name = "http" version = "0.2.12" @@ -1335,6 +2920,26 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + +[[package]] +name = "hybrid-array" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f471e0a81b2f90ffc0cb2f951ae04da57de8baa46fa99112b062a5173a5088d0" +dependencies = [ + "typenum", + "zeroize", +] + [[package]] name = "hyper" version = "0.14.32" @@ -1455,12 +3060,36 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.0", + "socket2 0.5.10", "tokio", "tower-service", "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[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.0.0" @@ -1547,6 +3176,18 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1568,6 +3209,27 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "igd-next" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97" +dependencies = [ + "async-trait", + "attohttpc", + "bytes", + "futures", + "http 1.3.1", + "http-body-util", + "hyper 1.7.0", + "hyper-util", + "log", + "rand 0.9.2", + "tokio", + "url", + "xmltree", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1576,6 +3238,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", + "serde", ] [[package]] @@ -1585,7 +3248,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.9.4", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", ] [[package]] @@ -1597,6 +3282,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7357b6e7aa75618c7864ebd0634b115a7218b0615f4cb1df33ac3eca23943d4" +dependencies = [ + "hybrid-array", +] + [[package]] name = "insta" version = "1.43.2" @@ -1609,6 +3303,27 @@ dependencies = [ "similar", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + [[package]] name = "io-uring" version = "0.7.10" @@ -1642,12 +3357,30 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e537132deb99c0eb4b752f0346b6a836200eaaa3516dd7e5514b63930a09e5d" +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + [[package]] name = "ipnet" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "ipnetwork" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" + [[package]] name = "iri-string" version = "0.7.8" @@ -1658,6 +3391,213 @@ dependencies = [ "serde", ] +[[package]] +name = "iroh" +version = "0.94.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9428cef1eafd2eac584269986d1949e693877ac12065b401dfde69f664b07ac" +dependencies = [ + "aead 0.6.0-rc.2", + "backon", + "bytes", + "cfg_aliases", + "crypto_box", + "data-encoding", + "derive_more 2.0.1", + "ed25519-dalek 3.0.0-pre.1", + "futures-util", + "getrandom 0.3.3", + "hickory-resolver", + "http 1.3.1", + "igd-next", + "instant", + "iroh-base", + "iroh-metrics", + "iroh-quinn", + "iroh-quinn-proto", + "iroh-quinn-udp", + "iroh-relay", + "n0-future", + "n0-snafu", + "n0-watcher", + "nested_enum_utils", + "netdev", + "netwatch", + "pin-project", + "pkarr", + "pkcs8 0.11.0-rc.7", + "portmapper", + "rand 0.9.2", + "reqwest 0.12.23", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "rustls-webpki", + "serde", + "smallvec", + "snafu", + "strum", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", + "webpki-roots", + "z32", +] + +[[package]] +name = "iroh-base" +version = "0.94.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7db6dfffe81a58daae02b72c7784c20feef5b5d3849b190ed1c96a8fa0b3cae8" +dependencies = [ + "curve25519-dalek 5.0.0-pre.1", + "data-encoding", + "derive_more 2.0.1", + "ed25519-dalek 3.0.0-pre.1", + "n0-snafu", + "nested_enum_utils", + "rand_core 0.9.3", + "serde", + "snafu", + "url", + "zeroize", + "zeroize_derive", +] + +[[package]] +name = "iroh-metrics" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c84c167b59ae22f940e78eb347ca5f02aa25608e994cb5a7cc016ac2d5eada18" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "postcard", + "ryu", + "serde", + "snafu", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "748d380f26f7c25307c0a7acd181b84b977ddc2a1b7beece1e5998623c323aa1" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "iroh-quinn" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde160ebee7aabede6ae887460cd303c8b809054224815addf1469d54a6fcf7" +dependencies = [ + "bytes", + "cfg_aliases", + "iroh-quinn-proto", + "iroh-quinn-udp", + "pin-project-lite", + "rustc-hash 2.1.1", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.16", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "iroh-quinn-proto" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "929d5d8fa77d5c304d3ee7cae9aede31f13908bd049f9de8c7c0094ad6f7c535" +dependencies = [ + "bytes", + "getrandom 0.2.16", + "rand 0.8.5", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.16", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "iroh-quinn-udp" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c53afaa1049f7c83ea1331f5ebb9e6ebc5fdd69c468b7a22dd598b02c9bcc973" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "iroh-relay" +version = "0.94.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "360e201ab1803201de9a125dd838f7a4d13e6ba3a79aeb46c7fbf023266c062e" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more 2.0.1", + "getrandom 0.3.3", + "hickory-resolver", + "http 1.3.1", + "http-body-util", + "hyper 1.7.0", + "hyper-util", + "iroh-base", + "iroh-metrics", + "iroh-quinn", + "iroh-quinn-proto", + "lru 0.16.2", + "n0-future", + "n0-snafu", + "nested_enum_utils", + "num_enum", + "pin-project", + "pkarr", + "postcard", + "rand 0.9.2", + "reqwest 0.12.23", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "sha1 0.11.0-rc.2", + "snafu", + "strum", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "webpki-roots", + "ws_stream_wasm", + "z32", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -1673,6 +3613,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1688,6 +3637,28 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[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", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + [[package]] name = "jobserver" version = "0.1.34" @@ -1700,19 +3671,53 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.80" +version = "0.3.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" +checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.8", +] [[package]] name = "lazycell" @@ -1720,6 +3725,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.176" @@ -1743,14 +3754,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link 0.2.0", + "windows-link 0.2.1", +] + +[[package]] +name = "liblzma" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899" +dependencies = [ + "liblzma-sys", +] + +[[package]] +name = "liblzma-sys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f2db66f3268487b5033077f266da6777d057949b8f93c8ad82e441df25e6186" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[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.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "bitflags 2.9.4", + "libc", + "plain", + "redox_syscall 0.7.3", ] [[package]] name = "libsqlite3-sys" -version = "0.28.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ "cc", "pkg-config", @@ -1770,7 +3819,7 @@ dependencies = [ "nom 8.0.0", "once_cell", "serde", - "sha2", + "sha2 0.10.9", "thiserror 2.0.16", "uuid", ] @@ -1793,6 +3842,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.13" @@ -1809,6 +3864,34 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "227748d55f2f0ab4735d87fd623798cb6b664512fe979705f829c9f81c934465" + +[[package]] +name = "lru" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96051b46fc183dc9cd4a223960ef37b9af631b55191852a8274bfef064cda20f" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1836,6 +3919,15 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.7.1" @@ -1854,6 +3946,18 @@ dependencies = [ "autocfg", ] +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak", + "rand_core 0.6.4", + "zeroize", +] + [[package]] name = "miette" version = "5.10.0" @@ -1905,16 +4009,80 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] +[[package]] +name = "moka" +version = "0.12.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "rustc_version", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "multimap" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "n0-future" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e746b307c1fd0c08771c3cafcd1746c3ccdb0d9c7b859d3caded366b6da76" +dependencies = [ + "cfg_aliases", + "derive_more 1.0.0", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-snafu" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1815107e577a95bfccedb4cfabc73d709c0db6d12de3f14e0f284a8c5036dc4f" +dependencies = [ + "anyhow", + "btparse", + "color-backtrace", + "snafu", + "tracing-error", +] + +[[package]] +name = "n0-watcher" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34c65e127e06e5a2781b28df6a33ea474a7bddc0ac0cfea888bd20c79a1b6516" +dependencies = [ + "derive_more 2.0.1", + "n0-future", + "snafu", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -1927,11 +4095,123 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] +[[package]] +name = "nested_enum_utils" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d5475271bdd36a4a2769eac1ef88df0f99428ea43e52dfd8b0ee5cb674695f" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "netdev" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67ab878b4c90faf36dab10ea51d48c69ae9019bcca47c048a7c9b273d5d7a823" +dependencies = [ + "dlopen2", + "ipnet", + "libc", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "once_cell", + "system-configuration 0.6.1", + "windows-sys 0.59.0", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ec2f5b6839be2a19d7fa5aab5bc444380f6311c2b693551cb80f45caaa7b5ef" +dependencies = [ + "bitflags 2.9.4", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.16", +] + +[[package]] +name = "netlink-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23" +dependencies = [ + "bytes", + "futures", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98d7ec7abdbfe67ee70af3f2002326491178419caea22254b9070e6ff0c83491" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more 2.0.1", + "iroh-quinn-udp", + "js-sys", + "libc", + "n0-future", + "n0-watcher", + "nested_enum_utils", + "netdev", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "pin-project-lite", + "serde", + "snafu", + "socket2 0.6.3", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows 0.62.2", + "windows-result 0.4.1", + "wmi", +] + [[package]] name = "nix" version = "0.26.4" @@ -1988,6 +4268,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "nonany" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b8866ec53810a9a4b3d434a29801e78c707430a9ae11c2db4b8b62bb9675a0" + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.9.4", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.9.4", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "ntimestamp" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c50f94c405726d3e0095e89e72f75ce7f6587b94a8bd8dc8054b73f65c0fd68c" +dependencies = [ + "base32", + "document-features", + "getrandom 0.2.16", + "httpdate", + "js-sys", + "once_cell", + "serde", +] + [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -1998,10 +4334,56 @@ dependencies = [ ] [[package]] -name = "num-conv" -version = "0.1.0" +name = "num-bigint" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +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.5", + "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" @@ -2010,6 +4392,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.9.4", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", ] [[package]] @@ -2026,6 +4450,10 @@ name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -2033,6 +4461,21 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "oneshot-fused-workaround" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17b52d0e4a06a4c7eb8d2943c0015fa628cf4ccc409429cebc0f5bed6d33a82" +dependencies = [ + "futures", +] + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -2083,6 +4526,21 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -2093,6 +4551,63 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +dependencies = [ + "memchr", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct 0.2.0", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2 0.10.9", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "parking" version = "2.2.1" @@ -2117,7 +4632,7 @@ checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.17", "smallvec", "windows-targets 0.52.6", ] @@ -2133,16 +4648,33 @@ dependencies = [ "subtle", ] +[[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 = "pbkdf2" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ - "digest", + "digest 0.10.7", "hmac", - "password-hash", - "sha2", + "password-hash 0.4.2", + "sha2 0.10.9", ] [[package]] @@ -2151,6 +4683,24 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e58fab693c712c0d4e88f8eb3087b6521d060bcaf76aeb20cb192d809115ba" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2167,6 +4717,59 @@ dependencies = [ "indexmap 2.11.4", ] +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -2199,12 +4802,108 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkarr" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "792c1328860f6874e90e3b387b4929819cc7783a6bd5a4728e918706eb436a48" +dependencies = [ + "async-compat", + "base32", + "bytes", + "cfg_aliases", + "document-features", + "dyn-clone", + "ed25519-dalek 3.0.0-pre.1", + "futures-buffered", + "futures-lite", + "getrandom 0.3.3", + "log", + "lru 0.13.0", + "ntimestamp", + "reqwest 0.12.23", + "self_cell", + "serde", + "sha1_smol", + "simple-dns", + "thiserror 2.0.16", + "tokio", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0-rc.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93eac55f10aceed84769df670ea4a32d2ffad7399400d41ee1c13b1cd8e1b478" +dependencies = [ + "der 0.8.0-rc.9", + "spki 0.8.0-rc.4", +] + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -2213,7 +4912,94 @@ checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "poly1305" +version = "0.9.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb78a635f75d76d856374961deecf61031c0b6f928c83dc9c0924ab6c019c298" +dependencies = [ + "cpufeatures", + "universal-hash 0.6.0-rc.2", +] + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portmapper" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d73aa9bd141e0ff6060fea89a5437883f3b9ceea1cda71c790b90e17d072a3b3" +dependencies = [ + "base64 0.22.1", + "bytes", + "derive_more 2.0.1", + "futures-lite", + "futures-util", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "nested_enum_utils", + "netwatch", + "num_enum", + "rand 0.9.2", + "serde", + "smallvec", + "snafu", + "socket2 0.6.3", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + +[[package]] +name = "postage" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" +dependencies = [ + "atomic 0.5.3", + "crossbeam-queue", + "futures", + "parking_lot", + "pin-project", + "static_assertions", + "thiserror 1.0.69", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] @@ -2250,6 +5036,57 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "priority-queue" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" +dependencies = [ + "equivalent", + "indexmap 2.11.4", + "serde", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.7", +] + +[[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 2.0.106", +] + [[package]] name = "proc-macro2" version = "1.0.101" @@ -2343,6 +5180,18 @@ dependencies = [ "prost 0.13.5", ] +[[package]] +name = "pwd-grp" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e2023f41b5fcb7c30eb5300a5733edfaa9e0e0d502d51b586f65633fd39e40c" +dependencies = [ + "derive-deftly", + "libc", + "paste", + "thiserror 2.0.16", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2356,7 +5205,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.6.0", + "socket2 0.5.10", "thiserror 2.0.16", "tokio", "tracing", @@ -2393,7 +5242,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.0", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -2413,6 +5262,18 @@ 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 = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -2472,6 +5333,46 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rand_jitter" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16df48f071248e67b8fc5e866d9448d45c08ad8b672baaaf796e2f15e606ff0" +dependencies = [ + "libc", + "rand_core 0.9.3", + "winapi", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rdrand" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92195228612ac8eed47adbc2ed0f04e513a4ccb98175b6f2bd04d963b533655" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "redox_syscall" version = "0.5.17" @@ -2481,6 +5382,46 @@ dependencies = [ "bitflags 2.9.4", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.9.4", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.16", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "regex" version = "1.11.2" @@ -2539,7 +5480,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper 0.1.2", - "system-configuration", + "system-configuration 0.5.1", "tokio", "tokio-native-tls", "tower-service", @@ -2559,6 +5500,7 @@ dependencies = [ "base64 0.22.1", "bytes", "futures-core", + "futures-util", "http 1.3.1", "http-body 1.0.1", "http-body-util", @@ -2578,16 +5520,43 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-rustls", + "tokio-util", "tower 0.5.2", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots", ] +[[package]] +name = "resolv-conf" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" + +[[package]] +name = "retry-error" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c322ea521636c5a3f13685a6266055b2dda7e54e2be35214d7c2a5d0672a5db" +dependencies = [ + "humantime", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -2603,10 +5572,41 @@ dependencies = [ ] [[package]] -name = "rusqlite" -version = "0.31.0" +name = "rsa" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature 2.2.0", + "spki 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.16", +] + +[[package]] +name = "rusqlite" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ "bitflags 2.9.4", "fallible-iterator", @@ -2614,6 +5614,8 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", + "time", ] [[package]] @@ -2653,6 +5655,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "0.38.44" @@ -2681,10 +5692,11 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.32" +version = "0.23.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" +checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -2693,6 +5705,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -2713,10 +5737,37 @@ dependencies = [ ] [[package]] -name = "rustls-webpki" -version = "0.103.6" +name = "rustls-platform-verifier" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" +checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.5.1", + "security-framework-sys", + "webpki-root-certs 0.26.11", + "windows-sys 0.59.0", +] + +[[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.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ "ring", "rustls-pki-types", @@ -2735,6 +5786,53 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "safelog" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee9f10dd250956c65d58a19507dd06ff976f898560fe843580d05134541f0898" +dependencies = [ + "derive_more 2.0.1", + "educe", + "either", + "fluid-let", + "thiserror 2.0.16", +] + +[[package]] +name = "salsa20" +version = "0.11.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3ff3b81c8a6e381bc1673768141383f9328048a60edddcfc752a8291a138443" +dependencies = [ + "cfg-if", + "cipher 0.5.0-rc.1", +] + +[[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 = "sanitize-filename" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" +dependencies = [ + "regex", +] + +[[package]] +name = "saturating-time" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63583a1dd0647d1484228529ab4ecaa874048d2956f117362aa5f5826456230" + [[package]] name = "schannel" version = "0.1.28" @@ -2756,6 +5854,30 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars_derive" version = "0.8.22" @@ -2768,12 +5890,32 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -2781,7 +5923,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.9.4", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.9.4", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2797,6 +5952,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16c2f82143577edb4921b71ede051dac62ca3c16084e918bf7b40c96ae10eb33" + [[package]] name = "semver" version = "1.0.27" @@ -2804,29 +5965,55 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] -name = "serde" -version = "1.0.226" +name = "send_wrapper" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[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.226" +name = "serde-value" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[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.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -2844,6 +6031,16 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.145" @@ -2877,6 +6074,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2889,6 +6095,47 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.11.4", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serdect" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3ef0e35b322ddfaecbc60f34ab448e157e48531288ee49fafbb053696b8ffe2" +dependencies = [ + "base16ct 0.3.0", + "serde", +] + [[package]] name = "sha-1" version = "0.10.1" @@ -2897,7 +6144,7 @@ checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", ] [[package]] @@ -2908,9 +6155,26 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", ] +[[package]] +name = "sha1" +version = "0.11.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e046edf639aa2e7afb285589e5405de2ef7e61d4b0ac1e30256e3eab911af9" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.11.0-rc.3", +] + +[[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" @@ -2919,7 +6183,28 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1e3878ab0f98e35b2df35fe53201d088299b41a6bb63e3e34dada2ac4abd924" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.11.0-rc.3", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest 0.10.7", + "keccak", ] [[package]] @@ -2931,6 +6216,17 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "bstr", + "dirs", + "os_str_bytes", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2946,24 +6242,106 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc280a6ff65c79fbd6622f64d7127f32b85563bca8c53cd2e9141d6744a9056d" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "simple-dns" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee851d0e5e7af3721faea1843e8015e820a234f81fda3dea9247e15bac9a86a" +dependencies = [ + "bitflags 2.9.4", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "slotmap-careful" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed92816c1fbb29891a525b92d5fa95757c9dee47044f76c8e06ceb1e052a8d64" +dependencies = [ + "paste", + "serde", + "slotmap", + "thiserror 2.0.16", + "void", +] + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "backtrace", + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "socket2" version = "0.5.10" @@ -2976,12 +6354,101 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.0" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.0", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8baeff88f34ed0691978ec34440140e1572b68c7dd4a495fd14a3dc1944daa80" +dependencies = [ + "base64ct", + "der 0.8.0-rc.9", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher 0.4.4", + "ssh-encoding", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468 0.7.0", + "sha2 0.10.9", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2 0.10.9", + "signature 2.2.0", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", ] [[package]] @@ -2991,11 +6458,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7a2b3c2bc9693bcb40870c4e9b5bf0d79f9cb46273321bf855ec513e919082" dependencies = [ "base64 0.21.7", - "digest", + "digest 0.10.7", "hex", "miette", "sha-1", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "xxhash-rust", ] @@ -3006,12 +6473,45 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3066,6 +6566,20 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "sysinfo" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -3073,8 +6587,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", - "core-foundation", - "system-configuration-sys", + "core-foundation 0.9.4", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.9.4", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", ] [[package]] @@ -3087,6 +6612,28 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.23.0" @@ -3100,6 +6647,15 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3151,22 +6707,35 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", + "js-sys", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", + "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +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 = "tiny-keccak" @@ -3187,6 +6756,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -3216,7 +6795,7 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "slab", - "socket2 0.6.0", + "socket2 0.6.3", "tokio-macros", "tracing", "windows-sys 0.59.0", @@ -3272,21 +6851,46 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] name = "tokio-util" -version = "0.7.16" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1b6348ebfaaecd771cecb69e832961d277f59845d4220a584701f72728152b7" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.3.3", + "http 1.3.1", + "httparse", + "rand 0.9.2", + "ring", + "rustls-pki-types", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + [[package]] name = "toml" version = "0.8.23" @@ -3294,9 +6898,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.11.4", + "serde_core", + "serde_spanned 1.1.0", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.13", ] [[package]] @@ -3308,6 +6927,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -3316,10 +6944,31 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.11.4", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.13", +] + +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap 2.11.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.13", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow 1.0.1", ] [[package]] @@ -3328,6 +6977,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" + [[package]] name = "tonic" version = "0.10.2" @@ -3399,6 +7054,959 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "tor-async-utils" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "895c61a46909134501c6815eceeb66c9c80fc494ee89429821b0f05ccf34b4f5" +dependencies = [ + "derive-deftly", + "educe", + "futures", + "oneshot-fused-workaround", + "pin-project", + "postage", + "thiserror 2.0.16", + "void", +] + +[[package]] +name = "tor-basic-utils" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac6e4d7e131b7d69bc85558383cd4ac61e46b4dd0d4ed51632f28fac98cac0c" +dependencies = [ + "derive_more 2.0.1", + "hex", + "itertools 0.14.0", + "libc", + "paste", + "rand 0.9.2", + "rand_chacha 0.9.0", + "serde", + "slab", + "smallvec", + "thiserror 2.0.16", + "weak-table", +] + +[[package]] +name = "tor-bytes" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64454947258e49f238a5f06a06250a0c54598a1c7409898b5c79505e6a99e7af" +dependencies = [ + "bytes", + "derive-deftly", + "digest 0.10.7", + "educe", + "getrandom 0.4.2", + "safelog", + "thiserror 2.0.16", + "tor-error", + "tor-llcrypto", + "zeroize", +] + +[[package]] +name = "tor-cell" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ab0c79bc92a957e85959cf397a2d8f9c8294c35fa4f65247a9393b20ac95551" +dependencies = [ + "amplify", + "bitflags 2.9.4", + "bytes", + "caret", + "derive-deftly", + "derive_more 2.0.1", + "educe", + "itertools 0.14.0", + "paste", + "rand 0.9.2", + "smallvec", + "thiserror 2.0.16", + "tor-basic-utils", + "tor-bytes", + "tor-cert", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-protover", + "tor-units", + "void", +] + +[[package]] +name = "tor-cert" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debc911738298ee801fce4577c36a50c55295b0bb9c5519461b83cc486a1f86e" +dependencies = [ + "caret", + "derive_builder_fork_arti", + "derive_more 2.0.1", + "digest 0.10.7", + "thiserror 2.0.16", + "tor-bytes", + "tor-checkable", + "tor-error", + "tor-llcrypto", +] + +[[package]] +name = "tor-chanmgr" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7af5b7c2f1e16d1304b5185fcbc91ca5c8df991c21be00702f925f055573eea1" +dependencies = [ + "async-trait", + "caret", + "cfg-if", + "derive-deftly", + "derive_more 2.0.1", + "educe", + "futures", + "oneshot-fused-workaround", + "percent-encoding", + "postage", + "rand 0.9.2", + "safelog", + "serde", + "serde_with", + "thiserror 2.0.16", + "tor-async-utils", + "tor-basic-utils", + "tor-cell", + "tor-config", + "tor-error", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-netdir", + "tor-proto", + "tor-rtcompat", + "tor-socksproto", + "tor-units", + "tracing", + "url", + "void", +] + +[[package]] +name = "tor-checkable" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b13a5b50bb55037f2e81b25dde42f420d57c75154216b8ef989006cea3ebee" +dependencies = [ + "humantime", + "signature 2.2.0", + "thiserror 2.0.16", + "tor-llcrypto", +] + +[[package]] +name = "tor-circmgr" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b878f3f7c6be0c7f130d90b347ada2e7c46519dfbdde8273eae2e5d1792caa87" +dependencies = [ + "amplify", + "async-trait", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more 2.0.1", + "downcast-rs", + "dyn-clone", + "educe", + "futures", + "humantime-serde", + "itertools 0.14.0", + "once_cell", + "oneshot-fused-workaround", + "pin-project", + "rand 0.9.2", + "retry-error", + "safelog", + "serde", + "thiserror 2.0.16", + "tor-async-utils", + "tor-basic-utils", + "tor-cell", + "tor-chanmgr", + "tor-config", + "tor-dircommon", + "tor-error", + "tor-guardmgr", + "tor-linkspec", + "tor-memquota", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-relay-selection", + "tor-rtcompat", + "tor-units", + "tracing", + "void", + "weak-table", +] + +[[package]] +name = "tor-config" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc74a00ab15bb986e3747c6969e40a58a63065d6f99077e7ee2f4657bb8b03" +dependencies = [ + "amplify", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "educe", + "either", + "figment", + "fs-mistrust", + "futures", + "humantime-serde", + "itertools 0.14.0", + "notify", + "paste", + "postage", + "regex", + "serde", + "serde-value", + "serde_ignored", + "strum", + "thiserror 2.0.16", + "toml 0.9.12+spec-1.1.0", + "tor-basic-utils", + "tor-error", + "tor-rtcompat", + "tracing", + "void", +] + +[[package]] +name = "tor-config-path" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3005ab7b9a26a7271e5adf3dfb4ae18c09a943e32aeccc4f6d1c53a60de74b8d" +dependencies = [ + "directories", + "serde", + "shellexpand", + "thiserror 2.0.16", + "tor-error", + "tor-general-addr", +] + +[[package]] +name = "tor-consdiff" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bfa2b7b71c72830f61c48da4bb3e13191e0c0e1404b9c5168c795e4f5feb4a8" +dependencies = [ + "digest 0.10.7", + "hex", + "thiserror 2.0.16", + "tor-llcrypto", +] + +[[package]] +name = "tor-dirclient" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ccd6fac844ac77c33ccdfcb56bf23ff40ebbb821ea708be79a481ec30e8c39c" +dependencies = [ + "async-compression", + "base64ct", + "derive_more 2.0.1", + "futures", + "hex", + "http 1.3.1", + "httparse", + "httpdate", + "itertools 0.14.0", + "memchr", + "thiserror 2.0.16", + "tor-circmgr", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tor-proto", + "tor-rtcompat", + "tracing", +] + +[[package]] +name = "tor-dircommon" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cf39a3c30321d145a4d60753ae7ef5bb58a66a00ac9e2bfc30bd823faf2a4" +dependencies = [ + "base64ct", + "derive-deftly", + "getset", + "humantime", + "humantime-serde", + "serde", + "tor-basic-utils", + "tor-checkable", + "tor-config", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tracing", +] + +[[package]] +name = "tor-dirmgr" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52919aa9dbb82a354c5b904bef82e91beb702b9f8ad14e6eac4237d6128bf67" +dependencies = [ + "async-trait", + "base64ct", + "derive_builder_fork_arti", + "derive_more 2.0.1", + "digest 0.10.7", + "educe", + "event-listener", + "fs-mistrust", + "fslock", + "futures", + "hex", + "humantime", + "humantime-serde", + "itertools 0.14.0", + "memmap2", + "oneshot-fused-workaround", + "paste", + "postage", + "rand 0.9.2", + "rusqlite", + "safelog", + "scopeguard", + "serde", + "serde_json", + "signature 2.2.0", + "static_assertions", + "strum", + "thiserror 2.0.16", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-checkable", + "tor-circmgr", + "tor-config", + "tor-consdiff", + "tor-dirclient", + "tor-dircommon", + "tor-error", + "tor-guardmgr", + "tor-llcrypto", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-rtcompat", + "tracing", +] + +[[package]] +name = "tor-error" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595b005e6f571ac3890a34a00f361200aab781fd0218f2c528c86fc7af088df5" +dependencies = [ + "derive_more 2.0.1", + "futures", + "paste", + "retry-error", + "static_assertions", + "strum", + "thiserror 2.0.16", + "tracing", + "void", +] + +[[package]] +name = "tor-general-addr" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727b8c8bc01c1587486055edab5c2cd0d5c960f5bb3fac796fc9911872b8b397" +dependencies = [ + "derive_more 2.0.1", + "thiserror 2.0.16", + "void", +] + +[[package]] +name = "tor-guardmgr" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d337f465a477c0fb3b2faafa4654d70ff9df3590e57d22707591dddb4e4450c1" +dependencies = [ + "amplify", + "base64ct", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more 2.0.1", + "dyn-clone", + "educe", + "futures", + "humantime", + "humantime-serde", + "itertools 0.14.0", + "num_enum", + "oneshot-fused-workaround", + "pin-project", + "postage", + "rand 0.9.2", + "safelog", + "serde", + "strum", + "thiserror 2.0.16", + "tor-async-utils", + "tor-basic-utils", + "tor-config", + "tor-dircommon", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-relay-selection", + "tor-rtcompat", + "tor-units", + "tracing", +] + +[[package]] +name = "tor-hscrypto" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3693cd43f05cd01ac0aaa060dae5c5e53c4364f89e0d769e33cd629a2fd3118" +dependencies = [ + "data-encoding", + "derive-deftly", + "derive_more 2.0.1", + "digest 0.10.7", + "hex", + "humantime", + "itertools 0.14.0", + "paste", + "rand 0.9.2", + "safelog", + "serde", + "signature 2.2.0", + "subtle", + "thiserror 2.0.16", + "tor-basic-utils", + "tor-bytes", + "tor-error", + "tor-key-forge", + "tor-llcrypto", + "tor-units", + "void", +] + +[[package]] +name = "tor-key-forge" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ade9065ae49cfe2ab020ca9ca9f2b3c5c9b5fc0d8980fa681d8b3a0668e042f" +dependencies = [ + "derive-deftly", + "derive_more 2.0.1", + "downcast-rs", + "paste", + "rand 0.9.2", + "rsa", + "signature 2.2.0", + "ssh-key", + "thiserror 2.0.16", + "tor-bytes", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-llcrypto", +] + +[[package]] +name = "tor-keymgr" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243c3163d376c4723cd67271fcd6e5d6b498a6865c6b98299640e1be01c38826" +dependencies = [ + "amplify", + "arrayvec", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more 2.0.1", + "downcast-rs", + "dyn-clone", + "fs-mistrust", + "glob-match", + "humantime", + "inventory", + "itertools 0.14.0", + "rand 0.9.2", + "safelog", + "serde", + "signature 2.2.0", + "ssh-key", + "thiserror 2.0.16", + "tor-basic-utils", + "tor-bytes", + "tor-config", + "tor-config-path", + "tor-error", + "tor-hscrypto", + "tor-key-forge", + "tor-llcrypto", + "tor-persist", + "tracing", + "visibility", + "walkdir", + "zeroize", +] + +[[package]] +name = "tor-linkspec" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f1ea8786900d6fbe4c9f775d341b1ba01bbd1f750d89bd63be78b6b01e1836" +dependencies = [ + "base64ct", + "by_address", + "caret", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more 2.0.1", + "hex", + "itertools 0.14.0", + "safelog", + "serde", + "serde_with", + "strum", + "thiserror 2.0.16", + "tor-basic-utils", + "tor-bytes", + "tor-config", + "tor-llcrypto", + "tor-memquota", + "tor-protover", +] + +[[package]] +name = "tor-llcrypto" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c6989a1c6d06ffd6835e2917edaae4aeef544f8e5fdd68b54cc365f2af523de" +dependencies = [ + "aes", + "base64ct", + "ctr", + "curve25519-dalek 4.1.3", + "der-parser", + "derive-deftly", + "derive_more 2.0.1", + "digest 0.10.7", + "ed25519-dalek 2.2.0", + "educe", + "getrandom 0.4.2", + "hex", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_core 0.6.4", + "rand_core 0.9.3", + "rand_jitter", + "rdrand", + "rsa", + "safelog", + "serde", + "sha1 0.10.6", + "sha2 0.10.9", + "sha3", + "signature 2.2.0", + "subtle", + "thiserror 2.0.16", + "tor-error", + "tor-memquota-cost", + "visibility", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "tor-log-ratelim" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f1cd642180923d12e3fab5996b4aa2189718da7f465df6eb196ce2b9c70e293" +dependencies = [ + "futures", + "humantime", + "thiserror 2.0.16", + "tor-error", + "tor-rtcompat", + "tracing", + "weak-table", +] + +[[package]] +name = "tor-memquota" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "599daea60fd3272eb72a795d1c593b45bbe15343cbc702340a81db124c06eed5" +dependencies = [ + "cfg-if", + "derive-deftly", + "derive_more 2.0.1", + "dyn-clone", + "educe", + "futures", + "itertools 0.14.0", + "paste", + "pin-project", + "serde", + "slotmap-careful", + "static_assertions", + "sysinfo", + "thiserror 2.0.16", + "tor-async-utils", + "tor-basic-utils", + "tor-config", + "tor-error", + "tor-log-ratelim", + "tor-memquota-cost", + "tor-rtcompat", + "tracing", + "void", +] + +[[package]] +name = "tor-memquota-cost" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd92b07c0fc24e6d8166a5ff45e5b8654e68d89743c46d01889a16ab74c0b578" +dependencies = [ + "derive-deftly", + "itertools 0.14.0", + "paste", + "void", +] + +[[package]] +name = "tor-netdir" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41be8f47f521fc95206d2ba5facac8fb1a5b5b82169bd41ebeecdf46d1e77246" +dependencies = [ + "async-trait", + "bitflags 2.9.4", + "derive_more 2.0.1", + "futures", + "humantime", + "itertools 0.14.0", + "num_enum", + "rand 0.9.2", + "serde", + "strum", + "thiserror 2.0.16", + "tor-basic-utils", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tor-protover", + "tor-units", + "tracing", + "typed-index-collections", +] + +[[package]] +name = "tor-netdoc" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea8bce73d2c78bd78a2a927336ca639cf6bd5d8ad092ebcd0b3fdeaa47dcc77e" +dependencies = [ + "amplify", + "base64ct", + "cipher 0.4.4", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more 2.0.1", + "digest 0.10.7", + "educe", + "enumset", + "hex", + "humantime", + "itertools 0.14.0", + "memchr", + "paste", + "phf", + "saturating-time", + "serde", + "serde_with", + "signature 2.2.0", + "smallvec", + "strum", + "subtle", + "thiserror 2.0.16", + "time", + "tinystr", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-llcrypto", + "tor-protover", + "void", + "zeroize", +] + +[[package]] +name = "tor-persist" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507ab4b6a3d59ed0df5804eeed66dcacde75e3be13d3694216cdfdb666bce625" +dependencies = [ + "derive-deftly", + "derive_more 2.0.1", + "filetime", + "fs-mistrust", + "fslock", + "futures", + "itertools 0.14.0", + "oneshot-fused-workaround", + "paste", + "sanitize-filename", + "serde", + "serde_json", + "thiserror 2.0.16", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-error", + "tracing", + "void", +] + +[[package]] +name = "tor-proto" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfc552d535d36539d5782bb02028590bc472d219e49da51a96810725e80ff56" +dependencies = [ + "amplify", + "async-trait", + "asynchronous-codec", + "bitvec", + "bytes", + "caret", + "cfg-if", + "cipher 0.4.4", + "coarsetime", + "criterion-cycles-per-byte", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more 2.0.1", + "digest 0.10.7", + "educe", + "enum_dispatch", + "futures", + "futures-util", + "hkdf", + "hmac", + "itertools 0.14.0", + "nonany", + "oneshot-fused-workaround", + "pin-project", + "postage", + "rand 0.9.2", + "rand_core 0.9.3", + "safelog", + "slotmap-careful", + "smallvec", + "static_assertions", + "subtle", + "sync_wrapper 1.0.2", + "thiserror 2.0.16", + "tokio", + "tokio-util", + "tor-async-utils", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-cert", + "tor-checkable", + "tor-config", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-log-ratelim", + "tor-memquota", + "tor-protover", + "tor-relay-crypto", + "tor-rtcompat", + "tor-rtmock", + "tor-units", + "tracing", + "typenum", + "visibility", + "void", + "zeroize", +] + +[[package]] +name = "tor-protover" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aed88527d070c4b7ea4e55a36d2d56d0500e30ca66298b5264f047f7f2f89cfa" +dependencies = [ + "caret", + "paste", + "serde_with", + "thiserror 2.0.16", + "tor-basic-utils", + "tor-bytes", +] + +[[package]] +name = "tor-relay-crypto" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7e57e9f71b22ae1df63dbccc8e428cb07feec0abd654735109fa563c10bbb90" +dependencies = [ + "derive-deftly", + "derive_more 2.0.1", + "humantime", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-key-forge", + "tor-keymgr", + "tor-llcrypto", + "tor-persist", +] + +[[package]] +name = "tor-relay-selection" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a372072ac9dea7d17e49693cc3f3ae77b3abf8125630516c9f2d622239b1920a" +dependencies = [ + "rand 0.9.2", + "serde", + "tor-basic-utils", + "tor-linkspec", + "tor-netdir", + "tor-netdoc", +] + +[[package]] +name = "tor-rtcompat" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14428b930e59003e801c0c32697c0aeb9b0495ad33ecbe8c6753bdb596233270" +dependencies = [ + "async-native-tls", + "async-trait", + "async_executors", + "asynchronous-codec", + "cfg-if", + "coarsetime", + "derive_more 2.0.1", + "dyn-clone", + "educe", + "futures", + "hex", + "libc", + "native-tls", + "paste", + "pin-project", + "socket2 0.6.3", + "thiserror 2.0.16", + "tokio", + "tokio-util", + "tor-error", + "tor-general-addr", + "tracing", + "void", + "zeroize", +] + +[[package]] +name = "tor-rtmock" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2da91a432cdaee8a93e0bb21b02f3e9c7667832ccbb4b54e00d9c1214638e70" +dependencies = [ + "amplify", + "assert_matches", + "async-trait", + "derive-deftly", + "derive_more 2.0.1", + "educe", + "futures", + "humantime", + "itertools 0.14.0", + "oneshot-fused-workaround", + "pin-project", + "priority-queue", + "slotmap-careful", + "strum", + "thiserror 2.0.16", + "tor-error", + "tor-general-addr", + "tor-rtcompat", + "tracing", + "tracing-test", + "void", +] + +[[package]] +name = "tor-socksproto" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adbc9115a2f506d9bb86ae4446f0ca70eb523dc2f5e900a33582e7c39decc23a" +dependencies = [ + "amplify", + "caret", + "derive-deftly", + "educe", + "safelog", + "subtle", + "thiserror 2.0.16", + "tor-bytes", + "tor-error", +] + +[[package]] +name = "tor-units" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da90e93b4b4aa4ec356ecbe9e19aced36fdd655e94ca459d1915120d873363f0" +dependencies = [ + "derive-deftly", + "derive_more 2.0.1", + "serde", + "thiserror 2.0.16", + "tor-memquota", +] + [[package]] name = "tower" version = "0.4.13" @@ -3498,6 +8106,16 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + [[package]] name = "tracing-journald" version = "0.3.1" @@ -3564,6 +8182,27 @@ dependencies = [ "tracing-log 0.2.0", ] +[[package]] +name = "tracing-test" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" +dependencies = [ + "quote", + "syn 2.0.106", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -3585,7 +8224,7 @@ dependencies = [ "log", "nix 0.26.4", "reqwest 0.11.27", - "schemars", + "schemars 0.8.22", "serde", "socket2 0.5.10", "ssri", @@ -3593,22 +8232,47 @@ dependencies = [ "tokio", "tracing", "widestring", - "windows", + "windows 0.48.0", "zip", ] +[[package]] +name = "typed-index-collections" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd" +dependencies = [ + "bincode", + "serde", +] + [[package]] name = "typenum" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[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-ident" version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + [[package]] name = "unicode-width" version = "0.1.14" @@ -3621,13 +8285,29 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "universal-hash" +version = "0.6.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55be643b40a21558f44806b53ee9319595bc7ca6896372e4e08e5d7d83c9cd6" +dependencies = [ + "crypto-common 0.2.0-rc.4", "subtle", ] @@ -3638,15 +8318,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] -name = "url" -version = "2.5.7" +name = "unty" +version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[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", + "serde_derive", ] [[package]] @@ -3667,6 +8354,7 @@ version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ + "getrandom 0.3.3", "js-sys", "serde", "wasm-bindgen", @@ -3690,6 +8378,33 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[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" @@ -3720,14 +8435,32 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[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 = "wasix" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1757e0d1f8456693c7e5c6c629bdb54884e032aa0bb53c155f6a39f94440d332" +dependencies = [ + "wasi 0.11.1+wasi-snapshot-preview1", ] [[package]] name = "wasm-bindgen" -version = "0.2.103" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" +checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" dependencies = [ "cfg-if", "once_cell", @@ -3736,38 +8469,21 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.106", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-futures" -version = "0.4.53" +version = "0.4.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" +checksum = "19280959e2844181895ef62f065c63e0ca07ece4771b53d89bfdb967d97cbf05" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.103" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" +checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3775,31 +8491,84 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.103" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" +checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn 2.0.106", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.103" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" +checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" dependencies = [ "unicode-ident", ] [[package]] -name = "web-sys" -version = "0.3.80" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" +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 2.11.4", + "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 2.9.4", + "hashbrown 0.15.5", + "indexmap 2.11.4", + "semver", +] + +[[package]] +name = "weak-table" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" + +[[package]] +name = "web-sys" +version = "0.3.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "749466a37ee189057f54748b200186b59a03417a117267baf3fd89cecc9fb837" dependencies = [ "js-sys", "wasm-bindgen", @@ -3816,10 +8585,28 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "1.0.2" +name = "webpki-root-certs" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" +dependencies = [ + "webpki-root-certs 1.0.3", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d651ec480de84b762e7be71e6efa7461699c19d9e2c272c8d93455f567786e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8" dependencies = [ "rustls-pki-types", ] @@ -3858,6 +8645,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[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.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3873,6 +8669,119 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.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 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[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 2.0.106", +] + +[[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 2.0.106", +] + [[package]] name = "windows-link" version = "0.1.3" @@ -3881,9 +8790,74 @@ checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[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" @@ -3927,7 +8901,22 @@ version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", +] + +[[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]] @@ -3978,6 +8967,30 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.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" @@ -3996,6 +9009,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[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" @@ -4014,6 +9033,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[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" @@ -4044,6 +9069,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[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" @@ -4062,6 +9093,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[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" @@ -4080,6 +9117,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[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" @@ -4098,6 +9141,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[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" @@ -4125,6 +9174,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" + [[package]] name = "winreg" version = "0.50.0" @@ -4141,24 +9196,170 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[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-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 2.11.4", + "prettyplease", + "syn 2.0.106", + "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 2.0.106", + "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 2.9.4", + "indexmap 2.11.4", + "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 2.11.4", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wmi" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120d8c2b6a7c96c27bf4a7947fd7f02d73ca7f5958b8bd72a696e46cb5521ee6" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.16", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "writeable" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.16", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x25519-dalek" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "rand_core 0.6.4", "serde", "zeroize", ] +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + [[package]] name = "xxhash-rust" version = "0.8.15" @@ -4189,6 +9390,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "z32" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2164e798d9e3d84ee2c91139ace54638059a3b23e361f5c11781c2c6459bde0f" + [[package]] name = "zerocopy" version = "0.8.27" @@ -4232,9 +9439,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ "zeroize_derive", ] @@ -4292,15 +9499,15 @@ dependencies = [ "aes", "byteorder", "bzip2", - "constant_time_eq", + "constant_time_eq 0.1.5", "crc32fast", "crossbeam-utils", "flate2", "hmac", "pbkdf2", - "sha1", + "sha1 0.10.6", "time", - "zstd", + "zstd 0.11.2+zstd.1.5.2", ] [[package]] @@ -4309,7 +9516,16 @@ version = "0.11.2+zstd.1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" dependencies = [ - "zstd-safe", + "zstd-safe 5.0.2+zstd.1.5.2", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe 7.2.4", ] [[package]] @@ -4322,6 +9538,15 @@ dependencies = [ "zstd-sys", ] +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + [[package]] name = "zstd-sys" version = "2.0.16+zstd.1.5.7" diff --git a/burrow/Cargo.toml b/burrow/Cargo.toml index d5e56c1..c3cfb75 100644 --- a/burrow/Cargo.toml +++ b/burrow/Cargo.toml @@ -33,6 +33,7 @@ serde_json = "1.0" blake2 = "0.10" chacha20poly1305 = "0.10" rand = "0.8" +bytes = "1" rand_core = "0.6" aead = "0.5" x25519-dalek = { version = "2.0", features = [ @@ -46,40 +47,50 @@ base64 = "0.21" fehler = "1.0" ip_network_table = "0.2" ip_network = "0.4" +ipnetwork = "0.21" async-channel = "2.1" schemars = "0.8" futures = "0.3.28" once_cell = "1.19" +arti-client = "0.40.0" +hickory-proto = "0.25.2" +tokio-util = { version = "0.7.18", features = ["compat"] } +tor-rtcompat = "0.40.0" console-subscriber = { version = "0.2.0", optional = true } console = "0.15.8" axum = "0.7.4" +argon2 = "0.5" reqwest = { version = "0.12", default-features = false, features = [ "json", "rustls-tls", ] } -rusqlite = { version = "0.31.0", features = ["blob"] } +rusqlite = { version = "0.38.0", features = ["blob"] } +iroh = "0.94.0" dotenv = "0.15.0" tonic = "0.12.0" prost = "0.13.1" prost-types = "0.13.1" tokio-stream = "0.1" async-stream = "0.2" -tower = "0.4.13" +tower = { version = "0.4.13", features = ["util"] } hyper-util = "0.1.6" toml = "0.8.15" rust-ini = "0.21.0" +subtle = "2.6" [target.'cfg(target_os = "linux")'.dependencies] caps = "0.5" +libc = "0.2" libsystemd = "0.7" tracing-journald = "0.3" [target.'cfg(target_vendor = "apple")'.dependencies] nix = { version = "0.27" } -rusqlite = { version = "0.31.0", features = ["bundled", "blob"] } +rusqlite = { version = "0.38.0", features = ["bundled", "blob"] } [dev-dependencies] insta = { version = "1.32", features = ["yaml"] } +tempfile = "3.13" [package.metadata.generate-rpm] assets = [ diff --git a/burrow/src/database.rs b/burrow/src/database.rs index c03048c..5039e03 100644 --- a/burrow/src/database.rs +++ b/burrow/src/database.rs @@ -54,7 +54,7 @@ END; pub fn initialize_tables(conn: &Connection) -> Result<()> { conn.execute(CREATE_WG_INTERFACE_TABLE, [])?; conn.execute(CREATE_WG_PEER_TABLE, [])?; - conn.execute(CREATE_NETWORK_TABLE, [])?; + conn.execute_batch(CREATE_NETWORK_TABLE)?; Ok(()) } diff --git a/burrow/src/lib.rs b/burrow/src/lib.rs index 6aae1fb..bbc8c17 100644 --- a/burrow/src/lib.rs +++ b/burrow/src/lib.rs @@ -1,22 +1,25 @@ +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +pub mod control; + #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub mod wireguard; +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +mod auth; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod daemon; #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub mod database; #[cfg(any(target_os = "linux", target_vendor = "apple"))] -mod auth; +pub mod mesh; +#[cfg(target_os = "linux")] +pub mod tor; pub(crate) mod tracing; #[cfg(target_vendor = "apple")] pub use daemon::apple::spawn_in_process; #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub use daemon::{ - rpc::DaemonResponse, - rpc::ServerInfo, - DaemonClient, - DaemonCommand, - DaemonResponseData, + rpc::DaemonResponse, rpc::ServerInfo, DaemonClient, DaemonCommand, DaemonResponseData, DaemonStartOptions, }; diff --git a/burrow/src/main.rs b/burrow/src/main.rs index e87b4c9..c1f512b 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -1,6 +1,8 @@ use anyhow::Result; use clap::{Args, Parser, Subcommand}; +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +mod control; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod daemon; pub(crate) mod tracing; @@ -10,6 +12,11 @@ mod wireguard; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod auth; +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +mod mesh; +#[cfg(target_os = "linux")] +mod tor; + #[cfg(any(target_os = "linux", target_vendor = "apple"))] use daemon::{DaemonClient, DaemonCommand}; @@ -66,6 +73,9 @@ enum Commands { NetworkReorder(NetworkReorderArgs), /// Delete Network NetworkDelete(NetworkDeleteArgs), + #[cfg(target_os = "linux")] + /// Run a command in a Linux user namespace with Tor-backed networking + TorExec(TorExecArgs), } #[derive(Args)] @@ -98,6 +108,14 @@ struct NetworkDeleteArgs { id: i32, } +#[cfg(target_os = "linux")] +#[derive(Args)] +struct TorExecArgs { + payload_path: String, + #[arg(required = true, num_args = 1.., trailing_var_arg = true)] + command: Vec, +} + #[cfg(any(target_os = "linux", target_vendor = "apple"))] async fn try_start() -> Result<()> { let mut client = BurrowClient::from_uds().await?; @@ -209,6 +227,17 @@ async fn try_network_delete(id: i32) -> Result<()> { Ok(()) } +#[cfg(target_os = "linux")] +async fn try_tor_exec(payload_path: &str, command: Vec) -> Result<()> { + let payload = tokio::fs::read(payload_path).await?; + let config = tor::Config::from_payload(&payload)?; + let exit_code = tor::run_exec(config, command).await?; + if exit_code != 0 { + std::process::exit(exit_code); + } + Ok(()) +} + #[cfg(any(target_os = "linux", target_vendor = "apple"))] fn handle_unexpected(res: Result) { match res { @@ -285,6 +314,8 @@ async fn main() -> Result<()> { Commands::NetworkList => try_network_list().await?, Commands::NetworkReorder(args) => try_network_reorder(args.id, args.index).await?, Commands::NetworkDelete(args) => try_network_delete(args.id).await?, + #[cfg(target_os = "linux")] + Commands::TorExec(args) => try_tor_exec(&args.payload_path, args.command.clone()).await?, } Ok(()) diff --git a/burrow/src/tor/config.rs b/burrow/src/tor/config.rs new file mode 100644 index 0000000..d3de9ec --- /dev/null +++ b/burrow/src/tor/config.rs @@ -0,0 +1,187 @@ +use std::{net::SocketAddr, path::PathBuf, str}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Config { + #[serde(default)] + pub account: Option, + #[serde(default)] + pub identity: Option, + #[serde(default)] + pub address: Vec, + #[serde(default)] + pub dns: Vec, + #[serde(default)] + pub mtu: Option, + #[serde(default)] + pub tun_name: Option, + #[serde(default)] + pub arti: ArtiConfig, + #[serde(default)] + pub tcp_stack: TcpStackConfig, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtiConfig { + pub state_dir: String, + pub cache_dir: String, +} + +impl Default for ArtiConfig { + fn default() -> Self { + Self { + state_dir: "/var/lib/burrow/arti/state".to_string(), + cache_dir: "/var/cache/burrow/arti".to_string(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum TcpStackConfig { + System(SystemTcpStackConfig), +} + +impl Default for TcpStackConfig { + fn default() -> Self { + Self::System(SystemTcpStackConfig::default()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SystemTcpStackConfig { + #[serde(default = "default_system_listen")] + pub listen: String, +} + +impl Default for SystemTcpStackConfig { + fn default() -> Self { + Self { + listen: default_system_listen(), + } + } +} + +impl Config { + pub fn from_payload(payload: &[u8]) -> Result { + if let Ok(config) = serde_json::from_slice(payload) { + return Ok(config); + } + + let payload = str::from_utf8(payload).context("tor payload must be valid UTF-8")?; + toml::from_str(payload).context("failed to parse tor payload as JSON or TOML") + } + + pub fn listen_addr(&self) -> Result { + match &self.tcp_stack { + TcpStackConfig::System(config) => config + .listen + .parse() + .with_context(|| format!("invalid system tcp listen address '{}'", config.listen)), + } + } + + pub fn authority(&self) -> String { + "arti://local".to_owned() + } + + pub fn account_name(&self) -> String { + self.account + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "default".to_owned()) + } + + pub fn identity_name(&self, network_id: i32) -> String { + self.identity + .clone() + .filter(|value| !value.trim().is_empty()) + .or_else(|| self.tun_name.clone()) + .unwrap_or_else(|| format!("tor-{network_id}")) + } + + pub fn runtime_dirs(&self, network_id: i32) -> (String, String) { + let authority = sanitize_path_component(&self.authority()); + let account = sanitize_path_component(&self.account_name()); + let identity = sanitize_path_component(&self.identity_name(network_id)); + ( + append_runtime_path(&self.arti.state_dir, &[&authority, &account, &identity]), + append_runtime_path(&self.arti.cache_dir, &[&authority, &account, &identity]), + ) + } +} + +fn default_system_listen() -> String { + "127.0.0.1:9040".to_string() +} + +fn append_runtime_path(base: &str, parts: &[&str]) -> String { + let mut path = PathBuf::from(base); + for part in parts { + path.push(part); + } + path.to_string_lossy().to_string() +} + +fn sanitize_path_component(value: &str) -> String { + let sanitized: String = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect(); + + if sanitized.is_empty() { + "default".to_owned() + } else { + sanitized + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_json_payload() { + let payload = br#"{ + "address":["100.64.0.2/32"], + "mtu":1400, + "arti":{"state_dir":"/tmp/state","cache_dir":"/tmp/cache"}, + "tcp_stack":{"kind":"system","listen":"127.0.0.1:9150"} + }"#; + + let config = Config::from_payload(payload).unwrap(); + assert_eq!(config.address, vec!["100.64.0.2/32"]); + assert_eq!(config.listen_addr().unwrap().to_string(), "127.0.0.1:9150"); + assert!(config.runtime_dirs(7).0.contains("arti___local")); + } + + #[test] + fn parses_toml_payload() { + let payload = r#" +address = ["100.64.0.3/32"] +mtu = 1280 +tun_name = "burrow-tor" + +[arti] +state_dir = "/tmp/state" +cache_dir = "/tmp/cache" + +[tcp_stack] +kind = "system" +listen = "127.0.0.1:9140" +"#; + + let config = Config::from_payload(payload.as_bytes()).unwrap(); + assert_eq!(config.tun_name.as_deref(), Some("burrow-tor")); + assert_eq!(config.listen_addr().unwrap().to_string(), "127.0.0.1:9140"); + assert_eq!(config.identity_name(11), "burrow-tor"); + } +} diff --git a/burrow/src/tor/dns.rs b/burrow/src/tor/dns.rs new file mode 100644 index 0000000..46ba96e --- /dev/null +++ b/burrow/src/tor/dns.rs @@ -0,0 +1,178 @@ +use std::{ + net::{IpAddr, SocketAddr}, + sync::Arc, +}; + +use anyhow::{Context, Result}; +use arti_client::TorClient; +use hickory_proto::{ + op::{Message, MessageType, ResponseCode}, + rr::{rdata::A, rdata::AAAA, RData, Record, RecordType}, +}; +use tokio::{net::UdpSocket, sync::watch, task::JoinError}; +use tor_rtcompat::PreferredRuntime; +use tracing::{debug, warn}; + +const DNS_TTL_SECS: u32 = 60; + +#[derive(Debug)] +pub struct TorDnsHandle { + shutdown: watch::Sender, + task: tokio::task::JoinHandle<()>, +} + +impl TorDnsHandle { + pub async fn shutdown(self) -> Result<()> { + let _ = self.shutdown.send(true); + match self.task.await { + Ok(()) => Ok(()), + Err(err) if err.is_cancelled() => Ok(()), + Err(err) => Err(join_error(err)), + } + } +} + +pub async fn spawn( + bind_addr: SocketAddr, + tor_client: Arc>, +) -> Result { + let socket = UdpSocket::bind(bind_addr) + .await + .with_context(|| format!("failed to bind tor dns proxy on {bind_addr}"))?; + let (shutdown_tx, mut shutdown_rx) = watch::channel(false); + let task = tokio::spawn(async move { + let mut buffer = [0u8; 4096]; + loop { + tokio::select! { + changed = shutdown_rx.changed() => { + match changed { + Ok(()) if *shutdown_rx.borrow() => break, + Ok(()) => continue, + Err(_) => break, + } + } + received = socket.recv_from(&mut buffer) => { + let (len, peer_addr) = match received { + Ok(value) => value, + Err(err) => { + warn!(?err, "tor dns proxy recv failed"); + continue; + } + }; + + let response = match build_response(&buffer[..len], tor_client.as_ref()).await { + Ok(message) => message, + Err(err) => { + debug!(?err, "tor dns proxy failed to answer query"); + continue; + } + }; + + if let Err(err) = socket.send_to(&response, peer_addr).await { + warn!(?err, "tor dns proxy send failed"); + } + } + } + } + }); + + Ok(TorDnsHandle { + shutdown: shutdown_tx, + task, + }) +} + +async fn build_response( + packet: &[u8], + tor_client: &TorClient, +) -> Result> { + let request = Message::from_vec(packet).context("failed to parse dns packet")?; + let mut response = Message::new(); + response + .set_id(request.id()) + .set_op_code(request.op_code()) + .set_message_type(MessageType::Response) + .set_recursion_desired(request.recursion_desired()) + .set_recursion_available(true) + .set_response_code(ResponseCode::NoError); + + for query in request.queries().iter().cloned() { + response.add_query(query.clone()); + match query.query_type() { + RecordType::A | RecordType::AAAA => { + let hostname = query.name().to_utf8(); + let hostname = hostname.trim_end_matches('.'); + match tor_client.resolve(hostname).await { + Ok(addrs) => { + for addr in addrs { + if let Some(answer) = + record_for_address(query.name().clone(), query.query_type(), addr) + { + response.add_answer(answer); + } + } + } + Err(err) => { + debug!(hostname, ?err, "tor dns lookup failed"); + response.set_response_code(ResponseCode::ServFail); + } + } + } + _ => { + response.set_response_code(ResponseCode::NotImp); + } + } + } + + response.to_vec().context("failed to encode dns response") +} + +fn record_for_address( + name: hickory_proto::rr::Name, + record_type: RecordType, + addr: IpAddr, +) -> Option { + match (record_type, addr) { + (RecordType::A, IpAddr::V4(ip)) => { + Some(Record::from_rdata(name, DNS_TTL_SECS, RData::A(A::from(ip)))) + } + (RecordType::AAAA, IpAddr::V6(ip)) => Some(Record::from_rdata( + name, + DNS_TTL_SECS, + RData::AAAA(AAAA::from(ip)), + )), + _ => None, + } +} + +fn join_error(err: JoinError) -> anyhow::Error { + anyhow::anyhow!("tor dns task failed: {err}") +} + +#[cfg(test)] +mod tests { + use super::*; + use hickory_proto::rr::Name; + use std::net::{Ipv4Addr, Ipv6Addr}; + + #[test] + fn builds_a_record_for_ipv4_answer() { + let record = record_for_address( + Name::from_ascii("example.com.").unwrap(), + RecordType::A, + IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), + ) + .unwrap(); + assert_eq!(record.record_type(), RecordType::A); + } + + #[test] + fn skips_mismatched_record_type() { + let record = record_for_address( + Name::from_ascii("example.com.").unwrap(), + RecordType::A, + IpAddr::V6(Ipv6Addr::LOCALHOST), + ); + assert!(record.is_none()); + } +} diff --git a/burrow/src/tor/exec.rs b/burrow/src/tor/exec.rs new file mode 100644 index 0000000..7f4317d --- /dev/null +++ b/burrow/src/tor/exec.rs @@ -0,0 +1,439 @@ +use std::{ + ffi::{OsStr, OsString}, + fs, + net::{IpAddr, Ipv4Addr, SocketAddr}, + os::unix::process::ExitStatusExt, + path::PathBuf, + process::{Command, ExitStatus, Stdio}, + sync::Arc, + time::Duration, +}; + +use anyhow::{bail, Context, Result}; +use tokio::process::Command as TokioCommand; +use tor_rtcompat::PreferredRuntime; +use tracing::{debug, info}; + +use super::{ + bootstrap_client, + dns::{spawn as spawn_dns, TorDnsHandle}, + runtime::{spawn_with_client, TorHandle}, + Config, SystemTcpStackConfig, TcpStackConfig, +}; + +const CHILD_PREFIX_LEN: u8 = 30; +const CHILD_DNS_PORT: u16 = 53; +const LISTENER_READY_TIMEOUT: Duration = Duration::from_secs(10); +const LISTENER_READY_POLL: Duration = Duration::from_millis(100); + +pub async fn run_exec(mut config: Config, command: Vec) -> Result { + if command.is_empty() { + bail!("tor-exec requires a command to run"); + } + ensure_root()?; + ensure_host_tool("ip")?; + ensure_host_tool("iptables")?; + ensure_host_tool("unshare")?; + + let requested_listener = config.listen_addr()?; + if requested_listener.port() == 0 { + bail!("tor-exec requires a fixed listener port"); + } + + let plan = NamespacePlan::new(requested_listener.port()); + let (state_dir, cache_dir) = config.runtime_dirs(std::process::id() as i32); + config.arti.state_dir = state_dir; + config.arti.cache_dir = cache_dir; + config.tcp_stack = TcpStackConfig::System(SystemTcpStackConfig { + listen: format!("{}:{}", plan.host_ip, plan.listener_port), + }); + + let namespace = NamespaceGuard::create(&plan)?; + let tor_client = bootstrap_client(&config).await?; + let tor_handle = spawn_with_client(config, tor_client.clone()).await?; + wait_for_listener(SocketAddr::new( + IpAddr::V4(plan.host_ip), + plan.listener_port, + )) + .await?; + let dns_handle = spawn_dns( + SocketAddr::new(IpAddr::V4(plan.host_ip), CHILD_DNS_PORT), + tor_client, + ) + .await?; + + let status = namespace.run_child(&command).await; + let dns_shutdown = dns_handle.shutdown().await; + let tor_shutdown = tor_handle.shutdown().await; + + let status = status?; + dns_shutdown?; + tor_shutdown?; + child_exit_code(status) +} + +fn ensure_root() -> Result<()> { + if unsafe { libc::geteuid() } != 0 { + bail!("tor-exec currently requires root on linux"); + } + Ok(()) +} + +fn ensure_host_tool(tool: &str) -> Result<()> { + let status = Command::new("sh") + .args(["-lc", &format!("command -v {tool} >/dev/null")]) + .status() + .with_context(|| format!("failed to probe required tool '{tool}'"))?; + if !status.success() { + bail!("required host tool '{tool}' is not available"); + } + Ok(()) +} + +async fn wait_for_listener(addr: SocketAddr) -> Result<()> { + let deadline = tokio::time::Instant::now() + LISTENER_READY_TIMEOUT; + loop { + match tokio::net::TcpStream::connect(addr).await { + Ok(stream) => { + drop(stream); + return Ok(()); + } + Err(err) if tokio::time::Instant::now() < deadline => { + debug!(%addr, ?err, "waiting for tor transparent listener"); + tokio::time::sleep(LISTENER_READY_POLL).await; + } + Err(err) => return Err(err).with_context(|| format!("timed out waiting for {addr}")), + } + } +} + +fn child_exit_code(status: ExitStatus) -> Result { + if let Some(code) = status.code() { + return Ok(code); + } + if let Some(signal) = status.signal() { + return Ok(128 + signal); + } + bail!("child process terminated without an exit code"); +} + +#[derive(Debug, Clone)] +struct NamespacePlan { + netns_name: String, + host_if: String, + child_if: String, + host_ip: Ipv4Addr, + child_ip: Ipv4Addr, + listener_port: u16, +} + +impl NamespacePlan { + fn new(listener_port: u16) -> Self { + let token = std::process::id() % 10_000; + let segment = ((std::process::id() % 200) as u8) + 20; + Self { + netns_name: format!("burrow-tor-{token}"), + host_if: format!("bth{token}"), + child_if: format!("btc{token}"), + host_ip: Ipv4Addr::new(100, 90, segment, 1), + child_ip: Ipv4Addr::new(100, 90, segment, 2), + listener_port, + } + } + + fn host_cidr(&self) -> String { + format!("{}/{}", self.host_ip, CHILD_PREFIX_LEN) + } + + fn child_cidr(&self) -> String { + format!("{}/{}", self.child_ip, CHILD_PREFIX_LEN) + } +} + +struct NamespaceGuard { + plan: NamespacePlan, + resolv_conf: PathBuf, + nat_rule_installed: bool, + forward_rule_installed: bool, + netns_created: bool, + host_link_created: bool, +} + +impl NamespaceGuard { + fn create(plan: &NamespacePlan) -> Result { + let mut guard = Self { + plan: plan.clone(), + resolv_conf: write_resolv_conf(plan.host_ip)?, + nat_rule_installed: false, + forward_rule_installed: false, + netns_created: false, + host_link_created: false, + }; + + let setup = (|| -> Result<()> { + run_host_command(["ip", "netns", "add", &guard.plan.netns_name])?; + guard.netns_created = true; + + run_host_command([ + "ip", + "link", + "add", + &guard.plan.host_if, + "type", + "veth", + "peer", + "name", + &guard.plan.child_if, + ])?; + guard.host_link_created = true; + + run_host_command([ + "ip", + "addr", + "add", + &guard.plan.host_cidr(), + "dev", + &guard.plan.host_if, + ])?; + run_host_command(["ip", "link", "set", &guard.plan.host_if, "up"])?; + run_host_command([ + "ip", + "link", + "set", + &guard.plan.child_if, + "netns", + &guard.plan.netns_name, + ])?; + run_host_command([ + "ip", + "netns", + "exec", + &guard.plan.netns_name, + "ip", + "link", + "set", + "lo", + "up", + ])?; + run_host_command([ + "ip", + "netns", + "exec", + &guard.plan.netns_name, + "ip", + "addr", + "add", + &guard.plan.child_cidr(), + "dev", + &guard.plan.child_if, + ])?; + run_host_command([ + "ip", + "netns", + "exec", + &guard.plan.netns_name, + "ip", + "link", + "set", + &guard.plan.child_if, + "up", + ])?; + run_host_command([ + "ip", + "netns", + "exec", + &guard.plan.netns_name, + "ip", + "route", + "add", + "default", + "via", + &guard.plan.host_ip.to_string(), + "dev", + &guard.plan.child_if, + ])?; + run_host_command([ + "iptables", + "-t", + "nat", + "-A", + "PREROUTING", + "-i", + &guard.plan.host_if, + "-p", + "tcp", + "-j", + "DNAT", + "--to-destination", + &format!("{}:{}", guard.plan.host_ip, guard.plan.listener_port), + ])?; + guard.nat_rule_installed = true; + + run_host_command([ + "iptables", + "-A", + "FORWARD", + "-i", + &guard.plan.host_if, + "-j", + "REJECT", + ])?; + guard.forward_rule_installed = true; + Ok(()) + })(); + + if let Err(err) = setup { + guard.cleanup(); + return Err(err); + } + + Ok(guard) + } + + async fn run_child(&self, command: &[String]) -> Result { + let mut args = vec![ + OsString::from("netns"), + OsString::from("exec"), + OsString::from(&self.plan.netns_name), + OsString::from("unshare"), + OsString::from("--user"), + OsString::from("--map-root-user"), + OsString::from("--mount"), + OsString::from("--pid"), + OsString::from("--fork"), + OsString::from("--kill-child"), + OsString::from("sh"), + OsString::from("-ceu"), + OsString::from(CHILD_SCRIPT), + OsString::from("sh"), + self.resolv_conf.as_os_str().to_os_string(), + ]; + args.extend(command.iter().map(OsString::from)); + + let status = TokioCommand::new("ip") + .args(args) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .await + .context("failed to execute child in tor namespace")?; + Ok(status) + } + + fn cleanup(&mut self) { + if self.forward_rule_installed { + let _ = run_host_command([ + "iptables", + "-D", + "FORWARD", + "-i", + &self.plan.host_if, + "-j", + "REJECT", + ]); + self.forward_rule_installed = false; + } + if self.nat_rule_installed { + let _ = run_host_command([ + "iptables", + "-t", + "nat", + "-D", + "PREROUTING", + "-i", + &self.plan.host_if, + "-p", + "tcp", + "-j", + "DNAT", + "--to-destination", + &format!("{}:{}", self.plan.host_ip, self.plan.listener_port), + ]); + self.nat_rule_installed = false; + } + if self.host_link_created { + let _ = run_host_command(["ip", "link", "delete", &self.plan.host_if]); + self.host_link_created = false; + } + if self.netns_created { + let _ = run_host_command(["ip", "netns", "delete", &self.plan.netns_name]); + self.netns_created = false; + } + let _ = fs::remove_file(&self.resolv_conf); + } +} + +impl Drop for NamespaceGuard { + fn drop(&mut self) { + self.cleanup(); + } +} + +fn write_resolv_conf(nameserver: Ipv4Addr) -> Result { + let path = std::env::temp_dir().join(format!("burrow-tor-resolv-{}.conf", std::process::id())); + fs::write(&path, format!("nameserver {nameserver}\noptions ndots:1\n")) + .with_context(|| format!("failed to write {}", path.display()))?; + Ok(path) +} + +fn run_host_command(args: [&str; N]) -> Result<()> { + let (program, rest) = args + .split_first() + .expect("run_host_command requires a program and arguments"); + let status = Command::new(program) + .args(rest) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .status() + .with_context(|| format!("failed to start host command {}", shell_words(&args)))?; + if status.success() { + Ok(()) + } else { + bail!("host command failed: {}", shell_words(&args)); + } +} + +fn shell_words(args: &[&str]) -> String { + args.iter() + .map(|arg| shlex_escape(arg)) + .collect::>() + .join(" ") +} + +fn shlex_escape(value: &str) -> String { + if value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || "-_./:=+".contains(ch)) + { + value.to_string() + } else { + format!("'{}'", value.replace('\'', "'\\''")) + } +} + +const CHILD_SCRIPT: &str = r#" +mount -t proc proc /proc +mount --bind "$1" /etc/resolv.conf +shift +exec "$@" +"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn namespace_plan_uses_short_interface_names() { + let plan = NamespacePlan::new(9040); + assert!(plan.host_if.len() <= 15); + assert!(plan.child_if.len() <= 15); + } + + #[test] + fn signal_exit_code_uses_shell_convention() { + let status = ExitStatus::from_raw(libc::SIGTERM); + assert_eq!(child_exit_code(status).unwrap(), 128 + libc::SIGTERM); + } +} diff --git a/burrow/src/tor/mod.rs b/burrow/src/tor/mod.rs new file mode 100644 index 0000000..3c936f7 --- /dev/null +++ b/burrow/src/tor/mod.rs @@ -0,0 +1,9 @@ +mod config; +mod dns; +mod exec; +mod runtime; +mod system; + +pub use config::{ArtiConfig, Config, SystemTcpStackConfig, TcpStackConfig}; +pub use exec::run_exec; +pub use runtime::{bootstrap_client, spawn, spawn_with_client, TorHandle}; diff --git a/burrow/src/tor/runtime.rs b/burrow/src/tor/runtime.rs new file mode 100644 index 0000000..e583b83 --- /dev/null +++ b/burrow/src/tor/runtime.rs @@ -0,0 +1,129 @@ +use std::{sync::Arc, time::Duration}; + +use anyhow::{Context, Result}; +use arti_client::{config::TorClientConfigBuilder, TorClient}; +use tokio::{ + sync::watch, + task::{JoinError, JoinSet}, +}; +use tokio_util::compat::FuturesAsyncReadCompatExt; +use tor_rtcompat::PreferredRuntime; +use tracing::{debug, error, info, warn}; + +use super::{system::SystemTcpStackRuntime, Config, TcpStackConfig}; + +#[derive(Debug)] +pub struct TorHandle { + shutdown: watch::Sender, + task: tokio::task::JoinHandle<()>, +} + +impl TorHandle { + pub async fn shutdown(self) -> Result<()> { + let _ = self.shutdown.send(true); + match self.task.await { + Ok(()) => Ok(()), + Err(err) if err.is_cancelled() => Ok(()), + Err(err) => Err(join_error(err)), + } + } +} + +pub async fn bootstrap_client(config: &Config) -> Result>> { + let builder = + TorClientConfigBuilder::from_directories(&config.arti.state_dir, &config.arti.cache_dir); + let tor_config = builder.build().context("failed to build arti config")?; + let tor_client = TorClient::create_bootstrapped(tor_config) + .await + .context("failed to bootstrap arti client")?; + Ok(Arc::new(tor_client)) +} + +pub async fn spawn(config: Config) -> Result { + let tor_client = bootstrap_client(&config).await?; + spawn_with_client(config, tor_client).await +} + +pub async fn spawn_with_client( + config: Config, + tor_client: Arc>, +) -> Result { + let (shutdown_tx, mut shutdown_rx) = watch::channel(false); + let task = match config.tcp_stack.clone() { + TcpStackConfig::System(system_config) => tokio::spawn(async move { + let stack = match SystemTcpStackRuntime::bind(&system_config).await { + Ok(stack) => stack, + Err(err) => { + error!(?err, "failed to bind system tcp stack listener"); + return; + } + }; + info!( + listen = %stack.local_addr(), + "system tcp stack listener bound for tor transparent proxy" + ); + + let mut connections = JoinSet::new(); + loop { + tokio::select! { + changed = shutdown_rx.changed() => { + match changed { + Ok(()) if *shutdown_rx.borrow() => break, + Ok(()) => continue, + Err(_) => break, + } + } + Some(res) = connections.join_next(), if !connections.is_empty() => { + match res { + Ok(Ok(())) => {} + Ok(Err(err)) => warn!(?err, "transparent proxy task failed"), + Err(err) => warn!(?err, "transparent proxy task panicked"), + } + } + accepted = stack.accept() => { + let (mut inbound, original_dst) = match accepted { + Ok(pair) => pair, + Err(err) => { + warn!(?err, "failed to accept transparent tcp connection"); + tokio::time::sleep(Duration::from_millis(50)).await; + continue; + } + }; + + let tor_client = tor_client.clone(); + connections.spawn(async move { + debug!(%original_dst, "accepted transparent tcp connection"); + let tor_stream = tor_client + .connect((original_dst.ip().to_string(), original_dst.port())) + .await + .with_context(|| format!("failed to connect to {original_dst} over tor"))?; + let mut tor_stream = tor_stream.compat(); + tokio::io::copy_bidirectional(&mut inbound, &mut tor_stream) + .await + .with_context(|| format!("failed to bridge tor stream for {original_dst}"))?; + Result::<()>::Ok(()) + }); + } + } + } + + connections.abort_all(); + while let Some(res) = connections.join_next().await { + match res { + Ok(Ok(())) => {} + Ok(Err(err)) => debug!(?err, "transparent proxy task failed during shutdown"), + Err(err) => debug!(?err, "transparent proxy task exited during shutdown"), + } + } + }), + }; + + Ok(TorHandle { + shutdown: shutdown_tx, + task, + }) +} + +fn join_error(err: JoinError) -> anyhow::Error { + anyhow::anyhow!("tor runtime task failed: {err}") +} diff --git a/burrow/src/tor/system.rs b/burrow/src/tor/system.rs new file mode 100644 index 0000000..db00e3c --- /dev/null +++ b/burrow/src/tor/system.rs @@ -0,0 +1,140 @@ +use std::net::SocketAddr; + +use anyhow::{Context, Result}; +use tokio::net::{TcpListener, TcpStream}; + +use super::SystemTcpStackConfig; + +pub struct SystemTcpStackRuntime { + listener: TcpListener, +} + +impl SystemTcpStackRuntime { + pub async fn bind(config: &SystemTcpStackConfig) -> Result { + let listener = TcpListener::bind(&config.listen) + .await + .with_context(|| format!("failed to bind transparent listener on {}", config.listen))?; + Ok(Self { listener }) + } + + pub fn local_addr(&self) -> SocketAddr { + self.listener + .local_addr() + .expect("listener should always have a local address") + } + + pub async fn accept(&self) -> Result<(TcpStream, SocketAddr)> { + let (stream, _) = self + .listener + .accept() + .await + .context("failed to accept transparent listener connection")?; + let original_dst = original_destination(&stream)?; + Ok((stream, original_dst)) + } +} + +#[cfg(target_os = "linux")] +fn original_destination(stream: &TcpStream) -> Result { + use std::{ + mem::{size_of, MaybeUninit}, + os::fd::AsRawFd, + }; + + let level = if stream.local_addr()?.is_ipv6() { + libc::SOL_IPV6 + } else { + libc::SOL_IP + }; + + let mut addr = MaybeUninit::::zeroed(); + let mut len = size_of::() as libc::socklen_t; + let rc = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + level, + 80, + addr.as_mut_ptr().cast(), + &mut len, + ) + }; + if rc != 0 { + return Err(std::io::Error::last_os_error()).context("SO_ORIGINAL_DST lookup failed"); + } + + socket_addr_from_storage(unsafe { &addr.assume_init() }, len as usize) +} + +#[cfg(not(target_os = "linux"))] +fn original_destination(_stream: &TcpStream) -> Result { + anyhow::bail!("system tcp stack transparent destination lookup is only implemented on linux") +} + +fn socket_addr_from_storage(addr: &libc::sockaddr_storage, len: usize) -> Result { + use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6}; + + if len < std::mem::size_of::() { + anyhow::bail!("socket address buffer was too short"); + } + + match addr.ss_family as i32 { + libc::AF_INET => { + let addr_in = unsafe { *(addr as *const _ as *const libc::sockaddr_in) }; + let ip = Ipv4Addr::from(u32::from_be(addr_in.sin_addr.s_addr)); + let port = u16::from_be(addr_in.sin_port); + Ok(SocketAddr::V4(SocketAddrV4::new(ip, port))) + } + libc::AF_INET6 => { + let addr_in = unsafe { *(addr as *const _ as *const libc::sockaddr_in6) }; + let ip = Ipv6Addr::from(addr_in.sin6_addr.s6_addr); + let port = u16::from_be(addr_in.sin6_port); + Ok(SocketAddr::V6(SocketAddrV6::new( + ip, + port, + addr_in.sin6_flowinfo, + addr_in.sin6_scope_id, + ))) + } + family => anyhow::bail!("unsupported socket address family {family}"), + } +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use std::{ + mem::size_of, + net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6}, + }; + + #[test] + fn parses_ipv4_socket_addr() { + let mut storage = unsafe { std::mem::zeroed::() }; + let addr_in = unsafe { &mut *(&mut storage as *mut _ as *mut libc::sockaddr_in) }; + addr_in.sin_family = libc::AF_INET as libc::sa_family_t; + addr_in.sin_port = u16::to_be(9040); + addr_in.sin_addr = libc::in_addr { + s_addr: u32::to_be(u32::from(Ipv4Addr::new(127, 0, 0, 1))), + }; + + let parsed = socket_addr_from_storage(&storage, size_of::()).unwrap(); + assert_eq!(parsed, SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 9040))); + } + + #[test] + fn parses_ipv6_socket_addr() { + let mut storage = unsafe { std::mem::zeroed::() }; + let addr_in = unsafe { &mut *(&mut storage as *mut _ as *mut libc::sockaddr_in6) }; + addr_in.sin6_family = libc::AF_INET6 as libc::sa_family_t; + addr_in.sin6_port = u16::to_be(9150); + addr_in.sin6_addr = libc::in6_addr { + s6_addr: Ipv6Addr::LOCALHOST.octets(), + }; + + let parsed = socket_addr_from_storage(&storage, size_of::()).unwrap(); + assert_eq!( + parsed, + SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 9150, 0, 0)) + ); + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..ff09ebf --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.93.1" +components = ["rustfmt"] +profile = "minimal" From f9062eae3384a5338c52f8724e98c01df2395f21 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Tue, 31 Mar 2026 12:50:28 -0700 Subject: [PATCH 030/102] Fix Apple simulator and Swift 6 build plumbing --- Apple/Configuration/Compiler.xcconfig | 3 +- Apple/Configuration/Constants/Constants.swift | 24 ++++++- .../Client/google/protobuf/timestamp.proto | 64 +++++++++++++++++++ .../PacketTunnelProvider.swift | 58 +++++++++++------ .../NetworkExtension/libburrow/build-rust.sh | 16 ++++- burrow/Cargo.toml | 11 +++- burrow/src/tracing.rs | 62 ++++++++++-------- 7 files changed, 188 insertions(+), 50 deletions(-) create mode 100644 Apple/Core/Client/google/protobuf/timestamp.proto diff --git a/Apple/Configuration/Compiler.xcconfig b/Apple/Configuration/Compiler.xcconfig index 6b071f1..a5f4339 100644 --- a/Apple/Configuration/Compiler.xcconfig +++ b/Apple/Configuration/Compiler.xcconfig @@ -40,5 +40,4 @@ APP_GROUP_IDENTIFIER = group.$(APP_BUNDLE_IDENTIFIER) APP_GROUP_IDENTIFIER[sdk=macosx*] = $(DEVELOPMENT_TEAM).$(APP_BUNDLE_IDENTIFIER) NETWORK_EXTENSION_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).network -// https://github.com/grpc/grpc-swift/issues/683#issuecomment-1130118953 -OTHER_SWIFT_FLAGS = $(inherited) -Xcc -fmodule-map-file=$(GENERATED_MODULEMAP_DIR)/CNIOAtomics.modulemap -Xcc -fmodule-map-file=$(GENERATED_MODULEMAP_DIR)/CNIODarwin.modulemap -Xcc -fmodule-map-file=$(GENERATED_MODULEMAP_DIR)/CGRPCZlib.modulemap +OTHER_SWIFT_FLAGS = $(inherited) diff --git a/Apple/Configuration/Constants/Constants.swift b/Apple/Configuration/Constants/Constants.swift index 3f8ae95..8844564 100644 --- a/Apple/Configuration/Constants/Constants.swift +++ b/Apple/Configuration/Constants/Constants.swift @@ -1,4 +1,5 @@ @_implementationOnly import CConstants +import Foundation import OSLog public enum Constants { @@ -27,9 +28,30 @@ public enum Constants { private static let _groupContainerURL: Result = { switch FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) { case .some(let url): .success(url) - case .none: .failure(.invalidAppGroupIdentifier) + case .none: + fallbackContainerURL().mapError { _ in .invalidAppGroupIdentifier } } }() + + private static func fallbackContainerURL() -> Result { +#if targetEnvironment(simulator) + Result { + let baseURL = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let url = baseURL + .appending(component: bundleIdentifier, directoryHint: .isDirectory) + .appending(component: "SimulatorFallback", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } +#else + .failure(Error.invalidAppGroupIdentifier) +#endif + } } extension Logger { diff --git a/Apple/Core/Client/google/protobuf/timestamp.proto b/Apple/Core/Client/google/protobuf/timestamp.proto new file mode 100644 index 0000000..7db2f6a --- /dev/null +++ b/Apple/Core/Client/google/protobuf/timestamp.proto @@ -0,0 +1,64 @@ +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +syntax = "proto3"; + +package google.protobuf; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/timestamppb"; +option java_package = "com.google.protobuf"; +option java_outer_classname = "TimestampProto"; +option java_multiple_files = true; +option objc_class_prefix = "GPB"; +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; + +// A Timestamp represents a point in time independent of any time zone or local +// calendar, encoded as a count of seconds and fractions of seconds at +// nanosecond resolution. The count is relative to an epoch at UTC midnight on +// January 1, 1970, in the proleptic Gregorian calendar which extends the +// Gregorian calendar backwards to year one. +// +// All minutes are 60 seconds long. Leap seconds are "smeared" so that no leap +// second table is needed for interpretation, using a 24-hour linear smear. +// +// The range is from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. By +// restricting to that range, we ensure that we can convert to and from RFC +// 3339 date strings. +message Timestamp { + // Represents seconds of UTC time since Unix epoch 1970-01-01T00:00:00Z. + // Must be from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59Z inclusive. + int64 seconds = 1; + + // Non-negative fractions of a second at nanosecond resolution. Negative + // second values with fractions must still have non-negative nanos values + // that count forward in time. Must be from 0 to 999,999,999 inclusive. + int32 nanos = 2; +} diff --git a/Apple/NetworkExtension/PacketTunnelProvider.swift b/Apple/NetworkExtension/PacketTunnelProvider.swift index a8e42e0..4f29543 100644 --- a/Apple/NetworkExtension/PacketTunnelProvider.swift +++ b/Apple/NetworkExtension/PacketTunnelProvider.swift @@ -5,7 +5,15 @@ import libburrow import NetworkExtension import os -class PacketTunnelProvider: NEPacketTunnelProvider { +private final class SendableCallbackBox: @unchecked Sendable { + let callback: Callback + + init(_ callback: Callback) { + self.callback = callback + } +} + +final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { enum Error: Swift.Error { case missingTunnelConfiguration } @@ -30,27 +38,41 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } } - override func startTunnel(options: [String: NSObject]? = nil) async throws { - do { - let configuration = try await Array(client.tunnelConfiguration(.init()).prefix(1)).first - guard let settings = configuration?.settings else { - throw Error.missingTunnelConfiguration + override func startTunnel( + options: [String: NSObject]?, + completionHandler: @escaping (Swift.Error?) -> Void + ) { + let completion = SendableCallbackBox(completionHandler) + Task { + do { + let configuration = try await Array(client.tunnelConfiguration(.init()).prefix(1)).first + guard let settings = configuration?.settings else { + throw Error.missingTunnelConfiguration + } + try await setTunnelNetworkSettings(settings) + _ = try await client.tunnelStart(.init()) + logger.log("Started tunnel with network settings: \(settings)") + completion.callback(nil) + } catch { + logger.error("Failed to start tunnel: \(error)") + completion.callback(error) } - try await setTunnelNetworkSettings(settings) - _ = try await client.tunnelStart(.init()) - logger.log("Started tunnel with network settings: \(settings)") - } catch { - logger.error("Failed to start tunnel: \(error)") - throw error } } - override func stopTunnel(with reason: NEProviderStopReason) async { - do { - _ = try await client.tunnelStop(.init()) - logger.log("Stopped client") - } catch { - logger.error("Failed to stop tunnel: \(error)") + override func stopTunnel( + with reason: NEProviderStopReason, + completionHandler: @escaping () -> Void + ) { + let completion = SendableCallbackBox(completionHandler) + Task { + do { + _ = try await client.tunnelStop(.init()) + logger.log("Stopped client") + } catch { + logger.error("Failed to stop tunnel: \(error)") + } + completion.callback() } } } diff --git a/Apple/NetworkExtension/libburrow/build-rust.sh b/Apple/NetworkExtension/libburrow/build-rust.sh index 6f455a9..5db2a2b 100755 --- a/Apple/NetworkExtension/libburrow/build-rust.sh +++ b/Apple/NetworkExtension/libburrow/build-rust.sh @@ -73,7 +73,21 @@ CARGO_PATH="$(dirname $PROTOC):$CARGO_PATH" # Run cargo without the various environment variables set by Xcode. # Those variables can confuse cargo and the build scripts it runs. -env -i PATH="$CARGO_PATH" PROTOC="$PROTOC" CARGO_TARGET_DIR="${CONFIGURATION_TEMP_DIR}/target" IPHONEOS_DEPLOYMENT_TARGET="$IPHONEOS_DEPLOYMENT_TARGET" MACOSX_DEPLOYMENT_TARGET="$MACOSX_DEPLOYMENT_TARGET" cargo build "${CARGO_ARGS[@]}" +CARGO_ENV=( + "PATH=$CARGO_PATH" + "PROTOC=$PROTOC" + "CARGO_TARGET_DIR=${CONFIGURATION_TEMP_DIR}/target" +) + +if [[ -n "$IPHONEOS_DEPLOYMENT_TARGET" ]]; then + CARGO_ENV+=("IPHONEOS_DEPLOYMENT_TARGET=$IPHONEOS_DEPLOYMENT_TARGET") +fi + +if [[ -n "$MACOSX_DEPLOYMENT_TARGET" ]]; then + CARGO_ENV+=("MACOSX_DEPLOYMENT_TARGET=$MACOSX_DEPLOYMENT_TARGET") +fi + +env -i "${CARGO_ENV[@]}" cargo build "${CARGO_ARGS[@]}" mkdir -p "${BUILT_PRODUCTS_DIR}" diff --git a/burrow/Cargo.toml b/burrow/Cargo.toml index c3cfb75..22f3d25 100644 --- a/burrow/Cargo.toml +++ b/burrow/Cargo.toml @@ -15,6 +15,8 @@ tokio = { version = "1.37", features = [ "macros", "sync", "io-util", + "net", + "process", "rt-multi-thread", "signal", "time", @@ -25,7 +27,6 @@ tun = { version = "0.1", path = "../tun", features = ["serde", "tokio"] } clap = { version = "4.4", features = ["derive"] } tracing = "0.1" tracing-log = "0.1" -tracing-oslog = { git = "https://github.com/Stormshield-robinc/tracing-oslog" } tracing-subscriber = { version = "0.3", features = ["std", "env-filter"] } log = "0.4" serde = { version = "1", features = ["derive"] } @@ -47,13 +48,14 @@ base64 = "0.21" fehler = "1.0" ip_network_table = "0.2" ip_network = "0.4" -ipnetwork = "0.21" +ipnetwork = { version = "0.21", features = ["serde"] } async-channel = "2.1" schemars = "0.8" futures = "0.3.28" once_cell = "1.19" arti-client = "0.40.0" hickory-proto = "0.25.2" +netstack-smoltcp = "0.2.1" tokio-util = { version = "0.7.18", features = ["compat"] } tor-rtcompat = "0.40.0" console-subscriber = { version = "0.2.0", optional = true } @@ -65,7 +67,6 @@ reqwest = { version = "0.12", default-features = false, features = [ "rustls-tls", ] } rusqlite = { version = "0.38.0", features = ["blob"] } -iroh = "0.94.0" dotenv = "0.15.0" tonic = "0.12.0" prost = "0.13.1" @@ -82,12 +83,16 @@ subtle = "2.6" caps = "0.5" libc = "0.2" libsystemd = "0.7" +nix = { version = "0.27", features = ["fs", "socket", "uio"] } tracing-journald = "0.3" [target.'cfg(target_vendor = "apple")'.dependencies] nix = { version = "0.27" } rusqlite = { version = "0.38.0", features = ["bundled", "blob"] } +[target.'cfg(target_os = "macos")'.dependencies] +tracing-oslog = { git = "https://github.com/Stormshield-robinc/tracing-oslog" } + [dev-dependencies] insta = { version = "1.32", features = ["yaml"] } tempfile = "3.13" diff --git a/burrow/src/tracing.rs b/burrow/src/tracing.rs index 861b41f..21e16ae 100644 --- a/burrow/src/tracing.rs +++ b/burrow/src/tracing.rs @@ -3,8 +3,7 @@ use std::sync::Once; use tracing::{error, info}; use tracing_subscriber::{ layer::{Layer, SubscriberExt}, - EnvFilter, - Registry, + EnvFilter, Registry, }; static TRACING: Once = Once::new(); @@ -15,36 +14,49 @@ pub fn initialize() { error!("Failed to initialize LogTracer: {}", e); } - #[cfg(target_os = "windows")] - let system_log = Some(tracing_subscriber::fmt::layer()); - - #[cfg(target_os = "linux")] - let system_log = match tracing_journald::layer() { - Ok(layer) => Some(layer), - Err(e) => { - if e.kind() != std::io::ErrorKind::NotFound { - error!("Failed to initialize journald: {}", e); - } - None - } - }; - - #[cfg(target_vendor = "apple")] - let system_log = Some(tracing_oslog::OsLogger::new( - "com.hackclub.burrow", - "tracing", - )); - - let stderr = (console::user_attended_stderr() || system_log.is_none()).then(|| { + let make_stderr = || { tracing_subscriber::fmt::layer() .with_level(true) .with_writer(std::io::stderr) .with_line_number(true) .compact() .with_filter(EnvFilter::from_default_env()) - }); + }; - let subscriber = Registry::default().with(stderr).with(system_log); + #[cfg(target_os = "windows")] + let subscriber = { + let system_log = Some(tracing_subscriber::fmt::layer()); + let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr); + Registry::default().with(stderr).with(system_log) + }; + + #[cfg(target_os = "linux")] + let subscriber = { + let system_log = match tracing_journald::layer() { + Ok(layer) => Some(layer), + Err(e) => { + if e.kind() != std::io::ErrorKind::NotFound { + error!("Failed to initialize journald: {}", e); + } + None + } + }; + let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr); + Registry::default().with(stderr).with(system_log) + }; + + #[cfg(target_os = "macos")] + let subscriber = { + let system_log = Some(tracing_oslog::OsLogger::new( + "com.hackclub.burrow", + "tracing", + )); + let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr); + Registry::default().with(stderr).with(system_log) + }; + + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + let subscriber = Registry::default().with(Some(make_stderr())); #[cfg(feature = "tokio-console")] let subscriber = subscriber.with( From 7670a75840294f087c00590a442d38c9fcab61f4 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Tue, 31 Mar 2026 12:52:21 -0700 Subject: [PATCH 031/102] Add Tailnet accounts and Tailscale login flow --- Apple/App/AppDelegate.swift | 37 + Apple/Burrow.xcodeproj/project.pbxproj | 8 - .../HackClub.colorset/Contents.json | 20 - .../HackClub.imageset/Contents.json | 12 - .../flag-standalone-wtransparent.pdf | Bin 3501 -> 0 bytes Apple/UI/BurrowView.swift | 806 +++++++++++++++++- Apple/UI/NetworkCarouselView.swift | 48 +- Apple/UI/NetworkExtensionTunnel.swift | 2 +- Apple/UI/NetworkView.swift | 4 +- Apple/UI/Networks/HackClub.swift | 27 - Apple/UI/Networks/Network.swift | 533 +++++++++++- Apple/UI/Networks/WireGuard.swift | 57 +- Apple/UI/OAuth2.swift | 293 ------- Tools/tailscale-login-bridge/go.mod | 66 ++ Tools/tailscale-login-bridge/go.sum | 229 +++++ Tools/tailscale-login-bridge/main.go | 133 +++ burrow/src/auth/client.rs | 24 - burrow/src/auth/mod.rs | 1 - burrow/src/auth/server/db.rs | 690 +++++++++++++-- burrow/src/auth/server/mod.rs | 379 +++++++- burrow/src/auth/server/providers/mod.rs | 8 - burrow/src/auth/server/providers/slack.rs | 102 --- burrow/src/auth/server/tailscale.rs | 320 +++++++ burrow/src/control/config.rs | 87 ++ burrow/src/control/mod.rs | 253 ++++++ burrow/src/daemon/mod.rs | 34 +- burrow/src/daemon/runtime.rs | 65 +- burrow/src/database.rs | 73 +- proto/burrow.proto | 2 +- 29 files changed, 3538 insertions(+), 775 deletions(-) delete mode 100644 Apple/UI/Assets.xcassets/HackClub.colorset/Contents.json delete mode 100644 Apple/UI/Assets.xcassets/HackClub.imageset/Contents.json delete mode 100644 Apple/UI/Assets.xcassets/HackClub.imageset/flag-standalone-wtransparent.pdf delete mode 100644 Apple/UI/Networks/HackClub.swift delete mode 100644 Apple/UI/OAuth2.swift create mode 100644 Tools/tailscale-login-bridge/go.mod create mode 100644 Tools/tailscale-login-bridge/go.sum create mode 100644 Tools/tailscale-login-bridge/main.go delete mode 100644 burrow/src/auth/client.rs delete mode 100644 burrow/src/auth/server/providers/mod.rs delete mode 100644 burrow/src/auth/server/providers/slack.rs create mode 100644 burrow/src/auth/server/tailscale.rs create mode 100644 burrow/src/control/config.rs create mode 100644 burrow/src/control/mod.rs diff --git a/Apple/App/AppDelegate.swift b/Apple/App/AppDelegate.swift index 0ea93f4..12fe52c 100644 --- a/Apple/App/AppDelegate.swift +++ b/Apple/App/AppDelegate.swift @@ -6,6 +6,8 @@ import SwiftUI @main @MainActor class AppDelegate: NSObject, NSApplicationDelegate { + private var windowController: NSWindowController? + private let quitItem: NSMenuItem = { let quitItem = NSMenuItem( title: "Quit Burrow", @@ -17,6 +19,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { return quitItem }() + private lazy var openItem: NSMenuItem = { + let item = NSMenuItem( + title: "Open Burrow", + action: #selector(openWindow), + keyEquivalent: "o" + ) + item.target = self + item.keyEquivalentModifierMask = .command + return item + }() + private let toggleItem: NSMenuItem = { let toggleView = NSHostingView(rootView: MenuItemToggleView()) toggleView.frame.size = CGSize(width: 300, height: 32) @@ -31,6 +44,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let menu = NSMenu() menu.items = [ toggleItem, + openItem, .separator(), quitItem ] @@ -49,5 +63,28 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { statusItem.menu = menu } + + @objc + private func openWindow() { + if let window = windowController?.window { + window.makeKeyAndOrderFront(nil) + NSApplication.shared.activate(ignoringOtherApps: true) + return + } + + let contentView = BurrowView() + let hostingController = NSHostingController(rootView: contentView) + let window = NSWindow(contentViewController: hostingController) + window.title = "Burrow" + window.setContentSize(NSSize(width: 820, height: 720)) + window.styleMask.insert([.titled, .closable, .miniaturizable, .resizable]) + window.center() + + let controller = NSWindowController(window: window) + controller.shouldCascadeWindows = true + controller.showWindow(nil) + windowController = controller + NSApplication.shared.activate(ignoringOtherApps: true) + } } #endif diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index 617b88f..995af28 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -23,7 +23,6 @@ D0D4E53A2C8D996F007F820A /* BurrowCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D0D4E56B2C8D9C2F007F820A /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49A2C8D921A007F820A /* Logging.swift */; }; D0D4E5702C8D9C62007F820A /* BurrowCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; }; - D0D4E5712C8D9C6F007F820A /* HackClub.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49D2C8D921A007F820A /* HackClub.swift */; }; D0D4E5722C8D9C6F007F820A /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49E2C8D921A007F820A /* Network.swift */; }; D0D4E5732C8D9C6F007F820A /* WireGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49F2C8D921A007F820A /* WireGuard.swift */; }; D0D4E5742C8D9C6F007F820A /* BurrowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A22C8D921A007F820A /* BurrowView.swift */; }; @@ -33,7 +32,6 @@ D0D4E5782C8D9C6F007F820A /* NetworkExtension+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */; }; D0D4E5792C8D9C6F007F820A /* NetworkExtensionTunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */; }; D0D4E57A2C8D9C6F007F820A /* NetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A82C8D921A007F820A /* NetworkView.swift */; }; - D0D4E57B2C8D9C6F007F820A /* OAuth2.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A92C8D921A007F820A /* OAuth2.swift */; }; D0D4E57C2C8D9C6F007F820A /* Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AA2C8D921A007F820A /* Tunnel.swift */; }; D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */; }; D0D4E57E2C8D9C6F007F820A /* TunnelStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */; }; @@ -160,7 +158,6 @@ D0D4E4972C8D921A007F820A /* swift-protobuf-config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "swift-protobuf-config.json"; sourceTree = ""; }; D0D4E4992C8D921A007F820A /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = ""; }; D0D4E49A2C8D921A007F820A /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; }; - D0D4E49D2C8D921A007F820A /* HackClub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HackClub.swift; sourceTree = ""; }; D0D4E49E2C8D921A007F820A /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = ""; }; D0D4E49F2C8D921A007F820A /* WireGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireGuard.swift; sourceTree = ""; }; D0D4E4A12C8D921A007F820A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -171,7 +168,6 @@ D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetworkExtension+Async.swift"; sourceTree = ""; }; D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkExtensionTunnel.swift; sourceTree = ""; }; D0D4E4A82C8D921A007F820A /* NetworkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkView.swift; sourceTree = ""; }; - D0D4E4A92C8D921A007F820A /* OAuth2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuth2.swift; sourceTree = ""; }; D0D4E4AA2C8D921A007F820A /* Tunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tunnel.swift; sourceTree = ""; }; D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelButton.swift; sourceTree = ""; }; D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelStatusView.swift; sourceTree = ""; }; @@ -340,7 +336,6 @@ D0D4E4A02C8D921A007F820A /* Networks */ = { isa = PBXGroup; children = ( - D0D4E49D2C8D921A007F820A /* HackClub.swift */, D0D4E49E2C8D921A007F820A /* Network.swift */, D0D4E49F2C8D921A007F820A /* WireGuard.swift */, ); @@ -358,7 +353,6 @@ D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */, D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */, D0D4E4A82C8D921A007F820A /* NetworkView.swift */, - D0D4E4A92C8D921A007F820A /* OAuth2.swift */, D0D4E4AA2C8D921A007F820A /* Tunnel.swift */, D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */, D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */, @@ -634,7 +628,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D0D4E5712C8D9C6F007F820A /* HackClub.swift in Sources */, D0D4E5722C8D9C6F007F820A /* Network.swift in Sources */, D0D4E5732C8D9C6F007F820A /* WireGuard.swift in Sources */, D0D4E5742C8D9C6F007F820A /* BurrowView.swift in Sources */, @@ -644,7 +637,6 @@ D0D4E5782C8D9C6F007F820A /* NetworkExtension+Async.swift in Sources */, D0D4E5792C8D9C6F007F820A /* NetworkExtensionTunnel.swift in Sources */, D0D4E57A2C8D9C6F007F820A /* NetworkView.swift in Sources */, - D0D4E57B2C8D9C6F007F820A /* OAuth2.swift in Sources */, D0D4E57C2C8D9C6F007F820A /* Tunnel.swift in Sources */, D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */, D0D4E57E2C8D9C6F007F820A /* TunnelStatusView.swift in Sources */, diff --git a/Apple/UI/Assets.xcassets/HackClub.colorset/Contents.json b/Apple/UI/Assets.xcassets/HackClub.colorset/Contents.json deleted file mode 100644 index 911b4b1..0000000 --- a/Apple/UI/Assets.xcassets/HackClub.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x50", - "green" : "0x37", - "red" : "0xEC" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Apple/UI/Assets.xcassets/HackClub.imageset/Contents.json b/Apple/UI/Assets.xcassets/HackClub.imageset/Contents.json deleted file mode 100644 index ddd0664..0000000 --- a/Apple/UI/Assets.xcassets/HackClub.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "flag-standalone-wtransparent.pdf", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Apple/UI/Assets.xcassets/HackClub.imageset/flag-standalone-wtransparent.pdf b/Apple/UI/Assets.xcassets/HackClub.imageset/flag-standalone-wtransparent.pdf deleted file mode 100644 index 1506fe9390e19160cb5f8732ffe6ba2c488975ce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3501 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 some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.title2.weight(.semibold)) + Text(detail) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } +} + +private enum ConfigurationSheet: String, Identifiable { + case wireGuard + case tor + case tailnet + + var id: String { rawValue } + + var kind: AccountNetworkKind { + switch self { + case .wireGuard: .wireGuard + case .tor: .tor + case .tailnet: .headscale + } + } +} + +private struct AccountDraft { + var title = "" + var accountName = "" + var identityName = "" + var wireGuardConfig = "" + + var tailnetProvider: TailnetProvider = .tailscale + var authority = "" + var tailnet = "" + var hostname = ProcessInfo.processInfo.hostName + var username = "" + var secret = "" + var authMode: AccountAuthMode = .web + + var torAddresses = "100.64.0.2/32" + var torDNS = "1.1.1.1, 1.0.0.1" + var torMTU = "1400" + var torListen = "127.0.0.1:9040" + + init(sheet: ConfigurationSheet) { + switch sheet { + case .wireGuard: + break + case .tor: + title = "Default Tor" + accountName = "default" + identityName = "apple" + case .tailnet: + title = "Tailnet" + accountName = "default" + identityName = "apple" + authority = TailnetProvider.tailscale.defaultAuthority ?? "" + } + } +} + +private struct ConfigurationSheetView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + + let sheet: ConfigurationSheet + let networkViewModel: NetworkViewModel + let accountStore: NetworkAccountStore + + @State private var draft: AccountDraft + @State private var isSubmitting = false + @State private var errorMessage: String? + @State private var loginSessionID: String? + @State private var loginStatus: TailnetLoginStatus? + @State private var pollingTask: Task? + @State private var didRunAutomation = false + + init( + sheet: ConfigurationSheet, + networkViewModel: NetworkViewModel, + accountStore: NetworkAccountStore + ) { + self.sheet = sheet + self.networkViewModel = networkViewModel + self.accountStore = accountStore + _draft = State(initialValue: AccountDraft(sheet: sheet)) + } + + var body: some View { + NavigationStack { + Form { + Section { + Text(sheet.kind.subtitle) + .font(.callout) + .foregroundStyle(.secondary) + if let availabilityNote = sheet.kind.availabilityNote { + Text(availabilityNote) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + Section("Identity") { + TextField("Title", text: $draft.title) + TextField("Account", text: $draft.accountName) + TextField("Identity", text: $draft.identityName) + if sheet == .tailnet { + TextField("Hostname", text: $draft.hostname) + .burrowLoginField() + .autocorrectionDisabled() + } + } + + switch sheet { + case .wireGuard: + Section("WireGuard Configuration") { + TextEditor(text: $draft.wireGuardConfig) + .font(.body.monospaced()) + .frame(minHeight: 220) + } + case .tor: + Section("Tor Preferences") { + TextField("Virtual Addresses", text: $draft.torAddresses) + TextField("DNS Resolvers", text: $draft.torDNS) + TextField("MTU", text: $draft.torMTU) + TextField("Transparent Listener", text: $draft.torListen) + } + case .tailnet: + tailnetSections + } + + if let errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.red) + } + } + } + .navigationTitle(sheet.kind.title) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button(confirmationTitle) { + submit() + } + .disabled(isSubmitting || submissionDisabled) + } + } + } + .frame(minWidth: 520, minHeight: 620) + .onAppear { + runAutomationIfNeeded() + } + .onDisappear { + pollingTask?.cancel() } } - private func addWireGuardNetwork() { + @ViewBuilder + private var tailnetSections: some View { + Section("Tailnet Provider") { + Picker("Provider", selection: $draft.tailnetProvider) { + ForEach(TailnetProvider.allCases) { provider in + Text(provider.title).tag(provider) + } + } + Text(draft.tailnetProvider.subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } + + Section("Tailnet") { + if draft.tailnetProvider.requiresControlURL { + TextField("Server URL", text: $draft.authority) + .burrowLoginField() + .autocorrectionDisabled() + } + TextField("Tailnet", text: $draft.tailnet) + .burrowLoginField() + .autocorrectionDisabled() + + if draft.tailnetProvider.usesWebLogin { + Text("Sign-in is brokered by `burrow auth-server` on the host and opens the real Tailscale login page in a browser.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + TextField("Username", text: $draft.username) + .burrowLoginField() + .autocorrectionDisabled() + Picker("Authentication", selection: $draft.authMode) { + ForEach([AccountAuthMode.none, .password, .preauthKey]) { mode in + Text(mode.title).tag(mode) + } + } + if draft.authMode != .none { + SecureField( + draft.authMode == .password ? "Password" : "Preauth Key", + text: $draft.secret + ) + } + } + } + + if draft.tailnetProvider.usesWebLogin { + Section("Tailscale Sign-In") { + if let loginStatus { + labeledValue("State", loginStatus.backendState) + if let tailnetName = loginStatus.tailnetName { + labeledValue("Tailnet", tailnetName) + } + if let dnsName = loginStatus.selfDNSName { + labeledValue("Device", dnsName) + } + if !loginStatus.tailscaleIPs.isEmpty { + labeledValue("Addresses", loginStatus.tailscaleIPs.joined(separator: ", ")) + } + if let authURL = loginStatus.authURL { + labeledValue("Login URL", authURL) + Button("Open Login Page") { + if let url = URL(string: authURL) { + openURL(url) + } + } + } + if !loginStatus.health.isEmpty { + Text(loginStatus.health.joined(separator: " • ")) + .font(.footnote) + .foregroundStyle(.secondary) + } + } else { + Text("Start sign-in to launch a local Tailscale bridge and fetch the real browser login URL.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } } - private func authenticateWithSlack() async throws { - guard - let authorizationEndpoint = URL(string: "https://slack.com/openid/connect/authorize"), - let tokenEndpoint = URL(string: "https://slack.com/api/openid.connect.token"), - let redirectURI = URL(string: "https://burrow.rs/callback/oauth2") else { return } - let session = OAuth2.Session( - authorizationEndpoint: authorizationEndpoint, - tokenEndpoint: tokenEndpoint, - redirectURI: redirectURI, - scopes: ["openid", "profile"], - clientID: "2210535565.6884042183125", - clientSecret: "2793c8a5255cae38830934c664eeb62d" - ) - let response = try await session.authorize(webAuthenticationSession) + private var confirmationTitle: String { + switch sheet { + case .wireGuard: + return "Add Network" + case .tor: + return "Save Account" + case .tailnet: + if draft.tailnetProvider.usesWebLogin { + return loginStatus?.running == true ? "Save Account" : "Start Sign-In" + } + return "Save Account" + } } + + private var submissionDisabled: Bool { + switch sheet { + case .wireGuard: + return draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .tor: + return normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil + case .tailnet: + if normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil { + return true + } + if draft.tailnetProvider.usesWebLogin { + return false + } + if draft.tailnetProvider.requiresControlURL && normalizedOptional(draft.authority) == nil { + return true + } + if draft.authMode != .none && normalizedOptional(draft.secret) == nil { + return true + } + return false + } + } + + private func submit() { + isSubmitting = true + errorMessage = nil + + Task { @MainActor in + defer { isSubmitting = false } + do { + switch sheet { + case .wireGuard: + try await submitWireGuard() + dismiss() + case .tor: + try submitTor() + dismiss() + case .tailnet: + try await submitTailnet() + } + } catch { + errorMessage = error.localizedDescription + } + } + } + + private func submitWireGuard() async throws { + let networkID = try await networkViewModel.addWireGuardNetwork( + configText: draft.wireGuardConfig + ) + + let title = titleOrFallback("WireGuard \(networkID)") + let record = NetworkAccountRecord( + id: UUID(), + kind: .wireGuard, + title: title, + authority: nil, + provider: nil, + accountName: normalized(draft.accountName, fallback: "default"), + identityName: normalized(draft.identityName, fallback: "network-\(networkID)"), + hostname: nil, + username: nil, + tailnet: nil, + authMode: .none, + note: "Linked to daemon network #\(networkID).", + createdAt: .now, + updatedAt: .now + ) + try accountStore.upsert(record, secret: nil) + } + + private func submitTor() throws { + let title = titleOrFallback("Tor \(normalized(draft.identityName, fallback: "apple"))") + let note = [ + "Addresses: \(csvSummary(draft.torAddresses))", + "DNS: \(csvSummary(draft.torDNS))", + "MTU: \(normalized(draft.torMTU, fallback: "1400"))", + "Listen: \(normalized(draft.torListen, fallback: "127.0.0.1:9040"))", + ].joined(separator: " • ") + + let record = NetworkAccountRecord( + id: UUID(), + kind: .tor, + title: title, + authority: "arti://local", + provider: nil, + accountName: normalized(draft.accountName, fallback: "default"), + identityName: normalized(draft.identityName, fallback: "apple"), + hostname: nil, + username: nil, + tailnet: nil, + authMode: .none, + note: note, + createdAt: .now, + updatedAt: .now + ) + try accountStore.upsert(record, secret: nil) + } + + private func submitTailnet() async throws { + if draft.tailnetProvider.usesWebLogin { + if loginStatus?.running == true { + try await saveTailnetAccount(secret: nil, username: nil) + dismiss() + } else { + try await startTailscaleLogin() + } + return + } + + let secret = draft.authMode == .none ? nil : draft.secret + let username = normalizedOptional(draft.username) + try await saveTailnetAccount(secret: secret, username: username) + dismiss() + } + + private func startTailscaleLogin() async throws { + let response = try await TailnetBridgeClient.startLogin( + TailnetLoginStartRequest( + accountName: normalized(draft.accountName, fallback: "default"), + identityName: normalized(draft.identityName, fallback: "apple"), + hostname: normalizedOptional(draft.hostname), + controlURL: draft.tailnetProvider.defaultAuthority + ) + ) + loginSessionID = response.sessionID + loginStatus = response.status + if let authURL = response.status.authURL, let url = URL(string: authURL) { + openLoginURL(url) + } + startPollingTailscaleLogin() + } + + private func runAutomationIfNeeded() { + guard !didRunAutomation, + sheet == .tailnet, + let automation = BurrowAutomationConfig.current, + automation.action == .tailnetLogin + else { + return + } + + didRunAutomation = true + draft.tailnetProvider = .tailscale + draft.title = automation.title ?? draft.title + draft.accountName = automation.accountName ?? draft.accountName + draft.identityName = automation.identityName ?? draft.identityName + draft.hostname = automation.hostname ?? draft.hostname + + Task { @MainActor in + do { + try await startTailscaleLogin() + } catch { + errorMessage = error.localizedDescription + } + } + } + + private func startPollingTailscaleLogin() { + pollingTask?.cancel() + guard let loginSessionID else { return } + pollingTask = Task { @MainActor in + while !Task.isCancelled { + do { + let status = try await TailnetBridgeClient.status(sessionID: loginSessionID) + let previousAuthURL = loginStatus?.authURL + loginStatus = status + if previousAuthURL == nil, + let authURL = status.authURL, + let url = URL(string: authURL) + { + openLoginURL(url) + } + if status.running { + return + } + } catch { + errorMessage = error.localizedDescription + return + } + try? await Task.sleep(for: .seconds(2)) + } + } + } + + private func openLoginURL(_ url: URL) { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(300)) + openURL(url) { accepted in + guard !accepted else { return } + errorMessage = "Burrow got a Tailscale login URL, but iOS did not open it automatically." + } + } + } + + private func saveTailnetAccount(secret: String?, username: String?) async throws { + let provider = draft.tailnetProvider + let title = titleOrFallback( + hostnameFallback( + from: provider.usesWebLogin ? (loginStatus?.tailnetName ?? "") : draft.authority, + fallback: provider.title + ) + ) + + let payload = TailnetNetworkPayload( + provider: provider, + authority: normalizedOptional(provider.defaultAuthority ?? draft.authority), + account: normalized(draft.accountName, fallback: "default"), + identity: normalized(draft.identityName, fallback: "apple"), + tailnet: normalizedOptional(loginStatus?.tailnetName ?? draft.tailnet), + hostname: normalizedOptional(draft.hostname) + ) + + var noteParts: [String] = [ + provider.title, + provider.usesWebLogin + ? "State: \(loginStatus?.backendState ?? "NeedsLogin")" + : "Auth: \(draft.authMode.title)", + ] + if let dnsName = loginStatus?.selfDNSName { + noteParts.append("Device: \(dnsName)") + } + if let magicDNSSuffix = loginStatus?.magicDNSSuffix { + noteParts.append("MagicDNS: \(magicDNSSuffix)") + } + + do { + let networkID = try await networkViewModel.addTailnetNetwork(payload: payload) + noteParts.append("Linked to daemon network #\(networkID)") + } catch { + noteParts.append("Daemon network add pending") + } + + let record = NetworkAccountRecord( + id: UUID(), + kind: .headscale, + title: title, + authority: payload.authority, + provider: provider, + accountName: payload.account, + identityName: payload.identity, + hostname: payload.hostname, + username: username, + tailnet: payload.tailnet, + authMode: provider.usesWebLogin ? .web : draft.authMode, + note: noteParts.joined(separator: " • "), + createdAt: .now, + updatedAt: .now + ) + try accountStore.upsert(record, secret: secret) + } + + private func normalized(_ value: String, fallback: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? fallback : trimmed + } + + private func normalizedOptional(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func titleOrFallback(_ fallback: String) -> String { + normalized(draft.title, fallback: fallback) + } + + private func csvSummary(_ value: String) -> String { + value + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: ", ") + } + + private func hostnameFallback(from value: String, fallback: String) -> String { + guard let url = URL(string: value), let host = url.host, !host.isEmpty else { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? fallback : trimmed + } + return host + } + + @ViewBuilder + private func labeledValue(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.body.monospaced()) + } + } +} + +private struct AccountRowView: View { + let account: NetworkAccountRecord + let hasSecret: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(account.title) + .font(.headline) + HStack(spacing: 8) { + Text(account.kind.title) + if let provider = account.provider { + Text(provider.title) + } + } + .font(.subheadline) + .foregroundStyle(account.kind.accentColor) + } + Spacer() + if hasSecret { + Label("Credential stored", systemImage: "key.fill") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if let authority = account.authority { + labeledValue("Authority", authority) + } + + labeledValue("Account", account.accountName) + labeledValue("Identity", account.identityName) + + if let hostname = account.hostname { + labeledValue("Hostname", hostname) + } + + if let username = account.username { + labeledValue("Username", username) + } + + if let tailnet = account.tailnet { + labeledValue("Tailnet", tailnet) + } + + if let note = account.note { + Text(note) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.thinMaterial) + ) + } + + @ViewBuilder + private func labeledValue(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.body.monospaced()) + } + } +} + +private extension View { + @ViewBuilder + func burrowLoginField() -> some View { + #if os(iOS) + textInputAutocapitalization(.never) + #else + self + #endif + } +} + +private struct BurrowAutomationConfig { + enum Action: String { + case tailnetLogin = "tailnet-login" + } + + let action: Action + let title: String? + let accountName: String? + let identityName: String? + let hostname: String? + + static let current: BurrowAutomationConfig? = { + let environment = ProcessInfo.processInfo.environment + guard let rawAction = environment["BURROW_UI_AUTOMATION"], + let action = Action(rawValue: rawAction) + else { + return nil + } + + return BurrowAutomationConfig( + action: action, + title: environment["BURROW_UI_AUTOMATION_TITLE"], + accountName: environment["BURROW_UI_AUTOMATION_ACCOUNT"], + identityName: environment["BURROW_UI_AUTOMATION_IDENTITY"], + hostname: environment["BURROW_UI_AUTOMATION_HOSTNAME"] + ) + }() } #if DEBUG @@ -72,4 +791,3 @@ struct NetworkView_Previews: PreviewProvider { } } #endif -#endif diff --git a/Apple/UI/NetworkCarouselView.swift b/Apple/UI/NetworkCarouselView.swift index f969356..4bbf12f 100644 --- a/Apple/UI/NetworkCarouselView.swift +++ b/Apple/UI/NetworkCarouselView.swift @@ -1,39 +1,45 @@ import SwiftUI struct NetworkCarouselView: View { - var networks: [any Network] = [ - HackClub(id: 1), - HackClub(id: 2), - WireGuard(id: 4), - HackClub(id: 5) - ] + var networks: [NetworkCardModel] var body: some View { - ScrollView(.horizontal) { - LazyHStack { - ForEach(networks, id: \.id) { network in - NetworkView(network: network) - .containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center) - .scrollTransition(.interactive, axis: .horizontal) { content, phase in - content - .scaleEffect(1.0 - abs(phase.value) * 0.1) + Group { + if networks.isEmpty { + ContentUnavailableView( + "No Networks Yet", + systemImage: "network.slash", + description: Text("Add a WireGuard network, or save a Tailnet account so Burrow can store a managed network when the daemon is reachable.") + ) + .frame(maxWidth: .infinity, minHeight: 175) + } else { + ScrollView(.horizontal) { + LazyHStack { + ForEach(networks) { network in + NetworkView(network: network) + .containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center) + .scrollTransition(.interactive, axis: .horizontal) { content, phase in + content + .scaleEffect(1.0 - abs(phase.value) * 0.1) + } } + } } + .scrollTargetLayout() + .scrollClipDisabled() + .scrollIndicators(.hidden) + .defaultScrollAnchor(.center) + .scrollTargetBehavior(.viewAligned) + .containerRelativeFrame(.horizontal) } } - .scrollTargetLayout() - .scrollClipDisabled() - .scrollIndicators(.hidden) - .defaultScrollAnchor(.center) - .scrollTargetBehavior(.viewAligned) - .containerRelativeFrame(.horizontal) } } #if DEBUG struct NetworkCarouselView_Previews: PreviewProvider { static var previews: some View { - NetworkCarouselView() + NetworkCarouselView(networks: [WireGuardCard(id: 1, detail: "10.13.13.2/24 · wg.burrow.rs:51820").card]) } } #endif diff --git a/Apple/UI/NetworkExtensionTunnel.swift b/Apple/UI/NetworkExtensionTunnel.swift index 7aaa3b1..23559f3 100644 --- a/Apple/UI/NetworkExtensionTunnel.swift +++ b/Apple/UI/NetworkExtensionTunnel.swift @@ -105,7 +105,7 @@ public final class NetworkExtensionTunnel: Tunnel { let proto = NETunnelProviderProtocol() proto.providerBundleIdentifier = bundleIdentifier - proto.serverAddress = "hackclub.com" + proto.serverAddress = "burrow.rs" manager.protocolConfiguration = proto try await manager.save() diff --git a/Apple/UI/NetworkView.swift b/Apple/UI/NetworkView.swift index b839d65..437adce 100644 --- a/Apple/UI/NetworkView.swift +++ b/Apple/UI/NetworkView.swift @@ -31,8 +31,8 @@ struct NetworkView: View { } extension NetworkView where Content == AnyView { - init(network: any Network) { + init(network: NetworkCardModel) { color = network.backgroundColor - content = { AnyView(network.label) } + content = { network.label } } } diff --git a/Apple/UI/Networks/HackClub.swift b/Apple/UI/Networks/HackClub.swift deleted file mode 100644 index b1c2023..0000000 --- a/Apple/UI/Networks/HackClub.swift +++ /dev/null @@ -1,27 +0,0 @@ -import BurrowCore -import SwiftUI - -struct HackClub: Network { - typealias NetworkType = Burrow_WireGuardNetwork - static let type: Burrow_NetworkType = .hackClub - - var id: Int32 - var backgroundColor: Color { .init("HackClub") } - - @MainActor var label: some View { - GeometryReader { reader in - VStack(alignment: .leading) { - Image("HackClub") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(height: reader.size.height / 4) - Spacer() - Text("@conradev") - .foregroundStyle(.white) - .font(.body.monospaced()) - } - .padding() - .frame(maxWidth: .infinity) - } - } -} diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift index c6d5fba..f38ab26 100644 --- a/Apple/UI/Networks/Network.swift +++ b/Apple/UI/Networks/Network.swift @@ -1,36 +1,539 @@ -import Atomics +import BurrowConfiguration import BurrowCore +import Foundation +import Security import SwiftProtobuf import SwiftUI -protocol Network { - associatedtype NetworkType: Message - associatedtype Label: View +struct NetworkCardModel: Identifiable { + let id: Int32 + let backgroundColor: Color + let label: AnyView +} - static var type: Burrow_NetworkType { get } +struct TailnetNetworkPayload: Codable, Sendable { + var provider: TailnetProvider + var authority: String? + var account: String + var identity: String + var tailnet: String? + var hostname: String? - var id: Int32 { get } - var backgroundColor: Color { get } + func encoded() throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(self) + } +} - @MainActor var label: Label { get } +struct TailnetLoginStartRequest: Codable, Sendable { + var accountName: String + var identityName: String + var hostname: String? + var controlURL: String? +} + +struct TailnetLoginStatus: Codable, Sendable { + var backendState: String + var authURL: String? + var running: Bool + var needsLogin: Bool + var tailnetName: String? + var magicDNSSuffix: String? + var selfDNSName: String? + var tailscaleIPs: [String] + var health: [String] +} + +struct TailnetLoginStartResponse: Codable, Sendable { + var sessionID: String + var status: TailnetLoginStatus +} + +enum TailnetBridgeClient { + private static let baseURL = URL(string: "http://127.0.0.1:8080")! + + static func startLogin(_ request: TailnetLoginStartRequest) async throws -> TailnetLoginStartResponse { + var urlRequest = URLRequest( + url: baseURL.appendingPathComponent("v1/tailscale/login/start") + ) + urlRequest.httpMethod = "POST" + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + urlRequest.httpBody = try encoder.encode(request) + + let (data, response) = try await URLSession.shared.data(for: urlRequest) + try validate(response: response, data: data) + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(TailnetLoginStartResponse.self, from: data) + } + + static func status(sessionID: String) async throws -> TailnetLoginStatus { + let url = baseURL + .appendingPathComponent("v1/tailscale/login") + .appendingPathComponent(sessionID) + let (data, response) = try await URLSession.shared.data(from: url) + try validate(response: response, data: data) + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(TailnetLoginStatus.self, from: data) + } + + private static func validate(response: URLResponse, data: Data) throws { + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + guard (200..<300).contains(http.statusCode) else { + let message = String(data: data, encoding: .utf8)?.trimmingCharacters( + in: .whitespacesAndNewlines + ) + throw TailnetBridgeError.server(message?.ifEmpty("HTTP \(http.statusCode)") ?? "HTTP \(http.statusCode)") + } + } +} + +enum TailnetBridgeError: LocalizedError { + case server(String) + + var errorDescription: String? { + switch self { + case .server(let message): + message + } + } } @Observable @MainActor final class NetworkViewModel: Sendable { private(set) var networks: [Burrow_Network] = [] + private(set) var connectionError: String? + private let socketURLResult: Result - private var task: Task! + nonisolated(unsafe) private var task: Task? - init(socketURL: URL) { + init(socketURLResult: Result) { + self.socketURLResult = socketURLResult + startStreaming() + } + + deinit { + task?.cancel() + } + + var cards: [NetworkCardModel] { + networks.map(Self.makeCard(for:)) + } + + var nextNetworkID: Int32 { + (networks.map(\.id).max() ?? 0) + 1 + } + + func addWireGuardNetwork(configText: String) async throws -> Int32 { + try await addNetwork(type: .wireGuard, payload: Data(configText.utf8)) + } + + func addTailnetNetwork(payload: TailnetNetworkPayload) async throws -> Int32 { + try await addNetwork(type: .tailnet, payload: payload.encoded()) + } + + private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 { + let socketURL = try socketURLResult.get() + let networkID = nextNetworkID + let request = Burrow_Network.with { + $0.id = networkID + $0.type = type + $0.payload = payload + } + + let client = NetworksClient.unix(socketURL: socketURL) + _ = try await client.networkAdd(request) + return networkID + } + + private func startStreaming() { + task?.cancel() + let socketURLResult = self.socketURLResult task = Task { [weak self] in - let client = NetworksClient.unix(socketURL: socketURL) - for try await networks in client.networkList(.init()) { - guard let viewModel = self else { continue } - Task { @MainActor in - viewModel.networks = networks.network + do { + let socketURL = try socketURLResult.get() + let client = NetworksClient.unix(socketURL: socketURL) + for try await response in client.networkList(.init()) { + guard !Task.isCancelled else { return } + await MainActor.run { + guard let self else { return } + self.networks = response.network + self.connectionError = nil + } + } + } catch { + guard !Task.isCancelled else { return } + await MainActor.run { + guard let self else { return } + self.connectionError = error.localizedDescription } } } } + + private static func makeCard(for network: Burrow_Network) -> NetworkCardModel { + switch network.type { + case .wireGuard: + WireGuardCard(network: network).card + case .tailnet: + TailnetCard(network: network).card + case .UNRECOGNIZED(let rawValue): + unsupportedCard( + id: network.id, + title: "Unknown Network", + detail: "Type \(rawValue) is not recognized by this build." + ) + @unknown default: + unsupportedCard( + id: network.id, + title: "Unsupported Network", + detail: "Update Burrow to view this network." + ) + } + } + + private static func unsupportedCard(id: Int32, title: String, detail: String) -> NetworkCardModel { + NetworkCardModel( + id: id, + backgroundColor: .gray.opacity(0.85), + label: AnyView( + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.title3.weight(.semibold)) + .foregroundStyle(.white) + Text(detail) + .font(.body) + .foregroundStyle(.white.opacity(0.9)) + Spacer() + Text("Network #\(id)") + .font(.footnote.monospaced()) + .foregroundStyle(.white.opacity(0.8)) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + ) + ) + } +} + +enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable { + case tailscale + case headscale + case burrow + + var id: String { rawValue } + + var title: String { + switch self { + case .tailscale: "Tailscale" + case .headscale: "Headscale" + case .burrow: "Burrow" + } + } + + var usesWebLogin: Bool { + self == .tailscale + } + + var requiresControlURL: Bool { + self != .tailscale + } + + var defaultAuthority: String? { + switch self { + case .tailscale: + "https://controlplane.tailscale.com" + case .headscale, .burrow: + nil + } + } + + var subtitle: String { + switch self { + case .tailscale: + "Use Tailscale's real browser login flow." + case .headscale: + "Store a Headscale control-plane endpoint and credentials." + case .burrow: + "Store Burrow control-plane credentials." + } + } +} + +enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { + case wireGuard + case tor + case headscale + + var id: String { rawValue } + + var title: String { + switch self { + case .wireGuard: "WireGuard" + case .tor: "Tor" + case .headscale: "Tailnet" + } + } + + var subtitle: String { + switch self { + case .wireGuard: "Import a tunnel and optional account metadata." + case .tor: "Store Arti account and identity preferences." + case .headscale: "Save Tailscale, Headscale, or Burrow control-plane identities." + } + } + + var accentColor: Color { + switch self { + case .wireGuard: .init("WireGuard") + case .tor: .orange + case .headscale: .mint + } + } + + var actionTitle: String { + switch self { + case .wireGuard: "Add Network" + case .tor: "Save Account" + case .headscale: "Save Account" + } + } + + var availabilityNote: String? { + switch self { + case .wireGuard: + nil + case .tor: + "Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet." + case .headscale: + "Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can be stored in the daemon." + } + } +} + +enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { + case none + case web + case password + case preauthKey + + var id: String { rawValue } + + var title: String { + switch self { + case .none: "None" + case .web: "Web Login" + case .password: "Password" + case .preauthKey: "Preauth Key" + } + } +} + +struct NetworkAccountRecord: Codable, Identifiable, Hashable, Sendable { + let id: UUID + var kind: AccountNetworkKind + var title: String + var authority: String? + var provider: TailnetProvider? + var accountName: String + var identityName: String + var hostname: String? + var username: String? + var tailnet: String? + var authMode: AccountAuthMode + var note: String? + var createdAt: Date + var updatedAt: Date +} + +struct TailnetCard { + var id: Int32 + var provider: String + var title: String + var detail: String + + init(network: Burrow_Network) { + let payload = (try? JSONDecoder().decode(TailnetNetworkPayload.self, from: network.payload)) + id = network.id + provider = payload?.provider.title ?? "Tailnet" + title = payload?.tailnet ?? payload?.hostname ?? "Tailnet" + detail = [ + payload?.provider.title, + payload?.authority, + payload.map { "Account: \($0.account)" }, + ] + .compactMap { $0 } + .joined(separator: " · ") + .ifEmpty("Stored Tailnet configuration") + } + + var card: NetworkCardModel { + NetworkCardModel( + id: id, + backgroundColor: .mint, + label: AnyView( + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(provider) + .font(.headline) + .foregroundStyle(.white.opacity(0.85)) + Text(title) + .font(.title3.weight(.semibold)) + .foregroundStyle(.white) + } + Spacer() + } + Spacer() + Text(detail) + .font(.body.monospaced()) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(4) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + ) + ) + } +} + +@Observable +@MainActor +final class NetworkAccountStore { + private static let storageKey = "burrow.network-accounts" + + private let defaults: UserDefaults + private(set) var accounts: [NetworkAccountRecord] = [] + + init(defaults: UserDefaults = UserDefaults(suiteName: Constants.appGroupIdentifier) ?? .standard) { + self.defaults = defaults + load() + } + + func upsert(_ record: NetworkAccountRecord, secret: String?) throws { + if let index = accounts.firstIndex(where: { $0.id == record.id }) { + accounts[index] = record + } else { + accounts.append(record) + } + accounts.sort { + if $0.kind == $1.kind { + return $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending + } + return $0.kind.rawValue < $1.kind.rawValue + } + try persist() + if let secret, !secret.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + try AccountSecretStore.store(secret, for: record.id) + } else { + try AccountSecretStore.removeSecret(for: record.id) + } + } + + func delete(_ record: NetworkAccountRecord) throws { + accounts.removeAll { $0.id == record.id } + try persist() + try AccountSecretStore.removeSecret(for: record.id) + } + + func hasStoredSecret(for record: NetworkAccountRecord) -> Bool { + AccountSecretStore.hasSecret(for: record.id) + } + + private func load() { + guard let data = defaults.data(forKey: Self.storageKey) else { + accounts = [] + return + } + + do { + accounts = try JSONDecoder().decode([NetworkAccountRecord].self, from: data) + } catch { + accounts = [] + } + } + + private func persist() throws { + let data = try JSONEncoder().encode(accounts) + defaults.set(data, forKey: Self.storageKey) + } +} + +private enum AccountSecretStore { + private static let service = "\(Constants.bundleIdentifier).accounts" + + static func hasSecret(for accountID: UUID) -> Bool { + let query = baseQuery(for: accountID) + return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess + } + + static func store(_ secret: String, for accountID: UUID) throws { + let data = Data(secret.utf8) + let query = baseQuery(for: accountID) + let status = SecItemCopyMatching(query as CFDictionary, nil) + + if status == errSecSuccess { + let updateStatus = SecItemUpdate( + query as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + guard updateStatus == errSecSuccess else { + throw AccountSecretStoreError.osStatus(updateStatus) + } + return + } + + var item = query + item[kSecValueData as String] = data + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + let addStatus = SecItemAdd(item as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw AccountSecretStoreError.osStatus(addStatus) + } + } + + static func removeSecret(for accountID: UUID) throws { + let status = SecItemDelete(baseQuery(for: accountID) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw AccountSecretStoreError.osStatus(status) + } + } + + private static func baseQuery(for accountID: UUID) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: accountID.uuidString, + ] + } +} + +private enum AccountSecretStoreError: LocalizedError { + case osStatus(OSStatus) + + var errorDescription: String? { + switch self { + case .osStatus(let status): + if let message = SecCopyErrorMessageString(status, nil) as String? { + return message + } + return "Keychain error \(status)" + } + } +} + +private extension String { + func ifEmpty(_ fallback: @autoclosure () -> String) -> String { + isEmpty ? fallback() : self + } } diff --git a/Apple/UI/Networks/WireGuard.swift b/Apple/UI/Networks/WireGuard.swift index cba67ef..c0426cd 100644 --- a/Apple/UI/Networks/WireGuard.swift +++ b/Apple/UI/Networks/WireGuard.swift @@ -1,14 +1,40 @@ import BurrowCore +import Foundation import SwiftUI -struct WireGuard: Network { - typealias NetworkType = Burrow_WireGuardNetwork - static let type: BurrowCore.Burrow_NetworkType = .wireGuard - +struct WireGuardCard { var id: Int32 - var backgroundColor: Color { .init("WireGuard") } + var title: String + var detail: String - @MainActor var label: some View { + init(id: Int32, title: String = "WireGuard", detail: String = "Stored configuration") { + self.id = id + self.title = title + self.detail = detail + } + + init(network: Burrow_Network) { + let payload = String(data: network.payload, encoding: .utf8) ?? "" + let address = Self.firstValue(for: "Address", in: payload) + let endpoint = Self.firstValue(for: "Endpoint", in: payload) + self.id = network.id + self.title = "WireGuard" + self.detail = [address, endpoint] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: " · ") + .ifEmpty("Stored configuration") + } + + var card: NetworkCardModel { + NetworkCardModel( + id: id, + backgroundColor: .init("WireGuard"), + label: AnyView(label) + ) + } + + private var label: some View { GeometryReader { reader in VStack(alignment: .leading) { HStack { @@ -23,12 +49,29 @@ struct WireGuard: Network { } .frame(maxWidth: .infinity, maxHeight: reader.size.height / 4) Spacer() - Text("@conradev") + Text(detail) .foregroundStyle(.white) .font(.body.monospaced()) + .lineLimit(3) } .padding() .frame(maxWidth: .infinity) } } + + private static func firstValue(for key: String, in config: String) -> String? { + config + .split(whereSeparator: \.isNewline) + .map(String.init) + .first(where: { $0.hasPrefix("\(key) = ") })? + .split(separator: "=", maxSplits: 1) + .last + .map { $0.trimmingCharacters(in: .whitespaces) } + } +} + +private extension String { + func ifEmpty(_ fallback: @autoclosure () -> String) -> String { + isEmpty ? fallback() : self + } } diff --git a/Apple/UI/OAuth2.swift b/Apple/UI/OAuth2.swift deleted file mode 100644 index 0fafc8d..0000000 --- a/Apple/UI/OAuth2.swift +++ /dev/null @@ -1,293 +0,0 @@ -import AuthenticationServices -import Foundation -import os -import SwiftUI - -enum OAuth2 { - enum Error: Swift.Error { - case unknown - case invalidAuthorizationURL - case invalidCallbackURL - case invalidRedirectURI - } - - struct Credential { - var accessToken: String - var refreshToken: String? - var expirationDate: Date? - } - - struct Session { - var authorizationEndpoint: URL - var tokenEndpoint: URL - var redirectURI: URL - var responseType = OAuth2.ResponseType.code - var scopes: Set - var clientID: String - var clientSecret: String - - fileprivate static let queue: OSAllocatedUnfairLock<[Int: CheckedContinuation]> = { - .init(initialState: [:]) - }() - - fileprivate static func handle(url: URL) { - let continuations = queue.withLock { continuations in - let copy = continuations - continuations.removeAll() - return copy - } - for (_, continuation) in continuations { - continuation.resume(returning: url) - } - } - - init( - authorizationEndpoint: URL, - tokenEndpoint: URL, - redirectURI: URL, - scopes: Set, - clientID: String, - clientSecret: String - ) { - self.authorizationEndpoint = authorizationEndpoint - self.tokenEndpoint = tokenEndpoint - self.redirectURI = redirectURI - self.scopes = scopes - self.clientID = clientID - self.clientSecret = clientSecret - } - - private var authorizationURL: URL { - get throws { - var queryItems: [URLQueryItem] = [ - .init(name: "client_id", value: clientID), - .init(name: "response_type", value: responseType.rawValue), - .init(name: "redirect_uri", value: redirectURI.absoluteString) - ] - if !scopes.isEmpty { - queryItems.append(.init(name: "scope", value: scopes.joined(separator: ","))) - } - guard var components = URLComponents(url: authorizationEndpoint, resolvingAgainstBaseURL: false) else { - throw OAuth2.Error.invalidAuthorizationURL - } - components.queryItems = queryItems - guard let authorizationURL = components.url else { throw OAuth2.Error.invalidAuthorizationURL } - return authorizationURL - } - } - - private func handle(callbackURL: URL) async throws -> OAuth2.AccessTokenResponse { - switch responseType { - case .code: - guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else { - throw OAuth2.Error.invalidCallbackURL - } - return try await handle(response: try components.decode(OAuth2.CodeResponse.self)) - default: - throw OAuth2.Error.invalidCallbackURL - } - } - - private func handle(response: OAuth2.CodeResponse) async throws -> OAuth2.AccessTokenResponse { - var components = URLComponents() - components.queryItems = [ - .init(name: "client_id", value: clientID), - .init(name: "client_secret", value: clientSecret), - .init(name: "grant_type", value: GrantType.authorizationCode.rawValue), - .init(name: "code", value: response.code), - .init(name: "redirect_uri", value: redirectURI.absoluteString) - ] - let httpBody = Data(components.percentEncodedQuery!.utf8) - - var request = URLRequest(url: tokenEndpoint) - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpMethod = "POST" - request.httpBody = httpBody - - let session = URLSession(configuration: .ephemeral) - let (data, _) = try await session.data(for: request) - return try OAuth2.decoder.decode(OAuth2.AccessTokenResponse.self, from: data) - } - - func authorize(_ session: WebAuthenticationSession) async throws -> Credential { - let authorizationURL = try authorizationURL - let callbackURL = try await session.start( - url: authorizationURL, - redirectURI: redirectURI - ) - return try await handle(callbackURL: callbackURL).credential - } - } - - private struct CodeResponse: Codable { - var code: String - var state: String? - } - - private struct AccessTokenResponse: Codable { - var accessToken: String - var tokenType: TokenType - var expiresIn: Double? - var refreshToken: String? - - var credential: Credential { - .init( - accessToken: accessToken, - refreshToken: refreshToken, - expirationDate: expiresIn.map { Date(timeIntervalSinceNow: $0) } - ) - } - } - - enum TokenType: Codable, RawRepresentable { - case bearer - case unknown(String) - - init(rawValue: String) { - self = switch rawValue.lowercased() { - case "bearer": .bearer - default: .unknown(rawValue) - } - } - - var rawValue: String { - switch self { - case .bearer: "bearer" - case .unknown(let type): type - } - } - } - - enum GrantType: Codable, RawRepresentable { - case authorizationCode - case unknown(String) - - init(rawValue: String) { - self = switch rawValue.lowercased() { - case "authorization_code": .authorizationCode - default: .unknown(rawValue) - } - } - - var rawValue: String { - switch self { - case .authorizationCode: "authorization_code" - case .unknown(let type): type - } - } - } - - enum ResponseType: Codable, RawRepresentable { - case code - case idToken - case unknown(String) - - init(rawValue: String) { - self = switch rawValue.lowercased() { - case "code": .code - case "id_token": .idToken - default: .unknown(rawValue) - } - } - - var rawValue: String { - switch self { - case .code: "code" - case .idToken: "id_token" - case .unknown(let type): type - } - } - } - - fileprivate static var decoder: JSONDecoder { - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - return decoder - } - - fileprivate static var encoder: JSONEncoder { - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - return encoder - } -} - -extension WebAuthenticationSession: @unchecked @retroactive Sendable { -} - -extension WebAuthenticationSession { -#if canImport(BrowserEngineKit) - @available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *) - fileprivate static func callback(for redirectURI: URL) throws -> ASWebAuthenticationSession.Callback { - switch redirectURI.scheme { - case "https": - guard let host = redirectURI.host else { throw OAuth2.Error.invalidRedirectURI } - return .https(host: host, path: redirectURI.path) - case "http": - throw OAuth2.Error.invalidRedirectURI - case .some(let scheme): - return .customScheme(scheme) - case .none: - throw OAuth2.Error.invalidRedirectURI - } - } -#endif - - fileprivate func start(url: URL, redirectURI: URL) async throws -> URL { - #if canImport(BrowserEngineKit) - if #available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *) { - return try await authenticate( - using: url, - callback: try Self.callback(for: redirectURI), - additionalHeaderFields: [:] - ) - } - #endif - - return try await withThrowingTaskGroup(of: URL.self) { group in - group.addTask { - return try await authenticate(using: url, callbackURLScheme: redirectURI.scheme ?? "") - } - - let id = Int.random(in: 0.. some View { - onOpenURL { url in OAuth2.Session.handle(url: url) } - } -} - -extension URLComponents { - fileprivate func decode(_ type: T.Type) throws -> T { - guard let queryItems else { - throw DecodingError.valueNotFound( - T.self, - .init(codingPath: [], debugDescription: "Missing query items") - ) - } - let data = try OAuth2.encoder.encode(try queryItems.values) - return try OAuth2.decoder.decode(T.self, from: data) - } -} - -extension Sequence where Element == URLQueryItem { - fileprivate var values: [String: String?] { - get throws { - try Dictionary(map { ($0.name, $0.value) }) { _, _ in - throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Duplicate query items")) - } - } - } -} diff --git a/Tools/tailscale-login-bridge/go.mod b/Tools/tailscale-login-bridge/go.mod new file mode 100644 index 0000000..0e19f33 --- /dev/null +++ b/Tools/tailscale-login-bridge/go.mod @@ -0,0 +1,66 @@ +module burrow.dev/tailscale-login-bridge + +go 1.26.1 + +require tailscale.com v1.96.5 + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/akutz/memconn v0.1.0 // indirect + github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect + github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect + github.com/aws/aws-sdk-go-v2/config v1.29.5 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.58 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect + github.com/aws/smithy-go v1.24.0 // indirect + github.com/coder/websocket v1.8.12 // indirect + github.com/creachadair/msync v0.7.1 // indirect + github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/gaissmai/bart v0.26.1 // indirect + github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect + github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/huin/goupnp v1.3.0 // indirect + github.com/jsimonetti/rtnetlink v1.4.0 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect + github.com/mdlayher/socket v0.5.0 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/pires/go-proxyproto v0.8.1 // indirect + github.com/prometheus-community/pro-bing v0.4.0 // indirect + github.com/safchain/ethtool v0.3.0 // indirect + github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect + github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect + github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect + github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect + github.com/x448/float16 v0.8.4 // indirect + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard/windows v0.5.3 // indirect + gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect +) diff --git a/Tools/tailscale-login-bridge/go.sum b/Tools/tailscale-login-bridge/go.sum new file mode 100644 index 0000000..5393a62 --- /dev/null +++ b/Tools/tailscale-login-bridge/go.sum @@ -0,0 +1,229 @@ +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= +filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +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/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= +github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= +github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k= +github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg= +github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y= +github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= +github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE= +github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ= +github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= +github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= +github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/creachadair/mds v0.25.9 h1:080Hr8laN2h+l3NeVCGMBpXtIPnl9mz8e4HLraGPqtA= +github.com/creachadair/mds v0.25.9/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs= +github.com/creachadair/msync v0.7.1 h1:SeZmuEBXQPe5GqV/C94ER7QIZPwtvFbeQiykzt/7uho= +github.com/creachadair/msync v0.7.1/go.mod h1:8CcFlLsSujfHE5wWm19uUBLHIPDAUr6LXDwneVMO008= +github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= +github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= +github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8= +github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +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/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= +github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= +github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= +github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I= +github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= +github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= +github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +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-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= +github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= +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/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= +github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= +github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= +github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= +github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= +github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= +github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= +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/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= +github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= +github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= +github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= +github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= +github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= +github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= +github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= +github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= +github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= +github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= +github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= +github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= +github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= +github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM= +github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= +github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw= +github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= +github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= +github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= +github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= +golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA= +gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 h1:5SXjd4ET5dYijLaf0O3aOenC0Z4ZafIWSpjUzsQaNho= +honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0/go.mod h1:EPDDhEZqVHhWuPI5zPAsjU0U7v9xNIWjoOVyZ5ZcniQ= +howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +tailscale.com v1.96.5 h1:gNkfA/KSZAl6jCH9cj8urq00HRWItDDTtGsyATI89jA= +tailscale.com v1.96.5/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY= diff --git a/Tools/tailscale-login-bridge/main.go b/Tools/tailscale-login-bridge/main.go new file mode 100644 index 0000000..82ca9b0 --- /dev/null +++ b/Tools/tailscale-login-bridge/main.go @@ -0,0 +1,133 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "time" + + "tailscale.com/client/local" + "tailscale.com/ipn" + "tailscale.com/tsnet" +) + +type statusResponse struct { + BackendState string `json:"backend_state"` + AuthURL string `json:"auth_url,omitempty"` + Running bool `json:"running"` + NeedsLogin bool `json:"needs_login"` + TailnetName string `json:"tailnet_name,omitempty"` + MagicDNSSuffix string `json:"magic_dns_suffix,omitempty"` + SelfDNSName string `json:"self_dns_name,omitempty"` + TailscaleIPs []string `json:"tailscale_ips,omitempty"` + Health []string `json:"health,omitempty"` +} + +func main() { + listen := flag.String("listen", "127.0.0.1:0", "local listen address") + stateDir := flag.String("state-dir", "", "persistent state directory") + hostname := flag.String("hostname", "burrow-apple", "tailnet hostname") + controlURL := flag.String("control-url", "", "optional control URL") + flag.Parse() + + if *stateDir == "" { + log.Fatal("--state-dir is required") + } + + if err := os.MkdirAll(*stateDir, 0o755); err != nil { + log.Fatalf("create state dir: %v", err) + } + + server := &tsnet.Server{ + Dir: *stateDir, + Hostname: *hostname, + UserLogf: log.Printf, + } + if *controlURL != "" { + server.ControlURL = *controlURL + } + defer server.Close() + + if err := server.Start(); err != nil { + log.Fatalf("start tsnet: %v", err) + } + + localClient, err := server.LocalClient() + if err != nil { + log.Fatalf("local client: %v", err) + } + + ln, err := net.Listen("tcp", *listen) + if err != nil { + log.Fatalf("listen: %v", err) + } + defer ln.Close() + + fmt.Printf("{\"listen_addr\":%q}\n", ln.Addr().String()) + _ = os.Stdout.Sync() + + mux := http.NewServeMux() + mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { + status, err := snapshot(r.Context(), localClient) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(status) + }) + mux.HandleFunc("/shutdown", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + go func() { + _ = server.Close() + time.Sleep(100 * time.Millisecond) + os.Exit(0) + }() + }) + + httpServer := &http.Server{ + Handler: mux, + } + log.Fatal(httpServer.Serve(ln)) +} + +func snapshot(ctx context.Context, localClient *local.Client) (*statusResponse, error) { + status, err := localClient.StatusWithoutPeers(ctx) + if err != nil { + return nil, err + } + if (status.BackendState == ipn.NeedsLogin.String() || status.BackendState == ipn.NoState.String()) && status.AuthURL == "" { + if err := localClient.StartLoginInteractive(ctx); err != nil { + return nil, err + } + status, err = localClient.StatusWithoutPeers(ctx) + if err != nil { + return nil, err + } + } + + response := &statusResponse{ + BackendState: status.BackendState, + AuthURL: status.AuthURL, + Running: status.BackendState == ipn.Running.String(), + NeedsLogin: status.BackendState == ipn.NeedsLogin.String(), + Health: append([]string(nil), status.Health...), + } + + if status.CurrentTailnet != nil { + response.TailnetName = status.CurrentTailnet.Name + response.MagicDNSSuffix = status.CurrentTailnet.MagicDNSSuffix + } + if status.Self != nil { + response.SelfDNSName = status.Self.DNSName + } + for _, ip := range status.TailscaleIPs { + response.TailscaleIPs = append(response.TailscaleIPs, ip.String()) + } + return response, nil +} diff --git a/burrow/src/auth/client.rs b/burrow/src/auth/client.rs deleted file mode 100644 index e9721f3..0000000 --- a/burrow/src/auth/client.rs +++ /dev/null @@ -1,24 +0,0 @@ -use std::env::var; - -use anyhow::Result; -use reqwest::Url; - -pub async fn login() -> Result<()> { - let state = "vt :P"; - let nonce = "no"; - - let mut url = Url::parse("https://slack.com/openid/connect/authorize")?; - let mut q = url.query_pairs_mut(); - q.append_pair("response_type", "code"); - q.append_pair("scope", "openid profile email"); - q.append_pair("client_id", &var("CLIENT_ID")?); - q.append_pair("state", state); - q.append_pair("team", &var("SLACK_TEAM_ID")?); - q.append_pair("nonce", nonce); - q.append_pair("redirect_uri", "https://burrow.rs/callback"); - drop(q); - - println!("Continue auth in your browser:\n{}", url.as_str()); - - Ok(()) -} diff --git a/burrow/src/auth/mod.rs b/burrow/src/auth/mod.rs index c07f47e..74f47ad 100644 --- a/burrow/src/auth/mod.rs +++ b/burrow/src/auth/mod.rs @@ -1,2 +1 @@ -pub mod client; pub mod server; diff --git a/burrow/src/auth/server/db.rs b/burrow/src/auth/server/db.rs index 995e64b..c31c473 100644 --- a/burrow/src/auth/server/db.rs +++ b/burrow/src/auth/server/db.rs @@ -1,91 +1,627 @@ -use anyhow::Result; +use anyhow::{anyhow, Context, Result}; +use argon2::{ + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; +use base64::{engine::general_purpose, Engine as _}; +use rand::RngCore; +use rusqlite::{params, Connection, OptionalExtension}; -use crate::daemon::rpc::grpc_defs::{Network, NetworkType}; +use crate::control::{ + DnsConfig, Hostinfo, LocalAuthResponse, MapRequest, MapResponse, Node, NodeCapMap, + PacketFilter, PeerCapMap, RegisterRequest, UserProfile, +}; + +const CREATE_SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS auth_user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + profile_pic_url TEXT, + groups_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS auth_local_credential ( + user_id INTEGER PRIMARY KEY REFERENCES auth_user(id) ON DELETE CASCADE, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + rotated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS auth_session ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES auth_user(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL DEFAULT (datetime('now', '+7 days')) +); + +CREATE TABLE IF NOT EXISTS control_node ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stable_id TEXT NOT NULL UNIQUE, + user_id INTEGER NOT NULL REFERENCES auth_user(id) ON DELETE CASCADE, + name TEXT NOT NULL, + node_key TEXT NOT NULL UNIQUE, + machine_key TEXT, + disco_key TEXT, + addresses_json TEXT NOT NULL, + allowed_ips_json TEXT NOT NULL, + endpoints_json TEXT NOT NULL, + home_derp INTEGER, + hostinfo_json TEXT, + tags_json TEXT NOT NULL DEFAULT '[]', + primary_routes_json TEXT NOT NULL DEFAULT '[]', + cap_version INTEGER NOT NULL DEFAULT 1, + cap_map_json TEXT NOT NULL DEFAULT '{}', + peer_cap_map_json TEXT NOT NULL DEFAULT '{}', + machine_authorized INTEGER NOT NULL DEFAULT 1, + node_key_expired INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen TEXT, + online INTEGER +); +"#; + +#[derive(Clone, Debug)] +pub struct StoredUser { + pub profile: UserProfile, +} + +pub fn init_db(path: &str) -> Result<()> { + let conn = Connection::open(path)?; + conn.execute_batch(CREATE_SCHEMA)?; + Ok(()) +} + +pub fn ensure_local_identity( + path: &str, + username: &str, + email: &str, + display_name: &str, + password: &str, +) -> Result { + let conn = Connection::open(path)?; + conn.execute( + "INSERT INTO auth_user (email, display_name) VALUES (?, ?) + ON CONFLICT(email) DO UPDATE SET display_name = excluded.display_name", + params![email, display_name], + )?; + let user_id: i64 = + conn.query_row("SELECT id FROM auth_user WHERE email = ?", [email], |row| { + row.get(0) + })?; + + let existing_hash: Option = conn + .query_row( + "SELECT password_hash FROM auth_local_credential WHERE user_id = ?", + [user_id], + |row| row.get(0), + ) + .optional()?; + + let password_hash = match existing_hash { + Some(hash) if verify_password(password, &hash) => hash, + _ => hash_password(password)?, + }; + + conn.execute( + "INSERT INTO auth_local_credential (user_id, username, password_hash) + VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, password_hash = excluded.password_hash, rotated_at = datetime('now')", + params![user_id, username, password_hash], + )?; + + load_user_profile(&conn, user_id) +} + +pub fn authenticate_local( + path: &str, + identifier: &str, + password: &str, +) -> Result> { + let conn = Connection::open(path)?; + let record = conn + .query_row( + "SELECT u.id, u.email, u.display_name, u.profile_pic_url, u.groups_json, c.password_hash + FROM auth_user u + JOIN auth_local_credential c ON c.user_id = u.id + WHERE c.username = ? OR u.email = ?", + params![identifier, identifier], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + )) + }, + ) + .optional()?; + + let Some((user_id, email, display_name, profile_pic_url, groups_json, password_hash)) = record + else { + return Ok(None); + }; + + if !verify_password(password, &password_hash) { + return Ok(None); + } + + let token = random_token(); + conn.execute( + "INSERT INTO auth_session (id, user_id) VALUES (?, ?)", + params![token, user_id], + )?; + + Ok(Some(LocalAuthResponse { + access_token: token, + user: UserProfile { + id: user_id, + login_name: email, + display_name, + profile_pic_url, + groups: parse_json(&groups_json)?, + }, + })) +} + +pub fn user_for_session(path: &str, token: &str) -> Result> { + let conn = Connection::open(path)?; + let user_id = conn + .query_row( + "SELECT user_id FROM auth_session WHERE id = ? AND expires_at > datetime('now')", + [token], + |row| row.get::<_, i64>(0), + ) + .optional()?; + let Some(user_id) = user_id else { + return Ok(None); + }; + + Ok(Some(load_user(&conn, user_id)?)) +} + +pub fn upsert_node(path: &str, user: &StoredUser, request: &RegisterRequest) -> Result { + let conn = Connection::open(path)?; + let existing = find_existing_node(&conn, user.profile.id, request)?; + let name = Node::preferred_name(request); + let allowed_ips = Node::normalized_allowed_ips(request); + + match existing { + Some((node_id, stable_id, created_at)) => { + conn.execute( + "UPDATE control_node + SET name = ?, node_key = ?, machine_key = ?, disco_key = ?, addresses_json = ?, allowed_ips_json = ?, + endpoints_json = ?, home_derp = ?, hostinfo_json = ?, tags_json = ?, primary_routes_json = ?, + cap_version = ?, cap_map_json = ?, peer_cap_map_json = ?, updated_at = datetime('now'), + last_seen = datetime('now'), online = 1 + WHERE id = ?", + params![ + name, + request.node_key, + request.machine_key, + request.disco_key, + to_json(&request.addresses)?, + to_json(&allowed_ips)?, + to_json(&request.endpoints)?, + request.home_derp, + optional_json(&request.hostinfo)?, + to_json(&request.tags)?, + to_json(&request.primary_routes)?, + request.version.max(1), + to_json(&request.cap_map)?, + to_json(&request.peer_cap_map)?, + node_id, + ], + )?; + load_node(&conn, node_id, stable_id, Some(created_at)) + } + None => { + conn.execute( + "INSERT INTO control_node ( + stable_id, user_id, name, node_key, machine_key, disco_key, addresses_json, allowed_ips_json, + endpoints_json, home_derp, hostinfo_json, tags_json, primary_routes_json, cap_version, + cap_map_json, peer_cap_map_json, last_seen, online + ) VALUES ('', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), 1)", + params![ + user.profile.id, + name, + request.node_key, + request.machine_key, + request.disco_key, + to_json(&request.addresses)?, + to_json(&allowed_ips)?, + to_json(&request.endpoints)?, + request.home_derp, + optional_json(&request.hostinfo)?, + to_json(&request.tags)?, + to_json(&request.primary_routes)?, + request.version.max(1), + to_json(&request.cap_map)?, + to_json(&request.peer_cap_map)?, + ], + )?; + let node_id = conn.last_insert_rowid(); + let stable_id = format!("bn-{node_id}"); + conn.execute( + "UPDATE control_node SET stable_id = ? WHERE id = ?", + params![stable_id, node_id], + )?; + load_node(&conn, node_id, stable_id, None) + } + } +} + +pub fn map_for_node( + path: &str, + user: &StoredUser, + request: &MapRequest, + domain: &str, +) -> Result { + let conn = Connection::open(path)?; + apply_map_request(&conn, user.profile.id, request)?; + let self_row = conn + .query_row( + "SELECT id, stable_id, created_at FROM control_node WHERE user_id = ? AND node_key = ?", + params![user.profile.id, request.node_key], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional()? + .ok_or_else(|| anyhow!("node not registered"))?; + + let node = load_node(&conn, self_row.0, self_row.1, Some(self_row.2))?; + let peers = load_peers(&conn, node.id)?; + Ok(MapResponse { + map_session_handle: Some(format!("map-{}", node.stable_id)), + seq: Some(request.map_session_seq.unwrap_or(0) + 1), + node, + peers, + domain: domain.to_owned(), + dns: Some(DnsConfig { + resolvers: vec!["1.1.1.1".to_owned(), "1.0.0.1".to_owned()], + search_domains: vec![domain.to_owned()], + magic_dns: true, + }), + packet_filters: vec![PacketFilter::default()], + }) +} pub static PATH: &str = "./server.sqlite3"; -pub fn init_db() -> Result<()> { - let conn = rusqlite::Connection::open(PATH)?; +fn apply_map_request(conn: &Connection, user_id: i64, request: &MapRequest) -> Result<()> { + let current = conn + .query_row( + "SELECT id FROM control_node WHERE user_id = ? AND node_key = ?", + params![user_id, request.node_key], + |row| row.get::<_, i64>(0), + ) + .optional()?; + let Some(node_id) = current else { + return Ok(()); + }; + + let hostinfo_json = optional_json(&request.hostinfo)?; + let endpoints_json = to_json(&request.endpoints)?; conn.execute( - "CREATE TABLE IF NOT EXISTS user ( - id PRIMARY KEY, - created_at TEXT NOT NULL - )", - (), + "UPDATE control_node + SET disco_key = COALESCE(?, disco_key), + hostinfo_json = CASE WHEN ? IS NULL THEN hostinfo_json ELSE ? END, + endpoints_json = CASE WHEN ? = '[]' THEN endpoints_json ELSE ? END, + updated_at = datetime('now'), + last_seen = datetime('now'), + online = 1 + WHERE id = ?", + params![ + request.disco_key, + hostinfo_json, + hostinfo_json, + endpoints_json, + endpoints_json, + node_id, + ], )?; - - conn.execute( - "CREATE TABLE IF NOT EXISTS user_connection ( - user_id INTEGER REFERENCES user(id) ON DELETE CASCADE, - openid_provider TEXT NOT NULL, - openid_user_id TEXT NOT NULL, - openid_user_name TEXT NOT NULL, - access_token TEXT NOT NULL, - refresh_token TEXT, - PRIMARY KEY (openid_provider, openid_user_id) - )", - (), - )?; - - conn.execute( - "CREATE TABLE IF NOT EXISTS device ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT, - public_key TEXT NOT NULL, - apns_token TEXT UNIQUE, - user_id INT REFERENCES user(id) ON DELETE CASCADE, - created_at TEXT NOT NULL DEFAULT (datetime('now')) CHECK(created_at IS datetime(created_at)), - ipv4 TEXT NOT NULL UNIQUE, - ipv6 TEXT NOT NULL UNIQUE, - access_token TEXT NOT NULL UNIQUE, - refresh_token TEXT NOT NULL UNIQUE, - expires_at TEXT NOT NULL DEFAULT (datetime('now', '+7 days')) CHECK(expires_at IS datetime(expires_at)) - )", - () - ).unwrap(); - Ok(()) } -pub fn store_connection( - openid_user: super::providers::OpenIdUser, - openid_provider: &str, - access_token: &str, - refresh_token: Option<&str>, -) -> Result<()> { - log::debug!("Storing openid user {:#?}", openid_user); - let conn = rusqlite::Connection::open(PATH)?; +fn find_existing_node( + conn: &Connection, + user_id: i64, + request: &RegisterRequest, +) -> Result> { + let mut candidates = vec![request.node_key.as_str()]; + if let Some(old) = request.old_node_key.as_deref() { + if old != request.node_key { + candidates.push(old); + } + } - conn.execute( - "INSERT OR IGNORE INTO user (id, created_at) VALUES (?, datetime('now'))", - (&openid_user.sub,), - )?; - conn.execute( - "INSERT INTO user_connection (user_id, openid_provider, openid_user_id, openid_user_name, access_token, refresh_token) VALUES ( - (SELECT id FROM user WHERE id = ?), - ?, - ?, - ?, - ?, - ? - )", - (&openid_user.sub, &openid_provider, &openid_user.sub, &openid_user.name, access_token, refresh_token), - )?; - - Ok(()) + for candidate in candidates { + let hit = conn + .query_row( + "SELECT id, stable_id, created_at FROM control_node WHERE user_id = ? AND node_key = ?", + params![user_id, candidate], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional()?; + if hit.is_some() { + return Ok(hit); + } + } + Ok(None) } -pub fn store_device( - openid_user: super::providers::OpenIdUser, - openid_provider: &str, - access_token: &str, - refresh_token: Option<&str>, -) -> Result<()> { - log::debug!("Storing openid user {:#?}", openid_user); - let conn = rusqlite::Connection::open(PATH)?; - - // TODO - - Ok(()) +fn load_peers(conn: &Connection, self_id: i64) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, stable_id, created_at FROM control_node WHERE id != ? AND machine_authorized = 1 ORDER BY id", + )?; + let peers = stmt + .query_map([self_id], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })? + .collect::>>()?; + peers + .into_iter() + .map(|(id, stable_id, created_at)| load_node(conn, id, stable_id, Some(created_at))) + .collect() +} + +fn load_node( + conn: &Connection, + id: i64, + stable_id: String, + created_at_hint: Option, +) -> Result { + let row = conn.query_row( + "SELECT user_id, name, node_key, machine_key, disco_key, addresses_json, allowed_ips_json, + endpoints_json, home_derp, hostinfo_json, tags_json, primary_routes_json, cap_version, + cap_map_json, peer_cap_map_json, machine_authorized, node_key_expired, + created_at, updated_at, last_seen, online + FROM control_node WHERE id = ?", + [id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, + row.get::<_, String>(10)?, + row.get::<_, String>(11)?, + row.get::<_, i32>(12)?, + row.get::<_, String>(13)?, + row.get::<_, String>(14)?, + row.get::<_, i64>(15)?, + row.get::<_, i64>(16)?, + row.get::<_, String>(17)?, + row.get::<_, String>(18)?, + row.get::<_, Option>(19)?, + row.get::<_, Option>(20)?, + )) + }, + )?; + Ok(Node { + id, + stable_id, + user_id: row.0, + name: row.1, + node_key: row.2, + machine_key: row.3, + disco_key: row.4, + addresses: parse_json(&row.5)?, + allowed_ips: parse_json(&row.6)?, + endpoints: parse_json(&row.7)?, + home_derp: row.8, + hostinfo: row.9.map(|raw| parse_json::(&raw)).transpose()?, + tags: parse_json(&row.10)?, + primary_routes: parse_json(&row.11)?, + cap_version: row.12, + cap_map: parse_json::(&row.13)?, + peer_cap_map: parse_json::(&row.14)?, + machine_authorized: row.15 != 0, + node_key_expired: row.16 != 0, + created_at: Some(created_at_hint.unwrap_or(row.17)), + updated_at: Some(row.18), + last_seen: row.19, + online: row.20.map(|value| value != 0), + }) +} + +fn load_user(conn: &Connection, user_id: i64) -> Result { + let profile = load_user_profile(conn, user_id)?; + Ok(StoredUser { profile }) +} + +fn load_user_profile(conn: &Connection, user_id: i64) -> Result { + let row = conn.query_row( + "SELECT email, display_name, profile_pic_url, groups_json FROM auth_user WHERE id = ?", + [user_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, String>(3)?, + )) + }, + )?; + Ok(UserProfile { + id: user_id, + login_name: row.0, + display_name: row.1, + profile_pic_url: row.2, + groups: parse_json(&row.3)?, + }) +} + +fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut argon2::password_hash::rand_core::OsRng); + let hash = Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|err| anyhow!("failed to hash password: {err}"))?; + Ok(hash.to_string()) +} + +fn verify_password(password: &str, password_hash: &str) -> bool { + PasswordHash::new(password_hash) + .ok() + .and_then(|hash| { + Argon2::default() + .verify_password(password.as_bytes(), &hash) + .ok() + }) + .is_some() +} + +fn random_token() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +fn to_json(value: &T) -> Result { + serde_json::to_string(value).context("failed to serialize json") +} + +fn optional_json(value: &Option) -> Result> { + value.as_ref().map(to_json).transpose() +} + +fn parse_json(value: &str) -> Result { + serde_json::from_str(value) + .with_context(|| format!("failed to decode json payload from '{value}'")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::control::{Hostinfo, RegisterRequest}; + use tempfile::TempDir; + + fn temp_db() -> Result<(TempDir, String)> { + let dir = tempfile::tempdir()?; + let db_path = dir.path().join("server.sqlite3"); + Ok((dir, db_path.to_string_lossy().to_string())) + } + + #[test] + fn local_auth_and_map_round_trip() -> Result<()> { + let (_dir, db_path) = temp_db()?; + init_db(&db_path)?; + ensure_local_identity( + &db_path, + "contact", + "contact@burrow.net", + "Burrow Contact", + "password-1", + )?; + + let auth = authenticate_local(&db_path, "contact", "password-1")? + .expect("expected login to succeed"); + let user = + user_for_session(&db_path, &auth.access_token)?.expect("expected session to resolve"); + + let node = upsert_node( + &db_path, + &user, + &RegisterRequest { + node_key: "nodekey:aaaa".to_owned(), + machine_key: Some("machinekey:aaaa".to_owned()), + disco_key: Some("discokey:aaaa".to_owned()), + addresses: vec!["100.64.0.1/32".to_owned()], + endpoints: vec!["203.0.113.10:41641".to_owned()], + hostinfo: Some(Hostinfo { + hostname: Some("burrow-dev".to_owned()), + os: Some("linux".to_owned()), + os_version: Some("6.13".to_owned()), + services: vec!["ssh".to_owned()], + request_tags: vec!["tag:dev".to_owned()], + }), + ..RegisterRequest::default() + }, + )?; + assert_eq!(node.name, "burrow-dev"); + assert_eq!(node.allowed_ips, vec!["100.64.0.1/32"]); + + let map = map_for_node( + &db_path, + &user, + &MapRequest { + node_key: "nodekey:aaaa".to_owned(), + stream: true, + endpoints: vec!["203.0.113.10:41641".to_owned()], + ..MapRequest::default() + }, + "burrow.net", + )?; + assert_eq!(map.node.node_key, "nodekey:aaaa"); + assert_eq!(map.domain, "burrow.net"); + assert!(map.dns.expect("dns config").magic_dns); + Ok(()) + } + + #[test] + fn register_can_rotate_node_keys() -> Result<()> { + let (_dir, db_path) = temp_db()?; + init_db(&db_path)?; + ensure_local_identity( + &db_path, + "contact", + "contact@burrow.net", + "Burrow Contact", + "password-1", + )?; + let auth = authenticate_local(&db_path, "contact@burrow.net", "password-1")? + .expect("expected login to succeed"); + let user = + user_for_session(&db_path, &auth.access_token)?.expect("expected session to resolve"); + + upsert_node( + &db_path, + &user, + &RegisterRequest { + node_key: "nodekey:old".to_owned(), + addresses: vec!["100.64.0.2/32".to_owned()], + ..RegisterRequest::default() + }, + )?; + + let rotated = upsert_node( + &db_path, + &user, + &RegisterRequest { + node_key: "nodekey:new".to_owned(), + old_node_key: Some("nodekey:old".to_owned()), + addresses: vec!["100.64.0.3/32".to_owned()], + ..RegisterRequest::default() + }, + )?; + assert_eq!(rotated.node_key, "nodekey:new"); + assert_eq!(rotated.addresses, vec!["100.64.0.3/32"]); + Ok(()) + } } diff --git a/burrow/src/auth/server/mod.rs b/burrow/src/auth/server/mod.rs index 88b3ff3..b0c0522 100644 --- a/burrow/src/auth/server/mod.rs +++ b/burrow/src/auth/server/mod.rs @@ -1,32 +1,277 @@ pub mod db; -pub mod providers; +pub mod tailscale; -use anyhow::Result; -use axum::{http::StatusCode, routing::post, Router}; -use providers::slack::auth; +use std::{env, path::Path}; + +use anyhow::{Context, Result}; +use axum::{ + extract::{Json, Path as AxumPath, State}, + http::{header::AUTHORIZATION, HeaderMap, StatusCode}, + response::IntoResponse, + routing::{get, post}, + Router, +}; use tokio::signal; +use crate::control::{ + LocalAuthRequest, LocalAuthResponse, MapRequest, MapResponse, RegisterRequest, + RegisterResponse, BURROW_TAILNET_DOMAIN, +}; + +#[derive(Clone, Debug)] +pub struct BootstrapIdentity { + pub username: String, + pub email: String, + pub display_name: String, + pub password_file: String, +} + +impl Default for BootstrapIdentity { + fn default() -> Self { + Self { + username: "contact".to_owned(), + email: "contact@burrow.net".to_owned(), + display_name: "Burrow Contact".to_owned(), + password_file: "intake/forgejo_pass_contact_at_burrow_net.txt".to_owned(), + } + } +} + +#[derive(Clone, Debug)] +pub struct AuthServerConfig { + pub listen: String, + pub db_path: String, + pub tailnet_domain: String, + pub bootstrap: BootstrapIdentity, +} + +impl Default for AuthServerConfig { + fn default() -> Self { + Self { + listen: "0.0.0.0:8080".to_owned(), + db_path: db::PATH.to_owned(), + tailnet_domain: BURROW_TAILNET_DOMAIN.to_owned(), + bootstrap: BootstrapIdentity::default(), + } + } +} + +impl AuthServerConfig { + pub fn from_env() -> Self { + let mut config = Self::default(); + if let Ok(value) = env::var("BURROW_AUTH_LISTEN") { + config.listen = value; + } + if let Ok(value) = env::var("BURROW_AUTH_DB_PATH") { + config.db_path = value; + } + if let Ok(value) = env::var("BURROW_AUTH_TAILNET_DOMAIN") { + config.tailnet_domain = value; + } + if let Ok(value) = env::var("BURROW_BOOTSTRAP_USERNAME") { + config.bootstrap.username = value; + } + if let Ok(value) = env::var("BURROW_BOOTSTRAP_EMAIL") { + config.bootstrap.email = value; + } + if let Ok(value) = env::var("BURROW_BOOTSTRAP_DISPLAY_NAME") { + config.bootstrap.display_name = value; + } + if let Ok(value) = env::var("BURROW_BOOTSTRAP_PASSWORD_FILE") { + config.bootstrap.password_file = value; + } + config + } + + fn bootstrap_password(&self) -> Result> { + let path = Path::new(&self.bootstrap.password_file); + if !path.exists() { + return Ok(None); + } + let password = std::fs::read_to_string(path).with_context(|| { + format!("failed to read bootstrap password from {}", path.display()) + })?; + let password = password.trim().to_owned(); + if password.is_empty() { + return Ok(None); + } + Ok(Some(password)) + } +} + +#[derive(Clone)] +struct AppState { + config: AuthServerConfig, + tailscale: tailscale::TailscaleBridgeManager, +} + +type AppResult = Result; + pub async fn serve() -> Result<()> { - db::init_db()?; + serve_with_config(AuthServerConfig::from_env()).await +} - let app = Router::new() - .route("/slack-auth", post(auth)) - .route("/device/new", post(device_new)); +pub async fn serve_with_config(config: AuthServerConfig) -> Result<()> { + db::init_db(&config.db_path)?; + if let Some(password) = config.bootstrap_password()? { + db::ensure_local_identity( + &config.db_path, + &config.bootstrap.username, + &config.bootstrap.email, + &config.bootstrap.display_name, + &password, + )?; + } - let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); - log::info!("Starting auth server on port 8080"); + let app = build_router(config.clone()); + let listener = tokio::net::TcpListener::bind(&config.listen).await?; + log::info!("Starting auth server on {}", config.listen); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) - .await - .unwrap(); - + .await?; Ok(()) } -async fn device_new() -> StatusCode { +pub fn build_router(config: AuthServerConfig) -> Router { + Router::new() + .route("/healthz", get(healthz)) + .route("/device/new", post(device_new)) + .route("/v1/auth/login", post(login_local)) + .route("/v1/control/register", post(control_register)) + .route("/v1/control/map", post(control_map)) + .route("/v1/tailscale/login/start", post(tailscale_login_start)) + .route("/v1/tailscale/login/:session_id", get(tailscale_login_status)) + .with_state(AppState { + config, + tailscale: tailscale::TailscaleBridgeManager::default(), + }) +} + +async fn login_local( + State(state): State, + Json(request): Json, +) -> AppResult> { + let db_path = state.config.db_path.clone(); + blocking(move || db::authenticate_local(&db_path, &request.identifier, &request.password)) + .await? + .map(Json) + .ok_or_else(|| (StatusCode::UNAUTHORIZED, "invalid credentials".to_owned())) +} + +async fn control_register( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> AppResult> { + let token = bearer_token(&headers)?; + let db_path = state.config.db_path.clone(); + let user = blocking({ + let db_path = db_path.clone(); + let token = token.clone(); + move || db::user_for_session(&db_path, &token) + }) + .await? + .ok_or_else(|| (StatusCode::UNAUTHORIZED, "unknown session".to_owned()))?; + + let response_user = user.profile.clone(); + let node = blocking(move || db::upsert_node(&db_path, &user, &request)).await?; + Ok(Json(RegisterResponse { + user: response_user, + machine_authorized: node.machine_authorized, + node_key_expired: node.node_key_expired, + auth_url: None, + error: None, + node, + })) +} + +async fn control_map( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> AppResult> { + let token = bearer_token(&headers)?; + let db_path = state.config.db_path.clone(); + let domain = state.config.tailnet_domain.clone(); + let user = blocking({ + let db_path = db_path.clone(); + let token = token.clone(); + move || db::user_for_session(&db_path, &token) + }) + .await? + .ok_or_else(|| (StatusCode::UNAUTHORIZED, "unknown session".to_owned()))?; + + let response = blocking(move || db::map_for_node(&db_path, &user, &request, &domain)).await?; + Ok(Json(response)) +} + +async fn tailscale_login_start( + State(state): State, + Json(request): Json, +) -> AppResult> { + let response = state + .tailscale + .start_login(request) + .await + .map_err(internal_error)?; + Ok(Json(response)) +} + +async fn tailscale_login_status( + AxumPath(session_id): AxumPath, + State(state): State, +) -> AppResult> { + state + .tailscale + .status(&session_id) + .await + .map_err(internal_error)? + .map(Json) + .ok_or_else(|| (StatusCode::NOT_FOUND, "unknown tailscale login session".to_owned())) +} + +async fn healthz() -> impl IntoResponse { StatusCode::OK } +async fn device_new() -> impl IntoResponse { + StatusCode::OK +} + +async fn blocking(work: F) -> AppResult +where + F: FnOnce() -> Result + Send + 'static, + T: Send + 'static, +{ + tokio::task::spawn_blocking(work) + .await + .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))? + .map_err(internal_error) +} + +fn internal_error(err: anyhow::Error) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) +} + +fn bearer_token(headers: &HeaderMap) -> AppResult { + let value = headers.get(AUTHORIZATION).ok_or_else(|| { + ( + StatusCode::UNAUTHORIZED, + "missing authorization header".to_owned(), + ) + })?; + let value = value.to_str().map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "invalid authorization header".to_owned(), + ) + })?; + value + .strip_prefix("Bearer ") + .map(ToOwned::to_owned) + .ok_or_else(|| (StatusCode::UNAUTHORIZED, "expected bearer token".to_owned())) +} + async fn shutdown_signal() { let ctrl_c = async { signal::ctrl_c() @@ -51,12 +296,102 @@ async fn shutdown_signal() { } } -// mod db { -// use rusqlite::{Connection, Result}; +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::{to_bytes, Body}, + http::{Request, StatusCode}, + }; + use tempfile::tempdir; + use tower::ServiceExt; -// #[derive(Debug)] -// struct User { -// id: i32, -// created_at: String, -// } -// } + #[tokio::test] + async fn login_register_and_map_round_trip() -> Result<()> { + let dir = tempdir()?; + let password_file = dir.path().join("bootstrap-password.txt"); + std::fs::write(&password_file, "bootstrap-pass\n")?; + let db_path = dir.path().join("server.sqlite3"); + let config = AuthServerConfig { + listen: "127.0.0.1:0".to_owned(), + db_path: db_path.to_string_lossy().to_string(), + tailnet_domain: "burrow.net".to_owned(), + bootstrap: BootstrapIdentity { + password_file: password_file.to_string_lossy().to_string(), + ..BootstrapIdentity::default() + }, + }; + + db::init_db(&config.db_path)?; + let password = config.bootstrap_password()?.expect("bootstrap password"); + db::ensure_local_identity( + &config.db_path, + &config.bootstrap.username, + &config.bootstrap.email, + &config.bootstrap.display_name, + &password, + )?; + + let app = build_router(config); + + let response = app + .clone() + .oneshot( + Request::post("/v1/auth/login") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&LocalAuthRequest { + identifier: "contact".to_owned(), + password: "bootstrap-pass".to_owned(), + })?))?, + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let login: LocalAuthResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await?)?; + + let response = app + .clone() + .oneshot( + Request::post("/v1/control/register") + .header("content-type", "application/json") + .header("authorization", format!("Bearer {}", login.access_token)) + .body(Body::from(serde_json::to_vec(&RegisterRequest { + node_key: "nodekey:1234".to_owned(), + machine_key: Some("machinekey:1234".to_owned()), + addresses: vec!["100.64.0.10/32".to_owned()], + endpoints: vec!["198.51.100.10:41641".to_owned()], + hostinfo: Some(crate::control::Hostinfo { + hostname: Some("devbox".to_owned()), + os: Some("linux".to_owned()), + os_version: Some("6.13".to_owned()), + services: vec!["ssh".to_owned()], + request_tags: vec!["tag:dev".to_owned()], + }), + ..RegisterRequest::default() + })?))?, + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + + let response = app + .oneshot( + Request::post("/v1/control/map") + .header("content-type", "application/json") + .header("authorization", format!("Bearer {}", login.access_token)) + .body(Body::from(serde_json::to_vec(&MapRequest { + node_key: "nodekey:1234".to_owned(), + stream: true, + endpoints: vec!["198.51.100.10:41641".to_owned()], + ..MapRequest::default() + })?))?, + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let map: MapResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await?)?; + assert_eq!(map.domain, "burrow.net"); + assert_eq!(map.node.name, "devbox"); + assert!(map.dns.expect("dns").magic_dns); + Ok(()) + } +} diff --git a/burrow/src/auth/server/providers/mod.rs b/burrow/src/auth/server/providers/mod.rs deleted file mode 100644 index 36ff0bd..0000000 --- a/burrow/src/auth/server/providers/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub mod slack; -pub use super::db; - -#[derive(serde::Deserialize, Default, Debug)] -pub struct OpenIdUser { - pub sub: String, - pub name: String, -} diff --git a/burrow/src/auth/server/providers/slack.rs b/burrow/src/auth/server/providers/slack.rs deleted file mode 100644 index 581cd1e..0000000 --- a/burrow/src/auth/server/providers/slack.rs +++ /dev/null @@ -1,102 +0,0 @@ -use anyhow::Result; -use axum::{ - extract::Json, - http::StatusCode, - routing::{get, post}, -}; -use reqwest::header::AUTHORIZATION; -use serde::Deserialize; - -use super::db::store_connection; - -#[derive(Deserialize)] -pub struct SlackToken { - slack_token: String, -} -pub async fn auth(Json(payload): Json) -> (StatusCode, String) { - let slack_user = match fetch_slack_user(&payload.slack_token).await { - Ok(user) => user, - Err(e) => { - log::error!("Failed to fetch Slack user: {:?}", e); - return (StatusCode::UNAUTHORIZED, String::new()); - } - }; - - log::info!( - "Slack user {} ({}) logged in.", - slack_user.name, - slack_user.sub - ); - - let conn = match store_connection(slack_user, "slack", &payload.slack_token, None) { - Ok(user) => user, - Err(e) => { - log::error!("Failed to fetch Slack user: {:?}", e); - return (StatusCode::UNAUTHORIZED, String::new()); - } - }; - - (StatusCode::OK, String::new()) -} - -async fn fetch_slack_user(access_token: &str) -> Result { - let client = reqwest::Client::new(); - let res = client - .get("https://slack.com/api/openid.connect.userInfo") - .header(AUTHORIZATION, format!("Bearer {}", access_token)) - .send() - .await? - .json::() - .await?; - - let res_ok = res - .get("ok") - .and_then(|v| v.as_bool()) - .ok_or(anyhow::anyhow!("Slack user object not ok!"))?; - - if !res_ok { - return Err(anyhow::anyhow!("Slack user object not ok!")); - } - - Ok(serde_json::from_value(res)?) -} - -// async fn fetch_save_slack_user_data(query: Query) -> anyhow::Result<()> { -// let client = reqwest::Client::new(); -// log::trace!("Code was {}", &query.code); -// let mut url = Url::parse("https://slack.com/api/openid.connect.token")?; - -// { -// let mut q = url.query_pairs_mut(); -// q.append_pair("client_id", &var("CLIENT_ID")?); -// q.append_pair("client_secret", &var("CLIENT_SECRET")?); -// q.append_pair("code", &query.code); -// q.append_pair("grant_type", "authorization_code"); -// q.append_pair("redirect_uri", "https://burrow.rs/callback"); -// } - -// let data = client -// .post(url) -// .send() -// .await? -// .json::() -// .await?; - -// if !data.ok { -// return Err(anyhow::anyhow!("Slack code exchange response not ok!")); -// } - -// if let Some(access_token) = data.access_token { -// log::trace!("Access token is {access_token}"); -// let user = slack::fetch_slack_user(&access_token) -// .await -// .map_err(|err| anyhow::anyhow!("Failed to fetch Slack user info {:#?}", err))?; - -// db::store_user(user, access_token, String::new()) -// .map_err(|_| anyhow::anyhow!("Failed to store user in db"))?; - -// Ok(()) -// } else { -// Err(anyhow::anyhow!("Access token not found in response")) -// } -// } diff --git a/burrow/src/auth/server/tailscale.rs b/burrow/src/auth/server/tailscale.rs new file mode 100644 index 0000000..fbe1980 --- /dev/null +++ b/burrow/src/auth/server/tailscale.rs @@ -0,0 +1,320 @@ +use std::{ + collections::HashMap, + env, + path::{Path, PathBuf}, + process::Stdio, + sync::Arc, + time::Duration, +}; + +use anyhow::{anyhow, Context, Result}; +use rand::RngCore; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + process::{Child, Command}, + sync::Mutex, + task::JoinHandle, +}; + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct TailscaleLoginStartRequest { + pub account_name: String, + pub identity_name: String, + #[serde(default)] + pub hostname: Option, + #[serde(default)] + pub control_url: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct TailscaleLoginStatus { + pub backend_state: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_url: Option, + #[serde(default)] + pub running: bool, + #[serde(default)] + pub needs_login: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tailnet_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub magic_dns_suffix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub self_dns_name: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tailscale_ips: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub health: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct TailscaleLoginStartResponse { + pub session_id: String, + pub status: TailscaleLoginStatus, +} + +#[derive(Clone, Default)] +pub struct TailscaleBridgeManager { + client: Client, + sessions: Arc>>>, +} + +struct ManagedSession { + session_id: String, + listen_url: String, + state_dir: PathBuf, + child: Arc>, + _stderr_task: JoinHandle<()>, +} + +#[derive(Debug, Deserialize)] +struct HelperHello { + listen_addr: String, +} + +impl TailscaleBridgeManager { + pub async fn start_login( + &self, + request: TailscaleLoginStartRequest, + ) -> Result { + let key = session_key(&request.account_name, &request.identity_name); + + if let Some(existing) = self.sessions.lock().await.get(&key).cloned() { + let status = self.fetch_status(existing.as_ref()).await?; + return Ok(TailscaleLoginStartResponse { + session_id: existing.session_id.clone(), + status, + }); + } + + let state_dir = state_root().join(session_dir_name(&request)); + tokio::fs::create_dir_all(&state_dir) + .await + .with_context(|| format!("failed to create {}", state_dir.display()))?; + + let mut child = helper_command(&request, &state_dir)? + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("failed to spawn tailscale login helper")?; + + let stdout = child + .stdout + .take() + .context("tailscale helper stdout unavailable")?; + let stderr = child + .stderr + .take() + .context("tailscale helper stderr unavailable")?; + + let hello_line = tokio::time::timeout(Duration::from_secs(20), async move { + let mut lines = BufReader::new(stdout).lines(); + lines.next_line().await + }) + .await + .context("timed out waiting for tailscale helper startup")?? + .context("tailscale helper exited before reporting listen address")?; + + let hello: HelperHello = + serde_json::from_str(&hello_line).context("invalid tailscale helper startup line")?; + + let stderr_task = tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + log::info!("tailscale-login-bridge: {line}"); + } + }); + + let session = Arc::new(ManagedSession { + session_id: random_session_id(), + listen_url: format!("http://{}", hello.listen_addr), + state_dir, + child: Arc::new(Mutex::new(child)), + _stderr_task: stderr_task, + }); + + let status = self.wait_for_status(session.as_ref()).await?; + let response = TailscaleLoginStartResponse { + session_id: session.session_id.clone(), + status, + }; + + self.sessions.lock().await.insert(key, session); + Ok(response) + } + + pub async fn status(&self, session_id: &str) -> Result> { + let session = { + let sessions = self.sessions.lock().await; + sessions + .values() + .find(|session| session.session_id == session_id) + .cloned() + }; + + match session { + Some(session) => self.fetch_status(session.as_ref()).await.map(Some), + None => Ok(None), + } + } + + async fn wait_for_status(&self, session: &ManagedSession) -> Result { + let mut last_error = None; + let mut last_status = None; + for _ in 0..40 { + match self.fetch_status(session).await { + Ok(status) if status.running || status.auth_url.is_some() => return Ok(status), + Ok(status) => last_status = Some(status), + Err(err) => last_error = Some(err), + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + if let Some(status) = last_status { + return Ok(status); + } + Err(last_error.unwrap_or_else(|| anyhow!("tailscale helper did not become ready"))) + } + + async fn fetch_status(&self, session: &ManagedSession) -> Result { + let mut child = session.child.lock().await; + if let Some(status) = child.try_wait()? { + return Err(anyhow!( + "tailscale helper exited with status {status} for {}", + session.state_dir.display() + )); + } + drop(child); + + let response = self + .client + .get(format!("{}/status", session.listen_url)) + .send() + .await + .context("failed to query tailscale helper status")? + .error_for_status() + .context("tailscale helper status request failed")?; + + response + .json::() + .await + .context("invalid tailscale helper status response") + } +} + +fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Result { + let mut command = if let Ok(path) = env::var("BURROW_TAILSCALE_HELPER") { + Command::new(path) + } else { + let helper_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("Tools/tailscale-login-bridge"); + let mut command = Command::new("go"); + command.current_dir(helper_dir).arg("run").arg("."); + command.env("GOWORK", "off"); + command + }; + + command + .arg("--listen") + .arg("127.0.0.1:0") + .arg("--state-dir") + .arg(state_dir) + .arg("--hostname") + .arg(default_hostname(request)); + + if let Some(control_url) = request.control_url.as_deref() { + let trimmed = control_url.trim(); + if !trimmed.is_empty() { + command.arg("--control-url").arg(trimmed); + } + } + + Ok(command) +} + +fn state_root() -> PathBuf { + if let Ok(path) = env::var("BURROW_TAILSCALE_STATE_ROOT") { + return PathBuf::from(path); + } + + let home = env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + if cfg!(target_vendor = "apple") { + return home + .join("Library") + .join("Application Support") + .join("Burrow") + .join("tailscale"); + } + home.join(".local").join("share").join("burrow").join("tailscale") +} + +fn session_dir_name(request: &TailscaleLoginStartRequest) -> String { + format!( + "{}-{}", + slug(&request.account_name), + slug(&request.identity_name) + ) +} + +fn session_key(account_name: &str, identity_name: &str) -> String { + format!("{account_name}:{identity_name}") +} + +fn default_hostname(request: &TailscaleLoginStartRequest) -> String { + request + .hostname + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| format!("burrow-{}", slug(&request.identity_name))) +} + +fn random_session_id() -> String { + let mut bytes = [0_u8; 12]; + rand::thread_rng().fill_bytes(&mut bytes); + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn slug(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + for ch in input.chars() { + if ch.is_ascii_alphanumeric() { + output.push(ch.to_ascii_lowercase()); + } else if ch == '-' || ch == '_' { + output.push('-'); + } + } + if output.is_empty() { + "default".to_owned() + } else { + output + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slug_sanitizes_input() { + assert_eq!(slug("Apple Phone"), "applephone"); + assert_eq!(slug("default_identity"), "default-identity"); + assert_eq!(slug(""), "default"); + } + + #[test] + fn state_dir_is_stable_by_account_and_identity() { + let request = TailscaleLoginStartRequest { + account_name: "default".to_owned(), + identity_name: "apple".to_owned(), + hostname: None, + control_url: None, + }; + assert_eq!(session_dir_name(&request), "default-apple"); + assert_eq!(default_hostname(&request), "burrow-apple"); + } +} diff --git a/burrow/src/control/config.rs b/burrow/src/control/config.rs new file mode 100644 index 0000000..3862bcd --- /dev/null +++ b/burrow/src/control/config.rs @@ -0,0 +1,87 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TailnetProvider { + Tailscale, + Headscale, + Burrow, +} + +impl Default for TailnetProvider { + fn default() -> Self { + Self::Tailscale + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct TailnetConfig { + #[serde(default)] + pub provider: TailnetProvider, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authority: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tailnet: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hostname: Option, +} + +impl TailnetConfig { + pub fn from_slice(bytes: &[u8]) -> Result { + let payload = std::str::from_utf8(bytes).context("tailnet payload must be valid UTF-8")?; + Self::from_str(payload) + } + + pub fn from_str(payload: &str) -> Result { + let trimmed = payload.trim(); + if trimmed.starts_with('{') { + return serde_json::from_str(trimmed).context("invalid tailnet JSON payload"); + } + toml::from_str(trimmed).context("invalid tailnet TOML payload") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_json_payload() { + let config = TailnetConfig::from_str( + r#"{ + "provider":"tailscale", + "account":"default", + "identity":"apple", + "tailnet":"example.ts.net", + "hostname":"burrow-phone" + }"#, + ) + .unwrap(); + assert_eq!(config.provider, TailnetProvider::Tailscale); + assert_eq!(config.account.as_deref(), Some("default")); + assert_eq!(config.identity.as_deref(), Some("apple")); + } + + #[test] + fn parses_toml_payload() { + let config = TailnetConfig::from_str( + r#" +provider = "headscale" +authority = "https://headscale.example.com" +account = "default" +identity = "apple" +"#, + ) + .unwrap(); + assert_eq!(config.provider, TailnetProvider::Headscale); + assert_eq!( + config.authority.as_deref(), + Some("https://headscale.example.com") + ); + } +} diff --git a/burrow/src/control/mod.rs b/burrow/src/control/mod.rs new file mode 100644 index 0000000..331a7d2 --- /dev/null +++ b/burrow/src/control/mod.rs @@ -0,0 +1,253 @@ +pub mod config; + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub use config::{TailnetConfig, TailnetProvider}; + +pub const BURROW_CAPABILITY_VERSION: i32 = 1; +pub const BURROW_TAILNET_DOMAIN: &str = "burrow.net"; + +pub type NodeCapMap = BTreeMap>; +pub type PeerCapMap = BTreeMap>; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Hostinfo { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hostname: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub os: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub os_version: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub services: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub request_tags: Vec, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct UserProfile { + pub id: i64, + pub login_name: String, + pub display_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_pic_url: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub groups: Vec, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct RegisterAuth { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oauth_access_token: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct Node { + pub id: i64, + pub stable_id: String, + pub name: String, + pub user_id: i64, + pub node_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub machine_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disco_key: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub addresses: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_ips: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub endpoints: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub home_derp: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hostinfo: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub primary_routes: Vec, + #[serde(default = "default_capability_version")] + pub cap_version: i32, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub cap_map: NodeCapMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub peer_cap_map: PeerCapMap, + #[serde(default)] + pub machine_authorized: bool, + #[serde(default)] + pub node_key_expired: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_seen: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub online: Option, +} + +impl Node { + pub fn preferred_name(request: &RegisterRequest) -> String { + if let Some(name) = request.name.as_deref() { + return name.to_owned(); + } + if let Some(hostname) = request + .hostinfo + .as_ref() + .and_then(|hostinfo| hostinfo.hostname.as_deref()) + { + return hostname.to_owned(); + } + format!("node-{}", short_key(&request.node_key)) + } + + pub fn normalized_allowed_ips(request: &RegisterRequest) -> Vec { + if request.allowed_ips.is_empty() { + return request.addresses.clone(); + } + request.allowed_ips.clone() + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct RegisterRequest { + #[serde(default = "default_capability_version")] + pub version: i32, + pub node_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub old_node_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub machine_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disco_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expiry: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub followup: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hostinfo: Option, + #[serde(default)] + pub ephemeral: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tailnet: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub addresses: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_ips: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub endpoints: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub home_derp: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub primary_routes: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub cap_map: NodeCapMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub peer_cap_map: PeerCapMap, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct RegisterResponse { + pub user: UserProfile, + pub node: Node, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_url: Option, + pub machine_authorized: bool, + pub node_key_expired: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct MapRequest { + #[serde(default = "default_capability_version")] + pub version: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compress: Option, + #[serde(default)] + pub keep_alive: bool, + pub node_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disco_key: Option, + #[serde(default)] + pub stream: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hostinfo: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub map_session_handle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub map_session_seq: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub endpoints: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub debug_flags: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connection_handle: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct DnsConfig { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub resolvers: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub search_domains: Vec, + #[serde(default)] + pub magic_dns: bool, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct PacketFilter { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sources: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub destinations: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub protocols: Vec, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct MapResponse { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub map_session_handle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seq: Option, + pub node: Node, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub peers: Vec, + pub domain: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dns: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub packet_filters: Vec, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct LocalAuthRequest { + pub identifier: String, + pub password: String, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct LocalAuthResponse { + pub access_token: String, + pub user: UserProfile, +} + +fn default_capability_version() -> i32 { + BURROW_CAPABILITY_VERSION +} + +fn short_key(key: &str) -> String { + key.chars().take(8).collect() +} diff --git a/burrow/src/daemon/mod.rs b/burrow/src/daemon/mod.rs index 8fe3d41..a016788 100644 --- a/burrow/src/daemon/mod.rs +++ b/burrow/src/daemon/mod.rs @@ -63,8 +63,6 @@ mod tests { }; use anyhow::{anyhow, Result}; - use iroh::PublicKey; - use serde_json::json; use tokio::time::{timeout, Duration}; use super::*; @@ -172,15 +170,15 @@ mod tests { .networks_client .network_add(Network { id: 2, - r#type: NetworkType::HackClub.into(), - payload: sample_hackclub_payload(), + r#type: NetworkType::WireGuard.into(), + payload: sample_wireguard_payload_with("10.77.0.2/32", 1380), }) .await?; - let networks_after_mesh_add = next_networks(&mut network_stream).await?; + let networks_after_second_add = next_networks(&mut network_stream).await?; assert_eq!( - network_ids(&networks_after_mesh_add), - vec![(1, NetworkType::WireGuard), (2, NetworkType::HackClub)] + network_ids(&networks_after_second_add), + vec![(1, NetworkType::WireGuard), (2, NetworkType::WireGuard)] ); let still_wireguard = next_configuration(&mut config_stream).await?; @@ -194,12 +192,12 @@ mod tests { let networks_after_reorder = next_networks(&mut network_stream).await?; assert_eq!( network_ids(&networks_after_reorder), - vec![(2, NetworkType::HackClub), (1, NetworkType::WireGuard)] + vec![(2, NetworkType::WireGuard), (1, NetworkType::WireGuard)] ); - let mesh_config = next_configuration(&mut config_stream).await?; - assert_eq!(mesh_config.addresses, vec!["10.77.0.2/32"]); - assert_eq!(mesh_config.mtu, 1380); + let second_wireguard_config = next_configuration(&mut config_stream).await?; + assert_eq!(second_wireguard_config.addresses, vec!["10.77.0.2/32"]); + assert_eq!(second_wireguard_config.mtu, 1380); daemon_task.abort(); let _ = daemon_task.await; @@ -237,16 +235,10 @@ Endpoint = wg.burrow.rs:51820 .to_vec() } - fn sample_hackclub_payload() -> Vec { - let endpoint_id = PublicKey::from_bytes(&[0; 32]).unwrap().to_string(); - json!({ - "endpoint_id": endpoint_id, - "addresses": ["127.0.0.1:7777"], - "local_addresses": ["10.77.0.2/32"], - "mtu": 1380, - "tun_name": "burrow-test-mesh", - }) - .to_string() + fn sample_wireguard_payload_with(address: &str, mtu: u16) -> Vec { + format!( + "[Interface]\nPrivateKey = OEPVdomeLTxTIBvv3TYsJRge0Hp9NMiY0sIrhT8OWG8=\nAddress = {address}\nListenPort = 51820\nMTU = {mtu}\n\n[Peer]\nPublicKey = 8GaFjVO6c4luCHG4ONO+1bFG8tO+Zz5/Gy+Geht1USM=\nPresharedKey = ha7j4BjD49sIzyF9SNlbueK0AMHghlj6+u0G3bzC698=\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = wg.burrow.rs:51820\n" + ) .into_bytes() } diff --git a/burrow/src/daemon/runtime.rs b/burrow/src/daemon/runtime.rs index 7fea964..84dfd2b 100644 --- a/burrow/src/daemon/runtime.rs +++ b/burrow/src/daemon/runtime.rs @@ -9,7 +9,7 @@ use super::rpc::{ ServerConfig, }; use crate::{ - mesh::iroh::{self as mesh_iroh, HackClubNetworkConfig, MeshHandle}, + control::TailnetConfig, wireguard::{Config, Interface as WireGuardInterface}, }; @@ -28,14 +28,14 @@ pub enum ResolvedTunnel { Passthrough { identity: RuntimeIdentity, }, + Tailnet { + identity: RuntimeIdentity, + config: TailnetConfig, + }, WireGuard { identity: RuntimeIdentity, config: Config, }, - HackClub { - identity: RuntimeIdentity, - config: HackClubNetworkConfig, - }, } impl ResolvedTunnel { @@ -53,24 +53,24 @@ impl ResolvedTunnel { }; match network.r#type() { + NetworkType::Tailnet => { + let config = TailnetConfig::from_slice(&network.payload)?; + Ok(Self::Tailnet { identity, config }) + } NetworkType::WireGuard => { let payload = String::from_utf8(network.payload.clone()) .context("wireguard payload must be valid UTF-8")?; let config = Config::from_content_fmt(&payload, "ini")?; Ok(Self::WireGuard { identity, config }) } - NetworkType::HackClub => { - let config = HackClubNetworkConfig::from_payload(&network.payload)?; - Ok(Self::HackClub { identity, config }) - } } } pub fn identity(&self) -> &RuntimeIdentity { match self { Self::Passthrough { identity } - | Self::WireGuard { identity, .. } - | Self::HackClub { identity, .. } => identity, + | Self::Tailnet { identity, .. } + | Self::WireGuard { identity, .. } => identity, } } @@ -81,12 +81,12 @@ impl ResolvedTunnel { name: None, mtu: Some(1500), }), - Self::WireGuard { config, .. } => ServerConfig::try_from(config), - Self::HackClub { config, .. } => Ok(ServerConfig { - address: config.local_addresses.clone(), - name: config.tun_name.clone(), - mtu: config.mtu.map(i32::from), + Self::Tailnet { .. } => Ok(ServerConfig { + address: Vec::new(), + name: None, + mtu: Some(1280), }), + Self::WireGuard { config, .. } => ServerConfig::try_from(config), } } @@ -96,6 +96,10 @@ impl ResolvedTunnel { ) -> Result { match self { Self::Passthrough { identity } => Ok(ActiveTunnel::Passthrough { identity }), + Self::Tailnet { config, .. } => Err(anyhow::anyhow!( + "tailnet runtime is not wired in this checkout yet ({:?})", + config.provider + )), Self::WireGuard { identity, config } => { let tun = TunOptions::new().open()?; tun_interface.write().await.replace(tun); @@ -110,23 +114,6 @@ impl ResolvedTunnel { } } } - Self::HackClub { identity, config } => { - let mut tun_opts = TunOptions::new(); - if let Some(name) = config.tun_name.as_deref() { - tun_opts = tun_opts.name(name); - } - - let tun = tun_opts.open()?; - tun_interface.write().await.replace(tun); - - match mesh_iroh::spawn_hackclub_tunnel(config, tun_interface.clone()).await { - Ok(handle) => Ok(ActiveTunnel::HackClub { identity, handle }), - Err(err) => { - tun_interface.write().await.take(); - Err(err) - } - } - } } } } @@ -140,18 +127,13 @@ pub enum ActiveTunnel { interface: Arc>, task: JoinHandle>, }, - HackClub { - identity: RuntimeIdentity, - handle: MeshHandle, - }, } impl ActiveTunnel { pub fn identity(&self) -> &RuntimeIdentity { match self { Self::Passthrough { identity } - | Self::WireGuard { identity, .. } - | Self::HackClub { identity, .. } => identity, + | Self::WireGuard { identity, .. } => identity, } } @@ -165,11 +147,6 @@ impl ActiveTunnel { task_result??; Ok(()) } - Self::HackClub { handle, .. } => { - let result = handle.shutdown().await; - tun_interface.write().await.take(); - result - } } } } diff --git a/burrow/src/database.rs b/burrow/src/database.rs index 5039e03..fe9a3c7 100644 --- a/burrow/src/database.rs +++ b/burrow/src/database.rs @@ -4,10 +4,10 @@ use anyhow::Result; use rusqlite::{params, Connection}; use crate::{ + control::TailnetConfig, daemon::rpc::grpc_defs::{ Network as RPCNetwork, NetworkDeleteRequest, NetworkReorderRequest, NetworkType, }, - mesh::iroh::HackClubNetworkConfig, wireguard::config::{Config, Interface, Peer}, }; @@ -203,8 +203,8 @@ fn validate_network_payload(network: &RPCNetwork) -> Result<()> { let payload_str = String::from_utf8(network.payload.clone())?; Config::from_content_fmt(&payload_str, "ini")?; } - NetworkType::HackClub => { - HackClubNetworkConfig::from_payload(&network.payload)?; + NetworkType::Tailnet => { + TailnetConfig::from_slice(&network.payload)?; } } Ok(()) @@ -243,8 +243,6 @@ fn renumber_networks(conn: &Connection, ordered_ids: &[i32]) -> Result<()> { #[cfg(test)] mod tests { use super::*; - use iroh::PublicKey; - use serde_json::json; use tempfile::tempdir; fn sample_wireguard_payload() -> Vec { @@ -262,19 +260,24 @@ Endpoint = wg.burrow.rs:51820 .to_vec() } - fn sample_hackclub_payload(name: &str, address: &str) -> Vec { - let endpoint_id = PublicKey::from_bytes(&[0; 32]).unwrap().to_string(); - json!({ - "endpoint_id": endpoint_id, - "addresses": ["127.0.0.1:7777"], - "local_addresses": [address], - "mtu": 1380, - "tun_name": name, - }) - .to_string() + fn sample_wireguard_payload_with_address(address: &str, mtu: u16) -> Vec { + format!( + "[Interface]\nPrivateKey = OEPVdomeLTxTIBvv3TYsJRge0Hp9NMiY0sIrhT8OWG8=\nAddress = {address}\nListenPort = 51820\nMTU = {mtu}\n\n[Peer]\nPublicKey = 8GaFjVO6c4luCHG4ONO+1bFG8tO+Zz5/Gy+Geht1USM=\nPresharedKey = ha7j4BjD49sIzyF9SNlbueK0AMHghlj6+u0G3bzC698=\nAllowedIPs = 0.0.0.0/0\nEndpoint = wg.burrow.rs:51820\n" + ) .into_bytes() } + fn sample_tailnet_payload() -> Vec { + br#"{ + "provider":"tailscale", + "account":"default", + "identity":"apple", + "tailnet":"example.ts.net", + "hostname":"burrow-phone" +}"# + .to_vec() + } + #[test] fn test_db() { let conn = Connection::open_in_memory().unwrap(); @@ -304,8 +307,18 @@ Endpoint = wg.burrow.rs:51820 &conn, &RPCNetwork { id: 2, - r#type: NetworkType::HackClub.into(), - payload: sample_hackclub_payload("burrow-test-0", "10.42.0.2/32"), + r#type: NetworkType::Tailnet.into(), + payload: sample_tailnet_payload(), + }, + ) + .unwrap(); + + add_network( + &conn, + &RPCNetwork { + id: 3, + r#type: NetworkType::WireGuard.into(), + payload: sample_wireguard_payload_with_address("10.42.0.2/32", 1380), }, ) .unwrap(); @@ -313,19 +326,29 @@ Endpoint = wg.burrow.rs:51820 assert!(add_network( &conn, &RPCNetwork { - id: 3, + id: 4, r#type: NetworkType::WireGuard.into(), payload: b"not-a-config".to_vec(), }, ) .is_err()); + assert!(add_network( + &conn, + &RPCNetwork { + id: 5, + r#type: NetworkType::Tailnet.into(), + payload: b"not-a-tailnet-config".to_vec(), + }, + ) + .is_err()); + let ids: Vec = list_networks(&conn) .unwrap() .into_iter() .map(|n| n.id) .collect(); - assert_eq!(ids, vec![1, 2]); + assert_eq!(ids, vec![1, 2, 3]); } #[test] @@ -333,17 +356,17 @@ Endpoint = wg.burrow.rs:51820 let conn = Connection::open_in_memory().unwrap(); initialize_tables(&conn).unwrap(); - for (id, name, address) in [ - (1, "burrow-test-1", "10.42.0.2/32"), - (2, "burrow-test-2", "10.42.0.3/32"), - (3, "burrow-test-3", "10.42.0.4/32"), + for (id, address, mtu) in [ + (1, "10.42.0.2/32", 1380), + (2, "10.42.0.3/32", 1381), + (3, "10.42.0.4/32", 1382), ] { add_network( &conn, &RPCNetwork { id, - r#type: NetworkType::HackClub.into(), - payload: sample_hackclub_payload(name, address), + r#type: NetworkType::WireGuard.into(), + payload: sample_wireguard_payload_with_address(address, mtu), }, ) .unwrap(); diff --git a/proto/burrow.proto b/proto/burrow.proto index 2355b8d..5b5a30b 100644 --- a/proto/burrow.proto +++ b/proto/burrow.proto @@ -45,7 +45,7 @@ message Network { enum NetworkType { WireGuard = 0; - HackClub = 1; + Tailnet = 1; } message NetworkListResponse { From 35f3b3ce4e725bb7c3469a8f31e158832085891c Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Tue, 31 Mar 2026 13:09:07 -0700 Subject: [PATCH 032/102] Use AuthenticationServices for Tailnet sign-in --- Apple/UI/BurrowView.swift | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index 66f42d0..1beb2e1 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -1,3 +1,4 @@ +import AuthenticationServices import BurrowConfiguration import Foundation import SwiftUI @@ -184,7 +185,7 @@ private struct AccountDraft { private struct ConfigurationSheetView: View { @Environment(\.dismiss) private var dismiss - @Environment(\.openURL) private var openURL + @Environment(\.webAuthenticationSession) private var webAuthenticationSession let sheet: ConfigurationSheet let networkViewModel: NetworkViewModel @@ -197,6 +198,7 @@ private struct ConfigurationSheetView: View { @State private var loginStatus: TailnetLoginStatus? @State private var pollingTask: Task? @State private var didRunAutomation = false + @State private var webAuthenticationTask: Task? init( sheet: ConfigurationSheet, @@ -280,6 +282,8 @@ private struct ConfigurationSheetView: View { } .onDisappear { pollingTask?.cancel() + webAuthenticationTask?.cancel() + webAuthenticationTask = nil } } @@ -307,7 +311,7 @@ private struct ConfigurationSheetView: View { .autocorrectionDisabled() if draft.tailnetProvider.usesWebLogin { - Text("Sign-in is brokered by `burrow auth-server` on the host and opens the real Tailscale login page in a browser.") + Text("Sign-in is brokered by `burrow auth-server` on the host and opens the real Tailscale login page in an in-app authentication session.") .font(.footnote) .foregroundStyle(.secondary) } else { @@ -343,9 +347,9 @@ private struct ConfigurationSheetView: View { } if let authURL = loginStatus.authURL { labeledValue("Login URL", authURL) - Button("Open Login Page") { + Button("Resume Sign-In") { if let url = URL(string: authURL) { - openURL(url) + openLoginURL(url) } } } @@ -479,6 +483,8 @@ private struct ConfigurationSheetView: View { private func submitTailnet() async throws { if draft.tailnetProvider.usesWebLogin { if loginStatus?.running == true { + webAuthenticationTask?.cancel() + webAuthenticationTask = nil try await saveTailnetAccount(secret: nil, username: nil) dismiss() } else { @@ -551,6 +557,8 @@ private struct ConfigurationSheetView: View { openLoginURL(url) } if status.running { + webAuthenticationTask?.cancel() + webAuthenticationTask = nil return } } catch { @@ -563,12 +571,25 @@ private struct ConfigurationSheetView: View { } private func openLoginURL(_ url: URL) { - Task { @MainActor in + webAuthenticationTask?.cancel() + webAuthenticationTask = Task { @MainActor in try? await Task.sleep(for: .milliseconds(300)) - openURL(url) { accepted in - guard !accepted else { return } - errorMessage = "Burrow got a Tailscale login URL, but iOS did not open it automatically." + do { + _ = try await webAuthenticationSession.authenticate( + using: url, + callbackURLScheme: "burrow", + preferredBrowserSession: .shared + ) + } catch is CancellationError { + return + } catch let error as ASWebAuthenticationSessionError + where error.code == .canceledLogin + { + return + } catch { + errorMessage = error.localizedDescription } + webAuthenticationTask = nil } } From 36a54628ba908fc93d91fbe9e66a3ea326e7c7d4 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Tue, 31 Mar 2026 13:40:13 -0700 Subject: [PATCH 033/102] Simplify iOS network add flow --- Apple/UI/BurrowView.swift | 207 +++++++++++++++++++++++------ Apple/UI/NetworkCarouselView.swift | 16 +++ 2 files changed, 181 insertions(+), 42 deletions(-) diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index 1beb2e1..e595475 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -18,33 +18,43 @@ public struct BurrowView: View { Text("Burrow") .font(.largeTitle) .fontWeight(.bold) - Text("Networks and accounts") - .font(.headline) - .foregroundStyle(.secondary) + if showsHeaderSubtitle { + Text("Networks and accounts") + .font(.headline) + .foregroundStyle(.secondary) + } } - Spacer() - Menu { - Button("Add WireGuard Network") { - activeSheet = .wireGuard + if showsToolbarAddMenu { + Spacer() + Menu { + Button("Add WireGuard Network") { + activeSheet = .wireGuard + } + Button("Save Tor Account") { + activeSheet = .tor + } + Button("Add Tailnet Account") { + activeSheet = .tailnet + } + } label: { + Image(systemName: "plus.circle.fill") + .font(.title) + .accessibilityLabel("Add") } - Button("Save Tor Account") { - activeSheet = .tor - } - Button("Add Tailnet Account") { - activeSheet = .tailnet - } - } label: { - Image(systemName: "plus.circle.fill") - .font(.title) - .accessibilityLabel("Add") } } .padding(.top) + if showsInlineQuickActions { + quickAddSection + } + VStack(alignment: .leading, spacing: 12) { sectionHeader( title: "Networks", - detail: "Stored daemon networks and their active account selectors" + detail: showsInlineQuickActions + ? nil + : "Stored daemon networks and their active account selectors" ) if let connectionError = networkViewModel.connectionError { Text(connectionError) @@ -54,25 +64,29 @@ public struct BurrowView: View { NetworkCarouselView(networks: networkViewModel.cards) } - VStack(alignment: .leading, spacing: 12) { - sectionHeader( - title: "Accounts", - detail: "Per-network identities and sign-in state" - ) - if accountStore.accounts.isEmpty { - ContentUnavailableView( - "No Accounts Yet", - systemImage: "person.crop.circle.badge.plus", - description: Text("Save a Tor account or sign in to a Tailnet provider to keep network identities ready on this device.") + if showsAccountsSection { + VStack(alignment: .leading, spacing: 12) { + sectionHeader( + title: "Accounts", + detail: showsInlineQuickActions + ? nil + : "Per-network identities and sign-in state" ) - .frame(maxWidth: .infinity, minHeight: 180) - } else { - LazyVStack(spacing: 12) { - ForEach(accountStore.accounts) { account in - AccountRowView( - account: account, - hasSecret: accountStore.hasStoredSecret(for: account) - ) + if accountStore.accounts.isEmpty { + ContentUnavailableView( + "No Accounts Yet", + systemImage: "person.crop.circle.badge.plus", + description: Text("Save a Tor account or sign in to a Tailnet provider to keep network identities ready on this device.") + ) + .frame(maxWidth: .infinity, minHeight: 180) + } else { + LazyVStack(spacing: 12) { + ForEach(accountStore.accounts) { account in + AccountRowView( + account: account, + hasSecret: accountStore.hasStoredSecret(for: account) + ) + } } } } @@ -81,7 +95,7 @@ public struct BurrowView: View { VStack(alignment: .leading, spacing: 8) { sectionHeader( title: "Tunnel", - detail: "Current system extension state" + detail: showsInlineQuickActions ? nil : "Current system extension state" ) TunnelStatusView() TunnelButton() @@ -120,18 +134,58 @@ public struct BurrowView: View { } @ViewBuilder - private func sectionHeader(title: String, detail: String) -> some View { + private var quickAddSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionHeader(title: "Add", detail: nil) + VStack(spacing: 12) { + ForEach(ConfigurationSheet.allCases) { sheet in + QuickAddButton(sheet: sheet) { + activeSheet = sheet + } + } + } + } + } + + @ViewBuilder + private func sectionHeader(title: String, detail: String?) -> some View { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.title2.weight(.semibold)) - Text(detail) - .font(.subheadline) - .foregroundStyle(.secondary) + if let detail, !detail.isEmpty { + Text(detail) + .font(.subheadline) + .foregroundStyle(.secondary) + } } } + + private var showsInlineQuickActions: Bool { + #if os(iOS) + true + #else + false + #endif + } + + private var showsToolbarAddMenu: Bool { + !showsInlineQuickActions + } + + private var showsHeaderSubtitle: Bool { + !showsInlineQuickActions + } + + private var showsAccountsSection: Bool { + #if os(iOS) + !accountStore.accounts.isEmpty + #else + true + #endif + } } -private enum ConfigurationSheet: String, Identifiable { +private enum ConfigurationSheet: String, CaseIterable, Identifiable { case wireGuard case tor case tailnet @@ -145,6 +199,75 @@ private enum ConfigurationSheet: String, Identifiable { case .tailnet: .headscale } } + + var iconName: String { + switch self { + case .wireGuard: + "wave.3.right" + case .tor: + "shield.lefthalf.filled.badge.checkmark" + case .tailnet: + "network.badge.shield.half.filled" + } + } + + var quickActionTitle: String { + switch self { + case .wireGuard: + "WireGuard" + case .tor: + "Tor" + case .tailnet: + "Tailnet" + } + } + + var quickActionSubtitle: String { + switch self { + case .wireGuard: + "Import a tunnel" + case .tor: + "Save an Arti profile" + case .tailnet: + "Sign in or save a control plane" + } + } + + var quickActionColor: Color { + switch self { + case .wireGuard: + .blue + case .tor, .tailnet: + kind.accentColor + } + } +} + +private struct QuickAddButton: View { + let sheet: ConfigurationSheet + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 14) { + Image(systemName: sheet.iconName) + .font(.title3.weight(.semibold)) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 4) { + Text(sheet.quickActionTitle) + .font(.headline) + Text(sheet.quickActionSubtitle) + .font(.caption) + .opacity(0.88) + } + + Spacer() + } + .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading) + } + .buttonStyle(.floating(color: sheet.quickActionColor, cornerRadius: 18)) + } } private struct AccountDraft { diff --git a/Apple/UI/NetworkCarouselView.swift b/Apple/UI/NetworkCarouselView.swift index 4bbf12f..e7368db 100644 --- a/Apple/UI/NetworkCarouselView.swift +++ b/Apple/UI/NetworkCarouselView.swift @@ -6,12 +6,28 @@ struct NetworkCarouselView: View { var body: some View { Group { if networks.isEmpty { + #if os(iOS) + VStack(alignment: .leading, spacing: 6) { + Text("No stored networks yet") + .font(.headline) + Text("WireGuard and Tailnet networks show up here as soon as you add one.") + .font(.footnote) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 18) + .fill(.thinMaterial) + ) + #else ContentUnavailableView( "No Networks Yet", systemImage: "network.slash", description: Text("Add a WireGuard network, or save a Tailnet account so Burrow can store a managed network when the daemon is reachable.") ) .frame(maxWidth: .infinity, minHeight: 175) + #endif } else { ScrollView(.horizontal) { LazyHStack { From 2f69987742a7d47fb3a6cf5e50c4e4f8b7ecbe52 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Tue, 31 Mar 2026 13:46:11 -0700 Subject: [PATCH 034/102] Fix iOS config sheet width --- Apple/UI/BurrowView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index e595475..b075279 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -399,7 +399,9 @@ private struct ConfigurationSheetView: View { } } } + #if os(macOS) .frame(minWidth: 520, minHeight: 620) + #endif .onAppear { runAutomationIfNeeded() } From 014bca073f98926626d82a5643180c421850dc4d Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Tue, 31 Mar 2026 14:27:14 -0700 Subject: [PATCH 035/102] Polish Apple network config sheets --- Apple/UI/BurrowView.swift | 274 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 261 insertions(+), 13 deletions(-) diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index b075279..a3dd628 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -2,6 +2,11 @@ import AuthenticationServices import BurrowConfiguration import Foundation import SwiftUI +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif public struct BurrowView: View { @State private var networkViewModel: NetworkViewModel @@ -338,15 +343,10 @@ private struct ConfigurationSheetView: View { NavigationStack { Form { Section { - Text(sheet.kind.subtitle) - .font(.callout) - .foregroundStyle(.secondary) - if let availabilityNote = sheet.kind.availabilityNote { - Text(availabilityNote) - .font(.footnote) - .foregroundStyle(.secondary) - } + sheetSummaryCard } + .listRowInsets(.init(top: 4, leading: 0, bottom: 4, trailing: 0)) + .listRowBackground(Color.clear) Section("Identity") { TextField("Title", text: $draft.title) @@ -364,7 +364,10 @@ private struct ConfigurationSheetView: View { Section("WireGuard Configuration") { TextEditor(text: $draft.wireGuardConfig) .font(.body.monospaced()) - .frame(minHeight: 220) + .frame(minHeight: wireGuardEditorHeight) + .contextMenu { + wireGuardContextActions + } } case .tor: Section("Tor Preferences") { @@ -385,23 +388,52 @@ private struct ConfigurationSheetView: View { } } .navigationTitle(sheet.kind.title) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } - ToolbarItem(placement: .confirmationAction) { - Button(confirmationTitle) { - submit() + #if os(iOS) + ToolbarItem(placement: .topBarTrailing) { + Menu { + sheetMenuActions + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityLabel("More") + } + #else + ToolbarItem(placement: .primaryAction) { + Menu { + sheetMenuActions + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityLabel("More") + } + #endif + if !showsBottomActionButton { + ToolbarItem(placement: .confirmationAction) { + Button(confirmationTitle) { + submit() + } + .disabled(isSubmitting || submissionDisabled) } - .disabled(isSubmitting || submissionDisabled) } } } #if os(macOS) .frame(minWidth: 520, minHeight: 620) #endif + .safeAreaInset(edge: .bottom) { + if showsBottomActionButton { + bottomActionBar + } + } .onAppear { runAutomationIfNeeded() } @@ -420,6 +452,7 @@ private struct ConfigurationSheetView: View { Text(provider.title).tag(provider) } } + .pickerStyle(.menu) Text(draft.tailnetProvider.subtitle) .font(.footnote) .foregroundStyle(.secondary) @@ -448,6 +481,7 @@ private struct ConfigurationSheetView: View { Text(mode.title).tag(mode) } } + .pickerStyle(.menu) if draft.authMode != .none { SecureField( draft.authMode == .password ? "Password" : "Preauth Key", @@ -492,6 +526,164 @@ private struct ConfigurationSheetView: View { } } + private var sheetSummaryCard: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + Image(systemName: sheet.iconName) + .font(.title3.weight(.semibold)) + .foregroundStyle(sheetAccentColor) + .frame(width: 28, height: 28) + .background( + Circle() + .fill(sheetAccentColor.opacity(0.14)) + ) + + VStack(alignment: .leading, spacing: 3) { + Text(summaryTitle) + .font(.headline) + Text(sheet.kind.subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + } + + if let availabilityNote = sheet.kind.availabilityNote { + Text(availabilityNote) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 18) + .fill(.thinMaterial) + ) + } + + @ViewBuilder + private var bottomActionBar: some View { + VStack(spacing: 0) { + Divider() + .overlay(.white.opacity(0.3)) + Button(confirmationTitle) { + submit() + } + .buttonStyle(.floating(color: sheetAccentColor, cornerRadius: 18)) + .disabled(isSubmitting || submissionDisabled) + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 8) + } + .background(.ultraThinMaterial) + } + + @ViewBuilder + private var sheetMenuActions: some View { + Button("Use Suggested Identity") { + applySuggestedIdentity() + } + + switch sheet { + case .wireGuard: + Button("Paste Configuration") { + pasteWireGuardConfiguration() + } + .disabled(clipboardString?.isEmpty ?? true) + + Button("Clear Configuration", role: .destructive) { + draft.wireGuardConfig = "" + } + .disabled(draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + case .tor: + Menu("Presets") { + Button("Recommended Tor Defaults") { + applyTorDefaults() + } + Button("Restore Suggested Identity") { + applySuggestedIdentity() + } + } + + case .tailnet: + Menu("Provider") { + ForEach(TailnetProvider.allCases) { provider in + Button(provider.title) { + applyTailnetProvider(provider) + } + } + } + + if !draft.tailnetProvider.usesWebLogin { + Menu("Authentication") { + ForEach([AccountAuthMode.none, .password, .preauthKey]) { mode in + Button(mode.title) { + draft.authMode = mode + if mode == .none { + draft.secret = "" + } + } + } + } + } + + Button("Restore Provider Defaults") { + applyTailnetDefaults(for: draft.tailnetProvider) + } + } + } + + @ViewBuilder + private var wireGuardContextActions: some View { + Button("Paste Configuration") { + pasteWireGuardConfiguration() + } + .disabled(clipboardString?.isEmpty ?? true) + + Button("Clear", role: .destructive) { + draft.wireGuardConfig = "" + } + .disabled(draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + private var sheetAccentColor: Color { + switch sheet { + case .wireGuard: + .blue + case .tor, .tailnet: + sheet.kind.accentColor + } + } + + private var summaryTitle: String { + switch sheet { + case .wireGuard: + "Import WireGuard" + case .tor: + "Configure Tor" + case .tailnet: + "Connect Tailnet" + } + } + + private var showsBottomActionButton: Bool { + #if os(iOS) + true + #else + false + #endif + } + + private var wireGuardEditorHeight: CGFloat { + #if os(iOS) + 180 + #else + 220 + #endif + } + private var confirmationTitle: String { switch sheet { case .wireGuard: @@ -775,6 +967,62 @@ private struct ConfigurationSheetView: View { try accountStore.upsert(record, secret: secret) } + private func applySuggestedIdentity() { + let defaults = AccountDraft(sheet: sheet) + if draft.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + draft.title = defaults.title + } + draft.accountName = defaults.accountName + draft.identityName = defaults.identityName + if sheet == .tailnet { + draft.hostname = defaults.hostname + } + } + + private func applyTorDefaults() { + let defaults = AccountDraft(sheet: .tor) + draft.title = defaults.title + draft.accountName = defaults.accountName + draft.identityName = defaults.identityName + draft.torAddresses = defaults.torAddresses + draft.torDNS = defaults.torDNS + draft.torMTU = defaults.torMTU + draft.torListen = defaults.torListen + } + + private func applyTailnetProvider(_ provider: TailnetProvider) { + draft.tailnetProvider = provider + applyTailnetDefaults(for: provider) + } + + private func applyTailnetDefaults(for provider: TailnetProvider) { + draft.authority = provider.defaultAuthority ?? "" + if provider.usesWebLogin { + draft.authMode = .web + draft.username = "" + draft.secret = "" + } else { + if draft.authMode == .web { + draft.authMode = .none + } + } + } + + private func pasteWireGuardConfiguration() { + guard let clipboardString else { return } + draft.wireGuardConfig = clipboardString + } + + private var clipboardString: String? { + #if canImport(UIKit) + UIPasteboard.general.string + #elseif canImport(AppKit) + NSPasteboard.general.string(forType: .string) + #else + nil + #endif + } + private func normalized(_ value: String, fallback: String) -> String { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? fallback : trimmed From d1ed826389e8b7358db581e2fb5dde0399b98e0b Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Tue, 31 Mar 2026 14:32:14 -0700 Subject: [PATCH 036/102] Unify Tailnet config presentation --- Apple/UI/BurrowView.swift | 163 ++++++++++++++++++++++++++++---------- 1 file changed, 120 insertions(+), 43 deletions(-) diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index a3dd628..ce93231 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -446,32 +446,35 @@ private struct ConfigurationSheetView: View { @ViewBuilder private var tailnetSections: some View { - Section("Tailnet Provider") { + Section("Connection") { Picker("Provider", selection: $draft.tailnetProvider) { ForEach(TailnetProvider.allCases) { provider in Text(provider.title).tag(provider) } } .pickerStyle(.menu) - Text(draft.tailnetProvider.subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - } - Section("Tailnet") { + tailnetProviderCard + if draft.tailnetProvider.requiresControlURL { TextField("Server URL", text: $draft.authority) .burrowLoginField() .autocorrectionDisabled() + } else { + LabeledContent("Server") { + Text("Tailscale managed") + .foregroundStyle(.secondary) + } } + TextField("Tailnet", text: $draft.tailnet) .burrowLoginField() .autocorrectionDisabled() + } + Section("Authentication") { if draft.tailnetProvider.usesWebLogin { - Text("Sign-in is brokered by `burrow auth-server` on the host and opens the real Tailscale login page in an in-app authentication session.") - .font(.footnote) - .foregroundStyle(.secondary) + tailnetWebLoginCard } else { TextField("Username", text: $draft.username) .burrowLoginField() @@ -488,40 +491,9 @@ private struct ConfigurationSheetView: View { text: $draft.secret ) } - } - } - - if draft.tailnetProvider.usesWebLogin { - Section("Tailscale Sign-In") { - if let loginStatus { - labeledValue("State", loginStatus.backendState) - if let tailnetName = loginStatus.tailnetName { - labeledValue("Tailnet", tailnetName) - } - if let dnsName = loginStatus.selfDNSName { - labeledValue("Device", dnsName) - } - if !loginStatus.tailscaleIPs.isEmpty { - labeledValue("Addresses", loginStatus.tailscaleIPs.joined(separator: ", ")) - } - if let authURL = loginStatus.authURL { - labeledValue("Login URL", authURL) - Button("Resume Sign-In") { - if let url = URL(string: authURL) { - openLoginURL(url) - } - } - } - if !loginStatus.health.isEmpty { - Text(loginStatus.health.joined(separator: " • ")) - .font(.footnote) - .foregroundStyle(.secondary) - } - } else { - Text("Start sign-in to launch a local Tailscale bridge and fetch the real browser login URL.") - .font(.footnote) - .foregroundStyle(.secondary) - } + Text("Credentials stay on-device. Burrow uses them when it needs to register or refresh this identity.") + .font(.footnote) + .foregroundStyle(.secondary) } } } @@ -554,6 +526,15 @@ private struct ConfigurationSheetView: View { .font(.footnote) .foregroundStyle(.secondary) } + + if sheet == .tailnet { + HStack(spacing: 8) { + summaryBadge(draft.tailnetProvider.title) + summaryBadge( + draft.tailnetProvider.usesWebLogin ? "Web Sign-In" : draft.authMode.title + ) + } + } } .padding(14) .background( @@ -562,6 +543,91 @@ private struct ConfigurationSheetView: View { ) } + private var tailnetProviderCard: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + Image(systemName: tailnetProviderIconName) + .font(.headline) + .foregroundStyle(sheetAccentColor) + .frame(width: 28, height: 28) + .background( + Circle() + .fill(sheetAccentColor.opacity(0.14)) + ) + + VStack(alignment: .leading, spacing: 2) { + Text(draft.tailnetProvider.title) + .font(.headline) + Text(draft.tailnetProvider.subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.thinMaterial) + ) + } + + @ViewBuilder + private var tailnetWebLoginCard: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Sign in with the shared browser session.") + .font(.subheadline.weight(.medium)) + + if let loginStatus { + labeledValue("State", loginStatus.backendState) + if let tailnetName = loginStatus.tailnetName { + labeledValue("Tailnet", tailnetName) + } + if let dnsName = loginStatus.selfDNSName { + labeledValue("Device", dnsName) + } + if !loginStatus.tailscaleIPs.isEmpty { + labeledValue("Addresses", loginStatus.tailscaleIPs.joined(separator: ", ")) + } + if let authURL = loginStatus.authURL { + Button("Resume Sign-In") { + if let url = URL(string: authURL) { + openLoginURL(url) + } + } + .buttonStyle(.borderless) + } + if !loginStatus.health.isEmpty { + Text(loginStatus.health.joined(separator: " • ")) + .font(.footnote) + .foregroundStyle(.secondary) + } + } else { + Text("Burrow launches the local bridge, then opens the real Tailscale sign-in page in-app.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.thinMaterial) + ) + } + + private func summaryBadge(_ label: String) -> some View { + Text(label) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Capsule() + .fill(.white.opacity(0.5)) + ) + } + @ViewBuilder private var bottomActionBar: some View { VStack(spacing: 0) { @@ -668,6 +734,17 @@ private struct ConfigurationSheetView: View { } } + private var tailnetProviderIconName: String { + switch draft.tailnetProvider { + case .tailscale: + "globe.badge.chevron.backward" + case .headscale: + "server.rack" + case .burrow: + "shield" + } + } + private var showsBottomActionButton: Bool { #if os(iOS) true From de25f240d541db883c4e05ed57db0075616c238b Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Tue, 31 Mar 2026 14:53:48 -0700 Subject: [PATCH 037/102] Add Burrow forge infrastructure and tailnet control plane --- Scripts/_burrow-flake.sh | 95 +++ Scripts/bootstrap-forge-intake.sh | 113 +++ Scripts/check-forge-host.sh | 170 +++++ Scripts/cloudflare-upsert-a-record.sh | 165 ++++ Scripts/forge-deploy.sh | 100 +++ Scripts/hcloud-upload-nixos-image.sh | 327 ++++++++ Scripts/hetzner-forge.sh | 284 +++++++ Scripts/nsc-build-and-upload-image.sh | 542 ++++++++++++++ Scripts/provision-forgejo-nsc.sh | 237 ++++++ Scripts/sync-forgejo-nsc-config.sh | 132 ++++ flake.lock | 86 +++ flake.nix | 192 +++++ nixos/README.md | 56 ++ nixos/hetzner-cloud-config.yaml | 10 + nixos/hosts/burrow-forge/default.nix | 58 ++ nixos/hosts/burrow-forge/disko-config.nix | 36 + .../burrow-forge/hardware-configuration.nix | 11 + nixos/keys/agent_at_burrow_net.pub | 1 + nixos/keys/contact_at_burrow_net.pub | 1 + nixos/modules/burrow-authentik.nix | 271 +++++++ nixos/modules/burrow-forge-runner.nix | 213 ++++++ nixos/modules/burrow-forge.nix | 247 ++++++ nixos/modules/burrow-forgejo-nsc.nix | 234 ++++++ nixos/modules/burrow-headscale-policy.hujson | 11 + nixos/modules/burrow-headscale.nix | 225 ++++++ services/forgejo-nsc/README.md | 179 +++++ services/forgejo-nsc/autoscaler.example.yaml | 30 + .../cmd/forgejo-nsc-autoscaler/main.go | 46 ++ .../cmd/forgejo-nsc-dispatcher/main.go | 90 +++ services/forgejo-nsc/config.example.yaml | 27 + services/forgejo-nsc/deploy/autoscaler.yaml | 31 + services/forgejo-nsc/deploy/dispatcher.yaml | 29 + services/forgejo-nsc/go.mod | 65 ++ services/forgejo-nsc/go.sum | 575 ++++++++++++++ services/forgejo-nsc/internal/app/service.go | 253 +++++++ .../forgejo-nsc/internal/app/service_test.go | 160 ++++ .../forgejo-nsc/internal/autoscaler/config.go | 108 +++ .../internal/autoscaler/service.go | 385 ++++++++++ .../forgejo-nsc/internal/config/config.go | 185 +++++ .../internal/config/config_test.go | 41 + .../forgejo-nsc/internal/forgejo/client.go | 454 +++++++++++ .../forgejo-nsc/internal/nsc/dispatcher.go | 460 ++++++++++++ services/forgejo-nsc/internal/nsc/macos.go | 708 ++++++++++++++++++ .../forgejo-nsc/internal/nsc/macos_nsc.go | 373 +++++++++ services/forgejo-nsc/internal/nsc/windows.go | 59 ++ .../forgejo-nsc/internal/nsc/windows_test.go | 98 +++ .../forgejo-nsc/internal/nsc/windows_winrm.go | 499 ++++++++++++ .../nsc/windows_winrm_integration_test.go | 59 ++ .../internal/nsc/windows_winrm_test.go | 65 ++ .../forgejo-nsc/internal/server/server.go | 151 ++++ .../internal/server/server_test.go | 111 +++ 51 files changed, 9058 insertions(+) create mode 100755 Scripts/_burrow-flake.sh create mode 100644 Scripts/bootstrap-forge-intake.sh create mode 100755 Scripts/check-forge-host.sh create mode 100755 Scripts/cloudflare-upsert-a-record.sh create mode 100755 Scripts/forge-deploy.sh create mode 100755 Scripts/hcloud-upload-nixos-image.sh create mode 100755 Scripts/hetzner-forge.sh create mode 100755 Scripts/nsc-build-and-upload-image.sh create mode 100755 Scripts/provision-forgejo-nsc.sh create mode 100755 Scripts/sync-forgejo-nsc-config.sh create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nixos/README.md create mode 100644 nixos/hetzner-cloud-config.yaml create mode 100644 nixos/hosts/burrow-forge/default.nix create mode 100644 nixos/hosts/burrow-forge/disko-config.nix create mode 100644 nixos/hosts/burrow-forge/hardware-configuration.nix create mode 100644 nixos/keys/agent_at_burrow_net.pub create mode 100644 nixos/keys/contact_at_burrow_net.pub create mode 100644 nixos/modules/burrow-authentik.nix create mode 100644 nixos/modules/burrow-forge-runner.nix create mode 100644 nixos/modules/burrow-forge.nix create mode 100644 nixos/modules/burrow-forgejo-nsc.nix create mode 100644 nixos/modules/burrow-headscale-policy.hujson create mode 100644 nixos/modules/burrow-headscale.nix create mode 100644 services/forgejo-nsc/README.md create mode 100644 services/forgejo-nsc/autoscaler.example.yaml create mode 100644 services/forgejo-nsc/cmd/forgejo-nsc-autoscaler/main.go create mode 100644 services/forgejo-nsc/cmd/forgejo-nsc-dispatcher/main.go create mode 100644 services/forgejo-nsc/config.example.yaml create mode 100644 services/forgejo-nsc/deploy/autoscaler.yaml create mode 100644 services/forgejo-nsc/deploy/dispatcher.yaml create mode 100644 services/forgejo-nsc/go.mod create mode 100644 services/forgejo-nsc/go.sum create mode 100644 services/forgejo-nsc/internal/app/service.go create mode 100644 services/forgejo-nsc/internal/app/service_test.go create mode 100644 services/forgejo-nsc/internal/autoscaler/config.go create mode 100644 services/forgejo-nsc/internal/autoscaler/service.go create mode 100644 services/forgejo-nsc/internal/config/config.go create mode 100644 services/forgejo-nsc/internal/config/config_test.go create mode 100644 services/forgejo-nsc/internal/forgejo/client.go create mode 100644 services/forgejo-nsc/internal/nsc/dispatcher.go create mode 100644 services/forgejo-nsc/internal/nsc/macos.go create mode 100644 services/forgejo-nsc/internal/nsc/macos_nsc.go create mode 100644 services/forgejo-nsc/internal/nsc/windows.go create mode 100644 services/forgejo-nsc/internal/nsc/windows_test.go create mode 100644 services/forgejo-nsc/internal/nsc/windows_winrm.go create mode 100644 services/forgejo-nsc/internal/nsc/windows_winrm_integration_test.go create mode 100644 services/forgejo-nsc/internal/nsc/windows_winrm_test.go create mode 100644 services/forgejo-nsc/internal/server/server.go create mode 100644 services/forgejo-nsc/internal/server/server_test.go diff --git a/Scripts/_burrow-flake.sh b/Scripts/_burrow-flake.sh new file mode 100755 index 0000000..ba4e372 --- /dev/null +++ b/Scripts/_burrow-flake.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +burrow_require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + exit 1 + fi +} + +burrow_cleanup_flake_tmpdirs() { + if [[ "${#BURROW_FLAKE_TMPDIRS[@]}" -eq 0 ]]; then + return + fi + rm -rf "${BURROW_FLAKE_TMPDIRS[@]}" +} + +burrow_prepare_flake_ref() { + local input="${1:-.}" + + case "${input}" in + path:*|git+*|github:*|tarball+*|http://*|https://*) + printf '%s\n' "${input}" + return 0 + ;; + esac + + local resolved + resolved="$(cd "${input}" && pwd)" + + local cache_root="${HOME}/.cache/burrow" + mkdir -p "${cache_root}" + + local copy_root + copy_root="$(mktemp -d "${cache_root}/flake-XXXXXX")" + mkdir -p "${copy_root}/repo" + + rsync -a \ + --delete \ + --exclude '.git' \ + --exclude '.direnv' \ + --exclude 'result' \ + --exclude 'burrow.sock' \ + --exclude 'node_modules' \ + --exclude 'target' \ + --exclude 'build' \ + "${resolved}/" "${copy_root}/repo/" + + BURROW_FLAKE_TMPDIRS+=("${copy_root}") + printf 'path:%s/repo\n' "${copy_root}" +} + +burrow_resolve_image_artifact() { + local store_path="$1" + + if [[ -f "${store_path}" ]]; then + printf '%s\n' "${store_path}" + return 0 + fi + + if [[ -d "${store_path}" ]]; then + local candidate + candidate="$( + find "${store_path}" -type f \ + \( -name '*.raw' -o -name '*.raw.*' -o -name '*.img' -o -name '*.img.*' \) \ + | sort \ + | head -n1 + )" + if [[ -n "${candidate}" ]]; then + printf '%s\n' "${candidate}" + return 0 + fi + fi + + echo "unable to locate disk image artifact under ${store_path}" >&2 + exit 1 +} + +burrow_detect_compression() { + local artifact="$1" + + case "${artifact}" in + *.bz2) + printf 'bz2\n' + ;; + *.xz) + printf 'xz\n' + ;; + *.zst|*.zstd) + printf 'zstd\n' + ;; + *) + printf '\n' + ;; + esac +} diff --git a/Scripts/bootstrap-forge-intake.sh b/Scripts/bootstrap-forge-intake.sh new file mode 100644 index 0000000..0cc1d91 --- /dev/null +++ b/Scripts/bootstrap-forge-intake.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +usage() { + cat <<'EOF' +Usage: Scripts/bootstrap-forge-intake.sh [options] + +Copy the minimum Burrow forge bootstrap secrets onto the target host under +/var/lib/burrow/intake with the ownership expected by the NixOS services. + +Options: + --host SSH target (default: root@git.burrow.net) + --ssh-key SSH private key used to reach the host + (default: intake/agent_at_burrow_net_ed25519) + --password-file Forgejo admin bootstrap password file + (default: intake/forgejo_pass_contact_at_burrow_net.txt) + --agent-key-file Agent SSH private key copied for runner bootstrap + (default: intake/agent_at_burrow_net_ed25519) + --no-verify Skip remote ls/stat verification after install + -h, --help Show this help text +EOF +} + +HOST="${BURROW_FORGE_HOST:-root@git.burrow.net}" +SSH_KEY="${BURROW_FORGE_SSH_KEY:-${REPO_ROOT}/intake/agent_at_burrow_net_ed25519}" +PASSWORD_FILE="${BURROW_FORGE_PASSWORD_FILE:-${REPO_ROOT}/intake/forgejo_pass_contact_at_burrow_net.txt}" +AGENT_KEY_FILE="${BURROW_FORGE_AGENT_KEY_FILE:-${REPO_ROOT}/intake/agent_at_burrow_net_ed25519}" +KNOWN_HOSTS_FILE="${BURROW_FORGE_KNOWN_HOSTS_FILE:-${HOME}/.cache/burrow/forge-known_hosts}" +VERIFY=1 + +while [[ $# -gt 0 ]]; do + case "$1" in + --host) + HOST="${2:?missing value for --host}" + shift 2 + ;; + --ssh-key) + SSH_KEY="${2:?missing value for --ssh-key}" + shift 2 + ;; + --password-file) + PASSWORD_FILE="${2:?missing value for --password-file}" + shift 2 + ;; + --agent-key-file) + AGENT_KEY_FILE="${2:?missing value for --agent-key-file}" + shift 2 + ;; + --no-verify) + VERIFY=0 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown option: $1" >&2 + usage >&2 + exit 64 + ;; + esac +done + +mkdir -p "$(dirname "${KNOWN_HOSTS_FILE}")" + +for path in "${SSH_KEY}" "${PASSWORD_FILE}" "${AGENT_KEY_FILE}"; do + if [[ ! -s "${path}" ]]; then + echo "required file missing or empty: ${path}" >&2 + exit 1 + fi +done + +ssh_opts=( + -i "${SSH_KEY}" + -o IdentitiesOnly=yes + -o UserKnownHostsFile="${KNOWN_HOSTS_FILE}" + -o StrictHostKeyChecking=accept-new +) + +remote_tmp="$(ssh "${ssh_opts[@]}" "${HOST}" "mktemp -d")" +cleanup() { + if [[ -n "${remote_tmp:-}" ]]; then + ssh "${ssh_opts[@]}" "${HOST}" "rm -rf '${remote_tmp}'" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +scp "${ssh_opts[@]}" \ + "${PASSWORD_FILE}" \ + "${AGENT_KEY_FILE}" \ + "${HOST}:${remote_tmp}/" + +ssh "${ssh_opts[@]}" "${HOST}" " + set -euo pipefail + install -d -m 0755 /var/lib/burrow/intake + install -m 0400 -o forgejo -g forgejo '${remote_tmp}/$(basename "${PASSWORD_FILE}")' /var/lib/burrow/intake/forgejo_pass_contact_at_burrow_net.txt + install -m 0400 -o root -g root '${remote_tmp}/$(basename "${AGENT_KEY_FILE}")' /var/lib/burrow/intake/agent_at_burrow_net_ed25519 +" + +if [[ "${VERIFY}" -eq 1 ]]; then + ssh "${ssh_opts[@]}" "${HOST}" " + set -euo pipefail + ls -l \ + /var/lib/burrow/intake/forgejo_pass_contact_at_burrow_net.txt \ + /var/lib/burrow/intake/agent_at_burrow_net_ed25519 + " +fi + +echo "Burrow forge bootstrap intake sync complete (host=${HOST})." diff --git a/Scripts/check-forge-host.sh b/Scripts/check-forge-host.sh new file mode 100755 index 0000000..90a6dcf --- /dev/null +++ b/Scripts/check-forge-host.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +usage() { + cat <<'EOF' +Usage: Scripts/check-forge-host.sh [options] + +Run a post-boot verification pass against the Burrow forge host. + +Options: + --host SSH target (default: root@git.burrow.net) + --ssh-key SSH private key (default: intake/agent_at_burrow_net_ed25519) + --expect-nsc Fail if forgejo-nsc services are not active + --expect-tailnet Fail if Authentik and Headscale services are not active + -h, --help Show this help text +EOF +} + +HOST="${BURROW_FORGE_HOST:-root@git.burrow.net}" +SSH_KEY="${BURROW_FORGE_SSH_KEY:-${REPO_ROOT}/intake/agent_at_burrow_net_ed25519}" +KNOWN_HOSTS_FILE="${BURROW_FORGE_KNOWN_HOSTS_FILE:-${HOME}/.cache/burrow/forge-known_hosts}" +EXPECT_NSC=0 +EXPECT_TAILNET=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --host) + HOST="${2:?missing value for --host}" + shift 2 + ;; + --ssh-key) + SSH_KEY="${2:?missing value for --ssh-key}" + shift 2 + ;; + --expect-nsc) + EXPECT_NSC=1 + shift + ;; + --expect-tailnet) + EXPECT_TAILNET=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown option: $1" >&2 + usage >&2 + exit 64 + ;; + esac +done + +mkdir -p "$(dirname "${KNOWN_HOSTS_FILE}")" + +if [[ ! -f "${SSH_KEY}" ]]; then + echo "forge SSH key not found: ${SSH_KEY}" >&2 + exit 1 +fi + +ssh \ + -i "${SSH_KEY}" \ + -o IdentitiesOnly=yes \ + -o UserKnownHostsFile="${KNOWN_HOSTS_FILE}" \ + -o StrictHostKeyChecking=accept-new \ + "${HOST}" \ + EXPECT_NSC="${EXPECT_NSC}" \ + EXPECT_TAILNET="${EXPECT_TAILNET}" \ + 'bash -s' <<'EOF' +set -euo pipefail + +base_services=( + forgejo.service + caddy.service + burrow-forgejo-bootstrap.service + burrow-forgejo-runner-bootstrap.service + burrow-forgejo-runner.service +) + +nsc_services=( + forgejo-nsc-dispatcher.service + forgejo-nsc-autoscaler.service +) + +tailnet_services=( + burrow-authentik-runtime.service + burrow-authentik-ready.service + headscale.service + headscale-bootstrap.service +) + +show_service() { + local service="$1" + systemctl show \ + --no-pager \ + --property Id \ + --property LoadState \ + --property UnitFileState \ + --property ActiveState \ + --property SubState \ + --property Result \ + "${service}" +} + +service_is_healthy() { + local service="$1" + local active_state + local result + local unit_type + + active_state="$(systemctl show --property ActiveState --value "${service}")" + result="$(systemctl show --property Result --value "${service}")" + unit_type="$(systemctl show --property Type --value "${service}")" + + if [[ "${active_state}" == "active" ]]; then + return 0 + fi + + if [[ "${unit_type}" == "oneshot" && "${active_state}" == "inactive" && "${result}" == "success" ]]; then + return 0 + fi + + return 1 +} + +for service in "${base_services[@]}"; do + echo "== ${service} ==" + show_service "${service}" + if ! service_is_healthy "${service}"; then + echo "required service is not active: ${service}" >&2 + exit 1 + fi +done + +for service in "${nsc_services[@]}"; do + echo "== ${service} ==" + show_service "${service}" || true + if [[ "${EXPECT_NSC}" == "1" && "$(systemctl is-active "${service}" 2>/dev/null || true)" != "active" ]]; then + echo "required NSC service is not active: ${service}" >&2 + exit 1 + fi +done + +for service in "${tailnet_services[@]}"; do + echo "== ${service} ==" + show_service "${service}" || true + if [[ "${EXPECT_TAILNET}" == "1" ]] && ! service_is_healthy "${service}"; then + echo "required tailnet service is not active: ${service}" >&2 + exit 1 + fi +done + +echo "== intake ==" +ls -l /var/lib/burrow/intake || true + +if command -v curl >/dev/null 2>&1; then + echo "== http-local ==" + curl -fsS -o /dev/null -w 'forgejo_login %{http_code}\n' http://127.0.0.1:3000/user/login + curl -fsS -o /dev/null -H 'Host: burrow.net' -w 'burrow_root %{http_code}\n' http://127.0.0.1/ + curl -fsS -o /dev/null -H 'Host: git.burrow.net' -w 'git_login %{http_code}\n' http://127.0.0.1/user/login + if [[ "${EXPECT_TAILNET}" == "1" ]]; then + curl -fsS -o /dev/null -H 'Host: auth.burrow.net' -w 'authentik_ready %{http_code}\n' http://127.0.0.1/-/health/ready/ + curl -sS -o /dev/null -H 'Host: ts.burrow.net' -w 'headscale_root %{http_code}\n' http://127.0.0.1/ || true + fi +fi +EOF diff --git a/Scripts/cloudflare-upsert-a-record.sh b/Scripts/cloudflare-upsert-a-record.sh new file mode 100755 index 0000000..88745af --- /dev/null +++ b/Scripts/cloudflare-upsert-a-record.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: Scripts/cloudflare-upsert-a-record.sh --zone --name --ipv4
[options] + +Upsert a DNS-only or proxied Cloudflare A record without putting the API token on +the process list. + +Options: + --zone Cloudflare zone name, for example burrow.net + --name Fully-qualified DNS record name + --ipv4
IPv4 address for the A record + --token-file Cloudflare API token file + default: intake/cloudflare-token.txt + --ttl Record TTL, or auto + default: auto + --proxied Whether to proxy through Cloudflare + default: false + -h, --help Show this help +EOF +} + +ZONE_NAME="" +RECORD_NAME="" +IPV4="" +TOKEN_FILE="intake/cloudflare-token.txt" +TTL_VALUE="auto" +PROXIED="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --zone) + ZONE_NAME="${2:?missing value for --zone}" + shift 2 + ;; + --name) + RECORD_NAME="${2:?missing value for --name}" + shift 2 + ;; + --ipv4) + IPV4="${2:?missing value for --ipv4}" + shift 2 + ;; + --token-file) + TOKEN_FILE="${2:?missing value for --token-file}" + shift 2 + ;; + --ttl) + TTL_VALUE="${2:?missing value for --ttl}" + shift 2 + ;; + --proxied) + PROXIED="${2:?missing value for --proxied}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "${ZONE_NAME}" || -z "${RECORD_NAME}" || -z "${IPV4}" ]]; then + usage >&2 + exit 2 +fi + +if [[ ! -f "${TOKEN_FILE}" ]]; then + echo "Cloudflare token file not found: ${TOKEN_FILE}" >&2 + exit 1 +fi + +if [[ ! "${IPV4}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + echo "Invalid IPv4 address: ${IPV4}" >&2 + exit 1 +fi + +case "${PROXIED}" in + true|false) + ;; + *) + echo "--proxied must be true or false" >&2 + exit 1 + ;; +esac + +case "${TTL_VALUE}" in + auto) + TTL_JSON=1 + ;; + ''|*[!0-9]*) + echo "--ttl must be a number of seconds or auto" >&2 + exit 1 + ;; + *) + TTL_JSON="${TTL_VALUE}" + ;; +esac + +TOKEN="$(tr -d '\r\n' < "${TOKEN_FILE}")" +if [[ -z "${TOKEN}" ]]; then + echo "Cloudflare token file is empty: ${TOKEN_FILE}" >&2 + exit 1 +fi + +cf_api() { + local method="$1" + local path="$2" + local body="${3-}" + if [[ -n "${body}" ]]; then + curl -fsS -X "${method}" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + --data "${body}" \ + "https://api.cloudflare.com/client/v4${path}" + else + curl -fsS -X "${method}" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + "https://api.cloudflare.com/client/v4${path}" + fi +} + +zone_lookup="$(cf_api GET "/zones?name=${ZONE_NAME}&status=active")" +zone_id="$(jq -r '.result[0].id // empty' <<<"${zone_lookup}")" + +if [[ -z "${zone_id}" ]]; then + echo "Active Cloudflare zone not found: ${ZONE_NAME}" >&2 + exit 1 +fi + +payload="$(jq -cn \ + --arg type "A" \ + --arg name "${RECORD_NAME}" \ + --arg content "${IPV4}" \ + --argjson proxied "${PROXIED}" \ + --argjson ttl "${TTL_JSON}" \ + '{type: $type, name: $name, content: $content, proxied: $proxied, ttl: $ttl}')" + +record_lookup="$(cf_api GET "/zones/${zone_id}/dns_records?type=A&name=${RECORD_NAME}")" +record_id="$(jq -r '.result[0].id // empty' <<<"${record_lookup}")" + +if [[ -n "${record_id}" ]]; then + result="$(cf_api PUT "/zones/${zone_id}/dns_records/${record_id}" "${payload}")" + action="updated" +else + result="$(cf_api POST "/zones/${zone_id}/dns_records" "${payload}")" + action="created" +fi + +jq -r --arg action "${action}" ' + if .success != true then + .errors | tostring | halt_error(1) + else + "Cloudflare DNS " + $action + ": " + .result.name + " -> " + .result.content + + " (proxied=" + (.result.proxied | tostring) + ", ttl=" + (.result.ttl | tostring) + ")" + end +' <<<"${result}" diff --git a/Scripts/forge-deploy.sh b/Scripts/forge-deploy.sh new file mode 100755 index 0000000..5c4b959 --- /dev/null +++ b/Scripts/forge-deploy.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# shellcheck source=Scripts/_burrow-flake.sh +source "${SCRIPT_DIR}/_burrow-flake.sh" + +usage() { + cat <<'EOF' +Usage: Scripts/forge-deploy.sh [--test|--switch] [--flake-attr ] [--allow-dirty] + +Standardized remote deploy path for the Burrow forge host. + +Defaults: + --switch + --flake-attr burrow-forge + +Environment: + BURROW_FORGE_HOST root@git.burrow.net + BURROW_FORGE_SSH_KEY intake/agent_at_burrow_net_ed25519 +EOF +} + +MODE="switch" +FLAKE_ATTR="burrow-forge" +ALLOW_DIRTY=0 +BURROW_FLAKE_TMPDIRS=() + +cleanup() { + burrow_cleanup_flake_tmpdirs +} +trap cleanup EXIT + +while [[ $# -gt 0 ]]; do + case "$1" in + --test) + MODE="test" + shift + ;; + --switch) + MODE="switch" + shift + ;; + --flake-attr) + FLAKE_ATTR="${2:?missing value for --flake-attr}" + shift 2 + ;; + --allow-dirty) + ALLOW_DIRTY=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "${REPO_ROOT}" + +if [[ ${ALLOW_DIRTY} -ne 1 ]] && [[ -n "$(git status --short)" ]]; then + echo "Refusing to deploy from a dirty checkout. Commit first, or pass --allow-dirty for incident-only work." >&2 + exit 1 +fi + +FORGE_HOST="${BURROW_FORGE_HOST:-root@git.burrow.net}" +FORGE_SSH_KEY="${BURROW_FORGE_SSH_KEY:-}" + +if [[ -z "${FORGE_SSH_KEY}" ]]; then + if [[ -f "${REPO_ROOT}/intake/agent_at_burrow_net_ed25519" ]]; then + FORGE_SSH_KEY="${REPO_ROOT}/intake/agent_at_burrow_net_ed25519" + else + FORGE_SSH_KEY="${HOME}/.ssh/agent_at_burrow_net_ed25519" + fi +fi + +if [[ ! -f "${FORGE_SSH_KEY}" ]]; then + echo "Forge SSH key not found at ${FORGE_SSH_KEY}." >&2 + echo "Set BURROW_FORGE_SSH_KEY or place the agent key in intake/." >&2 + exit 1 +fi + +FORGE_KNOWN_HOSTS_FILE="${BURROW_FORGE_KNOWN_HOSTS_FILE:-${HOME}/.cache/burrow/forge-known_hosts}" +mkdir -p "$(dirname "${FORGE_KNOWN_HOSTS_FILE}")" + +export NIX_SSHOPTS="-i ${FORGE_SSH_KEY} -o IdentitiesOnly=yes -o UserKnownHostsFile=${FORGE_KNOWN_HOSTS_FILE} -o StrictHostKeyChecking=accept-new" +flake_ref="$(burrow_prepare_flake_ref "${REPO_ROOT}")" + +nix --extra-experimental-features "nix-command flakes" shell nixpkgs#nixos-rebuild -c \ + nixos-rebuild "${MODE}" \ + --flake "${flake_ref}#${FLAKE_ATTR}" \ + --build-host "${FORGE_HOST}" \ + --target-host "${FORGE_HOST}" diff --git a/Scripts/hcloud-upload-nixos-image.sh b/Scripts/hcloud-upload-nixos-image.sh new file mode 100755 index 0000000..2590519 --- /dev/null +++ b/Scripts/hcloud-upload-nixos-image.sh @@ -0,0 +1,327 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# shellcheck source=Scripts/_burrow-flake.sh +source "${SCRIPT_DIR}/_burrow-flake.sh" + +DEFAULT_CONFIG="burrow-forge" +DEFAULT_FLAKE="." +DEFAULT_LOCATION="hel1" +DEFAULT_ARCHITECTURE="x86" +DEFAULT_TOKEN_FILE="${REPO_ROOT}/intake/hetzner-api-token.txt" + +CONFIG="${HCLOUD_IMAGE_CONFIG:-${DEFAULT_CONFIG}}" +FLAKE="${HCLOUD_IMAGE_FLAKE:-${DEFAULT_FLAKE}}" +LOCATION="${HCLOUD_IMAGE_LOCATION:-${DEFAULT_LOCATION}}" +ARCHITECTURE="${HCLOUD_IMAGE_ARCHITECTURE:-${DEFAULT_ARCHITECTURE}}" +TOKEN_FILE="${HCLOUD_TOKEN_FILE:-${DEFAULT_TOKEN_FILE}}" +DESCRIPTION="${HCLOUD_IMAGE_DESCRIPTION:-}" +UPLOAD_SERVER_TYPE="${HCLOUD_IMAGE_UPLOAD_SERVER_TYPE:-}" +UPLOAD_VERBOSE="${HCLOUD_IMAGE_UPLOAD_VERBOSE:-0}" +ARTIFACT_PATH_INPUT="" +OUTPUT_HASH="" +NO_UPDATE=0 +BUILDER_SPEC="${HCLOUD_IMAGE_BUILDER_SPEC:-}" +EXTRA_LABELS=() +NIX_BUILD_FLAGS=() +BURROW_FLAKE_TMPDIRS=() +LOCAL_STORE_DIR="" + +usage() { + cat <<'EOF' +Usage: Scripts/hcloud-upload-nixos-image.sh [options] + +Build a raw Burrow NixOS image and upload it into Hetzner Cloud as a snapshot. + +Options: + --config images.-raw output to build (default: burrow-forge) + --flake Flake path to build from (default: .) + --location Hetzner location for the temporary upload server (default: hel1) + --architecture CPU architecture of the image (default: x86) + --server-type Hetzner server type for the temporary upload server + --token-file Hetzner API token file (default: intake/hetzner-api-token.txt) + --artifact-path Prebuilt raw image artifact to upload directly + --output-hash Stable hash label for --artifact-path uploads + --builder-spec Complete builders string passed to nix build + --description Description for the resulting snapshot + --upload-verbose Pass -v N times to hcloud-upload-image + --label key=value Extra Hetzner image label (repeatable) + --nix-flag Extra argument passed to nix build (repeatable) + --no-update Reuse an existing snapshot with the same config/output hash + -h, --help Show this help text +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --config) + CONFIG="${2:?missing value for --config}" + shift 2 + ;; + --flake) + FLAKE="${2:?missing value for --flake}" + shift 2 + ;; + --location) + LOCATION="${2:?missing value for --location}" + shift 2 + ;; + --architecture) + ARCHITECTURE="${2:?missing value for --architecture}" + shift 2 + ;; + --server-type) + UPLOAD_SERVER_TYPE="${2:?missing value for --server-type}" + shift 2 + ;; + --token-file) + TOKEN_FILE="${2:?missing value for --token-file}" + shift 2 + ;; + --artifact-path) + ARTIFACT_PATH_INPUT="${2:?missing value for --artifact-path}" + shift 2 + ;; + --output-hash) + OUTPUT_HASH="${2:?missing value for --output-hash}" + shift 2 + ;; + --builder-spec) + BUILDER_SPEC="${2:?missing value for --builder-spec}" + shift 2 + ;; + --description) + DESCRIPTION="${2:?missing value for --description}" + shift 2 + ;; + --upload-verbose) + UPLOAD_VERBOSE="${2:?missing value for --upload-verbose}" + shift 2 + ;; + --label) + EXTRA_LABELS+=("${2:?missing value for --label}") + shift 2 + ;; + --nix-flag) + NIX_BUILD_FLAGS+=("${2:?missing value for --nix-flag}") + shift 2 + ;; + --no-update) + NO_UPDATE=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown option: $1" >&2 + usage >&2 + exit 64 + ;; + esac +done + +cleanup() { + burrow_cleanup_flake_tmpdirs + if [[ -n "${LOCAL_STORE_DIR}" && -d "${LOCAL_STORE_DIR}" ]]; then + rm -rf "${LOCAL_STORE_DIR}" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +burrow_require_cmd nix +burrow_require_cmd curl +burrow_require_cmd python3 +burrow_require_cmd rsync + +if [[ ! -f "${TOKEN_FILE}" ]]; then + echo "Hetzner API token file not found: ${TOKEN_FILE}" >&2 + exit 1 +fi + +HCLOUD_TOKEN="$(tr -d '\r\n' < "${TOKEN_FILE}")" +if [[ -z "${HCLOUD_TOKEN}" ]]; then + echo "Hetzner API token file is empty: ${TOKEN_FILE}" >&2 + exit 1 +fi + +flake_ref="$(burrow_prepare_flake_ref "${FLAKE}")" + +if [[ -z "${DESCRIPTION}" ]]; then + DESCRIPTION="Burrow ${CONFIG} $(date -u +%Y-%m-%dT%H:%M:%SZ)" +fi + +printf 'Building raw image for %s from %s\n' "${CONFIG}" "${flake_ref}" >&2 + +if [[ -z "${ARTIFACT_PATH_INPUT}" && -n "${BUILDER_SPEC}" && -z "${NIX_BUILD_STORE:-}" ]]; then + mkdir -p "${HOME}/.cache/burrow" + LOCAL_STORE_DIR="$(mktemp -d "${HOME}/.cache/burrow/local-store-XXXXXX")" +fi + +artifact_path="" +compression="" +output_hash="${OUTPUT_HASH}" +if [[ -n "${ARTIFACT_PATH_INPUT}" ]]; then + artifact_path="${ARTIFACT_PATH_INPUT}" + if [[ ! -f "${artifact_path}" ]]; then + echo "artifact path does not exist: ${artifact_path}" >&2 + exit 1 + fi + compression="$(burrow_detect_compression "${artifact_path}")" + if [[ -z "${output_hash}" ]]; then + if command -v sha256sum >/dev/null 2>&1; then + output_hash="$(sha256sum "${artifact_path}" | awk '{print $1}')" + else + output_hash="$(shasum -a 256 "${artifact_path}" | awk '{print $1}')" + fi + fi +else + nix_build_cmd=( + nix + --extra-experimental-features + "nix-command flakes" + build + "${flake_ref}#images.${CONFIG}-raw" + --no-link + --print-out-paths + ) + + if [[ -n "${BUILDER_SPEC}" ]]; then + nix_build_cmd+=(--builders "${BUILDER_SPEC}") + fi + if [[ -n "${NIX_BUILD_STORE:-}" ]]; then + nix_build_cmd+=(--store "${NIX_BUILD_STORE}") + elif [[ -n "${LOCAL_STORE_DIR}" ]]; then + nix_build_cmd+=(--store "${LOCAL_STORE_DIR}") + fi + + if [[ "${#NIX_BUILD_FLAGS[@]}" -gt 0 ]]; then + nix_build_cmd+=("${NIX_BUILD_FLAGS[@]}") + fi + + build_output="" + if ! build_output="$("${nix_build_cmd[@]}" 2>&1)"; then + printf '%s\n' "${build_output}" >&2 + exit 1 + fi + + store_path="$(printf '%s\n' "${build_output}" | tail -n1)" + if [[ -z "${store_path}" ]]; then + echo "nix build did not return a store path" >&2 + printf '%s\n' "${build_output}" >&2 + exit 1 + fi + + artifact_path="$(burrow_resolve_image_artifact "${store_path}")" + compression="$(burrow_detect_compression "${artifact_path}")" + output_hash="$(basename "${store_path}")" + output_hash="${output_hash%%-*}" +fi + +label_args=( + "burrow.nixos-config=${CONFIG}" + "burrow.nixos-output-hash=${output_hash}" +) +if [[ "${#EXTRA_LABELS[@]}" -gt 0 ]]; then + label_args+=("${EXTRA_LABELS[@]}") +fi +label_csv="$(IFS=,; printf '%s' "${label_args[*]}")" + +find_existing_image() { + HCLOUD_TOKEN="${HCLOUD_TOKEN}" \ + BURROW_LABEL_SELECTOR="burrow.nixos-config=${CONFIG},burrow.nixos-output-hash=${output_hash}" \ + python3 - <<'PY' +import json +import os +import sys +import urllib.parse +import urllib.request + +selector = urllib.parse.quote(os.environ["BURROW_LABEL_SELECTOR"], safe=",=") +req = urllib.request.Request( + f"https://api.hetzner.cloud/v1/images?type=snapshot&label_selector={selector}", + headers={"Authorization": f"Bearer {os.environ['HCLOUD_TOKEN']}"}, +) +with urllib.request.urlopen(req, timeout=30) as resp: + data = json.load(resp) + +images = sorted(data.get("images", []), key=lambda item: item.get("created") or "") +if images: + print(images[-1]["id"]) +PY +} + +if [[ "${NO_UPDATE}" -eq 1 ]]; then + existing_id="$(find_existing_image || true)" + if [[ -n "${existing_id}" ]]; then + printf 'Reusing existing Hetzner snapshot %s for %s\n' "${existing_id}" "${CONFIG}" >&2 + printf '%s\n' "${existing_id}" + exit 0 + fi +fi + +uploader_bin="${HCLOUD_UPLOAD_IMAGE_BIN:-}" +if [[ -z "${uploader_bin}" ]]; then + uploader_build_output="$( + nix --extra-experimental-features "nix-command flakes" build \ + "${flake_ref}#hcloud-upload-image" \ + --no-link \ + --print-out-paths 2>&1 + )" || { + printf '%s\n' "${uploader_build_output}" >&2 + exit 1 + } + uploader_bin="$(printf '%s\n' "${uploader_build_output}" | tail -n1)/bin/hcloud-upload-image" +fi + +if [[ ! -x "${uploader_bin}" ]]; then + echo "unable to resolve an executable hcloud-upload-image binary; set HCLOUD_UPLOAD_IMAGE_BIN explicitly" >&2 + exit 1 +fi + +upload_cmd=( + "${uploader_bin}" +) +if [[ "${UPLOAD_VERBOSE}" =~ ^[0-9]+$ ]] && [[ "${UPLOAD_VERBOSE}" -gt 0 ]]; then + for _ in $(seq 1 "${UPLOAD_VERBOSE}"); do + upload_cmd+=(-v) + done +fi +upload_cmd+=( + upload + --image-path "${artifact_path}" + --location "${LOCATION}" + --description "${DESCRIPTION}" + --labels "${label_csv}" +) +if [[ -n "${UPLOAD_SERVER_TYPE}" ]]; then + upload_cmd+=(--server-type "${UPLOAD_SERVER_TYPE}") +else + upload_cmd+=(--architecture "${ARCHITECTURE}") +fi +if [[ -n "${compression}" ]]; then + upload_cmd+=(--compression "${compression}") +fi + +printf 'Uploading %s to Hetzner Cloud via %s\n' "${artifact_path}" "${uploader_bin}" >&2 +HCLOUD_TOKEN="${HCLOUD_TOKEN}" "${upload_cmd[@]}" >&2 + +image_id="" +for _ in $(seq 1 24); do + image_id="$(find_existing_image || true)" + if [[ -n "${image_id}" ]]; then + break + fi + sleep 5 +done + +if [[ -z "${image_id}" ]]; then + echo "failed to locate uploaded Hetzner snapshot after upload completed" >&2 + exit 1 +fi + +printf '%s\n' "${image_id}" diff --git a/Scripts/hetzner-forge.sh b/Scripts/hetzner-forge.sh new file mode 100755 index 0000000..cfce7eb --- /dev/null +++ b/Scripts/hetzner-forge.sh @@ -0,0 +1,284 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +usage() { + cat <<'EOF' +Usage: Scripts/hetzner-forge.sh [show|create|delete|recreate|build-image|create-from-image|recreate-from-image] [options] + +Manage the Burrow forge server and its Hetzner snapshot lifecycle. + +Defaults: + action: show + server-name: burrow-forge + server-type: ccx23 + location: hel1 + image: ubuntu-24.04 + ssh keys: contact@burrow.net,agent@burrow.net + +Options: + --server-name Server name to manage. + --server-type Hetzner server type. + --location Hetzner location. + --image Image used at create time. + --config Burrow image config name for snapshot lookup/build (default: burrow-forge). + --ssh-key SSH key name to attach. Repeatable. + --token-file Hetzner API token file. + --flake Flake path used by image-build actions (default: .) + --upload-location Hetzner location used for image upload (default: same as --location) + --yes Required for delete and recreate. + -h, --help Show this help text. + +Environment: + HCLOUD_TOKEN_FILE Defaults to intake/hetzner-api-token.txt +EOF +} + +ACTION="show" +SERVER_NAME="burrow-forge" +SERVER_TYPE="ccx23" +LOCATION="hel1" +IMAGE="ubuntu-24.04" +CONFIG="burrow-forge" +FLAKE="." +UPLOAD_LOCATION="" +TOKEN_FILE="${HCLOUD_TOKEN_FILE:-intake/hetzner-api-token.txt}" +YES=0 +SSH_KEYS=("contact@burrow.net" "agent@burrow.net") + +if [[ $# -gt 0 ]]; then + case "$1" in + show|create|delete|recreate|build-image|create-from-image|recreate-from-image) + ACTION="$1" + shift + ;; + esac +fi + +while [[ $# -gt 0 ]]; do + case "$1" in + --server-name) + SERVER_NAME="${2:?missing value for --server-name}" + shift 2 + ;; + --server-type) + SERVER_TYPE="${2:?missing value for --server-type}" + shift 2 + ;; + --location) + LOCATION="${2:?missing value for --location}" + shift 2 + ;; + --image) + IMAGE="${2:?missing value for --image}" + shift 2 + ;; + --config) + CONFIG="${2:?missing value for --config}" + shift 2 + ;; + --ssh-key) + SSH_KEYS+=("${2:?missing value for --ssh-key}") + shift 2 + ;; + --token-file) + TOKEN_FILE="${2:?missing value for --token-file}" + shift 2 + ;; + --flake) + FLAKE="${2:?missing value for --flake}" + shift 2 + ;; + --upload-location) + UPLOAD_LOCATION="${2:?missing value for --upload-location}" + shift 2 + ;; + --yes) + YES=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ ! -f "${TOKEN_FILE}" ]]; then + echo "Hetzner API token file not found: ${TOKEN_FILE}" >&2 + exit 1 +fi + +if [[ -z "${UPLOAD_LOCATION}" ]]; then + UPLOAD_LOCATION="${LOCATION}" +fi + +if [[ "${ACTION}" == "delete" || "${ACTION}" == "recreate" || "${ACTION}" == "recreate-from-image" ]] && [[ ${YES} -ne 1 ]]; then + echo "--yes is required for ${ACTION}" >&2 + exit 1 +fi + +latest_snapshot_id() { + HCLOUD_TOKEN="$(tr -d '\r\n' < "${TOKEN_FILE}")" \ + BURROW_CONFIG="${CONFIG}" \ + python3 - <<'PY' +import json +import os +import urllib.parse +import urllib.request + +selector = urllib.parse.quote(f"burrow.nixos-config={os.environ['BURROW_CONFIG']}", safe=",=") +req = urllib.request.Request( + f"https://api.hetzner.cloud/v1/images?type=snapshot&label_selector={selector}", + headers={"Authorization": f"Bearer {os.environ['HCLOUD_TOKEN']}"}, +) +with urllib.request.urlopen(req, timeout=30) as resp: + data = json.load(resp) +images = sorted(data.get("images", []), key=lambda item: item.get("created") or "") +if images: + print(images[-1]["id"]) +PY +} + +if [[ "${ACTION}" == "build-image" ]]; then + exec "${SCRIPT_DIR}/nsc-build-and-upload-image.sh" \ + --config "${CONFIG}" \ + --flake "${FLAKE}" \ + --location "${UPLOAD_LOCATION}" \ + --upload-server-type "${SERVER_TYPE}" \ + --token-file "${TOKEN_FILE}" +fi + +if [[ "${ACTION}" == "create-from-image" || "${ACTION}" == "recreate-from-image" ]]; then + if [[ "${IMAGE}" == "ubuntu-24.04" ]]; then + IMAGE="$(latest_snapshot_id)" + fi + if [[ -z "${IMAGE}" ]]; then + echo "No Burrow snapshot found for config ${CONFIG}. Run build-image first." >&2 + exit 1 + fi + if [[ "${ACTION}" == "create-from-image" ]]; then + ACTION="create" + else + ACTION="recreate" + fi +fi + +ssh_keys_csv="" +for key in "${SSH_KEYS[@]}"; do + if [[ -n "${ssh_keys_csv}" ]]; then + ssh_keys_csv+="," + fi + ssh_keys_csv+="${key}" +done + +export BURROW_HCLOUD_ACTION="${ACTION}" +export BURROW_HCLOUD_SERVER_NAME="${SERVER_NAME}" +export BURROW_HCLOUD_SERVER_TYPE="${SERVER_TYPE}" +export BURROW_HCLOUD_LOCATION="${LOCATION}" +export BURROW_HCLOUD_IMAGE="${IMAGE}" +export BURROW_HCLOUD_TOKEN_FILE="${TOKEN_FILE}" +export BURROW_HCLOUD_SSH_KEYS="${ssh_keys_csv}" + +python3 - <<'PY' +import json +import os +import sys +from pathlib import Path + +import requests + +base = "https://api.hetzner.cloud/v1" +action = os.environ["BURROW_HCLOUD_ACTION"] +server_name = os.environ["BURROW_HCLOUD_SERVER_NAME"] +server_type = os.environ["BURROW_HCLOUD_SERVER_TYPE"] +location = os.environ["BURROW_HCLOUD_LOCATION"] +image = os.environ["BURROW_HCLOUD_IMAGE"] +token = Path(os.environ["BURROW_HCLOUD_TOKEN_FILE"]).read_text(encoding="utf-8").strip() +ssh_keys = [key for key in os.environ["BURROW_HCLOUD_SSH_KEYS"].split(",") if key] + +session = requests.Session() +session.headers.update({"Authorization": f"Bearer {token}", "Content-Type": "application/json"}) + + +def request(method: str, path: str, **kwargs) -> requests.Response: + response = session.request(method, f"{base}{path}", timeout=30, **kwargs) + response.raise_for_status() + return response + + +def find_server(): + response = request("GET", "/servers", params={"name": server_name}) + data = response.json() + for server in data.get("servers", []): + if server.get("name") == server_name: + return server + return None + + +def summarize(server): + ipv4 = (((server.get("public_net") or {}).get("ipv4")) or {}).get("ip") + image_name = ((server.get("image") or {}).get("name")) or "" + summary = { + "id": server.get("id"), + "name": server.get("name"), + "status": server.get("status"), + "server_type": ((server.get("server_type") or {}).get("name")), + "location": ((server.get("location") or {}).get("name")), + "image": image_name, + "ipv4": ipv4, + "created": server.get("created"), + } + print(json.dumps(summary, indent=2)) + + +server = find_server() + +if action == "show": + if server is None: + print(json.dumps({"name": server_name, "present": False}, indent=2)) + else: + summarize(server) + sys.exit(0) + +if action == "delete": + if server is None: + print(json.dumps({"name": server_name, "deleted": False, "reason": "not found"}, indent=2)) + sys.exit(0) + request("DELETE", f"/servers/{server['id']}") + print(json.dumps({"name": server_name, "deleted": True, "id": server["id"]}, indent=2)) + sys.exit(0) + +if action == "recreate" and server is not None: + request("DELETE", f"/servers/{server['id']}") + server = None + +if action in {"create", "recreate"}: + if server is not None: + summarize(server) + sys.exit(0) + + payload = { + "name": server_name, + "server_type": server_type, + "location": location, + "image": image, + "ssh_keys": ssh_keys, + "labels": { + "project": "burrow", + "role": "forge", + }, + } + response = request("POST", "/servers", json=payload) + created = response.json()["server"] + summarize(created) + sys.exit(0) + +raise SystemExit(f"unsupported action: {action}") +PY diff --git a/Scripts/nsc-build-and-upload-image.sh b/Scripts/nsc-build-and-upload-image.sh new file mode 100755 index 0000000..6fb99a9 --- /dev/null +++ b/Scripts/nsc-build-and-upload-image.sh @@ -0,0 +1,542 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# shellcheck source=Scripts/_burrow-flake.sh +source "${SCRIPT_DIR}/_burrow-flake.sh" + +CONFIG="${HCLOUD_IMAGE_CONFIG:-burrow-forge}" +FLAKE="${HCLOUD_IMAGE_FLAKE:-.}" +LOCATION="${HCLOUD_IMAGE_LOCATION:-hel1}" +TOKEN_FILE="${HCLOUD_TOKEN_FILE:-${REPO_ROOT}/intake/hetzner-api-token.txt}" +NSC_SSH_HOST="${NSC_SSH_HOST:-ssh.ord2.namespace.so}" +NSC_MACHINE_TYPE="${NSC_MACHINE_TYPE:-linux/amd64:32x64}" +NSC_BUILDER_DURATION="${NSC_BUILDER_DURATION:-4h}" +NSC_BUILDER_JOBS="${NSC_BUILDER_JOBS:-32}" +NSC_BUILDER_FEATURES="${NSC_BUILDER_FEATURES:-kvm,big-parallel}" +NSC_BIN="${NSC_BIN:-}" +REMOTE_COMPRESSION="${HCLOUD_IMAGE_REMOTE_COMPRESSION:-auto}" +UPLOAD_SERVER_TYPE="${HCLOUD_IMAGE_UPLOAD_SERVER_TYPE:-}" +KEEP_TMPDIR="${HCLOUD_IMAGE_KEEP_TMPDIR:-0}" +NO_UPDATE=0 +NIX_BUILD_FLAGS=() +EXTRA_LABELS=() +BURROW_FLAKE_TMPDIRS=() +BUILDER_ID="" + +usage() { + cat <<'EOF' +Usage: Scripts/nsc-build-and-upload-image.sh [options] + +Create a temporary Namespace Linux builder, build the Burrow raw image on it, +and upload the resulting artifact to Hetzner Cloud. + +Options: + --config images.-raw output to build (default: burrow-forge) + --flake Flake path to build from (default: .) + --location Hetzner upload location (default: hel1) + --token-file Hetzner API token file (default: intake/hetzner-api-token.txt) + --machine-type Namespace machine type (default: linux/amd64:32x64) + --ssh-host Namespace SSH endpoint (default: ssh.ord2.namespace.so) + --duration Namespace builder lifetime (default: 4h) + --builder-jobs Nix builder job count advertised to the local client + --builder-features Comma-separated Nix system features (default: "kvm,big-parallel") + --remote-compression + Compress raw/image artifacts on the Namespace builder + before copy-back. Modes: auto, none, xz, zstd + (default: auto) + --upload-server-type + Hetzner server type for the temporary upload host + --label key=value Extra Hetzner snapshot label (repeatable) + --nix-flag Extra argument passed to nix build (repeatable) + --no-update Reuse an existing snapshot with the same config/output hash + -h, --help Show this help text +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --config) + CONFIG="${2:?missing value for --config}" + shift 2 + ;; + --flake) + FLAKE="${2:?missing value for --flake}" + shift 2 + ;; + --location) + LOCATION="${2:?missing value for --location}" + shift 2 + ;; + --token-file) + TOKEN_FILE="${2:?missing value for --token-file}" + shift 2 + ;; + --machine-type) + NSC_MACHINE_TYPE="${2:?missing value for --machine-type}" + shift 2 + ;; + --ssh-host) + NSC_SSH_HOST="${2:?missing value for --ssh-host}" + shift 2 + ;; + --duration) + NSC_BUILDER_DURATION="${2:?missing value for --duration}" + shift 2 + ;; + --builder-jobs) + NSC_BUILDER_JOBS="${2:?missing value for --builder-jobs}" + shift 2 + ;; + --builder-features) + NSC_BUILDER_FEATURES="${2:?missing value for --builder-features}" + shift 2 + ;; + --remote-compression) + REMOTE_COMPRESSION="${2:?missing value for --remote-compression}" + shift 2 + ;; + --upload-server-type) + UPLOAD_SERVER_TYPE="${2:?missing value for --upload-server-type}" + shift 2 + ;; + --label) + EXTRA_LABELS+=("${2:?missing value for --label}") + shift 2 + ;; + --nix-flag) + NIX_BUILD_FLAGS+=("${2:?missing value for --nix-flag}") + shift 2 + ;; + --no-update) + NO_UPDATE=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown option: $1" >&2 + usage >&2 + exit 64 + ;; + esac +done + +cleanup() { + if [[ -n "${BUILDER_ID}" && -n "${NSC_BIN}" ]]; then + "${NSC_BIN}" destroy "${BUILDER_ID}" --force >/dev/null 2>&1 || true + fi + burrow_cleanup_flake_tmpdirs + if [[ "${KEEP_TMPDIR}" != "1" && -n "${TMPDIR_BURROW_NSC:-}" && -d "${TMPDIR_BURROW_NSC}" ]]; then + rm -rf "${TMPDIR_BURROW_NSC}" + fi +} +trap cleanup EXIT + +burrow_require_cmd nix +burrow_require_cmd curl +burrow_require_cmd python3 +burrow_require_cmd ssh +burrow_require_cmd ssh-keygen +burrow_require_cmd ssh-keyscan +burrow_require_cmd tar + +flake_ref="$(burrow_prepare_flake_ref "${FLAKE}")" + +if [[ -z "${NSC_BIN}" ]]; then + nsc_build_output="$( + nix --extra-experimental-features "nix-command flakes" build \ + "${flake_ref}#nsc" \ + --no-link \ + --print-out-paths 2>&1 + )" || { + printf '%s\n' "${nsc_build_output}" >&2 + exit 1 + } + NSC_BIN="$(printf '%s\n' "${nsc_build_output}" | tail -n1)/bin/nsc" +fi + +if [[ ! -x "${NSC_BIN}" ]]; then + echo "unable to resolve an executable nsc binary; set NSC_BIN explicitly" >&2 + exit 1 +fi + +if [[ -n "${NSC_SESSION:-}" && ! -f "${HOME}/.ns/session" ]]; then + mkdir -p "${HOME}/.ns" + printf '%s\n' "${NSC_SESSION}" > "${HOME}/.ns/session" + chmod 600 "${HOME}/.ns/session" +fi + +"${NSC_BIN}" auth check-login --duration 20m >/dev/null +"${NSC_BIN}" version >/dev/null || true + +TMPDIR_BURROW_NSC="$(mktemp -d "${HOME}/.cache/burrow/nsc-XXXXXX")" +ssh_key="${TMPDIR_BURROW_NSC}/builder" +known_hosts="${TMPDIR_BURROW_NSC}/known_hosts" +id_file="${TMPDIR_BURROW_NSC}/builder.id" + +ssh-keygen -q -t ed25519 -N "" -f "${ssh_key}" +ssh-keyscan -H "${NSC_SSH_HOST}" > "${known_hosts}" + +ssh_base=( + ssh + -i "${ssh_key}" + -o UserKnownHostsFile="${known_hosts}" + -o StrictHostKeyChecking=yes +) + +wait_for_ssh() { + local instance_id="$1" + for _ in $(seq 1 30); do + if "${ssh_base[@]}" -q "${instance_id}@${NSC_SSH_HOST}" true >/dev/null 2>&1; then + return 0 + fi + sleep 5 + done + return 1 +} + +configure_builder() { + local instance_id="$1" + "${ssh_base[@]}" "${instance_id}@${NSC_SSH_HOST}" <<'EOF' +set -euo pipefail + +if ! command -v nix >/dev/null 2>&1; then + curl -fsSL https://install.determinate.systems/nix | sh -s -- install linux --determinate --init none --no-confirm +fi + +if [ -e /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh ]; then + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh +fi + +mkdir -p /etc/nix +cat </etc/nix/nix.conf +build-users-group = +trusted-users = root $USER +auto-optimise-store = true +substituters = https://cache.nixos.org +builders-use-substitutes = true +CFG + +mkdir -p /nix/var/nix/daemon-socket + +if ! pgrep -x nix-daemon >/dev/null 2>&1; then + nohup nix-daemon >/dev/null 2>&1 /dev/null 2>&1; then + nohup nix-daemon >/dev/null 2>&1 &2 +exit 1 +EOF +} + +printf 'Creating temporary Namespace builder (%s)\n' "${NSC_MACHINE_TYPE}" >&2 +"${NSC_BIN}" create \ + --bare \ + --machine_type "${NSC_MACHINE_TYPE}" \ + --ssh_key "${ssh_key}.pub" \ + --duration "${NSC_BUILDER_DURATION}" \ + --label "burrow=true" \ + --label "purpose=hetzner-image-build" \ + --output_to "${id_file}" \ + >/dev/null + +BUILDER_ID="$(tr -d '\r\n' < "${id_file}")" +if [[ -z "${BUILDER_ID}" ]]; then + echo "nsc create did not return a builder id" >&2 + exit 1 +fi + +printf 'Waiting for Namespace builder %s\n' "${BUILDER_ID}" >&2 +wait_for_ssh "${BUILDER_ID}" +configure_builder "${BUILDER_ID}" >&2 + +remote_root="burrow-image-build-${BUILDER_ID}" +remote_flake_path="./${remote_root}" +local_flake_dir="${flake_ref#path:}" +remote_build_stdout="/tmp/burrow-image-build-${BUILDER_ID}.stdout" +remote_build_stderr="/tmp/burrow-image-build-${BUILDER_ID}.stderr" + +printf 'Syncing flake to Namespace builder %s\n' "${BUILDER_ID}" >&2 +tar -C "${local_flake_dir}" -cf - . \ + | "${ssh_base[@]}" "${BUILDER_ID}@${NSC_SSH_HOST}" "rm -rf '${remote_root}' && mkdir -p '${remote_root}' && tar -C '${remote_root}' -xf -" + +run_remote_build() { + local remote_cmd=( + env + "CONFIG=${CONFIG}" + "REMOTE_FLAKE_PATH=${remote_flake_path}" + "REMOTE_BUILD_STDOUT=${remote_build_stdout}" + "REMOTE_BUILD_STDERR=${remote_build_stderr}" + bash + -s + -- + ) + if [[ "${#NIX_BUILD_FLAGS[@]}" -gt 0 ]]; then + remote_cmd+=("${NIX_BUILD_FLAGS[@]}") + fi + + "${ssh_base[@]}" "${BUILDER_ID}@${NSC_SSH_HOST}" "${remote_cmd[@]}" <<'EOF' +set -euo pipefail + +config="${CONFIG}" +remote_flake_path="${REMOTE_FLAKE_PATH}" +remote_build_stdout="${REMOTE_BUILD_STDOUT}" +remote_build_stderr="${REMOTE_BUILD_STDERR}" +nix_build_cmd=( + nix + --extra-experimental-features + "nix-command flakes" + build + "path:${remote_flake_path}#images.${config}-raw" + --no-link + --print-out-paths +) +if [[ "$#" -gt 0 ]]; then + nix_build_cmd+=("$@") +fi + +rm -f "${remote_build_stdout}" "${remote_build_stderr}" +if ! "${nix_build_cmd[@]}" >"${remote_build_stdout}" 2>"${remote_build_stderr}"; then + cat "${remote_build_stderr}" >&2 + exit 1 +fi +EOF +} + +resolve_remote_store_path() { + "${ssh_base[@]}" "${BUILDER_ID}@${NSC_SSH_HOST}" \ + env "REMOTE_BUILD_STDOUT=${remote_build_stdout}" "REMOTE_BUILD_STDERR=${remote_build_stderr}" bash -s <<'EOF' +set -euo pipefail + +remote_build_stdout="${REMOTE_BUILD_STDOUT}" +remote_build_stderr="${REMOTE_BUILD_STDERR}" + +if [[ ! -s "${remote_build_stdout}" ]]; then + echo "remote build stdout file is missing or empty: ${remote_build_stdout}" >&2 + if [[ -s "${remote_build_stderr}" ]]; then + cat "${remote_build_stderr}" >&2 + fi + exit 1 +fi + +tail -n1 "${remote_build_stdout}" +EOF +} + +resolve_remote_artifact_path() { + local store_path="$1" + "${ssh_base[@]}" "${BUILDER_ID}@${NSC_SSH_HOST}" \ + env "REMOTE_STORE_PATH=${store_path}" bash -s <<'EOF' +set -euo pipefail + +store_path="${REMOTE_STORE_PATH}" +artifact_path="${store_path}" +if [[ -d "${artifact_path}" ]]; then + artifact_path="$(find "${artifact_path}" -type f \( -name '*.raw' -o -name '*.raw.*' -o -name '*.img' -o -name '*.img.*' \) | sort | head -n1)" +fi +if [[ -z "${artifact_path}" || ! -f "${artifact_path}" ]]; then + echo "unable to locate image artifact under ${store_path}" >&2 + exit 1 +fi + +printf '%s\n' "${artifact_path}" +EOF +} + +plan_remote_artifact_transfer() { + local artifact_path="$1" + local compression_mode="$2" + + "${ssh_base[@]}" "${BUILDER_ID}@${NSC_SSH_HOST}" \ + env "REMOTE_ARTIFACT_PATH=${artifact_path}" "REMOTE_COMPRESSION=${compression_mode}" bash -s <<'EOF' +set -euo pipefail + +artifact_path="${REMOTE_ARTIFACT_PATH}" +compression_mode="${REMOTE_COMPRESSION}" + +case "${artifact_path}" in + *.bz2) + printf '%s\tbz2\n' "$(basename "${artifact_path}")" + exit 0 + ;; + *.xz) + printf '%s\txz\n' "$(basename "${artifact_path}")" + exit 0 + ;; + *.zst|*.zstd) + printf '%s\tzstd\n' "$(basename "${artifact_path}")" + exit 0 + ;; +esac + +select_compression() { + case "${compression_mode}" in + auto) + if command -v zstd >/dev/null 2>&1; then + printf 'zstd\n' + return 0 + fi + if command -v xz >/dev/null 2>&1; then + printf 'xz\n' + return 0 + fi + printf 'none\n' + ;; + none|xz|zstd) + printf '%s\n' "${compression_mode}" + ;; + *) + echo "unsupported remote compression mode: ${compression_mode}" >&2 + exit 1 + ;; + esac +} + +mode="$(select_compression)" +case "${mode}" in + none) + printf '%s\tnone\n' "$(basename "${artifact_path}")" + ;; + zstd) + printf '%s.zst\tzstd\n' "$(basename "${artifact_path}")" + ;; + xz) + printf '%s.xz\txz\n' "$(basename "${artifact_path}")" + ;; +esac +EOF +} + +stream_remote_artifact() { + local artifact_path="$1" + local compression_mode="$2" + local destination="$3" + + "${ssh_base[@]}" "${BUILDER_ID}@${NSC_SSH_HOST}" \ + env "REMOTE_ARTIFACT_PATH=${artifact_path}" "REMOTE_COMPRESSION=${compression_mode}" bash -s <<'EOF' > "${destination}" +set -euo pipefail + +artifact_path="${REMOTE_ARTIFACT_PATH}" +compression_mode="${REMOTE_COMPRESSION}" + +case "${artifact_path}" in + *.bz2|*.xz|*.zst|*.zstd) + cat "${artifact_path}" + exit 0 + ;; +esac + +select_compression() { + case "${compression_mode}" in + auto) + if command -v zstd >/dev/null 2>&1; then + printf 'zstd\n' + return 0 + fi + if command -v xz >/dev/null 2>&1; then + printf 'xz\n' + return 0 + fi + printf 'none\n' + ;; + none|xz|zstd) + printf '%s\n' "${compression_mode}" + ;; + *) + echo "unsupported remote compression mode: ${compression_mode}" >&2 + exit 1 + ;; + esac +} + +mode="$(select_compression)" +case "${mode}" in + none) + cat "${artifact_path}" + ;; + zstd) + if ! command -v zstd >/dev/null 2>&1; then + echo "zstd requested but not available on Namespace builder" >&2 + exit 1 + fi + zstd -T0 -19 -c "${artifact_path}" + ;; + xz) + if ! command -v xz >/dev/null 2>&1; then + echo "xz requested but not available on Namespace builder" >&2 + exit 1 + fi + xz -T0 -c "${artifact_path}" + ;; +esac +EOF +} + +printf 'Building raw image on Namespace builder %s\n' "${BUILDER_ID}" >&2 +run_remote_build + +remote_store_path="$(resolve_remote_store_path)" +if [[ -z "${remote_store_path}" ]]; then + echo "remote build did not return a store path" >&2 + exit 1 +fi + +remote_artifact_path="$(resolve_remote_artifact_path "${remote_store_path}")" +if [[ -z "${remote_artifact_path}" ]]; then + echo "remote build did not return an artifact path" >&2 + exit 1 +fi + +transfer_plan="$(plan_remote_artifact_transfer "${remote_artifact_path}" "${REMOTE_COMPRESSION}")" +local_artifact_name="$(printf '%s\n' "${transfer_plan}" | cut -f1)" +transfer_compression="$(printf '%s\n' "${transfer_plan}" | cut -f2)" +if [[ -z "${local_artifact_name}" || -z "${transfer_compression}" ]]; then + echo "unable to determine artifact transfer plan for ${remote_artifact_path}" >&2 + exit 1 +fi + +output_hash="$(basename "${remote_store_path}")" +output_hash="${output_hash%%-*}" +local_artifact="${TMPDIR_BURROW_NSC}/${local_artifact_name}" + +printf 'Streaming built artifact back from Namespace builder %s (%s)\n' "${BUILDER_ID}" "${transfer_compression}" >&2 +stream_remote_artifact "${remote_artifact_path}" "${REMOTE_COMPRESSION}" "${local_artifact}" + +cmd=( + "${SCRIPT_DIR}/hcloud-upload-nixos-image.sh" + --config "${CONFIG}" + --flake "${FLAKE}" + --location "${LOCATION}" + --token-file "${TOKEN_FILE}" + --artifact-path "${local_artifact}" + --output-hash "${output_hash}" +) + +if [[ -n "${UPLOAD_SERVER_TYPE}" ]]; then + cmd+=(--server-type "${UPLOAD_SERVER_TYPE}") +fi + +if [[ "${NO_UPDATE}" -eq 1 ]]; then + cmd+=(--no-update) +fi +if [[ "${#EXTRA_LABELS[@]}" -gt 0 ]]; then + for label in "${EXTRA_LABELS[@]}"; do + cmd+=(--label "${label}") + done +fi + +"${cmd[@]}" diff --git a/Scripts/provision-forgejo-nsc.sh b/Scripts/provision-forgejo-nsc.sh new file mode 100755 index 0000000..b31de21 --- /dev/null +++ b/Scripts/provision-forgejo-nsc.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# shellcheck source=Scripts/_burrow-flake.sh +source "${SCRIPT_DIR}/_burrow-flake.sh" + +usage() { + cat <<'EOF' +Usage: Scripts/provision-forgejo-nsc.sh [options] + +Generate Burrow forgejo-nsc runtime inputs in intake/ and optionally refresh the +Namespace token from the currently logged-in namespace account. + +Options: + --host SSH target used to mint the Forgejo PAT. + Default: root@git.burrow.net + --ssh-key SSH private key for the forge host. + Default: intake/agent_at_burrow_net_ed25519 + --nsc-bin Override the nsc binary. + --no-refresh-token Reuse intake/forgejo_nsc_token.txt if it already exists. + --token-name Forgejo PAT name prefix (default: forgejo-nsc) + --contact-user Forgejo username used for PAT creation (default: contact) + --scope-owner Forgejo org/user owner for the default NSC scope (default: burrow) + --scope-name Forgejo repository name for the default NSC scope (default: burrow) + -h, --help Show this help text. +EOF +} + +HOST="${BURROW_FORGE_HOST:-root@git.burrow.net}" +SSH_KEY="${BURROW_FORGE_SSH_KEY:-${REPO_ROOT}/intake/agent_at_burrow_net_ed25519}" +NSC_BIN="${NSC_BIN:-}" +KNOWN_HOSTS_FILE="${BURROW_FORGE_KNOWN_HOSTS_FILE:-${HOME}/.cache/burrow/forge-known_hosts}" +REFRESH_TOKEN=1 +TOKEN_NAME_PREFIX="${FORGEJO_PAT_NAME:-forgejo-nsc}" +CONTACT_USER="${FORGEJO_CONTACT_USER:-contact}" +SCOPE_OWNER="${FORGEJO_SCOPE_OWNER:-burrow}" +SCOPE_NAME="${FORGEJO_SCOPE_NAME:-burrow}" +BURROW_FLAKE_TMPDIRS=() + +cleanup() { + burrow_cleanup_flake_tmpdirs +} +trap cleanup EXIT + +while [[ $# -gt 0 ]]; do + case "$1" in + --host) + HOST="${2:?missing value for --host}" + shift 2 + ;; + --ssh-key) + SSH_KEY="${2:?missing value for --ssh-key}" + shift 2 + ;; + --nsc-bin) + NSC_BIN="${2:?missing value for --nsc-bin}" + shift 2 + ;; + --no-refresh-token) + REFRESH_TOKEN=0 + shift + ;; + --token-name) + TOKEN_NAME_PREFIX="${2:?missing value for --token-name}" + shift 2 + ;; + --contact-user) + CONTACT_USER="${2:?missing value for --contact-user}" + shift 2 + ;; + --scope-owner) + SCOPE_OWNER="${2:?missing value for --scope-owner}" + shift 2 + ;; + --scope-name) + SCOPE_NAME="${2:?missing value for --scope-name}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown option: $1" >&2 + usage >&2 + exit 64 + ;; + esac +done + +mkdir -p "$(dirname "${KNOWN_HOSTS_FILE}")" + +burrow_require_cmd nix +burrow_require_cmd ssh +burrow_require_cmd python3 + +if [[ ! -f "${SSH_KEY}" ]]; then + echo "forge SSH key not found: ${SSH_KEY}" >&2 + exit 1 +fi + +mkdir -p "${REPO_ROOT}/intake" +chmod 700 "${REPO_ROOT}/intake" + +flake_ref="$(burrow_prepare_flake_ref "${REPO_ROOT}")" +if [[ -z "${NSC_BIN}" ]]; then + if command -v nsc >/dev/null 2>&1; then + NSC_BIN="$(command -v nsc)" + else + nsc_build_output="$( + nix --extra-experimental-features "nix-command flakes" build \ + "${flake_ref}#nsc" \ + --no-link \ + --print-out-paths 2>&1 + )" || { + printf '%s\n' "${nsc_build_output}" >&2 + exit 1 + } + NSC_BIN="$(printf '%s\n' "${nsc_build_output}" | tail -n1)/bin/nsc" + fi +fi + +if [[ ! -x "${NSC_BIN}" ]]; then + echo "unable to resolve an executable nsc binary; set NSC_BIN explicitly" >&2 + exit 1 +fi + +token_file="${REPO_ROOT}/intake/forgejo_nsc_token.txt" +dispatcher_out="${REPO_ROOT}/intake/forgejo_nsc_dispatcher.yaml" +autoscaler_out="${REPO_ROOT}/intake/forgejo_nsc_autoscaler.yaml" +dispatcher_src="${REPO_ROOT}/services/forgejo-nsc/deploy/dispatcher.yaml" +autoscaler_src="${REPO_ROOT}/services/forgejo-nsc/deploy/autoscaler.yaml" + +if [[ "${REFRESH_TOKEN}" -eq 1 || ! -s "${token_file}" ]]; then + "${NSC_BIN}" auth check-login --duration 20m >/dev/null + "${NSC_BIN}" auth generate-dev-token --output_to "${token_file}" >/dev/null + chmod 600 "${token_file}" +fi + +webhook_secret="$(python3 - <<'PY' +import secrets +print(secrets.token_hex(32)) +PY +)" + +token_name="${TOKEN_NAME_PREFIX}-$(date -u +%Y%m%dT%H%M%SZ)" +forgejo_pat="$( + ssh \ + -i "${SSH_KEY}" \ + -o IdentitiesOnly=yes \ + -o UserKnownHostsFile="${KNOWN_HOSTS_FILE}" \ + -o StrictHostKeyChecking=accept-new \ + "${HOST}" \ + "set -euo pipefail; forgejo_bin=\$(systemctl show -p ExecStart forgejo.service --value | sed -E 's/^\\{ path=([^ ;]+).*/\\1/'); sudo -u forgejo \"\${forgejo_bin}\" --config /var/lib/forgejo/custom/conf/app.ini --custom-path /var/lib/forgejo/custom --work-path /var/lib/forgejo admin user generate-access-token --username '${CONTACT_USER}' --scopes all --raw --token-name '${token_name}'" \ + | tr -d '\r\n' +)" + +if [[ -z "${forgejo_pat}" ]]; then + echo "failed to mint Forgejo PAT on ${HOST}" >&2 + exit 1 +fi + +ssh \ + -i "${SSH_KEY}" \ + -o IdentitiesOnly=yes \ + -o UserKnownHostsFile="${KNOWN_HOSTS_FILE}" \ + -o StrictHostKeyChecking=accept-new \ + "${HOST}" \ + 'bash -s' </tmp/forgejo-provision-org.json <&2 + cat /tmp/forgejo-provision-response.json >&2 + exit 1 + fi +fi + +repo_code="\$(api "\${base_url}/api/v1/repos/\${scope_owner}/\${scope_name}")" +if [[ "\${repo_code}" == "404" ]]; then + cat >/tmp/forgejo-provision-repo.json <&2 + cat /tmp/forgejo-provision-response.json >&2 + exit 1 + fi +fi +EOF + +FORGEJO_PAT="${forgejo_pat}" \ +WEBHOOK_SECRET="${webhook_secret}" \ +DISPATCHER_SRC="${dispatcher_src}" \ +AUTOSCALER_SRC="${autoscaler_src}" \ +DISPATCHER_OUT="${dispatcher_out}" \ +AUTOSCALER_OUT="${autoscaler_out}" \ +python3 - <<'PY' +import os +from pathlib import Path + +def render(src: str, dst: str) -> None: + text = Path(src).read_text(encoding="utf-8") + text = text.replace("PENDING-FORGEJO-PAT", os.environ["FORGEJO_PAT"]) + text = text.replace("PENDING-WEBHOOK-SECRET", os.environ["WEBHOOK_SECRET"]) + Path(dst).write_text(text, encoding="utf-8") + +render(os.environ["DISPATCHER_SRC"], os.environ["DISPATCHER_OUT"]) +render(os.environ["AUTOSCALER_SRC"], os.environ["AUTOSCALER_OUT"]) +PY + +chmod 600 "${dispatcher_out}" "${autoscaler_out}" + +echo "Rendered intake/forgejo_nsc_token.txt, intake/forgejo_nsc_dispatcher.yaml, and intake/forgejo_nsc_autoscaler.yaml." +echo "Minted Forgejo PAT ${token_name} for ${CONTACT_USER} on ${HOST}." diff --git a/Scripts/sync-forgejo-nsc-config.sh b/Scripts/sync-forgejo-nsc-config.sh new file mode 100755 index 0000000..77581f8 --- /dev/null +++ b/Scripts/sync-forgejo-nsc-config.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: Scripts/sync-forgejo-nsc-config.sh [options] + +Copy Burrow forgejo-nsc runtime inputs from intake/ onto the forge host and +restart the dispatcher/autoscaler units. + +Options: + --host SSH target (default: root@git.burrow.net) + --ssh-key SSH private key (default: intake/agent_at_burrow_net_ed25519) + --rotate-pat Re-render the intake files before syncing. + --no-restart Copy files only. + -h, --help Show this help text. +EOF +} + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +HOST="${BURROW_FORGE_HOST:-root@git.burrow.net}" +SSH_KEY="${BURROW_FORGE_SSH_KEY:-${REPO_ROOT}/intake/agent_at_burrow_net_ed25519}" +KNOWN_HOSTS_FILE="${BURROW_FORGE_KNOWN_HOSTS_FILE:-${HOME}/.cache/burrow/forge-known_hosts}" +ROTATE_PAT=0 +NO_RESTART=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --host) + HOST="${2:?missing value for --host}" + shift 2 + ;; + --ssh-key) + SSH_KEY="${2:?missing value for --ssh-key}" + shift 2 + ;; + --rotate-pat) + ROTATE_PAT=1 + shift + ;; + --no-restart) + NO_RESTART=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown option: $1" >&2 + usage >&2 + exit 64 + ;; + esac +done + +mkdir -p "$(dirname "${KNOWN_HOSTS_FILE}")" + +burrow_require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + exit 1 + fi +} + +burrow_require_cmd ssh +burrow_require_cmd scp + +if [[ ! -f "${SSH_KEY}" ]]; then + echo "forge SSH key not found: ${SSH_KEY}" >&2 + exit 1 +fi + +if [[ "${ROTATE_PAT}" -eq 1 ]]; then + "${SCRIPT_DIR}/provision-forgejo-nsc.sh" --host "${HOST}" --ssh-key "${SSH_KEY}" +fi + +token_file="${REPO_ROOT}/intake/forgejo_nsc_token.txt" +dispatcher_file="${REPO_ROOT}/intake/forgejo_nsc_dispatcher.yaml" +autoscaler_file="${REPO_ROOT}/intake/forgejo_nsc_autoscaler.yaml" + +for path in "${token_file}" "${dispatcher_file}" "${autoscaler_file}"; do + if [[ ! -s "${path}" ]]; then + echo "required runtime input missing or empty: ${path}" >&2 + exit 1 + fi +done + +ssh_opts=( + -i "${SSH_KEY}" + -o IdentitiesOnly=yes + -o UserKnownHostsFile="${KNOWN_HOSTS_FILE}" + -o StrictHostKeyChecking=accept-new +) + +remote_tmp="$(ssh "${ssh_opts[@]}" "${HOST}" "mktemp -d")" +cleanup() { + if [[ -n "${remote_tmp:-}" ]]; then + ssh "${ssh_opts[@]}" "${HOST}" "rm -rf '${remote_tmp}'" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +scp "${ssh_opts[@]}" \ + "${token_file}" \ + "${dispatcher_file}" \ + "${autoscaler_file}" \ + "${HOST}:${remote_tmp}/" + +ssh "${ssh_opts[@]}" "${HOST}" " + set -euo pipefail + install -d -m 0755 /var/lib/burrow/intake + install -m 0400 -o forgejo-nsc -g forgejo-nsc '${remote_tmp}/$(basename "${token_file}")' /var/lib/burrow/intake/forgejo_nsc_token.txt + install -m 0400 -o forgejo-nsc -g forgejo-nsc '${remote_tmp}/$(basename "${dispatcher_file}")' /var/lib/burrow/intake/forgejo_nsc_dispatcher.yaml + install -m 0400 -o forgejo-nsc -g forgejo-nsc '${remote_tmp}/$(basename "${autoscaler_file}")' /var/lib/burrow/intake/forgejo_nsc_autoscaler.yaml +" + +if [[ "${NO_RESTART}" -eq 0 ]]; then + ssh "${ssh_opts[@]}" "${HOST}" " + set -euo pipefail + systemctl restart forgejo-nsc-dispatcher.service forgejo-nsc-autoscaler.service + systemctl is-active forgejo-nsc-dispatcher.service forgejo-nsc-autoscaler.service + ls -l \ + /var/lib/burrow/intake/forgejo_nsc_token.txt \ + /var/lib/burrow/intake/forgejo_nsc_dispatcher.yaml \ + /var/lib/burrow/intake/forgejo_nsc_autoscaler.yaml + " +fi + +echo "forgejo-nsc runtime sync complete (host=${HOST}, restarted=$((1 - NO_RESTART)))." diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..677bd0d --- /dev/null +++ b/flake.lock @@ -0,0 +1,86 @@ +{ + "nodes": { + "disko": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1773506317, + "narHash": "sha256-qWKbLUJpavIpvOdX1fhHYm0WGerytFHRoh9lVck6Bh0=", + "type": "tarball", + "url": "https://codeload.github.com/nix-community/disko/tar.gz/master" + }, + "original": { + "type": "tarball", + "url": "https://codeload.github.com/nix-community/disko/tar.gz/master" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "type": "tarball", + "url": "https://codeload.github.com/numtide/flake-utils/tar.gz/main" + }, + "original": { + "type": "tarball", + "url": "https://codeload.github.com/numtide/flake-utils/tar.gz/main" + } + }, + "hcloud-upload-image-src": { + "flake": false, + "locked": { + "lastModified": 1766413232, + "narHash": "sha256-1u9tpzciYjB/EgBI81pg9w0kez7hHZON7+AHvfKW7k0=", + "type": "tarball", + "url": "https://codeload.github.com/apricote/hcloud-upload-image/tar.gz/v1.3.0" + }, + "original": { + "type": "tarball", + "url": "https://codeload.github.com/apricote/hcloud-upload-image/tar.gz/v1.3.0" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1773389992, + "narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=", + "type": "tarball", + "url": "https://codeload.github.com/NixOS/nixpkgs/tar.gz/nixos-unstable" + }, + "original": { + "type": "tarball", + "url": "https://codeload.github.com/NixOS/nixpkgs/tar.gz/nixos-unstable" + } + }, + "root": { + "inputs": { + "disko": "disko", + "flake-utils": "flake-utils", + "hcloud-upload-image-src": "hcloud-upload-image-src", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..38e38b6 --- /dev/null +++ b/flake.nix @@ -0,0 +1,192 @@ +{ + description = "Burrow development shell and forge host configuration"; + + inputs = { + nixpkgs.url = "tarball+https://codeload.github.com/NixOS/nixpkgs/tar.gz/nixos-unstable"; + flake-utils.url = "tarball+https://codeload.github.com/numtide/flake-utils/tar.gz/main"; + disko = { + url = "tarball+https://codeload.github.com/nix-community/disko/tar.gz/master"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + hcloud-upload-image-src = { + url = "tarball+https://codeload.github.com/apricote/hcloud-upload-image/tar.gz/v1.3.0"; + flake = false; + }; + }; + + outputs = { self, nixpkgs, flake-utils, disko, hcloud-upload-image-src }: + let + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + in + (flake-utils.lib.eachSystem supportedSystems (system: + let + pkgs = import nixpkgs { + inherit system; + }; + lib = pkgs.lib; + commonPackages = with pkgs; [ + cargo + rustc + rustfmt + clippy + protobuf + pkg-config + sqlite + git + openssh + curl + jq + nodejs_20 + python3 + rsync + ]; + nscPkg = + if pkgs.stdenv.isLinux || pkgs.stdenv.isDarwin then + let + version = "0.0.452"; + osName = + if pkgs.stdenv.isLinux then + "linux" + else if pkgs.stdenv.isDarwin then + "darwin" + else + throw "nsc: unsupported host OS ${pkgs.stdenv.hostPlatform.system}"; + archInfo = + if pkgs.stdenv.hostPlatform.isx86_64 then + { + arch = "amd64"; + hash = + if pkgs.stdenv.isLinux then + "sha256-FBqOJ0UQWTv2r4HWMHrR/aqFzDa0ej/mS8dSoaCe6fY=" + else + "sha256-3fRKWO0SCCa5PEym5yCB7dtyEx3xSxXSHfJYz8B+/4M="; + } + else if pkgs.stdenv.hostPlatform.isAarch64 then + { + arch = "arm64"; + hash = + if pkgs.stdenv.isLinux then + "sha256-A6twO8Ievbu7Gi5Hqon4ug5rCGOm/uHhlCya3px6+io=" + else + "sha256-n363xLaGhy+a6lw2F+WicQYGXnGYnqRW8aTQCSppwcw="; + } + else + throw "nsc: unsupported host platform ${pkgs.stdenv.hostPlatform.system}"; + src = pkgs.fetchurl { + url = "https://github.com/namespacelabs/foundation/releases/download/v${version}/nsc_${version}_${osName}_${archInfo.arch}.tar.gz"; + sha256 = archInfo.hash; + }; + in + pkgs.stdenvNoCC.mkDerivation { + pname = "nsc"; + inherit version src; + dontConfigure = true; + dontBuild = true; + unpackPhase = '' + tar -xzf "$src" + ''; + installPhase = '' + install -d "$out/bin" + install -m 0555 nsc "$out/bin/nsc" + install -m 0555 docker-credential-nsc "$out/bin/docker-credential-nsc" + install -m 0555 bazel-credential-nsc "$out/bin/bazel-credential-nsc" + ''; + } + else + null; + hcloudUploadImagePkg = pkgs.buildGoModule { + pname = "hcloud-upload-image"; + version = "1.3.0"; + src = hcloud-upload-image-src; + vendorHash = "sha256-IdOAUBPg0CEuHd2rdc7jOlw0XtnAhr3PVPJbnFs2+x4="; + subPackages = [ "." ]; + env.GOWORK = "off"; + ldflags = [ + "-s" + "-w" + ]; + }; + forgejoNscSrc = lib.cleanSourceWith { + src = ./services/forgejo-nsc; + filter = path: type: + let + p = toString path; + name = builtins.baseNameOf path; + hasDir = dir: lib.hasInfix "/${dir}/" p || lib.hasSuffix "/${dir}" p; + in + !(hasDir ".git" || hasDir "vendor" || hasDir "node_modules" || name == "result"); + }; + forgejoNscDispatcher = pkgs.buildGoModule { + pname = "forgejo-nsc-dispatcher"; + version = "0.1.0"; + src = forgejoNscSrc; + subPackages = [ "./cmd/forgejo-nsc-dispatcher" ]; + vendorHash = "sha256-Kpr+5Q7Dy4JiLuJVZbFeJAzLR7PLPYxhtJqfxMEytcs="; + }; + forgejoNscAutoscaler = pkgs.buildGoModule { + pname = "forgejo-nsc-autoscaler"; + version = "0.1.0"; + src = forgejoNscSrc; + subPackages = [ "./cmd/forgejo-nsc-autoscaler" ]; + vendorHash = "sha256-Kpr+5Q7Dy4JiLuJVZbFeJAzLR7PLPYxhtJqfxMEytcs="; + }; + in + { + devShells.default = pkgs.mkShell { + packages = + commonPackages + ++ [ + hcloudUploadImagePkg + forgejoNscDispatcher + forgejoNscAutoscaler + ] + ++ lib.optionals (nscPkg != null) [ nscPkg ]; + }; + + devShells.ci = pkgs.mkShell { + packages = + commonPackages + ++ [ + hcloudUploadImagePkg + ] + ++ lib.optionals (nscPkg != null) [ nscPkg ]; + }; + + formatter = pkgs.nixpkgs-fmt; + + packages = + { + hcloud-upload-image = hcloudUploadImagePkg; + forgejo-nsc-dispatcher = forgejoNscDispatcher; + forgejo-nsc-autoscaler = forgejoNscAutoscaler; + } + // lib.optionalAttrs (nscPkg != null) { nsc = nscPkg; }; + })) + // { + nixosModules.burrow-forge = import ./nixos/modules/burrow-forge.nix; + nixosModules.burrow-forge-runner = import ./nixos/modules/burrow-forge-runner.nix; + nixosModules.burrow-forgejo-nsc = import ./nixos/modules/burrow-forgejo-nsc.nix; + nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix; + nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.nix; + + nixosConfigurations.burrow-forge = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + specialArgs = { + inherit self; + }; + modules = [ + disko.nixosModules.disko + ./nixos/hosts/burrow-forge/default.nix + ]; + }; + + images = { + burrow-forge-raw = self.nixosConfigurations.burrow-forge.config.system.build.diskoImages; + }; + }; +} diff --git a/nixos/README.md b/nixos/README.md new file mode 100644 index 0000000..b546f1a --- /dev/null +++ b/nixos/README.md @@ -0,0 +1,56 @@ +# Burrow Forge Runbook + +This directory contains the Burrow forge host definition and the Hetzner bootstrap shape for `burrow-forge`. + +Mail hosting is intentionally not part of this NixOS host in the current plan. Burrow's first mail path is Forward Email with Burrow-owned custom S3 backups; see [`docs/FORWARDEMAIL.md`](../docs/FORWARDEMAIL.md). + +## Files + +- `hosts/burrow-forge/default.nix`: host entrypoint +- `modules/burrow-forge.nix`: Forgejo, Caddy, PostgreSQL, and admin bootstrap module +- `modules/burrow-forge-runner.nix`: Forgejo Actions runner and agent identity bootstrap +- `modules/burrow-forgejo-nsc.nix`: Namespace-backed ephemeral Forgejo runner services +- `modules/burrow-authentik.nix`: minimal Authentik IdP for Burrow control planes +- `modules/burrow-headscale.nix`: Headscale control plane rooted in Authentik OIDC +- `hetzner-cloud-config.yaml`: desired Hetzner host shape +- `keys/contact_at_burrow_net.pub`: initial operator SSH public key +- `keys/agent_at_burrow_net.pub`: automation SSH public key +- `../Scripts/hetzner-forge.sh`: Hetzner inventory and replace workflow +- `../Scripts/nsc-build-and-upload-image.sh`: temporary Namespace builder -> raw image -> Hetzner snapshot +- `../Scripts/bootstrap-forge-intake.sh`: copy the Forgejo bootstrap password and agent SSH key into `/var/lib/burrow/intake/` +- `../Scripts/check-forge-host.sh`: verify Forgejo, Caddy, the local runner, optional NSC services, and optional Tailnet services after boot +- `../Scripts/cloudflare-upsert-a-record.sh`: upsert DNS-only Cloudflare `A` records for Burrow host cutovers +- `../Scripts/forge-deploy.sh`: remote `nixos-rebuild` entrypoint for the forge host +- `../Scripts/provision-forgejo-nsc.sh`: render Burrow Namespace dispatcher/autoscaler runtime inputs and ensure the default Forgejo scope exists +- `../Scripts/sync-forgejo-nsc-config.sh`: copy intake-backed dispatcher/autoscaler inputs to the host + +## Intended Flow + +1. Build and upload the raw NixOS image with `Scripts/hetzner-forge.sh build-image` or `Scripts/nsc-build-and-upload-image.sh`. +2. Recreate `burrow-forge` from the latest labeled snapshot with `Scripts/hetzner-forge.sh recreate-from-image --yes`. +3. Run `Scripts/bootstrap-forge-intake.sh` to place the Forgejo bootstrap password file and automation SSH key under `/var/lib/burrow/intake/`. +4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account. +5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent `. +6. Run `Scripts/provision-forgejo-nsc.sh` locally, then `Scripts/sync-forgejo-nsc-config.sh` to place the Namespace dispatcher/autoscaler runtime inputs under `/var/lib/burrow/intake/`. +7. Ensure `/var/lib/burrow/intake/authentik.env` exists on the host, and let `services.burrow.headscale` generate `/var/lib/burrow/intake/authentik_headscale_client_secret.txt` on first boot if it is absent. +8. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. +9. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace. +10. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`. + +## Current Constraints + +- `burrow-forge` is live on NixOS in `hel1` at `89.167.47.21`, and `Scripts/check-forge-host.sh --expect-nsc` passes locally against that host. +- Public Burrow forge cutover completed on March 15, 2026: + - `burrow.net`, `git.burrow.net`, and `nsc-autoscaler.burrow.net` now publish public `A` records to `89.167.47.21` + - HTTP redirects to HTTPS on all three names + - `https://burrow.net` returns the root forge landing response + - `https://git.burrow.net` returns the live Forgejo front door + - `https://nsc-autoscaler.burrow.net` terminates TLS on Caddy and returns the expected application-level `404` for `/` +- The Cloudflare token currently in `intake/cloudflare-token.txt` is an account-scoped token: `POST /accounts//tokens/verify` succeeds, while `POST /user/tokens/verify` returns `Invalid API Token`. +- `burrow.rs` still resolves publicly to a Vercel `DEPLOYMENT_NOT_FOUND` response. +- Both domains publish Forward Email MX/TXT records. +- Forward Email custom S3 is live on both domains against the Hetzner `burrow` bucket and the public regional endpoint `https://hel1.your-objectstorage.com`. +- The current Hetzner account contains both: + - the older Ubuntu bootstrap server in `hil` + - the live `burrow-forge` NixOS server in `hel1` +- The remaining forge work is follow-on product/integration work, not host bring-up, mail backup wiring, or public DNS cutover. diff --git a/nixos/hetzner-cloud-config.yaml b/nixos/hetzner-cloud-config.yaml new file mode 100644 index 0000000..7334b3a --- /dev/null +++ b/nixos/hetzner-cloud-config.yaml @@ -0,0 +1,10 @@ +name: burrow-forge +server_type: ccx23 +location: hel1 +image: ubuntu-24.04 +ssh_keys: + - contact@burrow.net + - agent@burrow.net +labels: + project: burrow + role: forge diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix new file mode 100644 index 0000000..e21cd39 --- /dev/null +++ b/nixos/hosts/burrow-forge/default.nix @@ -0,0 +1,58 @@ +{ self, ... }: + +{ + imports = [ + ./hardware-configuration.nix + ./disko-config.nix + self.nixosModules.burrow-forge + self.nixosModules.burrow-forge-runner + self.nixosModules.burrow-forgejo-nsc + self.nixosModules.burrow-authentik + self.nixosModules.burrow-headscale + ]; + + system.stateVersion = "24.11"; + + time.timeZone = "America/Los_Angeles"; + + nix.settings.experimental-features = [ + "nix-command" + "flakes" + ]; + + services.burrow.forge = { + enable = true; + adminPasswordFile = "/var/lib/burrow/intake/forgejo_pass_contact_at_burrow_net.txt"; + authorizedKeys = [ + (builtins.readFile ../../keys/contact_at_burrow_net.pub) + (builtins.readFile ../../keys/agent_at_burrow_net.pub) + ]; + }; + + services.burrow.forgeRunner = { + enable = true; + sshPrivateKeyFile = "/var/lib/burrow/intake/agent_at_burrow_net_ed25519"; + }; + + services.burrow.forgejoNsc = { + enable = true; + nscTokenFile = "/var/lib/burrow/intake/forgejo_nsc_token.txt"; + dispatcher = { + configFile = "/var/lib/burrow/intake/forgejo_nsc_dispatcher.yaml"; + }; + autoscaler = { + enable = true; + configFile = "/var/lib/burrow/intake/forgejo_nsc_autoscaler.yaml"; + }; + }; + + services.burrow.authentik = { + enable = true; + envFile = "/var/lib/burrow/intake/authentik.env"; + headscaleClientSecretFile = "/var/lib/burrow/intake/authentik_headscale_client_secret.txt"; + }; + + services.burrow.headscale = { + enable = true; + }; +} diff --git a/nixos/hosts/burrow-forge/disko-config.nix b/nixos/hosts/burrow-forge/disko-config.nix new file mode 100644 index 0000000..d001422 --- /dev/null +++ b/nixos/hosts/burrow-forge/disko-config.nix @@ -0,0 +1,36 @@ +{ lib, ... }: + +{ + disko.devices = { + disk.main = { + type = "disk"; + device = lib.mkDefault "/dev/sda"; + imageName = "burrow-forge"; + imageSize = "80G"; + content = { + type = "gpt"; + partitions = { + ESP = { + size = "512M"; + type = "EF00"; + content = { + type = "filesystem"; + format = "vfat"; + mountpoint = "/boot"; + mountOptions = [ "umask=0077" ]; + }; + }; + + root = { + size = "100%"; + content = { + type = "filesystem"; + format = "ext4"; + mountpoint = "/"; + }; + }; + }; + }; + }; + }; +} diff --git a/nixos/hosts/burrow-forge/hardware-configuration.nix b/nixos/hosts/burrow-forge/hardware-configuration.nix new file mode 100644 index 0000000..27490e4 --- /dev/null +++ b/nixos/hosts/burrow-forge/hardware-configuration.nix @@ -0,0 +1,11 @@ +{ ... }: + +{ + # Derived from Hetzner Cloud rescue-mode hardware inspection. + boot.initrd.availableKernelModules = [ + "ahci" + "sd_mod" + "virtio_pci" + "virtio_scsi" + ]; +} diff --git a/nixos/keys/agent_at_burrow_net.pub b/nixos/keys/agent_at_burrow_net.pub new file mode 100644 index 0000000..de447b8 --- /dev/null +++ b/nixos/keys/agent_at_burrow_net.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEN0+tRJy7Y2DW0uGYHb86N2t02WyU5lDNX6FaxBF/G8 agent@burrow.net diff --git a/nixos/keys/contact_at_burrow_net.pub b/nixos/keys/contact_at_burrow_net.pub new file mode 100644 index 0000000..0daa6a3 --- /dev/null +++ b/nixos/keys/contact_at_burrow_net.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO42guJ5QvNMw3k6YKWlQnjcTsc+X4XI9F2GBtl8aHOa diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix new file mode 100644 index 0000000..70ef2d7 --- /dev/null +++ b/nixos/modules/burrow-authentik.nix @@ -0,0 +1,271 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.burrow.authentik; + runtimeDir = "/run/burrow-authentik"; + envFile = "${runtimeDir}/authentik.env"; + blueprintDir = "${runtimeDir}/blueprints"; + blueprintFile = "${blueprintDir}/burrow-authentik.yaml"; + postgresVolume = "burrow-authentik-postgresql:/var/lib/postgresql/data"; + dataVolume = "burrow-authentik-data:/data"; + authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' + version: 1 + metadata: + name: Burrow Authentik + labels: + blueprints.goauthentik.io/description: Minimal Burrow Authentik applications + entries: + - model: authentik_providers_oauth2.scopemapping + id: burrow-oidc-email + identifiers: + name: Burrow OIDC Email + attrs: + name: Burrow OIDC Email + scope_name: email + description: Verified email mapping for Burrow + expression: | + return { + "email": request.user.email, + "email_verified": True, + } + + - model: authentik_providers_oauth2.oauth2provider + id: burrow-oidc-provider-ts + identifiers: + name: Burrow Tailnet + attrs: + authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] + invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] + issuer_mode: per_provider + slug: ${cfg.headscaleProviderSlug} + client_type: confidential + client_id: ${cfg.headscaleDomain} + client_secret: !Env [AUTHENTIK_BURROW_TS_CLIENT_SECRET, ""] + include_claims_in_id_token: true + redirect_uris: + - matching_mode: strict + url: https://${cfg.headscaleDomain}/oidc/callback + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] + - !KeyOf burrow-oidc-email + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]] + signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] + + - model: authentik_core.application + identifiers: + slug: ${cfg.headscaleProviderSlug} + attrs: + name: Burrow Tailnet + slug: ${cfg.headscaleProviderSlug} + provider: !KeyOf burrow-oidc-provider-ts + meta_launch_url: https://${cfg.headscaleDomain}/ + ''; +in +{ + options.services.burrow.authentik = { + enable = lib.mkEnableOption "the Burrow Authentik identity provider"; + + domain = lib.mkOption { + type = lib.types.str; + default = "auth.burrow.net"; + description = "Public Authentik domain."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 9002; + description = "Local Authentik HTTP listen port."; + }; + + image = lib.mkOption { + type = lib.types.str; + default = "ghcr.io/goauthentik/server:2026.2.1"; + description = "Authentik container image reference."; + }; + + envFile = lib.mkOption { + type = lib.types.str; + default = "/var/lib/burrow/intake/authentik.env"; + description = "Host-local Authentik bootstrap environment file."; + }; + + headscaleDomain = lib.mkOption { + type = lib.types.str; + default = "ts.burrow.net"; + description = "Headscale public domain used for the bundled OIDC client."; + }; + + headscaleProviderSlug = lib.mkOption { + type = lib.types.str; + default = "ts"; + description = "Authentik provider slug for Headscale."; + }; + + headscaleClientSecretFile = lib.mkOption { + type = lib.types.str; + default = "/var/lib/burrow/intake/authentik_headscale_client_secret.txt"; + description = "Host-local file containing the Authentik Headscale OIDC client secret."; + }; + }; + + config = lib.mkIf cfg.enable { + virtualisation.podman.enable = true; + + systemd.tmpfiles.rules = [ + "d ${runtimeDir} 0750 root root -" + "d ${blueprintDir} 0750 root root -" + ]; + + systemd.services.burrow-authentik-runtime = { + description = "Render the Burrow Authentik runtime environment"; + before = [ + "podman-burrow-authentik-postgresql.service" + "podman-burrow-authentik-server.service" + "podman-burrow-authentik-worker.service" + ]; + wantedBy = [ + "podman-burrow-authentik-postgresql.service" + "podman-burrow-authentik-server.service" + "podman-burrow-authentik-worker.service" + ]; + after = lib.optionals config.services.burrow.headscale.enable [ + "burrow-headscale-client-secret.service" + ]; + wants = lib.optionals config.services.burrow.headscale.enable [ + "burrow-headscale-client-secret.service" + ]; + path = [ pkgs.coreutils ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + RemainAfterExit = true; + }; + script = '' + set -euo pipefail + + if [ ! -s ${lib.escapeShellArg cfg.envFile} ]; then + echo "Authentik env file missing: ${cfg.envFile}" >&2 + exit 1 + fi + + if [ ! -s ${lib.escapeShellArg cfg.headscaleClientSecretFile} ]; then + echo "Headscale client secret missing: ${cfg.headscaleClientSecretFile}" >&2 + exit 1 + fi + + install -d -m 0750 -o root -g root ${runtimeDir} ${blueprintDir} + install -m 0644 -o root -g root ${authentikBlueprint} ${blueprintFile} + + source ${lib.escapeShellArg cfg.envFile} + + read_secret() { + tr -d '\r\n' < "$1" + } + + cat > ${envFile} </dev/null; then + exit 0 + fi + sleep 2 + done + + echo "Authentik did not become ready on ${cfg.domain}" >&2 + exit 1 + ''; + }; + + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' + encode gzip zstd + reverse_proxy 127.0.0.1:${toString cfg.port} + ''; + }; +} diff --git a/nixos/modules/burrow-forge-runner.nix b/nixos/modules/burrow-forge-runner.nix new file mode 100644 index 0000000..1e183d2 --- /dev/null +++ b/nixos/modules/burrow-forge-runner.nix @@ -0,0 +1,213 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.burrow.forgeRunner; + runnerPkg = pkgs.forgejo-runner; + stateDir = cfg.stateDir; + runnerFile = "${stateDir}/.runner"; + configFile = "${stateDir}/runner.yaml"; + labelsCsv = lib.concatStringsSep "," (map (label: "${label}:host") cfg.labels); + sshPrivateKeyFile = cfg.sshPrivateKeyFile or ""; +in +{ + options.services.burrow.forgeRunner = { + enable = lib.mkEnableOption "the Burrow Forgejo Actions runner"; + + instanceUrl = lib.mkOption { + type = lib.types.str; + default = "http://127.0.0.1:3000"; + description = "Forgejo base URL used by the local runner for registration and job polling."; + }; + + labels = lib.mkOption { + type = with lib.types; listOf str; + default = [ "burrow-forge" ]; + description = "Runner labels exposed to Forgejo Actions."; + }; + + name = lib.mkOption { + type = lib.types.str; + default = "burrow-forge-agent"; + description = "Runner name shown in Forgejo."; + }; + + capacity = lib.mkOption { + type = lib.types.int; + default = 1; + description = "Maximum concurrent jobs on this runner."; + }; + + stateDir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/forgejo-runner-agent"; + description = "Persistent runner state directory."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "forgejo-runner-agent"; + description = "System user that runs the Forgejo runner."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "forgejo-runner-agent"; + description = "System group that runs the Forgejo runner."; + }; + + forgejoConfigFile = lib.mkOption { + type = lib.types.str; + default = "/var/lib/forgejo/custom/conf/app.ini"; + description = "Forgejo app.ini path used to generate runner tokens."; + }; + + gitUserName = lib.mkOption { + type = lib.types.str; + default = "agent"; + description = "Git commit author name for automation on the forge host."; + }; + + gitUserEmail = lib.mkOption { + type = lib.types.str; + default = "agent@burrow.net"; + description = "Git commit author email for automation on the forge host."; + }; + + sshPrivateKeyFile = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + description = "Optional host-local path to the agent SSH private key copied into the runner home."; + }; + }; + + config = lib.mkIf cfg.enable { + users.groups.${cfg.group} = { }; + + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + description = "Burrow Forgejo Actions runner"; + home = cfg.stateDir; + createHome = true; + shell = pkgs.bashInteractive; + }; + + environment.systemPackages = with pkgs; [ + runnerPkg + bash + coreutils + findutils + git + git-lfs + openssh + python3 + rsync + ]; + + systemd.tmpfiles.rules = [ + "d ${stateDir} 0750 ${cfg.user} ${cfg.group} - -" + ]; + + systemd.services.burrow-forgejo-runner-bootstrap = { + description = "Bootstrap Burrow Forgejo runner registration"; + after = [ "forgejo.service" "network-online.target" "systemd-tmpfiles-setup.service" ]; + wants = [ "forgejo.service" "network-online.target" "systemd-tmpfiles-setup.service" ]; + before = [ "burrow-forgejo-runner.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + umask 077 + + install -d -m 0750 -o ${cfg.user} -g ${cfg.group} ${stateDir} + cat > ${configFile} <> ${configFile} + done + cat >> ${configFile} <<'EOF' +cache: + enabled: false +EOF + chown ${cfg.user}:${cfg.group} ${configFile} + chmod 0640 ${configFile} + + install -d -m 0700 -o ${cfg.user} -g ${cfg.group} ${stateDir}/.ssh + ${pkgs.util-linux}/bin/runuser -u ${cfg.user} -- \ + ${pkgs.git}/bin/git config --global user.name ${lib.escapeShellArg cfg.gitUserName} + ${pkgs.util-linux}/bin/runuser -u ${cfg.user} -- \ + ${pkgs.git}/bin/git config --global user.email ${lib.escapeShellArg cfg.gitUserEmail} + + if [ -n ${lib.escapeShellArg sshPrivateKeyFile} ] && [ -s ${lib.escapeShellArg sshPrivateKeyFile} ]; then + install -m 0600 -o ${cfg.user} -g ${cfg.group} \ + ${lib.escapeShellArg sshPrivateKeyFile} \ + ${stateDir}/.ssh/id_ed25519 + cat > ${stateDir}/.ssh/config <&2 + exit 1 + fi + + ${pkgs.util-linux}/bin/runuser -u ${cfg.user} -- \ + ${runnerPkg}/bin/forgejo-runner register \ + --no-interactive \ + --instance ${lib.escapeShellArg cfg.instanceUrl} \ + --token "${"$"}token" \ + --name ${lib.escapeShellArg cfg.name} \ + --labels ${lib.escapeShellArg labelsCsv} \ + --config ${configFile} + fi + ''; + }; + + systemd.services.burrow-forgejo-runner = { + description = "Burrow Forgejo Actions runner"; + after = [ "burrow-forgejo-runner-bootstrap.service" ]; + wants = [ "burrow-forgejo-runner-bootstrap.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = stateDir; + Restart = "on-failure"; + RestartSec = 2; + ExecStart = pkgs.writeShellScript "burrow-forgejo-runner" '' + set -euo pipefail + export PATH="/run/wrappers/bin:/run/current-system/sw/bin:${"$"}{PATH:-}" + tmp="$(${pkgs.coreutils}/bin/mktemp)" + set +e + ${runnerPkg}/bin/forgejo-runner daemon --config ${configFile} 2>&1 | ${pkgs.coreutils}/bin/tee "${"$"}tmp" + rc="${"$"}{PIPESTATUS[0]}" + set -e + if ${pkgs.gnugrep}/bin/grep -qi "unregistered runner" "${"$"}tmp"; then + rm -f ${runnerFile} + fi + rm -f "${"$"}tmp" + exit "${"$"}rc" + ''; + }; + }; + }; +} diff --git a/nixos/modules/burrow-forge.nix b/nixos/modules/burrow-forge.nix new file mode 100644 index 0000000..e02475f --- /dev/null +++ b/nixos/modules/burrow-forge.nix @@ -0,0 +1,247 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.burrow.forge; + forgejoCfg = config.services.forgejo; + forgejoExe = lib.getExe forgejoCfg.package; + forgejoWorkPath = forgejoCfg.stateDir; + forgejoCustomPath = "${forgejoWorkPath}/custom"; + forgejoConfigFile = "${forgejoCustomPath}/conf/app.ini"; + forgejoAdminArgs = "--config ${lib.escapeShellArg forgejoConfigFile} --work-path ${lib.escapeShellArg forgejoWorkPath} --custom-path ${lib.escapeShellArg forgejoCustomPath}"; + homeRepoPath = "/${cfg.homeOwner}/${cfg.homeRepo}"; + homeRepoUrl = "https://${cfg.gitDomain}${homeRepoPath}"; +in +{ + options.services.burrow.forge = { + enable = lib.mkEnableOption "the Burrow Forge host"; + + gitDomain = lib.mkOption { + type = lib.types.str; + default = "git.burrow.net"; + description = "Public Forgejo domain."; + }; + + siteDomain = lib.mkOption { + type = lib.types.str; + default = "burrow.net"; + description = "Root site domain."; + }; + + homeOwner = lib.mkOption { + type = lib.types.str; + default = "hackclub"; + description = "Canonical Forgejo org/user for the Burrow home repository."; + }; + + homeRepo = lib.mkOption { + type = lib.types.str; + default = "burrow"; + description = "Canonical Forgejo repository name for the Burrow home repository."; + }; + + contactEmail = lib.mkOption { + type = lib.types.str; + default = "contact@burrow.net"; + description = "Operator contact email."; + }; + + nscAutoscalerDomain = lib.mkOption { + type = lib.types.str; + default = "nsc-autoscaler.burrow.net"; + description = "Public webhook domain for the Forgejo Namespace autoscaler."; + }; + + adminUsername = lib.mkOption { + type = lib.types.str; + default = "contact"; + description = "Initial Forgejo admin username."; + }; + + adminEmail = lib.mkOption { + type = lib.types.str; + default = "contact@burrow.net"; + description = "Initial Forgejo admin email."; + }; + + adminPasswordFile = lib.mkOption { + type = lib.types.str; + description = "Host-local path to the plaintext bootstrap password file for the initial Forgejo admin."; + }; + + authorizedKeys = lib.mkOption { + type = with lib.types; listOf str; + default = [ ]; + description = "SSH keys allowed for root login and operational bootstrap."; + }; + }; + + config = lib.mkIf cfg.enable { + networking.hostName = "burrow-forge"; + networking.useDHCP = lib.mkDefault true; + + services.qemuGuest.enable = true; + + boot.loader.grub = { + enable = true; + efiSupport = true; + efiInstallAsRemovable = true; + device = "nodev"; + }; + + fileSystems."/boot".neededForBoot = true; + + services.postgresql = { + enable = true; + package = pkgs.postgresql_16; + }; + + services.openssh = { + enable = true; + settings = { + PasswordAuthentication = false; + KbdInteractiveAuthentication = false; + PermitRootLogin = "prohibit-password"; + }; + }; + + users.users.root.openssh.authorizedKeys.keys = cfg.authorizedKeys; + + networking.firewall.allowedTCPPorts = [ + 22 + 80 + 443 + 2222 + ]; + + services.forgejo = { + enable = true; + database = { + type = "postgres"; + createDatabase = true; + }; + lfs.enable = true; + settings = { + server = { + DOMAIN = cfg.gitDomain; + ROOT_URL = "https://${cfg.gitDomain}/"; + HTTP_PORT = 3000; + SSH_DOMAIN = cfg.gitDomain; + SSH_PORT = 2222; + START_SSH_SERVER = true; + }; + + service = { + DISABLE_REGISTRATION = true; + REQUIRE_SIGNIN_VIEW = false; + DEFAULT_ALLOW_CREATE_ORGANIZATION = false; + ENABLE_NOTIFY_MAIL = false; + NO_REPLY_ADDRESS = cfg.adminEmail; + }; + + session = { + COOKIE_SECURE = true; + SAME_SITE = "strict"; + }; + + openid = { + ENABLE_OPENID_SIGNIN = false; + ENABLE_OPENID_SIGNUP = false; + }; + + actions = { + ENABLED = true; + }; + + repository = { + DEFAULT_BRANCH = "main"; + ENABLE_PUSH_CREATE_USER = false; + }; + + ui = { + DEFAULT_THEME = "forgejo-auto"; + }; + }; + }; + + services.caddy = { + enable = true; + email = cfg.contactEmail; + virtualHosts = + { + "${cfg.gitDomain}".extraConfig = '' + encode gzip zstd + @root path / + redir @root ${homeRepoPath} 308 + reverse_proxy 127.0.0.1:${toString config.services.forgejo.settings.server.HTTP_PORT} + ''; + "${cfg.siteDomain}".extraConfig = '' + @root path / + redir @root ${homeRepoUrl} 308 + respond 404 + ''; + } + // lib.optionalAttrs ( + config.services.burrow.forgejoNsc.enable && config.services.burrow.forgejoNsc.autoscaler.enable + ) { + "${cfg.nscAutoscalerDomain}".extraConfig = '' + encode gzip zstd + reverse_proxy 127.0.0.1:8090 + ''; + }; + }; + + systemd.services.burrow-forgejo-bootstrap = { + description = "Seed the initial Burrow Forgejo admin account"; + after = [ "forgejo.service" ]; + requires = [ "forgejo.service" ]; + wantedBy = [ "multi-user.target" ]; + path = [ + forgejoCfg.package + pkgs.coreutils + pkgs.gnugrep + ]; + serviceConfig = { + Type = "oneshot"; + User = forgejoCfg.user; + Group = forgejoCfg.group; + WorkingDirectory = forgejoCfg.stateDir; + }; + script = '' + set -euo pipefail + + if [ ! -s ${lib.escapeShellArg cfg.adminPasswordFile} ]; then + echo "bootstrap password file is missing; skipping admin bootstrap" >&2 + exit 0 + fi + + password="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.adminPasswordFile})" + if [ -z "$password" ]; then + echo "bootstrap password file is empty; skipping admin bootstrap" >&2 + exit 0 + fi + + log_file="$(mktemp)" + trap 'rm -f "$log_file"' EXIT + + if ! ${forgejoExe} admin user create \ + ${forgejoAdminArgs} \ + --admin \ + --username ${lib.escapeShellArg cfg.adminUsername} \ + --email ${lib.escapeShellArg cfg.adminEmail} \ + --password "$password" \ + --must-change-password=false >"$log_file" 2>&1; then + if grep -qi "already exists" "$log_file"; then + ${forgejoExe} admin user change-password \ + ${forgejoAdminArgs} \ + --username ${lib.escapeShellArg cfg.adminUsername} \ + --password "$password" \ + --must-change-password=false + else + cat "$log_file" >&2 + exit 1 + fi + fi + ''; + }; + }; +} diff --git a/nixos/modules/burrow-forgejo-nsc.nix b/nixos/modules/burrow-forgejo-nsc.nix new file mode 100644 index 0000000..ba116f7 --- /dev/null +++ b/nixos/modules/burrow-forgejo-nsc.nix @@ -0,0 +1,234 @@ +{ config, lib, pkgs, self, ... }: + +let + inherit (lib) + mkEnableOption + mkIf + mkOption + types + mkAfter + mkDefault + optional + optionalAttrs + optionalString + ; + + cfg = config.services.burrow.forgejoNsc; + dispatcherRuntimeConfig = "${cfg.stateDir}/dispatcher.yaml"; + autoscalerRuntimeConfig = "${cfg.stateDir}/autoscaler.yaml"; + + pendingCheck = configPath: pkgs.writeShellScript "forgejo-nsc-check-pending" '' + set -euo pipefail + if ${pkgs.gnugrep}/bin/grep -q 'PENDING-' '${configPath}'; then + echo "forgejo-nsc config still contains placeholder values (PENDING-); update ${configPath} before starting." >&2 + exit 1 + fi + ''; + + nscTokenPath = "${cfg.stateDir}/nsc.token"; + tokenSync = optionalString (cfg.nscTokenFile != null) '' + install -m 600 ${lib.escapeShellArg cfg.nscTokenFile} ${lib.escapeShellArg nscTokenPath} + chown ${cfg.user}:${cfg.group} ${nscTokenPath} + chmod 600 ${nscTokenPath} + ''; + dispatcherConfigSync = optionalString (cfg.dispatcher.configFile != null) '' + install -m 400 ${lib.escapeShellArg cfg.dispatcher.configFile} ${lib.escapeShellArg dispatcherRuntimeConfig} + chown ${cfg.user}:${cfg.group} ${lib.escapeShellArg dispatcherRuntimeConfig} + chmod 400 ${lib.escapeShellArg dispatcherRuntimeConfig} + ''; + autoscalerConfigSync = optionalString (cfg.autoscaler.configFile != null) '' + install -m 400 ${lib.escapeShellArg cfg.autoscaler.configFile} ${lib.escapeShellArg autoscalerRuntimeConfig} + chown ${cfg.user}:${cfg.group} ${lib.escapeShellArg autoscalerRuntimeConfig} + chmod 400 ${lib.escapeShellArg autoscalerRuntimeConfig} + ''; + + dispatcherEnv = + cfg.extraEnv + // optionalAttrs (cfg.nscTokenFile != null) { NSC_TOKEN_FILE = nscTokenPath; } + // optionalAttrs (cfg.nscTokenSpecFile != null) { NSC_TOKEN_SPEC_FILE = cfg.nscTokenSpecFile; } + // optionalAttrs (cfg.nscEndpoint != null) { NSC_ENDPOINT = cfg.nscEndpoint; }; +in { + options.services.burrow.forgejoNsc = { + enable = mkEnableOption "Forgejo Namespace Cloud runner dispatcher"; + + user = mkOption { + type = types.str; + default = "forgejo-nsc"; + description = "System user that runs the forgejo-nsc services."; + }; + + group = mkOption { + type = types.str; + default = "forgejo-nsc"; + description = "System group for the forgejo-nsc services."; + }; + + stateDir = mkOption { + type = types.str; + default = "/var/lib/forgejo-nsc"; + description = "State directory for the dispatcher/autoscaler."; + }; + + nscTokenFile = mkOption { + type = types.nullOr types.str; + default = null; + description = "Optional NSC token file (exported as NSC_TOKEN_FILE)."; + }; + + nscTokenSpecFile = mkOption { + type = types.nullOr types.str; + default = null; + description = "Optional NSC token spec file (exported as NSC_TOKEN_SPEC_FILE)."; + }; + + nscEndpoint = mkOption { + type = types.nullOr types.str; + default = null; + description = "Optional NSC endpoint override (exported as NSC_ENDPOINT)."; + }; + + extraEnv = mkOption { + type = types.attrsOf types.str; + default = { }; + description = "Extra environment variables injected into the services."; + }; + + nscPackage = mkOption { + type = types.nullOr types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.nsc or null; + description = "Optional nsc CLI package added to the service PATH."; + }; + + dispatcher = { + enable = mkOption { + type = types.bool; + default = true; + description = "Enable the forgejo-nsc dispatcher service."; + }; + + package = mkOption { + type = types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.forgejo-nsc-dispatcher; + description = "Package providing the forgejo-nsc dispatcher binary."; + }; + + configFile = mkOption { + type = types.nullOr types.str; + default = null; + description = "Host-local YAML config file for the dispatcher."; + }; + + allowPending = mkOption { + type = types.bool; + default = false; + description = "Allow placeholder values (PENDING-) in the dispatcher config."; + }; + }; + + autoscaler = { + enable = mkOption { + type = types.bool; + default = false; + description = "Enable the forgejo-nsc autoscaler service."; + }; + + package = mkOption { + type = types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.forgejo-nsc-autoscaler; + description = "Package providing the forgejo-nsc autoscaler binary."; + }; + + configFile = mkOption { + type = types.nullOr types.str; + default = null; + description = "Host-local YAML config file for the autoscaler."; + }; + + allowPending = mkOption { + type = types.bool; + default = false; + description = "Allow placeholder values (PENDING-) in the autoscaler config."; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = (!cfg.dispatcher.enable) || cfg.dispatcher.configFile != null; + message = "services.burrow.forgejoNsc.dispatcher.configFile must be set when the dispatcher is enabled."; + } + { + assertion = (!cfg.autoscaler.enable) || cfg.autoscaler.configFile != null; + message = "services.burrow.forgejoNsc.autoscaler.configFile must be set when the autoscaler is enabled."; + } + ]; + + users.groups.${cfg.group} = { }; + users.users.${cfg.user} = { + uid = mkDefault 2011; + isSystemUser = true; + group = cfg.group; + description = "Forgejo Namespace Cloud runner services"; + home = cfg.stateDir; + createHome = true; + shell = pkgs.bashInteractive; + }; + + systemd.tmpfiles.rules = mkAfter [ + "d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} - -" + ]; + + systemd.services.forgejo-nsc-dispatcher = mkIf cfg.dispatcher.enable { + description = "Forgejo Namespace Cloud dispatcher"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + unitConfig.ConditionPathExists = + optional (cfg.dispatcher.configFile != null) cfg.dispatcher.configFile + ++ optional (cfg.nscTokenFile != null) cfg.nscTokenFile; + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.stateDir; + ExecStart = "${cfg.dispatcher.package}/bin/forgejo-nsc-dispatcher --config ${dispatcherRuntimeConfig}"; + Restart = "on-failure"; + RestartSec = 5; + }; + path = lib.optional (cfg.nscPackage != null) cfg.nscPackage; + environment = dispatcherEnv; + preStart = lib.concatStringsSep "\n" (lib.filter (s: s != "") [ + (optionalString (!cfg.dispatcher.allowPending) (pendingCheck cfg.dispatcher.configFile)) + dispatcherConfigSync + tokenSync + ]); + }; + + systemd.services.forgejo-nsc-autoscaler = mkIf cfg.autoscaler.enable { + description = "Forgejo Namespace Cloud autoscaler"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" "forgejo-nsc-dispatcher.service" ]; + wants = [ "network-online.target" ]; + unitConfig.ConditionPathExists = + optional (cfg.autoscaler.configFile != null) cfg.autoscaler.configFile + ++ optional (cfg.nscTokenFile != null) cfg.nscTokenFile; + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.stateDir; + ExecStart = "${cfg.autoscaler.package}/bin/forgejo-nsc-autoscaler --config ${autoscalerRuntimeConfig}"; + Restart = "on-failure"; + RestartSec = 5; + }; + path = lib.optional (cfg.nscPackage != null) cfg.nscPackage; + environment = dispatcherEnv; + preStart = lib.concatStringsSep "\n" (lib.filter (s: s != "") [ + (optionalString (!cfg.autoscaler.allowPending) (pendingCheck cfg.autoscaler.configFile)) + autoscalerConfigSync + tokenSync + ]); + }; + }; +} diff --git a/nixos/modules/burrow-headscale-policy.hujson b/nixos/modules/burrow-headscale-policy.hujson new file mode 100644 index 0000000..aed7e22 --- /dev/null +++ b/nixos/modules/burrow-headscale-policy.hujson @@ -0,0 +1,11 @@ +{ + // Bootstrap with a simple allow-all policy; Burrow-specific lane segmentation + // can be layered on once the control plane is live. + acls: [ + { + action: "accept", + src: ["*"], + dst: ["*:*"], + }, + ], +} diff --git a/nixos/modules/burrow-headscale.nix b/nixos/modules/burrow-headscale.nix new file mode 100644 index 0000000..120468b --- /dev/null +++ b/nixos/modules/burrow-headscale.nix @@ -0,0 +1,225 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.burrow.headscale; + policyFile = ./burrow-headscale-policy.hujson; +in +{ + options.services.burrow.headscale = { + enable = lib.mkEnableOption "the Burrow Headscale control plane"; + + domain = lib.mkOption { + type = lib.types.str; + default = "ts.burrow.net"; + description = "Public Headscale control-plane domain."; + }; + + tailDomain = lib.mkOption { + type = lib.types.str; + default = "tail.burrow.net"; + description = "MagicDNS suffix served by Headscale."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8413; + description = "Local Headscale listen port."; + }; + + oidcIssuer = lib.mkOption { + type = lib.types.str; + default = "https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.headscaleProviderSlug}/"; + description = "OIDC issuer URL used by Headscale."; + }; + + oidcClientSecretFile = lib.mkOption { + type = lib.types.str; + default = config.services.burrow.authentik.headscaleClientSecretFile; + description = "Host-local file containing the OIDC client secret used by Headscale."; + }; + + bootstrapUsers = lib.mkOption { + type = with lib.types; listOf (submodule { + options = { + name = lib.mkOption { + type = str; + description = "Headscale username."; + }; + displayName = lib.mkOption { + type = str; + description = "Friendly display name."; + }; + email = lib.mkOption { + type = str; + description = "User email address."; + }; + }; + }); + default = [ + { + name = "contact"; + displayName = "Burrow"; + email = "contact@burrow.net"; + } + { + name = "conrad"; + displayName = "Conrad"; + email = "conrad@burrow.net"; + } + { + name = "agent"; + displayName = "Agent"; + email = "agent@burrow.net"; + } + { + name = "infra"; + displayName = "Infrastructure"; + email = "infra@burrow.net"; + } + ]; + description = "Users to create or reconcile inside Headscale."; + }; + }; + + config = lib.mkIf cfg.enable { + environment.systemPackages = [ pkgs.headscale ]; + + systemd.services.burrow-headscale-client-secret = { + description = "Ensure the Burrow Headscale OIDC client secret exists"; + before = + [ "headscale.service" ] + ++ lib.optionals config.services.burrow.authentik.enable [ "burrow-authentik-runtime.service" ]; + wantedBy = + [ "headscale.service" ] + ++ lib.optionals config.services.burrow.authentik.enable [ "burrow-authentik-runtime.service" ]; + path = [ + pkgs.coreutils + pkgs.openssl + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + RemainAfterExit = true; + }; + script = '' + set -euo pipefail + + install -d -m 0755 /var/lib/burrow/intake + + if [ ! -s ${lib.escapeShellArg cfg.oidcClientSecretFile} ]; then + umask 077 + ${pkgs.openssl}/bin/openssl rand -base64 48 > ${lib.escapeShellArg cfg.oidcClientSecretFile} + chown root:root ${lib.escapeShellArg cfg.oidcClientSecretFile} + chmod 0400 ${lib.escapeShellArg cfg.oidcClientSecretFile} + fi + ''; + }; + + services.headscale = { + enable = true; + address = "127.0.0.1"; + port = cfg.port; + settings = { + server_url = "https://${cfg.domain}"; + dns = { + magic_dns = true; + base_domain = cfg.tailDomain; + nameservers.global = [ + "1.1.1.1" + "1.0.0.1" + "2606:4700:4700::1111" + "2606:4700:4700::1001" + ]; + search_domains = [ cfg.tailDomain ]; + }; + database.sqlite.write_ahead_log = true; + log.level = "info"; + policy = { + mode = "file"; + path = policyFile; + }; + oidc = { + only_start_if_oidc_is_available = true; + issuer = cfg.oidcIssuer; + client_id = cfg.domain; + client_secret_path = "\${CREDENTIALS_DIRECTORY}/oidc_client_secret"; + scope = [ + "openid" + "profile" + "email" + ]; + pkce = { + enabled = true; + method = "S256"; + }; + }; + }; + }; + + systemd.services.headscale = { + after = + [ "burrow-headscale-client-secret.service" ] + ++ lib.optionals config.services.burrow.authentik.enable [ "burrow-authentik-ready.service" ]; + wants = + [ "burrow-headscale-client-secret.service" ] + ++ lib.optionals config.services.burrow.authentik.enable [ "burrow-authentik-ready.service" ]; + requires = + [ "burrow-headscale-client-secret.service" ] + ++ lib.optionals config.services.burrow.authentik.enable [ "burrow-authentik-ready.service" ]; + serviceConfig.LoadCredential = [ + "oidc_client_secret:${cfg.oidcClientSecretFile}" + ]; + }; + + systemd.services.headscale-bootstrap = { + description = "Bootstrap Burrow Headscale users"; + after = [ "headscale.service" ]; + requires = [ "headscale.service" ]; + wantedBy = [ "multi-user.target" ]; + path = [ + pkgs.coreutils + pkgs.headscale + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + + list_users() { + ${pkgs.headscale}/bin/headscale users list -o json + } + + ensure_user() { + local name="$1" + local display_name="$2" + local email="$3" + if list_users | ${pkgs.jq}/bin/jq -e --arg name "$name" 'map(select(.name == $name)) | length > 0' >/dev/null; then + return 0 + fi + ${pkgs.headscale}/bin/headscale users create "$name" --display-name "$display_name" --email "$email" >/dev/null + } + + for _ in $(seq 1 60); do + if list_users >/dev/null 2>&1; then + break + fi + sleep 1 + done + + ${lib.concatMapStringsSep "\n" (user: '' + ensure_user ${lib.escapeShellArg user.name} ${lib.escapeShellArg user.displayName} ${lib.escapeShellArg user.email} + '') cfg.bootstrapUsers} + ''; + }; + + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' + encode gzip zstd + reverse_proxy 127.0.0.1:${toString cfg.port} + ''; + }; +} diff --git a/services/forgejo-nsc/README.md b/services/forgejo-nsc/README.md new file mode 100644 index 0000000..79058bb --- /dev/null +++ b/services/forgejo-nsc/README.md @@ -0,0 +1,179 @@ +## forgejo-nsc-dispatcher + +This service exposes a simple HTTP API that tells Namespace Cloud to start +ephemeral Forgejo Actions runners on demand. It glues together three pieces: + +1. **Forgejo Actions** – the service requests a scoped registration token + for the repository/organization/instance where you want to run jobs. +2. **Namespace (`nsc`)** – the dispatcher shells out to the `nsc` CLI to create + a short‑lived environment, runs the `forgejo-runner` container inside it, + and exits after a single job (`forgejo-runner one-job`). The Namespace TTL is + the hard cap, not the typical lifetime. +3. **Your automation** – you call the service via HTTP (directly, through Caddy, + via Forgejo webhooks, etc.) whenever a new runner is needed. + +### Directory layout + +``` +. +├── cmd/forgejo-nsc-dispatcher # main entry point +├── internal/ # service packages (config, forgejo client, nsc dispatcher, HTTP server) +├── config.example.yaml # starter config referenced by README +├── flake.nix / flake.lock # reproducible builds (Go binary + container image) +└── .forgejo/workflows # CI that runs go test/build and publishes manifests +``` + +### Configuration + +Copy `config.example.yaml` and update it for your Forgejo instance and Namespace +profile. The important knobs are: + +- `forgejo.base_url` – HTTPS endpoint of your Forgejo server. A PAT with + `actions:runner` scope is required in `forgejo.token`. +- `forgejo.instance_url` – URL that spawned runners use to register back to Forgejo. + This must be reachable from the runner (typically the public URL like + `https://git.burrow.net`). On the forge host it commonly differs from `base_url` + (which may be `http://127.0.0.1:3000`). +- `forgejo.default_scope` – where new runners register + (`instance`, `organization`, or `repository`). +- `forgejo.default_labels` – labels applied to every spawned runner. GateForge + workflows via `runs-on: ["namespace-profile-linux-medium"]` (or other + `namespace-profile-linux-*` labels). +- `namespace.nsc_binary` – path to the `nsc` binary (the Nix container ships one + compiled from `namespacelabs/foundation` so `/app/bin/nsc` works out of the box). +- `namespace.image` – OCI image containing `forgejo-runner`. +- `namespace.machine_type` / `namespace.duration` – shape + TTL for the ephemeral + Namespace environment. The dispatcher destroys the instance after a job so the + TTL acts as a hard cap, not an idle timeout. + +### Running locally + +```shell +# Ensure nsc is available (e.g. `go build ./foundation/cmd/nsc`) +cp config.example.yaml config.yaml +nix develop # optional dev shell with Go toolchain +go run ./cmd/forgejo-nsc-dispatcher --config config.yaml +``` + +API example: + +```shell +curl -X POST http://localhost:8080/api/v1/dispatch \ + -H 'Content-Type: application/json' \ + -d '{ + "count": 1, + "ttl": "20m", + "labels": ["namespace-profile-linux-medium"], + "scope": {"level": "repository", "owner": "example", "name": "app"} + }' +``` + +### Deploying with Nix + GHCR + +- `nix build .#packages.x86_64-linux.container-amd64` produces a deterministic + tarball containing the service, the `nsc` binary, BusyBox, and `forgejo-runner`. +- The included `Build Container` workflow builds both `amd64` and `arm64` images + on Namespace runners and pushes them to `ghcr.io//`. + No Fly.io manifests are emitted – the multi‑arch manifest points only at GHCR. + +### How this fits behind Caddy (last-mile networking) + +The dispatcher is just an HTTP server. You can: + +1. Run it anywhere that can reach Forgejo and Namespace: bare metal, Namespace + cluster, Kubernetes, Fly, etc. +2. Put Caddy (or any reverse proxy) in front to terminate TLS, do auth, or + rewrite URLs. For example: + + ``` + forgejo-dispatcher.example.com { + reverse_proxy 127.0.0.1:8080 + basicauth /api/* { + user JDJhJDE... + } + } + ``` + +The service doesn’t assume Caddy, nor does it manipulate HTTP clients +directly – it simply waits for POST requests. As long as the dispatcher can +reach Forgejo’s REST API and run the `nsc` binary, you can drop it anywhere. + +### Autoscaling (webhook + poller) + +If you don’t want to call `/api/v1/dispatch` manually, there’s a companion +autoscaler (`cmd/forgejo-nsc-autoscaler`) that watches Forgejo job queues and +triggers the dispatcher for you. It operates in two modes simultaneously: + +1. **Polling** – every instance polls `GET /api/v1/.../actions/runners` to keep a + minimum number of idle Namespace runners per label. This continues until a + webhook is successfully processed, so the system is self-bootstrapping. +2. **Webhooks** – once Forgejo reaches the autoscaler via the `/webhook/{name}` + endpoint, the autoscaler stops polling and reacts to `workflow_job` events in + real time. Each payload is mapped to a target label set and results in a + dispatch call. + +You can manage multiple Forgejo instances by listing them under `instances` in +`autoscaler.example.yaml`: + +``` +listen: ":8090" +dispatcher: + url: "http://dispatcher:8080" + +instances: +- name: burrow + forgejo: + base_url: "https://git.burrow.net" + token: "PENDING-FORGEJO-PAT" + scope: + level: "repository" + owner: "burrow" + name: "burrow" + disable_polling: true # webhook-only mode + poll_interval: "30s" + webhook_secret: "supersecret" + webhook: + url: "https://nsc-autoscaler.burrow.net/webhook/burrow" + content_type: "json" + events: ["workflow_job"] + active: true + targets: + - labels: ["namespace-profile-linux-medium"] + min_idle: 0 # set to 0 to scale-to-zero between jobs + ttl: "20m" + - labels: ["namespace-profile-windows-large"] + min_idle: 0 + ttl: "45m" + machine_type: "windows/amd64:8x16" +``` + +For Burrow, use `Scripts/provision-forgejo-nsc.sh` to mint the Forgejo PAT, +generate a Namespace token from the logged-in namespace account, and render the +dispatcher/autoscaler configs into `intake/forgejo_nsc_{dispatcher,autoscaler}.yaml` +plus `intake/forgejo_nsc_token.txt`. + +For ongoing operations, use `Scripts/sync-forgejo-nsc-config.sh`: + +- `Scripts/sync-forgejo-nsc-config.sh` copies the intake-backed configs and + Namespace token onto `/var/lib/burrow/intake/` on the forge host, reapplies + file ownership for `forgejo-nsc`, and restarts the dispatcher/autoscaler. +- `Scripts/sync-forgejo-nsc-config.sh --rotate-pat` additionally mints a new + Forgejo PAT on the Burrow forge host and refreshes the local intake files. + +Run it next to the dispatcher: + +```bash +go run ./cmd/forgejo-nsc-autoscaler --config autoscaler.yaml +# or build the binary/container via `nix build .#forgejo-nsc-autoscaler` +``` + +If your Forgejo build doesn’t expose the runner listing API, set +`disable_polling: true` and rely on `webhook` entries. The autoscaler will +auto-create/update the webhook (using the PAT) so that new `workflow_job` events +immediately call the dispatcher even if the service isn’t publicly reachable yet. + +In Forgejo add a webhook pointing to `https://nsc-autoscaler.burrow.net/webhook/burrow` +with the shared secret (or let the autoscaler create it by specifying `webhook.url` +in config). The autoscaler continues polling until it receives the first valid +webhook (unless disabled), so you get capacity immediately even if outbound +webhooks from Forgejo aren’t yet configured. diff --git a/services/forgejo-nsc/autoscaler.example.yaml b/services/forgejo-nsc/autoscaler.example.yaml new file mode 100644 index 0000000..866d3b5 --- /dev/null +++ b/services/forgejo-nsc/autoscaler.example.yaml @@ -0,0 +1,30 @@ +listen: ":8090" +dispatcher: + url: "http://localhost:8080" + +instances: + - name: burrow + forgejo: + base_url: "https://git.burrow.net" + token: "PENDING-FORGEJO-PAT" + scope: + level: "repository" + owner: "burrow" + name: "burrow" + disable_polling: true + poll_interval: "30s" + webhook_secret: "supersecret" + webhook: + url: "https://nsc-autoscaler.burrow.net/webhook/burrow" + content_type: "json" + events: ["workflow_job"] + active: true + targets: + - labels: ["namespace-profile-linux-medium"] + min_idle: 1 + ttl: "20m" + machine_type: "8x16" + - labels: ["namespace-profile-windows-large"] + min_idle: 0 + ttl: "45m" + machine_type: "windows/amd64:8x16" diff --git a/services/forgejo-nsc/cmd/forgejo-nsc-autoscaler/main.go b/services/forgejo-nsc/cmd/forgejo-nsc-autoscaler/main.go new file mode 100644 index 0000000..bdbb6f8 --- /dev/null +++ b/services/forgejo-nsc/cmd/forgejo-nsc-autoscaler/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "syscall" + + "namespacelabs.dev/foundation/std/tasks" + "namespacelabs.dev/foundation/std/tasks/simplelog" + + "github.com/burrow/forgejo-nsc/internal/autoscaler" +) + +func main() { + var configPath string + flag.StringVar(&configPath, "config", "autoscaler.yaml", "Path to the autoscaler config file") + flag.Parse() + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + + cfg, err := autoscaler.LoadConfig(configPath) + if err != nil { + logger.Error("failed to load config", "error", err) + os.Exit(1) + } + + service, err := autoscaler.NewService(cfg) + if err != nil { + logger.Error("failed to initialize autoscaler", "error", err) + os.Exit(1) + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + ctx = tasks.WithSink(ctx, simplelog.NewSink(os.Stdout, 0)) + + if err := tasks.Action("autoscaler.run").Run(ctx, func(ctx context.Context) error { + return service.Start(ctx) + }); err != nil { + logger.Error("autoscaler exited", "error", err) + os.Exit(1) + } +} diff --git a/services/forgejo-nsc/cmd/forgejo-nsc-dispatcher/main.go b/services/forgejo-nsc/cmd/forgejo-nsc-dispatcher/main.go new file mode 100644 index 0000000..9dcbfb1 --- /dev/null +++ b/services/forgejo-nsc/cmd/forgejo-nsc-dispatcher/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/burrow/forgejo-nsc/internal/app" + "github.com/burrow/forgejo-nsc/internal/config" + "github.com/burrow/forgejo-nsc/internal/forgejo" + "github.com/burrow/forgejo-nsc/internal/nsc" + "github.com/burrow/forgejo-nsc/internal/server" +) + +func main() { + var configPath string + flag.StringVar(&configPath, "config", "config.yaml", "Path to the dispatcher config file.") + flag.Parse() + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + + cfg, err := config.Load(configPath) + if err != nil { + logger.Error("failed to load config", "error", err) + os.Exit(1) + } + + scope, err := cfg.Forgejo.DefaultScope.ToScope() + if err != nil { + logger.Error("invalid default scope", "error", err) + os.Exit(1) + } + + forgejoClient, err := forgejo.NewClient(cfg.Forgejo.BaseURL, cfg.Forgejo.Token) + if err != nil { + logger.Error("failed to create forgejo client", "error", err) + os.Exit(1) + } + + dispatcher, err := nsc.NewDispatcher(nsc.Options{ + BinaryPath: cfg.Namespace.NSCBinary, + ComputeBaseURL: cfg.Namespace.ComputeBaseURL, + DefaultImage: cfg.Namespace.Image, + DefaultMachine: cfg.Namespace.MachineType, + MacosBaseImageID: cfg.Namespace.MacosBaseImageID, + MacosMachineArch: cfg.Namespace.MacosMachineArch, + DefaultDuration: cfg.Namespace.Duration.Duration, + WorkDir: cfg.Namespace.WorkDir, + MaxParallel: cfg.Namespace.MaxParallel, + RunnerNamePrefix: cfg.Runner.NamePrefix, + Executor: cfg.Runner.Executor, + Network: cfg.Namespace.Network, + Logger: logger, + }) + if err != nil { + logger.Error("failed to create dispatcher", "error", err) + os.Exit(1) + } + + service := app.NewService(app.Config{ + DefaultScope: scope, + DefaultLabels: cfg.Forgejo.DefaultLabels, + InstanceURL: cfg.Forgejo.InstanceURL, + DefaultTTL: cfg.Namespace.Duration.Duration, + AllowLabels: cfg.Namespace.AllowLabels, + AllowScopes: cfg.Namespace.AllowScopes, + }, forgejoClient, dispatcher, logger) + + srv := server.New(cfg.Listen, service, logger) + + go func() { + logger.Info("dispatcher listening", "addr", cfg.Listen) + if err := srv.ListenAndServe(); err != nil && err != context.Canceled && err != http.ErrServerClosed { + logger.Error("server terminated", "error", err) + } + }() + + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, syscall.SIGTERM, syscall.SIGINT) + <-interrupt + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + _ = srv.Shutdown(ctx) +} diff --git a/services/forgejo-nsc/config.example.yaml b/services/forgejo-nsc/config.example.yaml new file mode 100644 index 0000000..5dc7551 --- /dev/null +++ b/services/forgejo-nsc/config.example.yaml @@ -0,0 +1,27 @@ +listen: ":8080" + +forgejo: + base_url: "https://forgejo.example.com" + token: "${FORGEJO_PERSONAL_ACCESS_TOKEN}" + default_scope: + level: "organization" + owner: "example" + default_labels: + - namespace-profile-linux-medium + timeout: "30s" + +namespace: + nsc_binary: "/app/bin/nsc" + compute_base_url: "https://ord4.compute.namespaceapis.com" + image: "ghcr.io/forgejo/runner:3" + machine_type: "8x16" + macos_base_image_id: "tahoe" + macos_machine_arch: "arm64" + duration: "30m" + workdir: "/var/lib/forgejo-runner" + max_parallel: 4 + network: "" + +runner: + name_prefix: "nscloud-" + executor: "shell" diff --git a/services/forgejo-nsc/deploy/autoscaler.yaml b/services/forgejo-nsc/deploy/autoscaler.yaml new file mode 100644 index 0000000..084b076 --- /dev/null +++ b/services/forgejo-nsc/deploy/autoscaler.yaml @@ -0,0 +1,31 @@ +listen: "127.0.0.1:8090" + +dispatcher: + url: "http://127.0.0.1:8080" + +instances: + - name: burrow + forgejo: + base_url: "http://127.0.0.1:3000" + token: "PENDING-FORGEJO-PAT" + scope: + level: "repository" + owner: "burrow" + name: "burrow" + disable_polling: false + poll_interval: "30s" + webhook_secret: "PENDING-WEBHOOK-SECRET" + webhook: + url: "https://nsc-autoscaler.burrow.net/webhook/burrow" + content_type: "json" + events: ["workflow_job"] + active: true + targets: + - labels: ["namespace-profile-linux-medium"] + min_idle: 0 + ttl: "20m" + machine_type: "8x16" + - labels: ["namespace-profile-windows-large"] + min_idle: 0 + ttl: "45m" + machine_type: "windows/amd64:8x16" diff --git a/services/forgejo-nsc/deploy/dispatcher.yaml b/services/forgejo-nsc/deploy/dispatcher.yaml new file mode 100644 index 0000000..6d2aac5 --- /dev/null +++ b/services/forgejo-nsc/deploy/dispatcher.yaml @@ -0,0 +1,29 @@ +listen: "127.0.0.1:8080" + +forgejo: + base_url: "http://127.0.0.1:3000" + instance_url: "https://git.burrow.net" + token: "PENDING-FORGEJO-PAT" + default_scope: + level: "repository" + owner: "burrow" + name: "burrow" + default_labels: + - namespace-profile-linux-medium + timeout: "30s" + +namespace: + nsc_binary: "/run/current-system/sw/bin/nsc" + compute_base_url: "https://ord4.compute.namespaceapis.com" + image: "code.forgejo.org/forgejo/runner:3" + machine_type: "8x16" + macos_base_image_id: "tahoe" + macos_machine_arch: "arm64" + duration: "30m" + workdir: "/var/lib/forgejo-runner" + max_parallel: 4 + network: "" + +runner: + name_prefix: "nscloud-" + executor: "shell" diff --git a/services/forgejo-nsc/go.mod b/services/forgejo-nsc/go.mod new file mode 100644 index 0000000..215aac1 --- /dev/null +++ b/services/forgejo-nsc/go.mod @@ -0,0 +1,65 @@ +module github.com/burrow/forgejo-nsc + +go 1.24.4 + +require ( + buf.build/gen/go/namespace/cloud/connectrpc/go v1.19.1-20260212004106-290ae81f8d6d.2 + buf.build/gen/go/namespace/cloud/protocolbuffers/go v1.36.11-20260212004106-290ae81f8d6d.1 + connectrpc.com/connect v1.19.1 + github.com/go-chi/chi/v5 v5.2.1 + github.com/google/uuid v1.6.0 + golang.org/x/crypto v0.48.0 + golang.org/x/sync v0.19.0 + google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 + namespacelabs.dev/foundation v0.0.478 +) + +require ( + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jxskiss/base62 v1.1.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/magiconair/properties v1.8.6 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-zglob v0.0.3 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/rivo/uniseg v0.4.2 // indirect + github.com/segmentio/ksuid v1.0.4 // indirect + github.com/spf13/afero v1.9.2 // indirect + github.com/spf13/cast v1.7.0 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.7 // indirect + github.com/spf13/viper v1.14.0 // indirect + github.com/subosito/gotenv v1.4.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/sdk v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.1 // indirect + golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/grpc v1.76.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + helm.sh/helm/v3 v3.18.4 // indirect + namespacelabs.dev/go-ids v0.0.0-20221124082625-9fc72ee06af7 // indirect +) diff --git a/services/forgejo-nsc/go.sum b/services/forgejo-nsc/go.sum new file mode 100644 index 0000000..6e2a0a9 --- /dev/null +++ b/services/forgejo-nsc/go.sum @@ -0,0 +1,575 @@ +buf.build/gen/go/namespace/cloud/connectrpc/go v1.19.1-20260212004106-290ae81f8d6d.2 h1:XaeFtt6yN8G5q2uYoiTjyshOyai1Q+GzwfEKlxrTzVw= +buf.build/gen/go/namespace/cloud/connectrpc/go v1.19.1-20260212004106-290ae81f8d6d.2/go.mod h1:QvCL7PUDMFotMXVUoWMeRClEEnCbh7S51xHy39mO+H4= +buf.build/gen/go/namespace/cloud/protocolbuffers/go v1.36.11-20260212004106-290ae81f8d6d.1 h1:xTgPJaOj5QNRPAA3nxW3fTz01aAOLr/6SG7C4Iqxm54= +buf.build/gen/go/namespace/cloud/protocolbuffers/go v1.36.11-20260212004106-290ae81f8d6d.1/go.mod h1:Il2wpJNQB40Yj3Rmuhg5xKJPSXaZVwij+Q30d1PNuNY= +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.44.3/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 v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +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/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/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= +cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +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/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +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/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.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +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-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/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/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +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/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.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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/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/martian/v3 v3.1.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-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.2/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/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/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +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/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +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/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/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw= +github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +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/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= +github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-zglob v0.0.3 h1:6Ry4EYsScDyt5di4OI6xw1bYhOqfE5S33Z1OPy+d+To= +github.com/mattn/go-zglob v0.0.3/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo= +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/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= +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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8= +github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= +github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= +github.com/spf13/afero v1.9.2 h1:j49Hj62F0n+DaZ1dDCvhABaPNSGNkt32oRFxI33IEMw= +github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.14.0 h1:Rg7d3Lo706X9tHsJMUjdiwMpHB7W8WnSVOssIY+JElU= +github.com/spf13/viper v1.14.0/go.mod h1:WT//axPky3FdvXHzGw33dNdXXXfFQqmEalje+egj8As= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= +github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +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= +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.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4= +go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/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-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= +golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= +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-20201208152925-83fdc39ff7b5/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.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +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-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-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-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-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-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +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-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/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-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-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-20191001151750-bb3f8db39f24/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-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/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-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +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/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-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-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-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-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-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-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +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.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +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.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +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/appengine v1.6.7/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-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-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +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.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +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.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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-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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.2/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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +helm.sh/helm/v3 v3.18.4 h1:pNhnHM3nAmDrxz6/UC+hfjDY4yeDATQCka2/87hkZXQ= +helm.sh/helm/v3 v3.18.4/go.mod h1:WVnwKARAw01iEdjpEkP7Ii1tT1pTPYfM1HsakFKM3LI= +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= +namespacelabs.dev/foundation v0.0.478 h1:3xFLZcrjih7Jjey2N7faSfr6EoBCg2LMTHipq/3Hlrg= +namespacelabs.dev/foundation v0.0.478/go.mod h1:svBrTIfZK773sytmjudGkCzQWNisxcQtcWNCs+uLznI= +namespacelabs.dev/go-ids v0.0.0-20221124082625-9fc72ee06af7 h1:8NlnfPlzDSJr8TYV/qarIWwhjLd1gOXf3Jme0M/oGBM= +namespacelabs.dev/go-ids v0.0.0-20221124082625-9fc72ee06af7/go.mod h1:J+Sd+ngeffnCsaO/M7zgs2bR8Klq/ZBhS0+bbnDEH2M= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/services/forgejo-nsc/internal/app/service.go b/services/forgejo-nsc/internal/app/service.go new file mode 100644 index 0000000..45b66eb --- /dev/null +++ b/services/forgejo-nsc/internal/app/service.go @@ -0,0 +1,253 @@ +package app + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "golang.org/x/sync/errgroup" + + "github.com/burrow/forgejo-nsc/internal/forgejo" + "github.com/burrow/forgejo-nsc/internal/nsc" +) + +type Dispatcher interface { + LaunchRunner(ctx context.Context, req nsc.LaunchRequest) (string, error) +} + +type ForgejoClient interface { + RegistrationToken(ctx context.Context, scope forgejo.Scope) (string, error) +} + +type Service struct { + forgejo ForgejoClient + dispatcher Dispatcher + logger *slog.Logger + + defaultScope forgejo.Scope + defaultLabels []string + instanceURL string + defaultTTL time.Duration + + allowLabels map[string]struct{} + allowScopes map[string]struct{} +} + +type Config struct { + DefaultScope forgejo.Scope + DefaultLabels []string + InstanceURL string + DefaultTTL time.Duration + AllowLabels []string + AllowScopes []string +} + +func NewService(cfg Config, forgejo ForgejoClient, dispatcher Dispatcher, logger *slog.Logger) *Service { + if logger == nil { + logger = slog.Default() + } + allowLabels := make(map[string]struct{}, len(cfg.AllowLabels)) + for _, label := range cfg.AllowLabels { + allowLabels[normalizeLabel(label)] = struct{}{} + } + allowScopes := make(map[string]struct{}, len(cfg.AllowScopes)) + for _, scope := range cfg.AllowScopes { + allowScopes[scope] = struct{}{} + } + return &Service{ + defaultScope: cfg.DefaultScope, + defaultLabels: cfg.DefaultLabels, + instanceURL: cfg.InstanceURL, + defaultTTL: cfg.DefaultTTL, + forgejo: forgejo, + dispatcher: dispatcher, + logger: logger, + allowLabels: allowLabels, + allowScopes: allowScopes, + } +} + +type DispatchRequest struct { + Count int + Labels []string + Scope *Scope + TTL time.Duration + Machine string + Image string + ExtraEnv map[string]string +} + +type Scope struct { + Level string + Owner string + Name string +} + +type DispatchResponse struct { + Runners []RunnerHandle `json:"runners"` +} + +type RunnerHandle struct { + Name string `json:"name"` +} + +func (s *Service) Dispatch(ctx context.Context, req DispatchRequest) (DispatchResponse, error) { + count := req.Count + if count <= 0 { + count = 1 + } + + scope, err := s.mergeScope(req.Scope) + if err != nil { + return DispatchResponse{}, err + } + + labels, err := s.mergeLabels(req.Labels) + if err != nil { + return DispatchResponse{}, err + } + if len(labels) == 0 { + return DispatchResponse{}, errors.New("no runner labels resolved") + } + + ttl := req.TTL + if ttl == 0 { + ttl = s.defaultTTL + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + res := DispatchResponse{ + Runners: make([]RunnerHandle, count), + } + eg, egCtx := errgroup.WithContext(ctx) + + for i := 0; i < count; i++ { + index := i + eg.Go(func() error { + token, err := s.forgejo.RegistrationToken(egCtx, scope) + if err != nil { + return fmt.Errorf("fetching registration token: %w", err) + } + + name, err := s.dispatcher.LaunchRunner(egCtx, nsc.LaunchRequest{ + Token: token, + InstanceURL: s.instanceURL, + Labels: labels, + Duration: ttl, + MachineType: req.Machine, + Image: req.Image, + ExtraEnv: req.ExtraEnv, + }) + if err != nil { + return err + } + + res.Runners[index] = RunnerHandle{Name: name} + return nil + }) + } + + if err := eg.Wait(); err != nil { + return DispatchResponse{}, err + } + + return res, nil +} + +func (s *Service) mergeScope(value *Scope) (forgejo.Scope, error) { + if value == nil { + return s.defaultScope, nil + } + + scope := forgejo.Scope{ + Level: forgejo.ScopeLevel(value.Level), + Owner: value.Owner, + Name: value.Name, + } + if scope.Level == "" { + return forgejo.Scope{}, errors.New("scope level is required") + } + switch scope.Level { + case forgejo.ScopeInstance: + if !s.scopeAllowed(scope) { + return forgejo.Scope{}, fmt.Errorf("scope %q not allowed", scopeKey(scope)) + } + return scope, nil + case forgejo.ScopeOrganization: + if scope.Owner == "" { + return forgejo.Scope{}, errors.New("organization scope requires owner") + } + if !s.scopeAllowed(scope) { + return forgejo.Scope{}, fmt.Errorf("scope %q not allowed", scopeKey(scope)) + } + return scope, nil + case forgejo.ScopeRepository: + if scope.Owner == "" || scope.Name == "" { + return forgejo.Scope{}, errors.New("repository scope requires owner and name") + } + if !s.scopeAllowed(scope) { + return forgejo.Scope{}, fmt.Errorf("scope %q not allowed", scopeKey(scope)) + } + return scope, nil + default: + return forgejo.Scope{}, fmt.Errorf("unsupported scope %q", scope.Level) + } +} + +func (s *Service) mergeLabels(labels []string) ([]string, error) { + var resolved []string + if len(labels) == 0 { + resolved = append([]string{}, s.defaultLabels...) + } else { + resolved = labels + } + if len(s.allowLabels) == 0 { + return resolved, nil + } + for _, label := range resolved { + norm := normalizeLabel(label) + if _, ok := s.allowLabels[norm]; !ok { + return nil, fmt.Errorf("label %q not allowed", label) + } + } + return resolved, nil +} + +func normalizeLabel(label string) string { + trimmed := strings.TrimSpace(label) + if trimmed == "" { + return "" + } + // Ignore any explicit executor suffix ("label:host"), since workflows + // and config allowlists typically deal in base label names. + if before, _, ok := strings.Cut(trimmed, ":"); ok { + return before + } + return trimmed +} + +func scopeKey(scope forgejo.Scope) string { + switch scope.Level { + case forgejo.ScopeInstance: + return "instance" + case forgejo.ScopeOrganization: + return fmt.Sprintf("organization:%s", scope.Owner) + case forgejo.ScopeRepository: + return fmt.Sprintf("repository:%s/%s", scope.Owner, scope.Name) + default: + return string(scope.Level) + } +} + +func (s *Service) scopeAllowed(scope forgejo.Scope) bool { + if len(s.allowScopes) == 0 { + return true + } + _, ok := s.allowScopes[scopeKey(scope)] + return ok +} diff --git a/services/forgejo-nsc/internal/app/service_test.go b/services/forgejo-nsc/internal/app/service_test.go new file mode 100644 index 0000000..2be3d3c --- /dev/null +++ b/services/forgejo-nsc/internal/app/service_test.go @@ -0,0 +1,160 @@ +package app + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/burrow/forgejo-nsc/internal/forgejo" + "github.com/burrow/forgejo-nsc/internal/nsc" +) + +type mockForgejo struct { + mu sync.Mutex + tokens []string + scopes []forgejo.Scope + err error + counter int +} + +func (m *mockForgejo) RegistrationToken(ctx context.Context, scope forgejo.Scope) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.scopes = append(m.scopes, scope) + if m.err != nil { + return "", m.err + } + if m.counter >= len(m.tokens) { + return "", context.Canceled + } + tok := m.tokens[m.counter] + m.counter++ + return tok, nil +} + +type mockDispatcher struct { + mu sync.Mutex + requests []nsc.LaunchRequest + responses []string + err error +} + +func (m *mockDispatcher) LaunchRunner(ctx context.Context, req nsc.LaunchRequest) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.err != nil { + return "", m.err + } + m.requests = append(m.requests, req) + idx := len(m.requests) - 1 + if idx < len(m.responses) { + return m.responses[idx], nil + } + return "runner", nil +} + +func TestServiceDispatchUsesDefaults(t *testing.T) { + forgejoMock := &mockForgejo{tokens: []string{"token"}} + dispatcherMock := &mockDispatcher{responses: []string{"runner-default"}} + + cfg := Config{ + DefaultScope: forgejo.Scope{Level: forgejo.ScopeInstance}, + DefaultLabels: []string{"nscloud"}, + InstanceURL: "https://forgejo.example.com", + DefaultTTL: 15 * time.Minute, + } + + service := NewService(cfg, forgejoMock, dispatcherMock, nil) + + resp, err := service.Dispatch(context.Background(), DispatchRequest{}) + if err != nil { + t.Fatalf("Dispatch returned error: %v", err) + } + if len(resp.Runners) != 1 || resp.Runners[0].Name != "runner-default" { + t.Fatalf("unexpected dispatch response: %+v", resp) + } + + if len(forgejoMock.scopes) != 1 || forgejoMock.scopes[0].Level != forgejo.ScopeInstance { + t.Fatalf("expected default scope, got %+v", forgejoMock.scopes) + } + + if len(dispatcherMock.requests) != 1 { + t.Fatalf("expected one dispatcher call, got %d", len(dispatcherMock.requests)) + } + req := dispatcherMock.requests[0] + if req.InstanceURL != cfg.InstanceURL { + t.Fatalf("expected instance URL %s, got %s", cfg.InstanceURL, req.InstanceURL) + } + if got := req.Labels; len(got) != 1 || got[0] != "nscloud" { + t.Fatalf("expected default labels, got %v", got) + } + if req.Duration != cfg.DefaultTTL { + t.Fatalf("expected duration %v, got %v", cfg.DefaultTTL, req.Duration) + } +} + +func TestServiceDispatchCustomScopeAndCount(t *testing.T) { + forgejoMock := &mockForgejo{tokens: []string{"token-1", "token-2"}} + dispatcherMock := &mockDispatcher{responses: []string{"runner-1", "runner-2"}} + + cfg := Config{ + DefaultScope: forgejo.Scope{Level: forgejo.ScopeInstance}, + DefaultLabels: []string{"default"}, + InstanceURL: "https://forgejo.example.com", + DefaultTTL: 10 * time.Minute, + } + + service := NewService(cfg, forgejoMock, dispatcherMock, nil) + + reqScope := &Scope{Level: string(forgejo.ScopeRepository), Owner: "acme", Name: "repo"} + res, err := service.Dispatch(context.Background(), DispatchRequest{ + Count: 2, + Labels: []string{"custom"}, + Scope: reqScope, + TTL: 5 * time.Minute, + Machine: "4x8", + Image: "runner:latest", + ExtraEnv: map[string]string{"FOO": "bar"}, + }) + if err != nil { + t.Fatalf("Dispatch returned error: %v", err) + } + if len(res.Runners) != 2 { + t.Fatalf("expected two runners, got %+v", res) + } + + if len(forgejoMock.scopes) != 2 { + t.Fatalf("expected two scope calls, got %d", len(forgejoMock.scopes)) + } + for _, scope := range forgejoMock.scopes { + if scope.Level != forgejo.ScopeRepository || scope.Owner != "acme" || scope.Name != "repo" { + t.Fatalf("unexpected scope: %+v", scope) + } + } + + if len(dispatcherMock.requests) != 2 { + t.Fatalf("expected two dispatcher calls, got %d", len(dispatcherMock.requests)) + } + for _, call := range dispatcherMock.requests { + if call.MachineType != "4x8" || call.Image != "runner:latest" { + t.Fatalf("unexpected machine/image in %+v", call) + } + if call.Duration != 5*time.Minute { + t.Fatalf("expected TTL to override default, got %v", call.Duration) + } + if call.Labels[0] != "custom" { + t.Fatalf("expected custom labels, got %v", call.Labels) + } + if call.ExtraEnv["FOO"] != "bar" { + t.Fatalf("expected env passthrough, got %v", call.ExtraEnv) + } + } +} + +func TestServiceDispatchErrorsWithoutLabels(t *testing.T) { + service := NewService(Config{DefaultScope: forgejo.Scope{Level: forgejo.ScopeInstance}}, &mockForgejo{}, &mockDispatcher{}, nil) + if _, err := service.Dispatch(context.Background(), DispatchRequest{}); err == nil { + t.Fatalf("expected error when no labels are available") + } +} diff --git a/services/forgejo-nsc/internal/autoscaler/config.go b/services/forgejo-nsc/internal/autoscaler/config.go new file mode 100644 index 0000000..7603e67 --- /dev/null +++ b/services/forgejo-nsc/internal/autoscaler/config.go @@ -0,0 +1,108 @@ +package autoscaler + +import ( + "fmt" + "os" + "time" + + "gopkg.in/yaml.v3" + + "github.com/burrow/forgejo-nsc/internal/config" +) + +type Config struct { + Listen string `yaml:"listen"` + Dispatcher DispatcherConfig `yaml:"dispatcher"` + Instances []InstanceConfig `yaml:"instances"` +} + +type DispatcherConfig struct { + URL string `yaml:"url"` + Timeout config.Duration `yaml:"timeout"` +} + +type InstanceConfig struct { + Name string `yaml:"name"` + Forgejo ForgejoInstance `yaml:"forgejo"` + Scope config.ScopeConfig `yaml:"scope"` + PollInterval config.Duration `yaml:"poll_interval"` + DisablePolling bool `yaml:"disable_polling"` + WebhookSecret string `yaml:"webhook_secret"` + Webhook WebhookConfig `yaml:"webhook"` + Dispatcher *DispatcherConfig `yaml:"dispatcher"` + Targets []TargetConfig `yaml:"targets"` +} + +type ForgejoInstance struct { + BaseURL string `yaml:"base_url"` + Token string `yaml:"token"` +} + +type WebhookConfig struct { + URL string `yaml:"url"` + ContentType string `yaml:"content_type"` + Events []string `yaml:"events"` + Active *bool `yaml:"active"` +} + +type TargetConfig struct { + Labels []string `yaml:"labels"` + MinIdle int `yaml:"min_idle"` + TTL config.Duration `yaml:"ttl"` + MachineType string `yaml:"machine_type"` + Image string `yaml:"image"` + Env map[string]string `yaml:"env"` +} + +func LoadConfig(path string) (Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return Config{}, err + } + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return Config{}, err + } + if cfg.Listen == "" { + cfg.Listen = ":8090" + } + if cfg.Dispatcher.URL == "" { + return Config{}, fmt.Errorf("dispatcher.url is required") + } + if cfg.Dispatcher.Timeout.Duration == 0 { + cfg.Dispatcher.Timeout = config.Duration{Duration: 15 * time.Second} + } + if len(cfg.Instances) == 0 { + return Config{}, fmt.Errorf("at least one instance must be configured") + } + for i := range cfg.Instances { + inst := &cfg.Instances[i] + if inst.Name == "" { + return Config{}, fmt.Errorf("instance[%d] missing name", i) + } + if inst.Forgejo.BaseURL == "" || inst.Forgejo.Token == "" { + return Config{}, fmt.Errorf("instance %s missing forgejo.base_url or token", inst.Name) + } + if inst.PollInterval.Duration == 0 { + inst.PollInterval = config.Duration{Duration: 30 * time.Second} + } + if len(inst.Webhook.Events) == 0 { + inst.Webhook.Events = []string{"workflow_job"} + } + if inst.Webhook.ContentType == "" { + inst.Webhook.ContentType = "json" + } + if len(inst.Targets) == 0 { + return Config{}, fmt.Errorf("instance %s requires at least one target", inst.Name) + } + for ti, tgt := range inst.Targets { + if len(tgt.Labels) == 0 { + return Config{}, fmt.Errorf("instance %s target[%d] missing labels", inst.Name, ti) + } + if tgt.MinIdle < 0 { + return Config{}, fmt.Errorf("instance %s target[%d] min_idle must be >= 0", inst.Name, ti) + } + } + } + return cfg, nil +} diff --git a/services/forgejo-nsc/internal/autoscaler/service.go b/services/forgejo-nsc/internal/autoscaler/service.go new file mode 100644 index 0000000..08d4a42 --- /dev/null +++ b/services/forgejo-nsc/internal/autoscaler/service.go @@ -0,0 +1,385 @@ +package autoscaler + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/go-chi/chi/v5" + + "namespacelabs.dev/foundation/std/tasks" + + "github.com/burrow/forgejo-nsc/internal/forgejo" +) + +type Service struct { + listen string + controllers map[string]*InstanceController + router chi.Router +} + +func NewService(cfg Config) (*Service, error) { + controllers := make(map[string]*InstanceController) + for _, inst := range cfg.Instances { + scope, err := inst.Scope.ToScope() + if err != nil { + return nil, err + } + forgejoClient, err := forgejo.NewClient(inst.Forgejo.BaseURL, inst.Forgejo.Token) + if err != nil { + return nil, err + } + dispCfg := cfg.Dispatcher + if inst.Dispatcher != nil && inst.Dispatcher.URL != "" { + dispCfg = *inst.Dispatcher + if dispCfg.Timeout.Duration == 0 { + dispCfg.Timeout = cfg.Dispatcher.Timeout + } + } + dClient := newDispatcherClient(dispCfg.URL, dispCfg.Timeout.Duration) + webhookActive := true + if inst.Webhook.Active != nil { + webhookActive = *inst.Webhook.Active + } + controller := &InstanceController{ + name: inst.Name, + cfg: inst, + scope: scope, + forgejo: forgejoClient, + dispatcher: dClient, + webhook: forgejo.WebhookConfig{ + URL: inst.Webhook.URL, + ContentType: inst.Webhook.ContentType, + Events: inst.Webhook.Events, + Active: webhookActive, + }, + secret: inst.WebhookSecret, + } + controllers[inst.Name] = controller + } + + router := chi.NewRouter() + service := &Service{ + listen: cfg.Listen, + controllers: controllers, + router: router, + } + + router.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + router.Post("/webhook/{instance}", service.handleWebhook) + + return service, nil +} + +func (s *Service) Start(ctx context.Context) error { + for _, controller := range s.controllers { + if err := controller.EnsureWebhook(ctx); err != nil { + return err + } + } + + var wg sync.WaitGroup + for _, controller := range s.controllers { + wg.Add(1) + go func(c *InstanceController) { + defer wg.Done() + c.Run(ctx) + }(controller) + } + + srv := &http.Server{ + Addr: s.listen, + Handler: s.router, + } + + go func() { + <-ctx.Done() + _ = srv.Shutdown(context.Background()) + }() + + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + wg.Wait() + return nil +} + +func (s *Service) handleWebhook(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "instance") + controller, ok := s.controllers[name] + if !ok { + http.Error(w, "unknown instance", http.StatusNotFound) + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + if controller.cfg.WebhookSecret != "" { + signature := r.Header.Get("X-Gitea-Signature") + if signature == "" { + http.Error(w, "missing signature", http.StatusUnauthorized) + return + } + if !verifySignature(controller.cfg.WebhookSecret, signature, body) { + http.Error(w, "invalid signature", http.StatusUnauthorized) + return + } + } + + var payload workflowJobPayload + if err := json.Unmarshal(body, &payload); err != nil { + http.Error(w, "bad payload", http.StatusBadRequest) + return + } + + controller.MarkWebhookSeen() + if payload.Action == "queued" { + controller.DispatchForJob(r.Context(), payload) + } + + w.WriteHeader(http.StatusAccepted) +} + +type workflowJobPayload struct { + Action string `json:"action"` + WorkflowJob struct { + Labels []string `json:"labels"` + } `json:"workflow_job"` +} + +type InstanceController struct { + name string + cfg InstanceConfig + scope forgejo.Scope + forgejo *forgejo.Client + dispatcher *dispatcherClient + ready atomic.Bool + webhook forgejo.WebhookConfig + secret string +} + +func (c *InstanceController) EnsureWebhook(ctx context.Context) error { + if c.webhook.URL == "" { + return nil + } + return tasks.Action("autoscaler.ensure-webhook").Arg("instance", c.name).Run(ctx, func(ctx context.Context) error { + return c.forgejo.EnsureWebhook(ctx, c.scope, c.webhook, c.secret) + }) +} + +func (c *InstanceController) Run(ctx context.Context) { + if c.cfg.DisablePolling { + <-ctx.Done() + return + } + ticker := time.NewTicker(c.cfg.PollInterval.Duration) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + _ = tasks.Action("autoscaler.poll").Arg("instance", c.name).Run(ctx, func(ctx context.Context) error { + return c.reconcile(ctx) + }) + } + } +} + +func (c *InstanceController) reconcile(ctx context.Context) error { + runners, err := c.forgejo.ListRunners(ctx, c.scope) + if err != nil { + // Keep polling even if runner listing fails; we can still dispatch based on queued jobs. + runners = nil + } + + for _, target := range c.cfg.Targets { + idle := countIdle(runners, target.Labels) + + need := 0 + if idle < target.MinIdle { + need = target.MinIdle - idle + } + + jobs, jobErr := c.forgejo.ListRunJobs(ctx, c.scope, target.Labels) + if jobErr != nil { + return jobErr + } + waiting := countWaitingJobs(jobs, target.Labels) + // Scale-to-zero friendly: if anything is waiting and there are no idle runners + // for that label set, dispatch exactly one runner to unblock the queue. + if waiting > 0 && idle == 0 && need < 1 { + need = 1 + } + + if need <= 0 { + continue + } + if err := c.dispatch(ctx, target, need, "poll"); err != nil { + return err + } + } + return nil +} + +func (c *InstanceController) dispatch(ctx context.Context, target TargetConfig, count int, reason string) error { + if count <= 0 { + return nil + } + req := dispatcherRequest{ + Count: count, + Labels: target.Labels, + } + if target.TTL.Duration > 0 { + req.TTL = target.TTL.Duration.String() + } + if target.MachineType != "" { + req.MachineType = target.MachineType + } + if target.Image != "" { + req.Image = target.Image + } + if len(target.Env) > 0 { + req.Env = target.Env + } + return tasks.Action("autoscaler.dispatch").Arg("instance", c.name).Arg("reason", reason).Arg("labels", strings.Join(target.Labels, ",")).Run(ctx, func(ctx context.Context) error { + return c.dispatcher.Dispatch(ctx, req) + }) +} + +func (c *InstanceController) DispatchForJob(ctx context.Context, payload workflowJobPayload) { + action := strings.ToLower(payload.Action) + if action != "queued" && action != "waiting" { + return + } + jobLabels := payload.WorkflowJob.Labels + for _, target := range c.cfg.Targets { + if labelsMatch(jobLabels, target.Labels) { + _ = c.dispatch(ctx, target, 1, "webhook") + return + } + } +} + +func (c *InstanceController) MarkWebhookSeen() { + c.ready.Store(true) +} + +func countIdle(runners []forgejo.Runner, labels []string) int { + count := 0 + for _, runner := range runners { + if strings.ToLower(runner.Status) != "online" || runner.Busy { + continue + } + if labelsMatch(extractLabels(runner.Labels), labels) { + count++ + } + } + return count +} + +func countWaitingJobs(jobs []forgejo.RunJob, labels []string) int { + count := 0 + for _, job := range jobs { + if status := strings.ToLower(job.Status); status != "waiting" && status != "queued" { + continue + } + if labelsMatch(job.RunsOn, labels) { + count++ + } + } + return count +} + +func extractLabels(src []forgejo.RunnerLabel) []string { + result := make([]string, 0, len(src)) + for _, lbl := range src { + result = append(result, lbl.Name) + } + return result +} + +func labelsMatch(have, want []string) bool { + set := make(map[string]struct{}, len(have)) + for _, label := range have { + set[label] = struct{}{} + } + for _, label := range want { + if _, ok := set[label]; !ok { + return false + } + } + return true +} + +func verifySignature(secret, signature string, body []byte) bool { + parts := strings.SplitN(signature, "=", 2) + if len(parts) == 2 { + signature = parts[1] + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + expected := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(expected), []byte(signature)) +} + +type dispatcherClient struct { + url string + client *http.Client +} + +type dispatcherRequest struct { + Count int `json:"count"` + Labels []string `json:"labels"` + TTL string `json:"ttl,omitempty"` + MachineType string `json:"machine_type,omitempty"` + Image string `json:"image,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + +func newDispatcherClient(url string, timeout time.Duration) *dispatcherClient { + if timeout == 0 { + timeout = 30 * time.Second + } + return &dispatcherClient{ + url: url, + client: &http.Client{ + Timeout: timeout, + }, + } +} + +func (d *dispatcherClient) Dispatch(ctx context.Context, req dispatcherRequest) error { + body, _ := json.Marshal(req) + endpoint := strings.TrimSuffix(d.url, "/") + "/api/v1/dispatch" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + httpReq.Header.Set("Content-Type", "application/json") + resp, err := d.client.Do(httpReq) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("dispatcher returned %s", resp.Status) + } + return nil +} diff --git a/services/forgejo-nsc/internal/config/config.go b/services/forgejo-nsc/internal/config/config.go new file mode 100644 index 0000000..264cbd0 --- /dev/null +++ b/services/forgejo-nsc/internal/config/config.go @@ -0,0 +1,185 @@ +package config + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + + "gopkg.in/yaml.v3" + + "github.com/burrow/forgejo-nsc/internal/forgejo" +) + +// Duration wraps time.Duration to support YAML unmarshalling from strings. +type Duration struct { + time.Duration +} + +// UnmarshalYAML implements yaml.v3 unmarshalling for Duration. +func (d *Duration) UnmarshalYAML(value *yaml.Node) error { + switch value.Tag { + case "!!int": + var seconds int64 + if err := value.Decode(&seconds); err != nil { + return err + } + d.Duration = time.Duration(seconds) * time.Second + return nil + default: + parsed, err := time.ParseDuration(value.Value) + if err != nil { + return err + } + d.Duration = parsed + return nil + } +} + +// MarshalYAML implements yaml.v3 marshalling. +func (d Duration) MarshalYAML() (any, error) { + return d.Duration.String(), nil +} + +type Config struct { + Listen string `yaml:"listen"` + Forgejo ForgejoConfig `yaml:"forgejo"` + Namespace NamespaceConfig `yaml:"namespace"` + Runner RunnerConfig `yaml:"runner"` +} + +type ForgejoConfig struct { + BaseURL string `yaml:"base_url"` + // InstanceURL is the URL runners should use when registering with Forgejo. + // This must be reachable from the spawned runner (e.g. the public URL like + // https://git.burrow.net), and may differ from BaseURL (which can be a local + // loopback URL on the forge host). + InstanceURL string `yaml:"instance_url"` + Token string `yaml:"token"` + DefaultScope ScopeConfig `yaml:"default_scope"` + DefaultLabels []string `yaml:"default_labels"` + Timeout Duration `yaml:"timeout"` + ExtraHeaders yaml.Node `yaml:"extra_headers"` +} + +type ScopeConfig struct { + Level string `yaml:"level"` + Owner string `yaml:"owner,omitempty"` + Name string `yaml:"name,omitempty"` +} + +type NamespaceConfig struct { + NSCBinary string `yaml:"nsc_binary"` + // ComputeBaseURL is the Namespace Cloud Compute API endpoint (Connect RPC base URL). + // This is used for macOS runners, since NSC "run" is container-based (Linux-only). + // Example: "https://ord4.compute.namespaceapis.com" + ComputeBaseURL string `yaml:"compute_base_url"` + Image string `yaml:"image"` + MachineType string `yaml:"machine_type"` + // MacosBaseImageID selects which macOS base image to use (e.g. "tahoe"). + MacosBaseImageID string `yaml:"macos_base_image_id"` + // MacosMachineArch is the architecture used for macOS instances (typically "arm64"). + MacosMachineArch string `yaml:"macos_machine_arch"` + Duration Duration `yaml:"duration"` + WorkDir string `yaml:"workdir"` + MaxParallel int64 `yaml:"max_parallel"` + Environment []string `yaml:"environment"` + AllowLabels []string `yaml:"allow_labels"` + AllowScopes []string `yaml:"allow_scopes"` + Network string `yaml:"network"` + InstanceTags []string `yaml:"instance_tags"` +} + +type RunnerConfig struct { + NamePrefix string `yaml:"name_prefix"` + Executor string `yaml:"executor"` +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + + if err := cfg.Validate(); err != nil { + return nil, err + } + + return &cfg, nil +} + +func (c *Config) Validate() error { + if c.Listen == "" { + c.Listen = ":8080" + } + if c.Runner.NamePrefix == "" { + c.Runner.NamePrefix = "nscloud-" + } + if c.Runner.Executor == "" { + c.Runner.Executor = "shell" + } + + if c.Forgejo.BaseURL == "" { + return errors.New("forgejo.base_url is required") + } + if c.Forgejo.InstanceURL == "" { + // Backwards-compatible default: assume runners can reach the same URL. + c.Forgejo.InstanceURL = c.Forgejo.BaseURL + } + if c.Forgejo.Token == "" { + return errors.New("forgejo.token is required") + } + if c.Forgejo.Timeout.Duration == 0 { + c.Forgejo.Timeout.Duration = 30 * time.Second + } + if _, err := c.Forgejo.DefaultScope.ToScope(); err != nil { + return err + } + + if c.Namespace.NSCBinary == "" { + c.Namespace.NSCBinary = "nsc" + } + if c.Namespace.Image == "" { + c.Namespace.Image = "code.forgejo.org/forgejo/runner:11" + } + if c.Namespace.MacosBaseImageID == "" { + c.Namespace.MacosBaseImageID = "tahoe" + } + if c.Namespace.MacosMachineArch == "" { + c.Namespace.MacosMachineArch = "arm64" + } + if c.Namespace.Duration.Duration == 0 { + c.Namespace.Duration.Duration = 30 * time.Minute + } + if c.Namespace.MaxParallel <= 0 { + c.Namespace.MaxParallel = 4 + } + + return nil +} + +func (s ScopeConfig) ToScope() (forgejo.Scope, error) { + level := forgejo.ScopeLevel(strings.ToLower(s.Level)) + switch level { + case forgejo.ScopeInstance: + return forgejo.Scope{Level: level}, nil + case forgejo.ScopeOrganization: + if s.Owner == "" { + return forgejo.Scope{}, errors.New("forgejo default scope requires owner for organization level") + } + return forgejo.Scope{Level: level, Owner: s.Owner}, nil + case forgejo.ScopeRepository: + if s.Owner == "" || s.Name == "" { + return forgejo.Scope{}, errors.New("forgejo default scope requires owner and name for repository level") + } + return forgejo.Scope{Level: level, Owner: s.Owner, Name: s.Name}, nil + default: + return forgejo.Scope{}, fmt.Errorf("unknown scope level %q", s.Level) + } +} diff --git a/services/forgejo-nsc/internal/config/config_test.go b/services/forgejo-nsc/internal/config/config_test.go new file mode 100644 index 0000000..e42f3c9 --- /dev/null +++ b/services/forgejo-nsc/internal/config/config_test.go @@ -0,0 +1,41 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestLoadConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + content := ` +listen: ":9090" +forgejo: + base_url: https://forgejo.test + token: abc + default_scope: + level: instance +namespace: + nsc_binary: /usr/bin/nsc + image: ghcr.io/forgejo/runner:3 + duration: 15m +runner: + name_prefix: custom- +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Listen != ":9090" { + t.Fatalf("unexpected listen addr: %s", cfg.Listen) + } + if cfg.Namespace.Duration.Duration != 15*time.Minute { + t.Fatalf("duration parsing failed: %s", cfg.Namespace.Duration.Duration) + } +} diff --git a/services/forgejo-nsc/internal/forgejo/client.go b/services/forgejo-nsc/internal/forgejo/client.go new file mode 100644 index 0000000..7f63e0c --- /dev/null +++ b/services/forgejo-nsc/internal/forgejo/client.go @@ -0,0 +1,454 @@ +package forgejo + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "path" + "strings" + "time" +) + +type ScopeLevel string + +const ( + ScopeInstance ScopeLevel = "instance" + ScopeOrganization ScopeLevel = "organization" + ScopeRepository ScopeLevel = "repository" +) + +type Scope struct { + Level ScopeLevel + Owner string + Name string +} + +type Client struct { + baseURL *url.URL + token string + client *http.Client +} + +type Runner struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Busy bool `json:"busy"` + Labels []RunnerLabel `json:"labels"` +} + +type RunnerLabel struct { + Name string `json:"name"` +} + +type RunJob struct { + ID int64 `json:"id"` + Name string `json:"name"` + RunsOn []string `json:"runs_on"` + Status string `json:"status"` + TaskID int64 `json:"task_id"` +} + +type WebhookConfig struct { + URL string + ContentType string + Events []string + Active bool +} + +type Option func(*Client) + +func WithHTTPClient(httpClient *http.Client) Option { + return func(c *Client) { + if httpClient != nil { + c.client = httpClient + } + } +} + +func NewClient(rawURL, token string, opts ...Option) (*Client, error) { + if rawURL == "" { + return nil, errors.New("forgejo base URL is required") + } + + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + client := &Client{ + baseURL: u, + token: strings.TrimSpace(token), + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } + + for _, opt := range opts { + opt(client) + } + + if client.token == "" { + return nil, errors.New("forgejo token is required") + } + + return client, nil +} + +type registrationTokenResponse struct { + Token string `json:"token"` + TTL time.Time `json:"expires_at"` +} + +func (c *Client) RegistrationToken(ctx context.Context, scope Scope) (string, error) { + endpoint, err := c.registrationEndpoint(scope) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", fmt.Sprintf("token %s", c.token)) + req.Header.Set("Accept", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return "", fmt.Errorf("forgejo returned %s", resp.Status) + } + + var decoded registrationTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + return "", err + } + if decoded.Token == "" { + return "", errors.New("forgejo response missing token") + } + + return decoded.Token, nil +} + +func (c *Client) ListRunners(ctx context.Context, scope Scope) ([]Runner, error) { + endpoint, err := c.runnersEndpoint(scope) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", fmt.Sprintf("token %s", c.token)) + req.Header.Set("Accept", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("forgejo returned %s", resp.Status) + } + + var decoded []Runner + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + return nil, err + } + + return decoded, nil +} + +func (c *Client) ListRunJobs(ctx context.Context, scope Scope, labels []string) ([]RunJob, error) { + endpoint, err := c.runJobsEndpoint(scope) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + if len(labels) > 0 { + query := req.URL.Query() + query.Set("labels", strings.Join(labels, ",")) + req.URL.RawQuery = query.Encode() + } + req.Header.Set("Authorization", fmt.Sprintf("token %s", c.token)) + req.Header.Set("Accept", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("forgejo returned %s", resp.Status) + } + + var decoded []RunJob + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + return nil, err + } + + if decoded == nil { + decoded = []RunJob{} + } + return decoded, nil +} + +func (c *Client) EnsureWebhook(ctx context.Context, scope Scope, cfg WebhookConfig, secret string) error { + if cfg.URL == "" { + return nil + } + + hooks, err := c.listWebhooks(ctx, scope) + if err != nil { + return err + } + + for _, hook := range hooks { + if strings.EqualFold(hook.Config.URL, cfg.URL) { + return c.updateWebhook(ctx, scope, hook.ID, cfg, secret) + } + } + + return c.createWebhook(ctx, scope, cfg, secret) +} + +func (c *Client) registrationEndpoint(scope Scope) (string, error) { + var segments []string + switch scope.Level { + case ScopeRepository: + if scope.Owner == "" || scope.Name == "" { + return "", errors.New("repository scope requires owner and name") + } + segments = []string{"api", "v1", "repos", scope.Owner, scope.Name, "actions", "runners", "registration-token"} + case ScopeOrganization: + if scope.Owner == "" { + return "", errors.New("organization scope requires owner") + } + segments = []string{"api", "v1", "orgs", scope.Owner, "actions", "runners", "registration-token"} + case ScopeInstance: + segments = []string{"api", "v1", "admin", "actions", "runners", "registration-token"} + default: + return "", fmt.Errorf("unsupported scope level %q", scope.Level) + } + + clone := *c.baseURL + clone.Path = path.Join(append([]string{clone.Path}, segments...)...) + return clone.String(), nil +} + +type webhook struct { + ID int64 `json:"id"` + Config webhookConfigPayload `json:"config"` +} + +type webhookConfigPayload struct { + URL string `json:"url"` + ContentType string `json:"content_type"` +} + +func (c *Client) listWebhooks(ctx context.Context, scope Scope) ([]webhook, error) { + endpoint, err := c.webhooksEndpoint(scope) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", fmt.Sprintf("token %s", c.token)) + req.Header.Set("Accept", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("forgejo returned %s", resp.Status) + } + + var hooks []webhook + if err := json.NewDecoder(resp.Body).Decode(&hooks); err != nil { + return nil, err + } + + return hooks, nil +} + +func (c *Client) createWebhook(ctx context.Context, scope Scope, cfg WebhookConfig, secret string) error { + payload := webhookRequestPayload{ + Type: "gitea", + Config: map[string]string{ + "url": cfg.URL, + "content_type": cfg.ContentType, + "secret": secret, + "insecure_ssl": "0", + }, + Events: cfg.Events, + Active: cfg.Active, + } + + body, err := json.Marshal(payload) + if err != nil { + return err + } + + endpoint, err := c.webhooksEndpoint(scope) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", fmt.Sprintf("token %s", c.token)) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("forgejo returned %s", resp.Status) + } + + return nil +} + +func (c *Client) updateWebhook(ctx context.Context, scope Scope, id int64, cfg WebhookConfig, secret string) error { + payload := webhookRequestPayload{ + Type: "gitea", + Config: map[string]string{ + "url": cfg.URL, + "content_type": cfg.ContentType, + "secret": secret, + "insecure_ssl": "0", + }, + Events: cfg.Events, + Active: cfg.Active, + } + + body, err := json.Marshal(payload) + if err != nil { + return err + } + + endpoint, err := c.webhooksEndpoint(scope) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, fmt.Sprintf("%s/%d", endpoint, id), bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", fmt.Sprintf("token %s", c.token)) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("forgejo returned %s", resp.Status) + } + + return nil +} + +func (c *Client) webhooksEndpoint(scope Scope) (string, error) { + var segments []string + switch scope.Level { + case ScopeRepository: + if scope.Owner == "" || scope.Name == "" { + return "", errors.New("repository scope requires owner and name") + } + segments = []string{"api", "v1", "repos", scope.Owner, scope.Name, "hooks"} + case ScopeOrganization: + if scope.Owner == "" { + return "", errors.New("organization scope requires owner") + } + segments = []string{"api", "v1", "orgs", scope.Owner, "hooks"} + default: + return "", fmt.Errorf("webhook management not supported for scope level %q", scope.Level) + } + + clone := *c.baseURL + clone.Path = path.Join(append([]string{clone.Path}, segments...)...) + return clone.String(), nil +} + +type webhookRequestPayload struct { + Type string `json:"type"` + Config map[string]string `json:"config"` + Events []string `json:"events"` + Active bool `json:"active"` +} + +func (c *Client) runnersEndpoint(scope Scope) (string, error) { + var segments []string + switch scope.Level { + case ScopeRepository: + if scope.Owner == "" || scope.Name == "" { + return "", errors.New("repository scope requires owner and name") + } + segments = []string{"api", "v1", "repos", scope.Owner, scope.Name, "actions", "runners"} + case ScopeOrganization: + if scope.Owner == "" { + return "", errors.New("organization scope requires owner") + } + segments = []string{"api", "v1", "orgs", scope.Owner, "actions", "runners"} + case ScopeInstance: + segments = []string{"api", "v1", "actions", "runners"} + default: + return "", fmt.Errorf("unsupported scope level %q", scope.Level) + } + + clone := *c.baseURL + clone.Path = path.Join(append([]string{clone.Path}, segments...)...) + return clone.String(), nil +} + +func (c *Client) runJobsEndpoint(scope Scope) (string, error) { + var segments []string + switch scope.Level { + case ScopeRepository: + if scope.Owner == "" || scope.Name == "" { + return "", errors.New("repository scope requires owner and name") + } + segments = []string{"api", "v1", "repos", scope.Owner, scope.Name, "actions", "runners", "jobs"} + case ScopeOrganization: + if scope.Owner == "" { + return "", errors.New("organization scope requires owner") + } + segments = []string{"api", "v1", "orgs", scope.Owner, "actions", "runners", "jobs"} + default: + return "", fmt.Errorf("run jobs not supported for scope level %q", scope.Level) + } + + clone := *c.baseURL + clone.Path = path.Join(append([]string{clone.Path}, segments...)...) + return clone.String(), nil +} diff --git a/services/forgejo-nsc/internal/nsc/dispatcher.go b/services/forgejo-nsc/internal/nsc/dispatcher.go new file mode 100644 index 0000000..49cb4ec --- /dev/null +++ b/services/forgejo-nsc/internal/nsc/dispatcher.go @@ -0,0 +1,460 @@ +package nsc + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os/exec" + "strings" + "time" + + "github.com/google/uuid" + "golang.org/x/sync/semaphore" +) + +type Options struct { + BinaryPath string + DefaultImage string + DefaultMachine string + DefaultDuration time.Duration + WorkDir string + MaxParallel int64 + RunnerNamePrefix string + Executor string + Network string + ComputeBaseURL string + MacosBaseImageID string + MacosMachineArch string + Logger *slog.Logger +} + +type LaunchRequest struct { + Token string + InstanceURL string + Labels []string + Duration time.Duration + MachineType string + Image string + ExtraEnv map[string]string +} + +type Dispatcher struct { + opts Options + sem *semaphore.Weighted + log *slog.Logger +} + +func NewDispatcher(opts Options) (*Dispatcher, error) { + if opts.BinaryPath == "" { + return nil, errors.New("nsc binary path is required") + } + if opts.DefaultImage == "" { + return nil, errors.New("default Namespace runner image is required") + } + if opts.RunnerNamePrefix == "" { + opts.RunnerNamePrefix = "nscloud-" + } + if opts.Executor == "" { + opts.Executor = "shell" + } + if opts.MacosBaseImageID == "" { + opts.MacosBaseImageID = "tahoe" + } + if opts.MacosMachineArch == "" { + opts.MacosMachineArch = "arm64" + } + if opts.MaxParallel <= 0 { + opts.MaxParallel = 4 + } + if opts.DefaultDuration == 0 { + opts.DefaultDuration = 30 * time.Minute + } + logger := opts.Logger + if logger == nil { + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + + return &Dispatcher{ + opts: opts, + sem: semaphore.NewWeighted(opts.MaxParallel), + log: logger, + }, nil +} + +func (d *Dispatcher) LaunchRunner(ctx context.Context, req LaunchRequest) (string, error) { + if req.Token == "" { + return "", errors.New("registration token is required") + } + if req.InstanceURL == "" { + return "", errors.New("forgejo instance url is required") + } + if err := d.sem.Acquire(ctx, 1); err != nil { + return "", err + } + defer d.sem.Release(1) + + runnerName := d.generateName() + duration := req.Duration + if duration == 0 { + duration = d.opts.DefaultDuration + } + machineType := choose(req.MachineType, d.opts.DefaultMachine) + image := choose(req.Image, d.opts.DefaultImage) + + if hasWindowsLabel(req.Labels) { + if err := d.launchWindowsRunnerViaWinRM(ctx, runnerName, req, duration, machineType); err != nil { + return "", err + } + return runnerName, nil + } + + if hasMacOSLabel(req.Labels) { + // Compute macOS shapes differ from the Linux "run" defaults. If the request + // didn't specify a machine type, ensure we pick a macOS-valid default. + if machineType == "" || machineType == d.opts.DefaultMachine { + machineType = "12x28" + } + + // Prefer the Compute API path because it uses the service token (NSC_TOKEN_FILE) + // and does not require an interactive `nsc login` session. + if err := d.launchMacOSRunner(ctx, runnerName, req, duration, machineType); err != nil { + d.log.Warn("macos compute launch failed; falling back to nsc create+ssh", "runner", runnerName, "err", err) + if err := d.launchMacOSRunnerViaNSC(ctx, runnerName, req, duration, machineType); err != nil { + return "", err + } + } + return runnerName, nil + } + + env := map[string]string{ + "FORGEJO_INSTANCE_URL": req.InstanceURL, + "FORGEJO_RUNNER_TOKEN": req.Token, + "FORGEJO_RUNNER_NAME": runnerName, + "FORGEJO_RUNNER_LABELS": strings.Join(req.Labels, ","), + "FORGEJO_RUNNER_EXEC": d.opts.Executor, + } + for k, v := range req.ExtraEnv { + env[k] = v + } + if _, ok := env["NSC_CACHE_PATH"]; !ok { + env["NSC_CACHE_PATH"] = "/nix/store" + } + + script := d.bootstrapScript() + args := []string{ + "run", + "--wait", + "--output", + "json", + "--duration", duration.String(), + "--image", image, + "--name", runnerName, + "--user", "root", + } + if machineType != "" { + args = append(args, "--machine_type", machineType) + } + if d.opts.Network != "" { + args = append(args, "--network", d.opts.Network) + } + for key, value := range env { + if value == "" { + continue + } + args = append(args, "-e", fmt.Sprintf("%s=%s", key, value)) + } + if d.opts.WorkDir != "" { + args = append(args, "-e", fmt.Sprintf("FORGEJO_RUNNER_WORKDIR=%s", d.opts.WorkDir)) + } + + args = append(args, "--", "/bin/sh", "-c", script) + + cmd := exec.CommandContext(ctx, d.opts.BinaryPath, args...) + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + + start := time.Now() + d.log.Info("launching Namespace runner", + "runner", runnerName, + "machine_type", machineType, + "image", image, + ) + err := cmd.Run() + if err != nil { + return "", fmt.Errorf("nsc run failed: %w\n%s", err, buf.String()) + } + + if output := strings.TrimSpace(buf.String()); output != "" { + d.log.Info("runner output", "runner", runnerName, "output", output) + } + + d.log.Info("runner completed", + "runner", runnerName, + "duration", time.Since(start), + ) + + if instanceID := parseInstanceID(buf.String()); instanceID != "" { + waitCtx, cancel := context.WithTimeout(context.Background(), duration) + defer cancel() + stopped := d.waitForInstanceStop(waitCtx, runnerName, instanceID, duration) + if !stopped { + d.log.Warn("runner did not stop before timeout", "runner", runnerName, "instance", instanceID) + } + d.destroyInstance(waitCtx, runnerName, instanceID) + } + + return runnerName, nil +} + +func (d *Dispatcher) generateName() string { + id := strings.ReplaceAll(uuid.NewString(), "-", "") + return d.opts.RunnerNamePrefix + id[:12] +} + +func parseInstanceID(output string) string { + if jsonBlob := extractJSON(output); jsonBlob != "" { + var payload struct { + ClusterID string `json:"cluster_id"` + } + if err := json.Unmarshal([]byte(jsonBlob), &payload); err == nil && payload.ClusterID != "" { + return payload.ClusterID + } + } + const marker = "ID:" + idx := strings.Index(output, marker) + if idx == -1 { + return "" + } + rest := strings.TrimSpace(output[idx+len(marker):]) + if rest == "" { + return "" + } + fields := strings.Fields(rest) + if len(fields) == 0 { + return "" + } + return fields[0] +} + +func extractJSON(output string) string { + trimmed := strings.TrimSpace(output) + if trimmed == "" { + return "" + } + start := strings.IndexAny(trimmed, "[{") + if start == -1 { + return "" + } + end := strings.LastIndexAny(trimmed, "]}") + if end == -1 || end < start { + return "" + } + return trimmed[start : end+1] +} + +type describeResponse struct { + Resource string `json:"resource"` + PerResource map[string]describeTarget `json:"per_resource"` +} + +type describeTarget struct { + Tombstone string `json:"tombstone"` + Container []describeContainer `json:"container"` +} + +type describeContainer struct { + Status string `json:"status"` + TerminatedAt string `json:"terminated_at"` +} + +func instanceStopped(output string) bool { + jsonBlob := extractJSON(output) + if jsonBlob == "" { + return false + } + var payload []describeResponse + if err := json.Unmarshal([]byte(jsonBlob), &payload); err != nil { + return false + } + if len(payload) == 0 { + return false + } + for _, entry := range payload { + for _, target := range entry.PerResource { + if target.Tombstone != "" { + return true + } + if len(target.Container) == 0 { + continue + } + for _, container := range target.Container { + if container.Status != "stopped" && container.TerminatedAt == "" { + return false + } + } + } + } + return true +} + +func (d *Dispatcher) waitForInstanceStop(ctx context.Context, runnerName, instanceID string, timeout time.Duration) bool { + if timeout <= 0 { + timeout = d.opts.DefaultDuration + } + deadline := time.Now().Add(timeout) + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + stopped, err := d.checkInstanceStopped(ctx, instanceID) + if err != nil { + d.log.Warn("runner stop check failed", "runner", runnerName, "instance", instanceID, "err", err) + return false + } + if stopped { + return true + } + if time.Now().After(deadline) { + return false + } + select { + case <-ctx.Done(): + return false + case <-ticker.C: + } + } +} + +func (d *Dispatcher) checkInstanceStopped(ctx context.Context, instanceID string) (bool, error) { + cmd := exec.CommandContext(ctx, d.opts.BinaryPath, "describe", "--output", "json", instanceID) + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + if err := cmd.Run(); err != nil { + output := strings.ToLower(buf.String()) + if strings.Contains(output, "destroyed") || strings.Contains(output, "not found") { + return true, nil + } + return false, fmt.Errorf("nsc describe failed: %w\n%s", err, strings.TrimSpace(buf.String())) + } + return instanceStopped(buf.String()), nil +} + +func (d *Dispatcher) destroyInstance(ctx context.Context, runnerName, instanceID string) { + cmd := exec.CommandContext(ctx, d.opts.BinaryPath, "destroy", "--force", instanceID) + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + if err := cmd.Run(); err != nil { + d.log.Warn("runner destroy failed", "runner", runnerName, "instance", instanceID, "err", err, "output", strings.TrimSpace(buf.String())) + return + } + if output := strings.TrimSpace(buf.String()); output != "" { + d.log.Info("runner destroyed", "runner", runnerName, "instance", instanceID, "output", output) + } else { + d.log.Info("runner destroyed", "runner", runnerName, "instance", instanceID) + } +} + +func choose(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func (d *Dispatcher) bootstrapScript() string { + var builder strings.Builder + builder.WriteString(`set -euo pipefail +mkdir -p "${FORGEJO_RUNNER_WORKDIR:-/tmp/forgejo-runner}" +cd "${FORGEJO_RUNNER_WORKDIR:-/tmp/forgejo-runner}" + +if ! command -v node >/dev/null 2>&1; then + apk add --no-cache nodejs npm >/dev/null +fi +if ! command -v sudo >/dev/null 2>&1; then + apk add --no-cache sudo bash >/dev/null +fi +if ! command -v curl >/dev/null 2>&1; then + apk add --no-cache curl >/dev/null +fi +if ! command -v xz >/dev/null 2>&1; then + apk add --no-cache xz >/dev/null +fi +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +node --version >/dev/null + +cat > runner.yaml <<'EOF' +log: + level: info +runner: + file: .runner + capacity: 1 + name: ${FORGEJO_RUNNER_NAME} + labels: +EOF +`) + builder.WriteString(`runner_exec="${FORGEJO_RUNNER_EXEC:-host}" +if [ "$runner_exec" = "shell" ]; then + runner_exec="host" +fi + +resolved_labels="" +for label in ${FORGEJO_RUNNER_LABELS//,/ } ; do + if [ -z "${label}" ]; then + continue + fi + case "${label}" in + *:*) resolved="${label}" ;; + *) + if [ "$runner_exec" = "host" ]; then + resolved="${label}:host" + else + resolved="${label}:${runner_exec}" + fi + ;; + esac + echo " - ${resolved}" >> runner.yaml + if [ -z "${resolved_labels}" ]; then + resolved_labels="${resolved}" + else + resolved_labels="${resolved_labels},${resolved}" + fi +done +`) + builder.WriteString(`cat >> runner.yaml <<'EOF' +cache: + enabled: false +EOF + +forgejo-runner register \ + --no-interactive \ + --instance "${FORGEJO_INSTANCE_URL}" \ + --token "${FORGEJO_RUNNER_TOKEN}" \ + --name "${FORGEJO_RUNNER_NAME}" \ + --labels "${resolved_labels}" \ + --config runner.yaml + +runner_mode="${FORGEJO_RUNNER_MODE:-one-job}" +case "$runner_mode" in + one-job) + forgejo-runner one-job --config runner.yaml + ;; + daemon) + forgejo-runner daemon --config runner.yaml + ;; + *) + echo "Unknown FORGEJO_RUNNER_MODE: ${runner_mode}" >&2 + exit 1 + ;; +esac +`) + return builder.String() +} diff --git a/services/forgejo-nsc/internal/nsc/macos.go b/services/forgejo-nsc/internal/nsc/macos.go new file mode 100644 index 0000000..9bf3837 --- /dev/null +++ b/services/forgejo-nsc/internal/nsc/macos.go @@ -0,0 +1,708 @@ +package nsc + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" + + computev1betaconnect "buf.build/gen/go/namespace/cloud/connectrpc/go/proto/namespace/cloud/compute/v1beta/computev1betaconnect" + computev1beta "buf.build/gen/go/namespace/cloud/protocolbuffers/go/proto/namespace/cloud/compute/v1beta" + stdlib "buf.build/gen/go/namespace/cloud/protocolbuffers/go/proto/namespace/stdlib" + "connectrpc.com/connect" + "golang.org/x/crypto/ssh" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func hasMacOSLabel(labels []string) bool { + for _, label := range labels { + l := strings.TrimSpace(label) + if l == "" { + continue + } + if strings.HasPrefix(l, "namespace-profile-macos-") { + return true + } + } + return false +} + +type lockedBuffer struct { + mu sync.Mutex + b bytes.Buffer +} + +func (lb *lockedBuffer) Write(p []byte) (int, error) { + lb.mu.Lock() + defer lb.mu.Unlock() + return lb.b.Write(p) +} + +func (lb *lockedBuffer) Len() int { + lb.mu.Lock() + defer lb.mu.Unlock() + return lb.b.Len() +} + +func (lb *lockedBuffer) String() string { + lb.mu.Lock() + defer lb.mu.Unlock() + return lb.b.String() +} + +func macosSupportDiskSelectors(baseImageID string) []*stdlib.Label { + id := strings.TrimSpace(baseImageID) + if id == "" { + id = "tahoe" + } + + // Allow specifying selectors directly, e.g. "macos.version=26.x,image.with=xcode-26". + if strings.Contains(id, "=") { + var out []*stdlib.Label + for _, part := range strings.Split(id, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + name, value, ok := strings.Cut(part, "=") + name = strings.TrimSpace(name) + value = strings.TrimSpace(value) + if !ok || name == "" || value == "" { + continue + } + out = append(out, &stdlib.Label{Name: name, Value: value}) + } + if len(out) > 0 { + return out + } + } + + // Human-friendly presets used by burrow config. + switch strings.ToLower(id) { + case "sonoma", "macos-14", "macos14", "14": + return []*stdlib.Label{{Name: "macos.version", Value: "14.x"}} + case "sequoia", "macos-15", "macos15", "15": + return []*stdlib.Label{{Name: "macos.version", Value: "15.x"}} + case "tahoe", "macos-26", "macos26", "26": + // Constrain to the Xcode 26 support disk explicitly, since Apple builds + // depend on Xcode being present and Compute currently errors if it can't + // resolve a support disk selection. + return []*stdlib.Label{{Name: "macos.version", Value: "26.x"}, {Name: "image.with", Value: "xcode-26"}} + default: + return []*stdlib.Label{{Name: "macos.version", Value: "26.x"}} + } +} + +func macosComputeBaseImageID(baseImageID string) string { + id := strings.TrimSpace(baseImageID) + if id == "" { + return "tahoe" + } + // If selectors were provided directly, we cannot safely infer a canonical + // base image ID from them. + if strings.Contains(id, "=") { + return "" + } + switch strings.ToLower(id) { + case "sonoma", "macos-14", "macos14", "14": + return "sonoma" + case "sequoia", "macos-15", "macos15", "15": + return "sequoia" + case "tahoe", "macos-26", "macos26", "26": + return "tahoe" + default: + return id + } +} + +type nscBearerTokenFile struct { + BearerToken string `json:"bearer_token"` +} + +func readNSCBearerToken() (string, error) { + path := os.Getenv("NSC_TOKEN_FILE") + if path == "" { + return "", errors.New("NSC_TOKEN_FILE is required for macos runners") + } + raw, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read NSC_TOKEN_FILE: %w", err) + } + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" { + return "", errors.New("NSC_TOKEN_FILE is empty") + } + // Support the on-host format used by burrow: {"bearer_token":"..."}. + var parsed nscBearerTokenFile + if err := json.Unmarshal([]byte(trimmed), &parsed); err == nil && parsed.BearerToken != "" { + return parsed.BearerToken, nil + } + // Fallback: allow a raw bearer token. + return trimmed, nil +} + +func parseMachineTypeCPUxMemGB(machineType string) (vcpu int32, memoryMB int32, err error) { + parts := strings.Split(machineType, "x") + if len(parts) != 2 { + return 0, 0, fmt.Errorf("invalid machine_type %q: expected CPUxMemoryGB (e.g. 12x28)", machineType) + } + cpu64, err := strconv.ParseInt(parts[0], 10, 32) + if err != nil { + return 0, 0, fmt.Errorf("invalid machine_type %q: cpu: %w", machineType, err) + } + memGB64, err := strconv.ParseInt(parts[1], 10, 32) + if err != nil { + return 0, 0, fmt.Errorf("invalid machine_type %q: memory: %w", machineType, err) + } + return int32(cpu64), int32(memGB64 * 1024), nil +} + +func (d *Dispatcher) launchMacOSRunner(ctx context.Context, runnerName string, req LaunchRequest, ttl time.Duration, machineType string) error { + if machineType == "" { + return errors.New("machine_type is required for macos runners") + } + vcpu, memoryMB, err := parseMachineTypeCPUxMemGB(machineType) + if err != nil { + return err + } + bearer, err := readNSCBearerToken() + if err != nil { + return err + } + + httpClient := &http.Client{Timeout: 60 * time.Second} + client := computev1betaconnect.NewComputeServiceClient(httpClient, d.opts.ComputeBaseURL) + + workdir := d.opts.WorkDir + if strings.TrimSpace(workdir) == "" { + workdir = "/tmp/forgejo-runner" + } + + env := map[string]string{ + "FORGEJO_INSTANCE_URL": req.InstanceURL, + "FORGEJO_RUNNER_TOKEN": req.Token, + "FORGEJO_RUNNER_NAME": runnerName, + "FORGEJO_RUNNER_LABELS": strings.Join(req.Labels, ","), + "FORGEJO_RUNNER_EXEC": d.opts.Executor, + "FORGEJO_RUNNER_WORKDIR": workdir, + } + for k, v := range req.ExtraEnv { + env[k] = v + } + // Best-effort caching: workflows call Scripts/nscloud-cache.sh, which is a + // no-op unless NSC_CACHE_PATH is set. This may still be skipped if spacectl + // lacks credentials, but setting the path is harmless and keeps behavior + // consistent across macOS / Linux runners. + if _, ok := env["NSC_CACHE_PATH"]; !ok { + env["NSC_CACHE_PATH"] = "/Users/runner/.cache/nscloud" + } + + deadline := timestamppb.New(time.Now().Add(ttl)) + + createReq := &computev1beta.CreateInstanceRequest{ + Shape: &computev1beta.InstanceShape{ + VirtualCpu: vcpu, + MemoryMegabytes: memoryMB, + MachineArch: d.opts.MacosMachineArch, + Os: "macos", + // Namespace macOS compute requires selectors to pick the base image + // ("support disk"), otherwise instance creation fails. + Selectors: macosSupportDiskSelectors(d.opts.MacosBaseImageID), + }, + DocumentedPurpose: fmt.Sprintf("burrow forgejo runner %s", runnerName), + Deadline: deadline, + Labels: []*stdlib.Label{ + {Name: "nsc.source", Value: "forgejo-nsc"}, + {Name: "burrow.service", Value: "forgejo-runner"}, + {Name: "burrow.runner", Value: runnerName}, + }, + Applications: []*computev1beta.ApplicationRequest{ + { + Name: "forgejo-runner", + Command: "/bin/bash", + Args: []string{"-lc", macosBootstrapScript()}, + Environment: env, + WorkloadType: computev1beta.ApplicationRequest_JOB, + }, + }, + } + if imageID := macosComputeBaseImageID(d.opts.MacosBaseImageID); imageID != "" { + createReq.Experimental = &computev1beta.CreateInstanceRequest_ExperimentalFeatures{ + MacosBaseImageId: imageID, + } + } + + d.log.Info("launching Namespace macos runner", + "runner", runnerName, + "compute_base_url", d.opts.ComputeBaseURL, + "macos_base_image_id", d.opts.MacosBaseImageID, + "shape", fmt.Sprintf("%dx%d", vcpu, memoryMB/1024), + "arch", d.opts.MacosMachineArch, + ) + + reqCreate := connect.NewRequest(createReq) + reqCreate.Header().Set("Authorization", "Bearer "+bearer) + resp, err := client.CreateInstance(ctx, reqCreate) + if err != nil { + return fmt.Errorf("compute create instance failed: %w", err) + } + if resp.Msg == nil || resp.Msg.Metadata == nil { + return errors.New("compute create instance returned no metadata") + } + instanceID := resp.Msg.Metadata.InstanceId + + waitErr := d.waitForMacOSRunnerStop(ctx, client, bearer, runnerName, instanceID, ttl) + d.destroyComputeInstance(context.Background(), client, bearer, runnerName, instanceID) + return waitErr +} + +func (d *Dispatcher) runMacOSComputeSSHScript(ctx context.Context, runnerName, instanceID, script string) error { + bearer, err := readNSCBearerToken() + if err != nil { + return err + } + + httpClient := &http.Client{Timeout: 60 * time.Second} + client := computev1betaconnect.NewComputeServiceClient(httpClient, d.opts.ComputeBaseURL) + + getReq := connect.NewRequest(&computev1beta.GetSSHConfigRequest{ + InstanceId: instanceID, + // TargetContainer is optional. Keep it empty to run commands in the default instance environment. + }) + getReq.Header().Set("Authorization", "Bearer "+bearer) + + resp, err := client.GetSSHConfig(ctx, getReq) + if err != nil { + return fmt.Errorf("compute get ssh config failed: %w", err) + } + if resp.Msg == nil { + return errors.New("compute get ssh config returned empty response") + } + if resp.Msg.Endpoint == "" { + return errors.New("compute get ssh config returned empty endpoint") + } + if len(resp.Msg.SshPrivateKey) == 0 { + return errors.New("compute get ssh config returned empty ssh private key") + } + if strings.TrimSpace(resp.Msg.Username) == "" { + return errors.New("compute get ssh config returned empty username") + } + + signer, err := ssh.ParsePrivateKey(resp.Msg.SshPrivateKey) + if err != nil { + return fmt.Errorf("parse ssh private key: %w", err) + } + + addr := fmt.Sprintf("%s:22", resp.Msg.Endpoint) + conn, err := net.Dial("tcp", addr) + if err != nil { + return fmt.Errorf("dial ssh endpoint: %w", err) + } + defer conn.Close() + + sshCfg := &ssh.ClientConfig{ + User: resp.Msg.Username, + Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), // Endpoint is short-lived and key is delivered out-of-band. + Timeout: 30 * time.Second, + } + + c, chans, reqs, err := ssh.NewClientConn(conn, addr, sshCfg) + if err != nil { + return fmt.Errorf("ssh client conn: %w", err) + } + clientSSH := ssh.NewClient(c, chans, reqs) + defer clientSSH.Close() + + session, err := clientSSH.NewSession() + if err != nil { + return fmt.Errorf("ssh new session: %w", err) + } + defer session.Close() + + var buf bytes.Buffer + session.Stdout = &buf + session.Stderr = &buf + session.Stdin = strings.NewReader(script) + + // Feed the bootstrap script via stdin so we don't need to quote/escape it. + // + // Note: Some SSH servers do not reliably parse exec strings with arguments. + // Running bare `/bin/bash` still reads from stdin and avoids argument parsing. + if err := session.Run("/bin/bash"); err != nil { + outRaw := buf.String() + out := strings.TrimSpace(outRaw) + + // Some SSH servers reject exec requests and only allow interactive shells, + // and others will "succeed" but still interpret stdin under the default + // login shell (showing the zsh banner / prompts). + // + // In those cases, retry via Shell() with a PTY. + exitStatus := 0 + exitErr, isExitErr := err.(*ssh.ExitError) + if isExitErr { + exitStatus = exitErr.ExitStatus() + } + + looksInteractive := strings.Contains(outRaw, "The default interactive shell is now zsh") || + strings.Contains(outRaw, " runner$ ") || + strings.Contains(outRaw, "bash-3.2$") + shouldFallback := !isExitErr || looksInteractive + + if shouldFallback { + d.log.Warn("compute ssh exec bootstrap failed; retrying via interactive shell", + "runner", runnerName, + "instance", instanceID, + "exit_status", exitStatus, + ) + + session2, err2 := clientSSH.NewSession() + if err2 != nil { + return fmt.Errorf("ssh new session (fallback): %w", err2) + } + defer session2.Close() + + // bytes.Buffer isn't safe for concurrent writes + reads; the SSH session + // writes from background goroutines. Wrap it so we can poll for a prompt + // before sending commands. + lb := &lockedBuffer{} + session2.Stdout = lb + session2.Stderr = lb + + in, err2 := session2.StdinPipe() + if err2 != nil { + return fmt.Errorf("ssh stdin pipe (fallback): %w", err2) + } + + // Request a PTY to match interactive semantics even when the caller + // doesn't have a local terminal. + _ = session2.RequestPty("xterm", 24, 80, nil) + + if err2 := session2.Shell(); err2 != nil { + return fmt.Errorf("ssh shell (fallback): %w", err2) + } + + // Wait briefly for the prompt/banner so the first command isn't dropped. + // We also emit a sentinel `echo` to verify the TTY is live. + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + n := lb.Len() + if n > 0 { + break + } + time.Sleep(50 * time.Millisecond) + } + + // Stream the script then exit. Prefer LF line endings; macOS shells and + // PTYs can treat CRLF as literal CR characters (breaking heredoc + // delimiters and quoting). + writeTTY := func(s string) { + if s == "" { + return + } + s = strings.ReplaceAll(s, "\r\n", "\n") + _, _ = io.WriteString(in, s) + } + + scriptTTY := strings.ReplaceAll(script, "\r\n", "\n") + + // Cut down noise in logs and reduce the chance of ZSH line-editing + // behavior corrupting long inputs. + writeTTY("stty -echo 2>/dev/null || true\n") + writeTTY("echo BURROW_BOOTSTRAP_TTY_OK\n") + + // Avoid heredocs for the script itself (PTY newline handling is fragile). + // Instead, stream base64 in short chunks to a file, then decode and run it. + enc := base64.StdEncoding.EncodeToString([]byte(scriptTTY)) + idSafe := strings.ReplaceAll(instanceID, "-", "_") + b64Path := "/tmp/burrow-bootstrap-" + idSafe + ".b64" + shPath := "/tmp/burrow-bootstrap-" + idSafe + ".sh" + + writeTTY("rm -f " + b64Path + " " + shPath + "\n") + writeTTY(": > " + b64Path + "\n") + + const chunkSize = 80 + for i := 0; i < len(enc); i += chunkSize { + j := i + chunkSize + if j > len(enc) { + j = len(enc) + } + chunk := enc[i:j] + // Base64 chunks contain only [A-Za-z0-9+/=], which are safe to pass + // unquoted. Avoid quotes entirely so a truncated line can't leave + // the remote shell in a multi-line continuation state. + writeTTY("printf %s " + chunk + " >> " + b64Path + "\n") + time.Sleep(5 * time.Millisecond) + } + + // macOS uses `base64 -D` (BSD), some environments use `-d` (GNU). + writeTTY("base64 -D " + b64Path + " > " + shPath + " 2>/dev/null || base64 -d " + b64Path + " > " + shPath + "\n") + writeTTY("/bin/bash " + shPath + "\n") + writeTTY("exit\n") + _ = in.Close() + + if err2 := session2.Wait(); err2 != nil { + out2 := strings.TrimSpace(lb.String()) + if len(out2) > 16*1024 { + out2 = out2[len(out2)-16*1024:] + } + return fmt.Errorf("compute ssh runner bootstrap failed (shell fallback): %w\n%s", err2, out2) + } + + d.log.Info("macos runner bootstrap completed via compute ssh shell", "runner", runnerName, "instance", instanceID) + return nil + } + + if len(out) > 16*1024 { + out = out[len(out)-16*1024:] + } + return fmt.Errorf("compute ssh runner bootstrap failed: %w\n%s", err, out) + } + + d.log.Info("macos runner bootstrap completed via compute ssh", "runner", runnerName, "instance", instanceID) + return nil +} + +func (d *Dispatcher) waitForMacOSRunnerStop(ctx context.Context, client computev1betaconnect.ComputeServiceClient, bearer, runnerName, instanceID string, ttl time.Duration) error { + if ttl <= 0 { + ttl = d.opts.DefaultDuration + } + deadline := time.Now().Add(ttl) + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + + for { + stopped, err := d.checkComputeInstanceStopped(ctx, client, bearer, instanceID) + if err != nil { + d.log.Warn("macos runner stop check failed", "runner", runnerName, "instance", instanceID, "err", err) + } else if stopped { + return nil + } + + if time.Now().After(deadline) { + return fmt.Errorf("macos runner exceeded ttl (%s) without stopping", ttl) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func (d *Dispatcher) checkComputeInstanceStopped(ctx context.Context, client computev1betaconnect.ComputeServiceClient, bearer, instanceID string) (bool, error) { + describeReq := connect.NewRequest(&computev1beta.DescribeInstanceRequest{InstanceId: instanceID}) + describeReq.Header().Set("Authorization", "Bearer "+bearer) + resp, err := client.DescribeInstance(ctx, describeReq) + if err != nil { + // NotFound means the instance is already gone. + if connect.CodeOf(err) == connect.CodeNotFound { + return true, nil + } + return false, err + } + if resp.Msg == nil || resp.Msg.Metadata == nil { + return false, errors.New("describe instance returned no metadata") + } + switch resp.Msg.Metadata.Status { + case computev1beta.InstanceMetadata_DESTROYED: + return true, nil + case computev1beta.InstanceMetadata_ERROR: + // Best-effort include shutdown reasons; do not include unbounded output. + var b strings.Builder + for _, reason := range resp.Msg.ShutdownReasons { + if reason == nil { + continue + } + if b.Len() > 0 { + b.WriteString("; ") + } + b.WriteString(reason.String()) + if b.Len() > 1024 { + break + } + } + msg := strings.TrimSpace(b.String()) + if msg == "" { + msg = "unknown shutdown reason" + } + return true, fmt.Errorf("instance entered error state: %s", msg) + default: + if resp.Msg.Metadata.DestroyedAt != nil { + return true, nil + } + return false, nil + } +} + +func (d *Dispatcher) destroyComputeInstance(ctx context.Context, client computev1betaconnect.ComputeServiceClient, bearer, runnerName, instanceID string) { + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + destroyReq := connect.NewRequest(&computev1beta.DestroyInstanceRequest{InstanceId: instanceID}) + destroyReq.Header().Set("Authorization", "Bearer "+bearer) + if _, err := client.DestroyInstance(ctx, destroyReq); err != nil { + if connect.CodeOf(err) == connect.CodeNotFound { + d.log.Info("macos runner destroyed", "runner", runnerName, "instance", instanceID, "status", "not_found") + return + } + d.log.Warn("macos runner destroy failed", "runner", runnerName, "instance", instanceID, "err", err) + return + } + d.log.Info("macos runner destroyed", "runner", runnerName, "instance", instanceID) +} + +func macosBootstrapScript() string { + // Keep this script self-contained: it runs on a fresh macOS VM base image. + var b strings.Builder + b.WriteString(`set -euo pipefail + +workdir="${FORGEJO_RUNNER_WORKDIR:-/tmp/forgejo-runner}" +mkdir -p "${workdir}" +cd "${workdir}" + +export PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH}" + +if ! command -v curl >/dev/null 2>&1; then + echo "curl is required" >&2 + exit 1 +fi + +if ! command -v nix >/dev/null 2>&1; then + echo "Installing nix (Determinate Systems installer)..." + installer="/tmp/nix-installer.$$" + curl -fsSL -o "${installer}" https://install.determinate.systems/nix + chmod +x "${installer}" + + if command -v sudo >/dev/null 2>&1; then + if sudo -n true 2>/dev/null; then + sudo -n sh "${installer}" install --no-confirm + else + sudo sh "${installer}" install --no-confirm + fi + else + sh "${installer}" install --no-confirm + fi + + rm -f "${installer}" +fi + +if [[ -f /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh ]]; then + # shellcheck disable=SC1091 + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh +fi + +export PATH="/nix/var/nix/profiles/default/bin:/nix/var/nix/profiles/default/sbin:${PATH}" + +# Flake builds need nix-command + flakes enabled. Workflows may layer additional +# config, but ensure a sane default exists. +mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/nix" +cat > "${XDG_CONFIG_HOME:-$HOME/.config}/nix/nix.conf" <<'EOF' +experimental-features = nix-command flakes +sandbox = true +fallback = true +substituters = https://cache.nixos.org +trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= +EOF + +mkdir -p bin +export PATH="${PWD}/bin:${PATH}" + +runner_version="v12.6.4" +runner_src_tgz="forgejo-runner-${runner_version}.tar.gz" +runner_src_url="https://code.forgejo.org/forgejo/runner/archive/${runner_version}.tar.gz" +runner_src_dir="forgejo-runner-src" + +if ! command -v forgejo-runner >/dev/null 2>&1; then + rm -rf "${runner_src_dir}" + mkdir -p "${runner_src_dir}" + curl -fsSL "${runner_src_url}" -o "${runner_src_tgz}" + tar -xzf "${runner_src_tgz}" -C "${runner_src_dir}" --strip-components=1 + + toolchain="$(grep -E '^toolchain ' "${runner_src_dir}/go.mod" | awk '{print $2}' | head -n 1 || true)" + if [ -z "${toolchain}" ]; then + toolchain="go1.25.7" + fi + + if ! command -v go >/dev/null 2>&1; then + go_tgz="${toolchain}.darwin-arm64.tar.gz" + go_url="https://go.dev/dl/${go_tgz}" + curl -fsSL "${go_url}" -o "${go_tgz}" + tar -xzf "${go_tgz}" + export GOROOT="${PWD}/go" + export PATH="${GOROOT}/bin:${PATH}" + fi + + export GOPATH="${PWD}/.gopath" + export GOMODCACHE="${PWD}/.gomodcache" + export GOCACHE="${PWD}/.gocache" + mkdir -p "${GOPATH}" "${GOMODCACHE}" "${GOCACHE}" + + (cd "${runner_src_dir}" && go build -o "${workdir}/bin/forgejo-runner" .) + chmod +x "${workdir}/bin/forgejo-runner" +fi + +cat > runner.yaml <<'EOF' +log: + level: info +runner: + file: .runner + capacity: 1 + name: ${FORGEJO_RUNNER_NAME} + labels: +EOF + +runner_exec="${FORGEJO_RUNNER_EXEC:-host}" +if [ "$runner_exec" = "shell" ]; then + runner_exec="host" +fi + +resolved_labels="" +for label in ${FORGEJO_RUNNER_LABELS//,/ } ; do + if [ -z "${label}" ]; then + continue + fi + case "${label}" in + *:*) resolved="${label}" ;; + *) + resolved="${label}:host" + ;; + esac + echo " - ${resolved}" >> runner.yaml + if [ -z "${resolved_labels}" ]; then + resolved_labels="${resolved}" + else + resolved_labels="${resolved_labels},${resolved}" + fi +done + +cat >> runner.yaml <<'EOF' +cache: + enabled: false +EOF + +forgejo-runner register \ + --no-interactive \ + --instance "${FORGEJO_INSTANCE_URL}" \ + --token "${FORGEJO_RUNNER_TOKEN}" \ + --name "${FORGEJO_RUNNER_NAME}" \ + --labels "${resolved_labels}" \ + --config runner.yaml + +forgejo-runner one-job --config runner.yaml +`) + return b.String() +} diff --git a/services/forgejo-nsc/internal/nsc/macos_nsc.go b/services/forgejo-nsc/internal/nsc/macos_nsc.go new file mode 100644 index 0000000..c22fadb --- /dev/null +++ b/services/forgejo-nsc/internal/nsc/macos_nsc.go @@ -0,0 +1,373 @@ +package nsc + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +func normalizeMacOSNSCMachineType(machineType string) (normalized string, changed bool, err error) { + vcpu, memoryMB, err := parseMachineTypeCPUxMemGB(machineType) + if err != nil { + return "", false, err + } + memGB := memoryMB / 1024 + if memGB <= 0 || vcpu <= 0 { + return "", false, fmt.Errorf("invalid machine_type %q after parse: vcpu=%d memGB=%d", machineType, vcpu, memGB) + } + + // NSC CLI (and the underlying InstanceService) enforce discrete cpu/mem sets + // for macOS. Normalize requested values by rounding up to the closest allowed + // values to keep provisioning stable even when configs drift. + // + // Observed allowed sets from Namespace API error output for macos/arm64: + // cpu: [4 6 8 12] + // mem: [7 14 28 56] (GB) + allowedCPU := []int32{4, 6, 8, 12} + allowedMemGB := []int32{7, 14, 28, 56} + + roundUp := func(v int32, allowed []int32) (int32, bool) { + for _, a := range allowed { + if v <= a { + return a, a != v + } + } + // Clamp to max if above all allowed values. + return allowed[len(allowed)-1], true + } + + newCPU, cpuChanged := roundUp(vcpu, allowedCPU) + newMemGB, memChanged := roundUp(memGB, allowedMemGB) + + normalized = fmt.Sprintf("%dx%d", newCPU, newMemGB) + changed = cpuChanged || memChanged + return normalized, changed, nil +} + +func (d *Dispatcher) launchMacOSRunnerViaNSC(ctx context.Context, runnerName string, req LaunchRequest, ttl time.Duration, machineType string) error { + if machineType == "" { + return errors.New("machine_type is required for macos runners") + } + if strings.TrimSpace(os.Getenv("NSC_TOKEN_FILE")) == "" { + // The Burrow forge host feeds NSC_TOKEN_FILE from the intake-backed runtime token. + return errors.New("NSC_TOKEN_FILE is required for macos runners") + } + + selectors := macosSelectorsArg(d.opts.MacosBaseImageID) + if selectors == "" { + return errors.New("macos selectors resolved empty") + } + + normalizedMachineType := machineType + if n, changed, err := normalizeMacOSNSCMachineType(machineType); err != nil { + return err + } else if changed { + normalizedMachineType = n + } + + // If capacity is constrained for the requested (large) shape, try a small + // set of progressively smaller shapes before failing the dispatch request. + // This keeps macOS builds flowing even when large runners are scarce. + candidates := []string{normalizedMachineType, "8x28", "6x14", "4x7"} + seen := map[string]struct{}{} + var uniq []string + for _, c := range candidates { + c = strings.TrimSpace(c) + if c == "" { + continue + } + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + uniq = append(uniq, c) + } + candidates = uniq + + type attemptCfg struct { + waitTimeout time.Duration + createTimeout time.Duration + } + attempts := []attemptCfg{ + {waitTimeout: 6 * time.Minute, createTimeout: 8 * time.Minute}, + {waitTimeout: 4 * time.Minute, createTimeout: 6 * time.Minute}, + {waitTimeout: 3 * time.Minute, createTimeout: 5 * time.Minute}, + } + + createInstance := func(mt string, a attemptCfg) (instanceID string, out string, err error) { + tmpDir, err := os.MkdirTemp("", "forgejo-nsc-macos-*") + if err != nil { + return "", "", fmt.Errorf("mktemp: %w", err) + } + defer os.RemoveAll(tmpDir) + + metaPath := filepath.Join(tmpDir, "create.json") + cidPath := filepath.Join(tmpDir, "create.cid") + + arch := strings.TrimSpace(d.opts.MacosMachineArch) + if arch == "" { + arch = "arm64" + } + // Namespace CLI requires the "os/arch:" prefix to create a macOS instance. + // Without it, `nsc create` defaults to Linux even if selectors include macos.*. + machineType := fmt.Sprintf("macos/%s:%s", arch, mt) + + args := []string{ + "create", + "--duration", ttl.String(), + "--machine_type", machineType, + "--selectors", selectors, + "--bare", + "--cidfile", cidPath, + "--log_actions", + "--purpose", fmt.Sprintf("burrow forgejo runner %s", runnerName), + // Prefer plain output for debuggability (progress, capacity errors, etc). + "--output", "plain", + "--output_json_to", metaPath, + // macOS instances can take a while to become ready. + "--wait_timeout", a.waitTimeout.String(), + } + args = prependNSCRegionArgs(args, d.opts.ComputeBaseURL) + + createCtx, cancel := context.WithTimeout(ctx, a.createTimeout) + defer cancel() + + cmd := exec.CommandContext(createCtx, d.opts.BinaryPath, args...) + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + + if err := cmd.Run(); err != nil { + // Best-effort cleanup: if the instance ID was written before the command failed + // (or before we timed it out), attempt to destroy it to avoid idling machines. + if instanceID := strings.TrimSpace(mustReadFile(cidPath)); instanceID != "" { + d.destroyNSCInstance(context.Background(), runnerName, instanceID) + } + if errors.Is(createCtx.Err(), context.DeadlineExceeded) { + return "", buf.String(), fmt.Errorf("nsc create timed out after %s", a.createTimeout) + } + return "", buf.String(), fmt.Errorf("nsc create failed: %w", err) + } + + instanceID, err = readNSCCreateInstanceID(metaPath) + if err != nil { + return "", buf.String(), fmt.Errorf("nsc create output parse failed: %w", err) + } + if instanceID == "" { + return "", buf.String(), fmt.Errorf("nsc create returned empty instance id") + } + return instanceID, buf.String(), nil + } + + var ( + instanceID string + lastOut string + lastErr error + ) + for i, mt := range candidates { + a := attempts[i] + if i >= len(attempts) { + a = attempts[len(attempts)-1] + } + + d.log.Info("launching Namespace macos runner via nsc", + "runner", runnerName, + "attempt", i+1, + "machine_type", mt, + "requested_machine_type", machineType, + "selectors", selectors, + ) + + id, out, err := createInstance(mt, a) + lastOut = out + lastErr = err + if err != nil { + // Timeouts are treated as retryable (capacity constrained). + if strings.Contains(err.Error(), "timed out") || strings.Contains(strings.ToLower(out), "capacity") { + continue + } + return fmt.Errorf("%w\n%s", err, out) + } + instanceID = id + break + } + if instanceID == "" { + if lastErr != nil { + return fmt.Errorf("%w\n%s", lastErr, lastOut) + } + return fmt.Errorf("nsc create failed without producing an instance id\n%s", lastOut) + } + + // Always attempt cleanup even if the runner fails. + defer d.destroyNSCInstance(context.Background(), runnerName, instanceID) + + script := macosBootstrapWrapperScript(runnerName, req, d.opts.Executor, d.opts.WorkDir) + // Use the Compute SSH config endpoint (direct TCP) instead of `nsc ssh`, which + // relies on a websocket-based SSH proxy that is not supported by the + // revokable tenant token we run the dispatcher with. + if err := d.runMacOSComputeSSHScript(ctx, runnerName, instanceID, script); err != nil { + return err + } + return nil +} + +func mustReadFile(path string) string { + raw, err := os.ReadFile(path) + if err != nil { + return "" + } + return string(raw) +} + +func macosSelectorsArg(baseImageID string) string { + id := strings.TrimSpace(baseImageID) + if id == "" { + id = "tahoe" + } + // Allow passing selectors directly via config, e.g. "macos.version=26.x,image.with=xcode-26". + if strings.Contains(id, "=") { + return id + } + switch strings.ToLower(id) { + case "sonoma", "macos-14", "macos14", "14": + return "macos.version=14.x" + case "sequoia", "macos-15", "macos15", "15": + return "macos.version=15.x" + case "tahoe", "macos-26", "macos26", "26": + return "macos.version=26.x,image.with=xcode-26" + default: + return "macos.version=26.x" + } +} + +type nscCreateMetadata struct { + InstanceID string `json:"instance_id"` + ClusterID string `json:"cluster_id"` + ID string `json:"id"` +} + +func readNSCCreateInstanceID(path string) (string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read %s: %w", path, err) + } + var meta nscCreateMetadata + if err := json.Unmarshal(raw, &meta); err != nil { + return "", err + } + if meta.InstanceID != "" { + return meta.InstanceID, nil + } + if meta.ClusterID != "" { + return meta.ClusterID, nil + } + if meta.ID != "" { + return meta.ID, nil + } + return "", nil +} + +func (d *Dispatcher) destroyNSCInstance(ctx context.Context, runnerName, instanceID string) { + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + args := []string{"destroy", "--force", instanceID} + args = prependNSCRegionArgs(args, d.opts.ComputeBaseURL) + cmd := exec.CommandContext(ctx, d.opts.BinaryPath, args...) + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + if err := cmd.Run(); err != nil { + d.log.Warn("nsc destroy failed", "runner", runnerName, "instance", instanceID, "err", err, "output", strings.TrimSpace(buf.String())) + return + } + d.log.Info("nsc instance destroyed", "runner", runnerName, "instance", instanceID) +} + +func macosBootstrapWrapperScript(runnerName string, req LaunchRequest, executor, workdir string) string { + if strings.TrimSpace(workdir) == "" { + workdir = "/tmp/forgejo-runner" + } + + // Pass all values via stdin script so secrets do not appear in the nsc ssh argv. + env := map[string]string{ + "FORGEJO_INSTANCE_URL": req.InstanceURL, + "FORGEJO_RUNNER_TOKEN": req.Token, + "FORGEJO_RUNNER_NAME": runnerName, + "FORGEJO_RUNNER_LABELS": strings.Join(req.Labels, ","), + "FORGEJO_RUNNER_EXEC": executor, + "FORGEJO_RUNNER_WORKDIR": workdir, + } + for k, v := range req.ExtraEnv { + env[k] = v + } + + var b strings.Builder + b.WriteString("set -euo pipefail\n") + for k, v := range env { + if strings.TrimSpace(k) == "" { + continue + } + // Single-quote shell escaping: safe for arbitrary tokens. + b.WriteString("export ") + b.WriteString(k) + b.WriteString("=") + b.WriteString(shellSingleQuote(v)) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(macosBootstrapScript()) + return b.String() +} + +func shellSingleQuote(value string) string { + // 'foo' -> '\'' within single quotes: '"'"' + return "'" + strings.ReplaceAll(value, "'", `'\"'\"'`) + "'" +} + +func prependNSCRegionArgs(args []string, computeBaseURL string) []string { + region := strings.TrimSpace(os.Getenv("NSC_REGION")) + if region == "" { + region = regionFromComputeBaseURL(computeBaseURL) + } + if region == "" { + // Default to the burrow region used for other Namespace integrations. + region = "ord4" + } + return append([]string{"--region", region}, args...) +} + +func regionFromComputeBaseURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil { + return "" + } + host := u.Hostname() + if host == "" { + return "" + } + parts := strings.Split(host, ".") + if len(parts) == 0 { + return "" + } + // ord4.compute.namespaceapis.com -> ord4 + if strings.HasSuffix(host, ".compute.namespaceapis.com") || strings.Contains(host, ".compute.") { + return parts[0] + } + return "" +} diff --git a/services/forgejo-nsc/internal/nsc/windows.go b/services/forgejo-nsc/internal/nsc/windows.go new file mode 100644 index 0000000..5c82d29 --- /dev/null +++ b/services/forgejo-nsc/internal/nsc/windows.go @@ -0,0 +1,59 @@ +package nsc + +import ( + "regexp" + "strings" +) + +const windowsDefaultMachineType = "windows/amd64:8x16" + +var cpuMemShapePattern = regexp.MustCompile(`^\d+x\d+$`) + +func hasWindowsLabel(labels []string) bool { + for _, label := range labels { + l := strings.TrimSpace(label) + if l == "" { + continue + } + base := l + if before, _, ok := strings.Cut(l, ":"); ok { + base = before + } + if strings.HasPrefix(base, "namespace-profile-windows-") { + return true + } + } + return false +} + +func normalizeWindowsMachineType(machineType string, labels []string) string { + mt := strings.TrimSpace(machineType) + if strings.HasPrefix(mt, "windows/") { + return mt + } + if cpuMemShapePattern.MatchString(mt) { + return "windows/amd64:" + mt + } + + // Label-derived defaults: keep a simple shape ladder for explicit profile sizes. + for _, label := range labels { + base := strings.TrimSpace(label) + if before, _, ok := strings.Cut(base, ":"); ok { + base = before + } + switch { + case strings.HasPrefix(base, "namespace-profile-windows-small"): + return "windows/amd64:2x4" + case strings.HasPrefix(base, "namespace-profile-windows-medium"): + return "windows/amd64:4x8" + case strings.HasPrefix(base, "namespace-profile-windows-large"): + return windowsDefaultMachineType + } + } + return windowsDefaultMachineType +} + +func powershellSingleQuote(value string) string { + // PowerShell single-quoted string escaping: ' -> '' + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} diff --git a/services/forgejo-nsc/internal/nsc/windows_test.go b/services/forgejo-nsc/internal/nsc/windows_test.go new file mode 100644 index 0000000..2f1b5e6 --- /dev/null +++ b/services/forgejo-nsc/internal/nsc/windows_test.go @@ -0,0 +1,98 @@ +package nsc + +import "testing" + +func TestHasWindowsLabel(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + labels []string + want bool + }{ + { + name: "namespace windows label", + labels: []string{"namespace-profile-windows-large"}, + want: true, + }, + { + name: "namespace windows label with host suffix", + labels: []string{"namespace-profile-windows-large:host"}, + want: true, + }, + { + name: "non namespace windows-like label", + labels: []string{"burrow-winrunner:host"}, + want: false, + }, + { + name: "macos label", + labels: []string{"namespace-profile-macos-large"}, + want: false, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := hasWindowsLabel(tc.labels) + if got != tc.want { + t.Fatalf("hasWindowsLabel(%v) = %v, want %v", tc.labels, got, tc.want) + } + }) + } +} + +func TestNormalizeWindowsMachineType(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + machine string + labels []string + wantPrefix string + }{ + { + name: "explicit windows machine type keeps value", + machine: "windows/amd64:8x16", + labels: []string{"namespace-profile-windows-large"}, + wantPrefix: "windows/amd64:8x16", + }, + { + name: "shape only is normalized", + machine: "4x8", + labels: []string{"namespace-profile-windows-large"}, + wantPrefix: "windows/amd64:4x8", + }, + { + name: "large label default", + machine: "", + labels: []string{"namespace-profile-windows-large"}, + wantPrefix: "windows/amd64:8x16", + }, + { + name: "medium label default", + machine: "", + labels: []string{"namespace-profile-windows-medium"}, + wantPrefix: "windows/amd64:4x8", + }, + { + name: "fallback default", + machine: "", + labels: []string{"namespace-profile-windows-custom"}, + wantPrefix: "windows/amd64:8x16", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := normalizeWindowsMachineType(tc.machine, tc.labels) + if got != tc.wantPrefix { + t.Fatalf("normalizeWindowsMachineType(%q, %v) = %q, want %q", tc.machine, tc.labels, got, tc.wantPrefix) + } + }) + } +} diff --git a/services/forgejo-nsc/internal/nsc/windows_winrm.go b/services/forgejo-nsc/internal/nsc/windows_winrm.go new file mode 100644 index 0000000..22f13c9 --- /dev/null +++ b/services/forgejo-nsc/internal/nsc/windows_winrm.go @@ -0,0 +1,499 @@ +package nsc + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +type windowsProxyOutput struct { + Endpoint string `json:"endpoint"` + RDP struct { + Credentials struct { + Username string `json:"username"` + Password string `json:"password"` + } `json:"credentials"` + } `json:"rdp"` +} + +func (d *Dispatcher) launchWindowsRunnerViaWinRM(ctx context.Context, runnerName string, req LaunchRequest, ttl time.Duration, machineType string) error { + script := windowsBootstrapScript(runnerName, req, d.opts.Executor, d.opts.WorkDir) + return d.launchWindowsScriptViaWinRM(ctx, runnerName, ttl, machineType, req.Labels, script) +} + +func (d *Dispatcher) launchWindowsScriptViaWinRM(ctx context.Context, runnerName string, ttl time.Duration, machineType string, labels []string, script string) error { + if ttl <= 0 { + ttl = d.opts.DefaultDuration + } + + mt := normalizeWindowsMachineType(machineType, labels) + instanceID, createOutput, err := d.createWindowsInstance(ctx, runnerName, ttl, mt) + if err != nil { + return fmt.Errorf("windows create failed: %w\n%s", err, createOutput) + } + defer d.destroyNSCInstance(context.Background(), runnerName, instanceID) + + username, password, err := d.resolveWindowsCredentials(ctx, instanceID) + if err != nil { + return err + } + + if err := d.probeWindowsWinRMService(ctx, instanceID); err != nil { + return err + } + + endpoint, stopForward, err := d.startWindowsWinRMPortForward(ctx, instanceID) + if err != nil { + return err + } + defer stopForward() + + if err := d.runWindowsWinRMPowerShell(ctx, endpoint, username, password, script); err != nil { + return err + } + + return nil +} + +func (d *Dispatcher) createWindowsInstance(ctx context.Context, runnerName string, ttl time.Duration, machineType string) (instanceID string, output string, err error) { + tmpDir, err := os.MkdirTemp("", "forgejo-nsc-windows-*") + if err != nil { + return "", "", fmt.Errorf("mktemp: %w", err) + } + defer os.RemoveAll(tmpDir) + + metaPath := filepath.Join(tmpDir, "create.json") + cidPath := filepath.Join(tmpDir, "create.cid") + + args := []string{ + "create", + "--duration", ttl.String(), + "--machine_type", machineType, + "--cidfile", cidPath, + "--purpose", fmt.Sprintf("burrow forgejo runner %s", runnerName), + "--output", "plain", + "--output_json_to", metaPath, + "--wait_timeout", "6m", + } + args = prependNSCRegionArgs(args, d.opts.ComputeBaseURL) + + createCtx, cancel := context.WithTimeout(ctx, 8*time.Minute) + defer cancel() + + cmd := exec.CommandContext(createCtx, d.opts.BinaryPath, args...) + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + + if err := cmd.Run(); err != nil { + if created := strings.TrimSpace(mustReadFile(cidPath)); created != "" { + d.destroyNSCInstance(context.Background(), runnerName, created) + } + if errors.Is(createCtx.Err(), context.DeadlineExceeded) { + return "", buf.String(), fmt.Errorf("nsc create timed out after %s", 8*time.Minute) + } + return "", buf.String(), fmt.Errorf("nsc create failed: %w", err) + } + + instanceID, err = readNSCCreateInstanceID(metaPath) + if err != nil { + return "", buf.String(), fmt.Errorf("nsc create output parse failed: %w", err) + } + if instanceID == "" { + return "", buf.String(), errors.New("nsc create returned empty instance id") + } + return instanceID, buf.String(), nil +} + +func (d *Dispatcher) resolveWindowsCredentials(ctx context.Context, instanceID string) (username string, password string, err error) { + tmpDir, err := os.MkdirTemp("", "forgejo-nsc-winproxy-*") + if err != nil { + return "", "", fmt.Errorf("mktemp: %w", err) + } + defer os.RemoveAll(tmpDir) + + outPath := filepath.Join(tmpDir, "proxy.json") + outFile, err := os.Create(outPath) + if err != nil { + return "", "", fmt.Errorf("create proxy output file: %w", err) + } + defer outFile.Close() + + var stderr bytes.Buffer + args := []string{"instance", "proxy", instanceID, "-s", "rdp", "-o", "json"} + args = prependNSCRegionArgs(args, d.opts.ComputeBaseURL) + + proxyCtx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + + cmd := exec.CommandContext(proxyCtx, d.opts.BinaryPath, args...) + cmd.Stdout = outFile + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + return "", "", fmt.Errorf("start nsc instance proxy: %w", err) + } + + waitDone := make(chan struct{}) + var waitErr error + go func() { + waitErr = cmd.Wait() + close(waitDone) + }() + + var payload windowsProxyOutput + deadline := time.Now().Add(45 * time.Second) + for time.Now().Before(deadline) { + raw, _ := os.ReadFile(outPath) + jsonBlob := extractJSON(string(raw)) + if jsonBlob != "" { + if err := json.Unmarshal([]byte(jsonBlob), &payload); err == nil { + username = strings.TrimSpace(payload.RDP.Credentials.Username) + password = strings.TrimSpace(payload.RDP.Credentials.Password) + if username != "" && password != "" { + break + } + } + } + select { + case <-waitDone: + if waitErr != nil { + return "", "", fmt.Errorf("nsc instance proxy exited before credentials were available: %w\n%s", waitErr, stderr.String()) + } + default: + } + time.Sleep(1 * time.Second) + } + + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + <-waitDone + + if username == "" || password == "" { + raw, _ := os.ReadFile(outPath) + return "", "", fmt.Errorf("failed to resolve windows credentials from nsc instance proxy output\nstdout=%s\nstderr=%s", strings.TrimSpace(string(raw)), strings.TrimSpace(stderr.String())) + } + return username, password, nil +} + +func (d *Dispatcher) probeWindowsWinRMService(ctx context.Context, instanceID string) error { + args := []string{"instance", "proxy", instanceID, "-s", "winrm", "-o", "json", "--once"} + args = prependNSCRegionArgs(args, d.opts.ComputeBaseURL) + + probeCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + cmd := exec.CommandContext(probeCtx, d.opts.BinaryPath, args...) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + + err := cmd.Run() + raw := strings.TrimSpace(out.String()) + if endpoint, ok := parseProxyEndpoint(raw); ok && endpoint != "" { + return nil + } + + if indicatesMissingProxyService(raw, "winrm") { + return fmt.Errorf("namespace windows non-interactive channel unavailable: instance does not expose winrm service (rdp-only)\n%s", raw) + } + + if errors.Is(probeCtx.Err(), context.DeadlineExceeded) { + return fmt.Errorf("timed out probing Namespace winrm service before bootstrap\n%s", raw) + } + + if err != nil { + return fmt.Errorf("nsc winrm service probe failed: %w\n%s", err, raw) + } + return fmt.Errorf("nsc winrm service probe did not yield endpoint output\n%s", raw) +} + +func parseProxyEndpoint(raw string) (string, bool) { + jsonBlob := extractJSON(raw) + if jsonBlob == "" { + return "", false + } + var payload struct { + Endpoint string `json:"endpoint"` + } + if err := json.Unmarshal([]byte(jsonBlob), &payload); err != nil { + return "", false + } + endpoint := strings.TrimSpace(payload.Endpoint) + if endpoint == "" { + return "", false + } + return endpoint, true +} + +func indicatesMissingProxyService(raw string, service string) bool { + service = strings.TrimSpace(service) + if service == "" { + return false + } + token := fmt.Sprintf("does not have service %q", service) + return strings.Contains(raw, token) +} + +func (d *Dispatcher) startWindowsWinRMPortForward(ctx context.Context, instanceID string) (endpoint string, stop func(), err error) { + args := []string{"instance", "port-forward", instanceID, "--target_port", "5985"} + args = prependNSCRegionArgs(args, d.opts.ComputeBaseURL) + + forwardCtx, cancel := context.WithCancel(ctx) + cmd := exec.CommandContext(forwardCtx, d.opts.BinaryPath, args...) + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return "", nil, fmt.Errorf("port-forward stdout pipe: %w", err) + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + cancel() + return "", nil, fmt.Errorf("start nsc port-forward: %w", err) + } + + waitDone := make(chan struct{}) + var waitErr error + go func() { + waitErr = cmd.Wait() + close(waitDone) + }() + + endpointCh := make(chan string, 1) + scanErrCh := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "Listening on ") { + endpointCh <- strings.TrimSpace(strings.TrimPrefix(line, "Listening on ")) + return + } + } + if err := scanner.Err(); err != nil { + scanErrCh <- err + } + }() + + select { + case endpoint = <-endpointCh: + stop = func() { + cancel() + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + <-waitDone + } + return endpoint, stop, nil + case err := <-scanErrCh: + cancel() + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + <-waitDone + return "", nil, fmt.Errorf("failed reading port-forward output: %w", err) + case <-waitDone: + cancel() + if waitErr != nil { + return "", nil, fmt.Errorf("nsc port-forward exited early: %w\n%s", waitErr, stderr.String()) + } + return "", nil, fmt.Errorf("nsc port-forward exited without endpoint\n%s", stderr.String()) + case <-time.After(45 * time.Second): + cancel() + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + <-waitDone + return "", nil, fmt.Errorf("timed out waiting for WinRM port-forward endpoint\n%s", stderr.String()) + case <-ctx.Done(): + cancel() + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + <-waitDone + return "", nil, ctx.Err() + } +} + +func (d *Dispatcher) runWindowsWinRMPowerShell(ctx context.Context, endpoint, username, password, script string) error { + pythonPath, err := exec.LookPath("python3") + if err != nil { + return fmt.Errorf("python3 is required for windows WinRM bootstrap: %w", err) + } + + workdir := strings.TrimSpace(d.opts.WorkDir) + if workdir == "" { + workdir = "/tmp/forgejo-runner" + } + if err := os.MkdirAll(workdir, 0o755); err != nil { + return fmt.Errorf("create workdir %s: %w", workdir, err) + } + + venvPath := filepath.Join(workdir, ".winrm-venv") + venvPython := filepath.Join(venvPath, "bin", "python") + if _, err := os.Stat(venvPython); err != nil { + cmd := exec.CommandContext(ctx, pythonPath, "-m", "venv", venvPath) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + if err := cmd.Run(); err != nil { + return fmt.Errorf("create python venv for winrm failed: %w\n%s", err, out.String()) + } + } + + ensurePyWinRM := ` +import importlib.util, subprocess, sys +if importlib.util.find_spec("winrm") is None: + subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet", "pywinrm"]) +` + ensureCmd := exec.CommandContext(ctx, venvPython, "-c", ensurePyWinRM) + var ensureOut bytes.Buffer + ensureCmd.Stdout = &ensureOut + ensureCmd.Stderr = &ensureOut + if err := ensureCmd.Run(); err != nil { + return fmt.Errorf("install pywinrm failed: %w\n%s", err, ensureOut.String()) + } + + runScript := ` +import base64, os, sys, time, traceback, winrm + +endpoint = os.environ["WINRM_ENDPOINT"] +user = os.environ["WINRM_USER"] +password = os.environ["WINRM_PASS"] +script = base64.b64decode(os.environ["WINRM_SCRIPT_B64"]).decode("utf-8") + +deadline = time.time() + 300.0 +last_err = None + +while time.time() < deadline: + try: + session = winrm.Session(f"http://{endpoint}/wsman", auth=(user, password), transport="ntlm") + result = session.run_ps(script) + sys.stdout.write(result.std_out.decode("utf-8", errors="replace")) + sys.stderr.write(result.std_err.decode("utf-8", errors="replace")) + print(f"winrm_exit={result.status_code}") + sys.exit(result.status_code) + except Exception as err: + last_err = err + time.sleep(5.0) + +sys.stderr.write("timed out waiting for WinRM connectivity after 300s\\n") +if last_err is not None: + traceback.print_exception(last_err, file=sys.stderr) +sys.exit(111) +` + runCmd := exec.CommandContext(ctx, venvPython, "-c", runScript) + runCmd.Env = append(os.Environ(), + "WINRM_ENDPOINT="+endpoint, + "WINRM_USER="+username, + "WINRM_PASS="+password, + "WINRM_SCRIPT_B64="+base64.StdEncoding.EncodeToString([]byte(script)), + ) + var runOut bytes.Buffer + runCmd.Stdout = &runOut + runCmd.Stderr = &runOut + if err := runCmd.Run(); err != nil { + return fmt.Errorf("windows winrm bootstrap command failed: %w\n%s", err, runOut.String()) + } + return nil +} + +func windowsBootstrapScript(runnerName string, req LaunchRequest, executor, workdir string) string { + if strings.TrimSpace(workdir) == "" { + workdir = `C:\burrow\forgejo-runner` + } + + runnerExec := strings.TrimSpace(executor) + if runnerExec == "" || runnerExec == "shell" { + runnerExec = "host" + } + + safeName := strings.NewReplacer(`\`, "-", ":", "-", "/", "-", " ", "-").Replace(runnerName) + workRoot := strings.TrimRight(workdir, `\`) + `\` + safeName + + var b strings.Builder + b.WriteString("$ErrorActionPreference = 'Stop'\n") + b.WriteString("$ProgressPreference = 'SilentlyContinue'\n") + b.WriteString("[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n") + b.WriteString("$runnerName = " + powershellSingleQuote(runnerName) + "\n") + b.WriteString("$runnerToken = " + powershellSingleQuote(req.Token) + "\n") + b.WriteString("$instanceURL = " + powershellSingleQuote(req.InstanceURL) + "\n") + b.WriteString("$labelsCsv = " + powershellSingleQuote(strings.Join(req.Labels, ",")) + "\n") + b.WriteString("$runnerExec = " + powershellSingleQuote(runnerExec) + "\n") + b.WriteString("$workRoot = " + powershellSingleQuote(workRoot) + "\n") + b.WriteString(` +New-Item -Path $workRoot -ItemType Directory -Force | Out-Null +Set-Location $workRoot + +$runnerVersion = "12.6.4" +$zipUrl = "https://code.forgejo.org/forgejo/runner/releases/download/v${runnerVersion}/forgejo-runner-${runnerVersion}-windows-amd64.zip" +$zipPath = Join-Path $workRoot "forgejo-runner.zip" +$extractDir = Join-Path $workRoot "forgejo-runner" + +if (Test-Path $extractDir) { + Remove-Item -Path $extractDir -Recurse -Force +} + +Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath +Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force + +$runnerExe = Join-Path $extractDir "forgejo-runner.exe" +if (-not (Test-Path $runnerExe)) { + throw "Missing forgejo-runner.exe after extract: $runnerExe" +} + +$labels = @() +foreach ($label in ($labelsCsv -split ",")) { + $trimmed = $label.Trim() + if ([string]::IsNullOrWhiteSpace($trimmed)) { continue } + if ($trimmed.Contains(":")) { + $labels += $trimmed + } else { + $labels += ("{0}:{1}" -f $trimmed, $runnerExec) + } +} +if ($labels.Count -eq 0) { + throw "No runner labels resolved for windows bootstrap" +} + +$labelLines = ($labels | ForEach-Object { " - $_" }) -join [Environment]::NewLine +$configPath = Join-Path $workRoot "runner.yaml" +$runnerYaml = @" +log: + level: info +runner: + file: .runner + capacity: 1 + name: $runnerName + labels: +$labelLines +cache: + enabled: false +"@ +Set-Content -Path $configPath -Value $runnerYaml -Encoding UTF8 + +$labelsArg = ($labels -join ",") +& $runnerExe register --no-interactive --instance $instanceURL --token $runnerToken --name $runnerName --labels $labelsArg --config $configPath +if ($LASTEXITCODE -ne 0) { + throw ("forgejo-runner register failed: {0}" -f $LASTEXITCODE) +} + +& $runnerExe one-job --config $configPath +if ($LASTEXITCODE -ne 0) { + throw ("forgejo-runner one-job failed: {0}" -f $LASTEXITCODE) +} +`) + return b.String() +} diff --git a/services/forgejo-nsc/internal/nsc/windows_winrm_integration_test.go b/services/forgejo-nsc/internal/nsc/windows_winrm_integration_test.go new file mode 100644 index 0000000..407749b --- /dev/null +++ b/services/forgejo-nsc/internal/nsc/windows_winrm_integration_test.go @@ -0,0 +1,59 @@ +package nsc + +import ( + "context" + "io" + "log/slog" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestWindowsWinRMScriptRoundTrip(t *testing.T) { + if os.Getenv("NSC_WINDOWS_E2E") != "1" { + t.Skip("set NSC_WINDOWS_E2E=1 to run Namespace Windows integration test") + } + + nscBinary, err := exec.LookPath("nsc") + if err != nil { + t.Skipf("nsc not found in PATH: %v", err) + } + + authCheck := exec.Command(nscBinary, "auth", "check-login") + if out, err := authCheck.CombinedOutput(); err != nil { + t.Skipf("nsc auth check-login failed: %v (%s)", err, strings.TrimSpace(string(out))) + } + + machineType := strings.TrimSpace(os.Getenv("NSC_WINDOWS_E2E_MACHINE_TYPE")) + if machineType == "" { + machineType = "windows/amd64:4x8" + } + + dispatcher, err := NewDispatcher(Options{ + BinaryPath: nscBinary, + DefaultImage: "code.forgejo.org/forgejo/runner:11", + DefaultMachine: machineType, + DefaultDuration: 20 * time.Minute, + MaxParallel: 1, + WorkDir: t.TempDir(), + ComputeBaseURL: strings.TrimSpace(os.Getenv("NSC_COMPUTE_BASE_URL")), + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatalf("NewDispatcher() error: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + script := "Write-Output ('winrm-ok:' + $env:COMPUTERNAME)" + labels := []string{"namespace-profile-windows-medium"} + if err := dispatcher.launchWindowsScriptViaWinRM(ctx, "nsc-winrm-itest", 20*time.Minute, machineType, labels, script); err != nil { + if strings.Contains(err.Error(), "does not expose winrm service (rdp-only)") { + t.Skipf("namespace windows control channel is rdp-only: %v", err) + } + t.Fatalf("launchWindowsScriptViaWinRM() error: %v", err) + } +} diff --git a/services/forgejo-nsc/internal/nsc/windows_winrm_test.go b/services/forgejo-nsc/internal/nsc/windows_winrm_test.go new file mode 100644 index 0000000..538d009 --- /dev/null +++ b/services/forgejo-nsc/internal/nsc/windows_winrm_test.go @@ -0,0 +1,65 @@ +package nsc + +import "testing" + +func TestParseProxyEndpoint(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + want string + wantOK bool + }{ + { + name: "plain json payload", + raw: `{"endpoint":"127.0.0.1:61234"}`, + want: "127.0.0.1:61234", + wantOK: true, + }, + { + name: "json wrapped with extra output", + raw: `Connected. +{"endpoint":"127.0.0.1:61235","rdp":{"credentials":{"username":"runneradmin","password":"runneradmin"}}}`, + want: "127.0.0.1:61235", + wantOK: true, + }, + { + name: "missing endpoint field", + raw: `{"rdp":{"credentials":{"username":"runneradmin"}}}`, + wantOK: false, + }, + { + name: "non-json output", + raw: `Failed: instance does not have service "winrm"`, + wantOK: false, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, ok := parseProxyEndpoint(tc.raw) + if ok != tc.wantOK { + t.Fatalf("parseProxyEndpoint(%q) ok=%v, want %v", tc.raw, ok, tc.wantOK) + } + if got != tc.want { + t.Fatalf("parseProxyEndpoint(%q) endpoint=%q, want %q", tc.raw, got, tc.want) + } + }) + } +} + +func TestIndicatesMissingProxyService(t *testing.T) { + t.Parallel() + + raw := `Failed: instance does not have service "winrm"` + if !indicatesMissingProxyService(raw, "winrm") { + t.Fatalf("indicatesMissingProxyService should return true for missing winrm message") + } + if indicatesMissingProxyService(raw, "ssh") { + t.Fatalf("indicatesMissingProxyService should be false when service name does not match") + } +} diff --git a/services/forgejo-nsc/internal/server/server.go b/services/forgejo-nsc/internal/server/server.go new file mode 100644 index 0000000..b4bb1d2 --- /dev/null +++ b/services/forgejo-nsc/internal/server/server.go @@ -0,0 +1,151 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "github.com/burrow/forgejo-nsc/internal/app" +) + +type Server struct { + httpServer *http.Server + app *app.Service + log *slog.Logger +} + +func New(listen string, svc *app.Service, logger *slog.Logger) *Server { + if logger == nil { + logger = slog.Default() + } + + router := chi.NewRouter() + router.Use(middleware.RequestID) + router.Use(middleware.RealIP) + router.Use(middleware.Logger) + router.Use(middleware.Recoverer) + + s := &Server{ + app: svc, + log: logger, + httpServer: &http.Server{ + Addr: listen, + Handler: router, + ReadTimeout: 30 * time.Second, + // Dispatch requests can legitimately run for the duration of a build. + // A short WriteTimeout will kill the request context mid-provisioning. + WriteTimeout: 2 * time.Hour, + IdleTimeout: 60 * time.Second, + }, + } + + router.Get("/healthz", s.handleHealthz) + router.Post("/api/v1/dispatch", s.handleDispatch) + + return s +} + +func (s *Server) ListenAndServe() error { + return s.httpServer.ListenAndServe() +} + +func (s *Server) Shutdown(ctx context.Context) error { + return s.httpServer.Shutdown(ctx) +} + +// Handler exposes the underlying HTTP handler for tests. +func (s *Server) Handler() http.Handler { + return s.httpServer.Handler +} + +type dispatchRequest struct { + Count int `json:"count"` + Labels []string `json:"labels"` + Scope *dispatchScope `json:"scope"` + TTL string `json:"ttl"` + Machine string `json:"machine_type"` + Image string `json:"image"` + Env map[string]string `json:"env"` +} + +type dispatchScope struct { + Level string `json:"level"` + Owner string `json:"owner"` + Name string `json:"name"` +} + +func (s *Server) handleDispatch(w http.ResponseWriter, r *http.Request) { + var payload dispatchRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + s.writeError(w, http.StatusBadRequest, err) + return + } + + duration, err := parseDuration(payload.TTL) + if err != nil { + s.writeError(w, http.StatusBadRequest, err) + return + } + + var scope *app.Scope + if payload.Scope != nil { + scope = &app.Scope{ + Level: payload.Scope.Level, + Owner: payload.Scope.Owner, + Name: payload.Scope.Name, + } + } + + resp, err := s.app.Dispatch(r.Context(), app.DispatchRequest{ + Count: payload.Count, + Labels: payload.Labels, + Scope: scope, + TTL: duration, + Machine: payload.Machine, + Image: payload.Image, + ExtraEnv: payload.Env, + }) + if err != nil { + s.writeError(w, http.StatusInternalServerError, err) + return + } + + s.writeJSON(w, http.StatusOK, resp) +} + +func parseDuration(value string) (time.Duration, error) { + if value == "" { + return 0, nil + } + dur, err := time.ParseDuration(value) + if err != nil { + return 0, err + } + if dur <= 0 { + return 0, errors.New("ttl must be positive") + } + return dur, nil +} + +func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { + s.writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) writeError(w http.ResponseWriter, code int, err error) { + s.log.Error("request failed", "err", err, "status", code) + s.writeJSON(w, code, map[string]string{ + "error": err.Error(), + }) +} + +func (s *Server) writeJSON(w http.ResponseWriter, code int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(payload) +} diff --git a/services/forgejo-nsc/internal/server/server_test.go b/services/forgejo-nsc/internal/server/server_test.go new file mode 100644 index 0000000..09a9743 --- /dev/null +++ b/services/forgejo-nsc/internal/server/server_test.go @@ -0,0 +1,111 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/burrow/forgejo-nsc/internal/app" + "github.com/burrow/forgejo-nsc/internal/forgejo" + "github.com/burrow/forgejo-nsc/internal/nsc" +) + +type serverForgejoMock struct { + mu sync.Mutex + token string + scopes []forgejo.Scope +} + +func (m *serverForgejoMock) RegistrationToken(ctx context.Context, scope forgejo.Scope) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.scopes = append(m.scopes, scope) + return m.token, nil +} + +type serverDispatcherMock struct { + mu sync.Mutex + requests []nsc.LaunchRequest + result string +} + +func (m *serverDispatcherMock) LaunchRunner(ctx context.Context, req nsc.LaunchRequest) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.requests = append(m.requests, req) + if m.result != "" { + return m.result, nil + } + return "runner", nil +} + +func TestDispatchEndpoint(t *testing.T) { + forgejoMock := &serverForgejoMock{token: "token"} + dispatcherMock := &serverDispatcherMock{result: "runner-http"} + + cfg := app.Config{ + DefaultScope: forgejo.Scope{Level: forgejo.ScopeInstance}, + DefaultLabels: []string{"fallback"}, + InstanceURL: "https://forgejo.example.com", + DefaultTTL: 30 * time.Minute, + } + + service := app.NewService(cfg, forgejoMock, dispatcherMock, nil) + srv := New(":0", service, nil) + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + body := map[string]any{ + "count": 1, + "ttl": "45m", + "labels": []string{"nscloud-arm"}, + "scope": map[string]string{"level": string(forgejo.ScopeOrganization), "owner": "acme"}, + "machine_type": "8x16", + "image": "runner:http", + "env": map[string]string{"FOO": "bar"}, + } + + payload, _ := json.Marshal(body) + + resp, err := http.Post(ts.URL+"/api/v1/dispatch", "application/json", bytes.NewReader(payload)) + if err != nil { + t.Fatalf("POST failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 OK, got %d", resp.StatusCode) + } + + var decoded app.DispatchResponse + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(decoded.Runners) != 1 || decoded.Runners[0].Name != "runner-http" { + t.Fatalf("unexpected response: %+v", decoded) + } + + if len(forgejoMock.scopes) != 1 || forgejoMock.scopes[0].Level != forgejo.ScopeOrganization { + t.Fatalf("expected organization scope, got %+v", forgejoMock.scopes) + } + + if len(dispatcherMock.requests) != 1 { + t.Fatalf("expected dispatcher call") + } + call := dispatcherMock.requests[0] + if call.Duration != 45*time.Minute { + t.Fatalf("expected ttl override, got %v", call.Duration) + } + if call.Labels[0] != "nscloud-arm" { + t.Fatalf("expected labels passthrough, got %v", call.Labels) + } + if call.ExtraEnv["FOO"] != "bar" { + t.Fatalf("expected env passthrough") + } +} From b8347f62ba00897306b9a8964b638455d5acaa39 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Tue, 31 Mar 2026 14:56:27 -0700 Subject: [PATCH 038/102] Fix Headscale bootstrap policy syntax --- nixos/modules/burrow-headscale-policy.hujson | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nixos/modules/burrow-headscale-policy.hujson b/nixos/modules/burrow-headscale-policy.hujson index aed7e22..8f0bcd2 100644 --- a/nixos/modules/burrow-headscale-policy.hujson +++ b/nixos/modules/burrow-headscale-policy.hujson @@ -1,11 +1,11 @@ { // Bootstrap with a simple allow-all policy; Burrow-specific lane segmentation // can be layered on once the control plane is live. - acls: [ + "acls": [ { - action: "accept", - src: ["*"], - dst: ["*:*"], + "action": "accept", + "src": ["*"], + "dst": ["*:*"], }, ], } From 8aebf56d6d225180b2d338a0ae2eb247ced7ba5d Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Tue, 31 Mar 2026 14:59:30 -0700 Subject: [PATCH 039/102] Resolve Burrow forge domains locally --- nixos/hosts/burrow-forge/default.nix | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index e21cd39..344fe96 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -20,6 +20,11 @@ "flakes" ]; + networking.extraHosts = '' + 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net + ::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net + ''; + services.burrow.forge = { enable = true; adminPasswordFile = "/var/lib/burrow/intake/forgejo_pass_contact_at_burrow_net.txt"; From 20964e8ed7b1584fbe5ee946d3dee3f2da699a75 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Tue, 31 Mar 2026 16:38:02 -0700 Subject: [PATCH 040/102] Move forge tailnet secrets to agenix --- Scripts/check-forge-host.sh | 7 ++ flake.lock | 84 +++++++++++++++++- flake.nix | 8 +- nixos/README.md | 4 +- nixos/hosts/burrow-forge/default.nix | 21 ++++- nixos/modules/burrow-headscale.nix | 4 +- secrets.nix | 14 +++ secrets/infra/authentik.env.age | Bin 0 -> 732 bytes .../infra/headscale-oidc-client-secret.age | Bin 0 -> 485 bytes 9 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 secrets.nix create mode 100644 secrets/infra/authentik.env.age create mode 100644 secrets/infra/headscale-oidc-client-secret.age diff --git a/Scripts/check-forge-host.sh b/Scripts/check-forge-host.sh index 90a6dcf..f4d646d 100755 --- a/Scripts/check-forge-host.sh +++ b/Scripts/check-forge-host.sh @@ -157,6 +157,13 @@ done echo "== intake ==" ls -l /var/lib/burrow/intake || true +if [[ "${EXPECT_TAILNET}" == "1" ]]; then + echo "== agenix ==" + ls -l /run/agenix || true + test -s /run/agenix/burrowAuthentikEnv + test -s /run/agenix/burrowHeadscaleOidcClientSecret +fi + if command -v curl >/dev/null 2>&1; then echo "== http-local ==" curl -fsS -o /dev/null -w 'forgejo_login %{http_code}\n' http://127.0.0.1:3000/user/login diff --git a/flake.lock b/flake.lock index 677bd0d..599e193 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,50 @@ { "nodes": { + "agenix": { + "inputs": { + "darwin": "darwin", + "home-manager": "home-manager", + "nixpkgs": [ + "nixpkgs" + ], + "systems": "systems" + }, + "locked": { + "lastModified": 1770165109, + "narHash": "sha256-9VnK6Oqai65puVJ4WYtCTvlJeXxMzAp/69HhQuTdl/I=", + "owner": "ryantm", + "repo": "agenix", + "rev": "b027ee29d959fda4b60b57566d64c98a202e0feb", + "type": "github" + }, + "original": { + "owner": "ryantm", + "repo": "agenix", + "type": "github" + } + }, + "darwin": { + "inputs": { + "nixpkgs": [ + "agenix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1744478979, + "narHash": "sha256-dyN+teG9G82G+m+PX/aSAagkC+vUv0SgUw3XkPhQodQ=", + "owner": "lnl7", + "repo": "nix-darwin", + "rev": "43975d782b418ebf4969e9ccba82466728c2851b", + "type": "github" + }, + "original": { + "owner": "lnl7", + "ref": "master", + "repo": "nix-darwin", + "type": "github" + } + }, "disko": { "inputs": { "nixpkgs": [ @@ -19,7 +64,7 @@ }, "flake-utils": { "inputs": { - "systems": "systems" + "systems": "systems_2" }, "locked": { "lastModified": 1731533236, @@ -45,6 +90,27 @@ "url": "https://codeload.github.com/apricote/hcloud-upload-image/tar.gz/v1.3.0" } }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "agenix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1745494811, + "narHash": "sha256-YZCh2o9Ua1n9uCvrvi5pRxtuVNml8X2a03qIFfRKpFs=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "abfad3d2958c9e6300a883bd443512c55dfeb1be", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "home-manager", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1773389992, @@ -59,6 +125,7 @@ }, "root": { "inputs": { + "agenix": "agenix", "disko": "disko", "flake-utils": "flake-utils", "hcloud-upload-image-src": "hcloud-upload-image-src", @@ -79,6 +146,21 @@ "repo": "default", "type": "github" } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 38e38b6..5814c19 100644 --- a/flake.nix +++ b/flake.nix @@ -4,6 +4,10 @@ inputs = { nixpkgs.url = "tarball+https://codeload.github.com/NixOS/nixpkgs/tar.gz/nixos-unstable"; flake-utils.url = "tarball+https://codeload.github.com/numtide/flake-utils/tar.gz/main"; + agenix = { + url = "github:ryantm/agenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; disko = { url = "tarball+https://codeload.github.com/nix-community/disko/tar.gz/master"; inputs.nixpkgs.follows = "nixpkgs"; @@ -14,7 +18,7 @@ }; }; - outputs = { self, nixpkgs, flake-utils, disko, hcloud-upload-image-src }: + outputs = { self, nixpkgs, flake-utils, agenix, disko, hcloud-upload-image-src }: let supportedSystems = [ "x86_64-linux" @@ -161,6 +165,7 @@ packages = { + agenix = agenix.packages.${system}.agenix; hcloud-upload-image = hcloudUploadImagePkg; forgejo-nsc-dispatcher = forgejoNscDispatcher; forgejo-nsc-autoscaler = forgejoNscAutoscaler; @@ -180,6 +185,7 @@ inherit self; }; modules = [ + agenix.nixosModules.default disko.nixosModules.disko ./nixos/hosts/burrow-forge/default.nix ]; diff --git a/nixos/README.md b/nixos/README.md index b546f1a..7924944 100644 --- a/nixos/README.md +++ b/nixos/README.md @@ -12,6 +12,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B - `modules/burrow-forgejo-nsc.nix`: Namespace-backed ephemeral Forgejo runner services - `modules/burrow-authentik.nix`: minimal Authentik IdP for Burrow control planes - `modules/burrow-headscale.nix`: Headscale control plane rooted in Authentik OIDC +- `../secrets.nix`: agenix recipient map for tracked Burrow forge secrets - `hetzner-cloud-config.yaml`: desired Hetzner host shape - `keys/contact_at_burrow_net.pub`: initial operator SSH public key - `keys/agent_at_burrow_net.pub`: automation SSH public key @@ -32,7 +33,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B 4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account. 5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent `. 6. Run `Scripts/provision-forgejo-nsc.sh` locally, then `Scripts/sync-forgejo-nsc-config.sh` to place the Namespace dispatcher/autoscaler runtime inputs under `/var/lib/burrow/intake/`. -7. Ensure `/var/lib/burrow/intake/authentik.env` exists on the host, and let `services.burrow.headscale` generate `/var/lib/burrow/intake/authentik_headscale_client_secret.txt` on first boot if it is absent. +7. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age` and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`. 8. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. 9. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace. 10. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`. @@ -40,6 +41,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B ## Current Constraints - `burrow-forge` is live on NixOS in `hel1` at `89.167.47.21`, and `Scripts/check-forge-host.sh --expect-nsc` passes locally against that host. +- Authentik and Headscale secrets now live in tracked agenix blobs under `secrets/infra/` and decrypt to `/run/agenix/` on the forge host. - Public Burrow forge cutover completed on March 15, 2026: - `burrow.net`, `git.burrow.net`, and `nsc-autoscaler.burrow.net` now publish public `A` records to `89.167.47.21` - HTTP redirects to HTTPS on all three names diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 344fe96..43f65a3 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -1,4 +1,4 @@ -{ self, ... }: +{ config, self, ... }: { imports = [ @@ -20,6 +20,20 @@ "flakes" ]; + age.identityPaths = [ "/var/lib/agenix/agenix.key" ]; + age.secrets.burrowAuthentikEnv = { + file = ../../../secrets/infra/authentik.env.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; + age.secrets.burrowHeadscaleOidcClientSecret = { + file = ../../../secrets/infra/headscale-oidc-client-secret.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; + networking.extraHosts = '' 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net ::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net @@ -53,11 +67,12 @@ services.burrow.authentik = { enable = true; - envFile = "/var/lib/burrow/intake/authentik.env"; - headscaleClientSecretFile = "/var/lib/burrow/intake/authentik_headscale_client_secret.txt"; + envFile = config.age.secrets.burrowAuthentikEnv.path; + headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; }; services.burrow.headscale = { enable = true; + oidcClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; }; } diff --git a/nixos/modules/burrow-headscale.nix b/nixos/modules/burrow-headscale.nix index 120468b..ad5ec68 100644 --- a/nixos/modules/burrow-headscale.nix +++ b/nixos/modules/burrow-headscale.nix @@ -191,7 +191,9 @@ in set -euo pipefail list_users() { - ${pkgs.headscale}/bin/headscale users list -o json + local users_json + users_json="$(${pkgs.headscale}/bin/headscale users list -o json)" + printf '%s\n' "$users_json" | ${pkgs.jq}/bin/jq -c 'if type == "array" then . else [] end' } ensure_user() { diff --git a/secrets.nix b/secrets.nix new file mode 100644 index 0000000..4382fd6 --- /dev/null +++ b/secrets.nix @@ -0,0 +1,14 @@ +let + contact = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO42guJ5QvNMw3k6YKWlQnjcTsc+X4XI9F2GBtl8aHOa"; + agent = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEN0+tRJy7Y2DW0uGYHb86N2t02WyU5lDNX6FaxBF/G8 agent@burrow.net"; + burrowForgeHost = "age1quxf27gnun0xghlnxf3jrmqr3h3a3fzd8qxpallsaztd2u74pdfq9e7w9l"; + burrowForgeRecipients = [ + contact + agent + burrowForgeHost + ]; +in +{ + "secrets/infra/authentik.env.age".publicKeys = burrowForgeRecipients; + "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; +} diff --git a/secrets/infra/authentik.env.age b/secrets/infra/authentik.env.age new file mode 100644 index 0000000000000000000000000000000000000000..f9f613687871d9959a66369ee2bb51b7aee03d40 GIT binary patch literal 732 zcmYdHPt{G$OD?J`D9Oyv)5|YP*Do{V(zR14F3!+RO))YxHMCSHtuXPk2vi772~N~@ zF95<9V8QFn3kzR$_CHnq_S%DV5p26mx+684!hDN2q zzJ}WRKCWD!CMA8%a+??W@pu0B>HoN|%P!WuQmS8yY+0;*$o60C z))Lv;xfcF)5;i+d9K5T2jA_A-KSIl28+mMV_@Zd#Tb6Y>Fj3nknfvtgnQ|u>3QL=L zbQ4awdR`1%P=Dnw8+XOmQjwkdvhgNtMY^t6&3^@-*9%bi$$Ca?S5|sy_2Zj2W*@tI zQHUb^d zIaMb-ZEM#@F(1j=Hf!mJAFpP}-Q070`kA1Re$%(^cLb~Mm;^rCljN3`wf^JvH*H`3 zE@r+L9;MC5_c&r^_P!ezKCX$fjrz>hxARug<3pB9GagUzKf3)oOXueG+w)9IUw+}e L!T9idEMFG@Ez~Rz literal 0 HcmV?d00001 diff --git a/secrets/infra/headscale-oidc-client-secret.age b/secrets/infra/headscale-oidc-client-secret.age new file mode 100644 index 0000000000000000000000000000000000000000..925512cb9692b0538eddfa2c4f83fa98fe8b0c66 GIT binary patch literal 485 zcmYdHPt{G$OD?J`D9Oyv)5|YP*Do{V(zR14F3!+RO))YxHMCSHtuXPk2vkVUFtKzj zbBgi^%rVRGEiCsB^Urn8b58XQ%PVqr&NnbiN_H#pHOMb^OXo7mb$3e&OUm{L^>NA% z^$Bz_t@3hlk4OxUit-3_N!PY8_sen&_p$J)ibS`~vnVRpFU#M8vv(&&c@{_h82dECHfv& zRcUUeIi(qyVdY%9y1EJmIp!rPp5FR_`o`Lhrn#ve1unrphQ>)QDTS3uE=B%9RT-6; zc_B{T;l5n|jDkMxPks7=?X%{x*x64b<-NaLpAhb{IqP``&x#I)%a>nX44CnBUV6-w z-=DT{J=1x!L)_w=ZrjDAmOr`SC%GO>TATKLPE48PoNtpt9ZpUDoEB1N%4hKGN7?CF Le_GV{RPF=-y+N=y literal 0 HcmV?d00001 From be5b7d90dbd98a75ba19ae9356f61a2e3dc05169 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Tue, 31 Mar 2026 23:28:35 -0700 Subject: [PATCH 041/102] Enable Google Authentik login on forge --- Scripts/authentik-sync-google-source.sh | 284 ++++++++++++++++++ flake.lock | 4 +- nixos/README.md | 2 +- nixos/hosts/burrow-forge/default.nix | 14 + nixos/modules/burrow-authentik.nix | 77 +++++ secrets.nix | 2 + secrets/infra/authentik-google-client-id.age | Bin 0 -> 493 bytes .../infra/authentik-google-client-secret.age | 9 + 8 files changed, 389 insertions(+), 3 deletions(-) create mode 100755 Scripts/authentik-sync-google-source.sh create mode 100644 secrets/infra/authentik-google-client-id.age create mode 100644 secrets/infra/authentik-google-client-secret.age diff --git a/Scripts/authentik-sync-google-source.sh b/Scripts/authentik-sync-google-source.sh new file mode 100755 index 0000000..a4c9edb --- /dev/null +++ b/Scripts/authentik-sync-google-source.sh @@ -0,0 +1,284 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +google_client_id="${AUTHENTIK_GOOGLE_CLIENT_ID:-}" +google_client_secret="${AUTHENTIK_GOOGLE_CLIENT_SECRET:-}" +source_slug="${AUTHENTIK_GOOGLE_SOURCE_SLUG:-google}" +source_name="${AUTHENTIK_GOOGLE_SOURCE_NAME:-Google}" +identification_stage_name="${AUTHENTIK_GOOGLE_IDENTIFICATION_STAGE_NAME:-default-authentication-identification}" +authentication_flow_slug="${AUTHENTIK_GOOGLE_AUTHENTICATION_FLOW_SLUG:-default-source-authentication}" +enrollment_flow_slug="${AUTHENTIK_GOOGLE_ENROLLMENT_FLOW_SLUG:-default-source-enrollment}" +login_mode="${AUTHENTIK_GOOGLE_LOGIN_MODE:-redirect}" +user_matching_mode="${AUTHENTIK_GOOGLE_USER_MATCHING_MODE:-email_link}" +policy_engine_mode="${AUTHENTIK_GOOGLE_POLICY_ENGINE_MODE:-any}" +google_account_map_json="${AUTHENTIK_GOOGLE_ACCOUNT_MAP_JSON:-[]}" +property_mapping_name="${AUTHENTIK_GOOGLE_PROPERTY_MAPPING_NAME:-Burrow Google Account Map}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-google-source.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + AUTHENTIK_GOOGLE_CLIENT_ID + AUTHENTIK_GOOGLE_CLIENT_SECRET + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_GOOGLE_SOURCE_SLUG + AUTHENTIK_GOOGLE_SOURCE_NAME + AUTHENTIK_GOOGLE_IDENTIFICATION_STAGE_NAME + AUTHENTIK_GOOGLE_AUTHENTICATION_FLOW_SLUG + AUTHENTIK_GOOGLE_ENROLLMENT_FLOW_SLUG + AUTHENTIK_GOOGLE_LOGIN_MODE promoted|redirect + AUTHENTIK_GOOGLE_USER_MATCHING_MODE identifier|email_link|email_deny|username_link|username_deny + AUTHENTIK_GOOGLE_POLICY_ENGINE_MODE all|any + AUTHENTIK_GOOGLE_ACCOUNT_MAP_JSON JSON array of alias mappings + AUTHENTIK_GOOGLE_PROPERTY_MAPPING_NAME +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ -z "$bootstrap_token" ]]; then + echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 + exit 1 +fi + +if [[ -z "$google_client_id" || -z "$google_client_secret" || "$google_client_id" == PENDING* || "$google_client_secret" == PENDING* ]]; then + echo "Google OAuth credentials are not configured; skipping Authentik Google source sync." >&2 + echo "Set Authorized redirect URI in Google to ${authentik_url}/source/oauth/callback/${source_slug}/" >&2 + exit 0 +fi + +if ! printf '%s' "$google_account_map_json" | jq -e 'type == "array"' >/dev/null; then + echo "error: AUTHENTIK_GOOGLE_ACCOUNT_MAP_JSON must be a JSON array" >&2 + exit 1 +fi + +case "$login_mode" in + promoted|redirect) ;; + *) + echo "warning: unsupported AUTHENTIK_GOOGLE_LOGIN_MODE=$login_mode; falling back to redirect" >&2 + login_mode="redirect" + ;; +esac + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + + if [[ -n "$data" ]]; then + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + else + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + fi +} + +wait_for_authentik() { + for _ in $(seq 1 90); do + if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +lookup_single_result() { + local path="$1" + local jq_filter="$2" + + api GET "$path" | jq -r "$jq_filter" | head -n1 +} + +wait_for_authentik + +flow_pk="$( + lookup_single_result \ + "/api/v3/flows/instances/?slug=${authentication_flow_slug}" \ + '.results[] | select(.slug != null) | .pk // empty' +)" +if [[ -z "$flow_pk" ]]; then + echo "error: could not resolve Authentik authentication flow slug ${authentication_flow_slug}" >&2 + exit 1 +fi + +enrollment_flow_pk="$( + lookup_single_result \ + "/api/v3/flows/instances/?slug=${enrollment_flow_slug}" \ + '.results[] | select(.slug != null) | .pk // empty' +)" +if [[ -z "$enrollment_flow_pk" ]]; then + echo "error: could not resolve Authentik enrollment flow slug ${enrollment_flow_slug}" >&2 + exit 1 +fi + +identification_stage="$( + api GET "/api/v3/stages/identification/" \ + | jq -c --arg name "$identification_stage_name" '.results[] | select(.name == $name)' +)" +if [[ -z "$identification_stage" ]]; then + echo "error: could not resolve Authentik identification stage ${identification_stage_name}" >&2 + exit 1 +fi + +stage_pk="$(printf '%s\n' "$identification_stage" | jq -r '.pk')" + +property_mapping_payload='[]' +if [[ "$(printf '%s' "$google_account_map_json" | jq 'length')" -gt 0 ]]; then + alias_map_python="$( + printf '%s' "$google_account_map_json" \ + | jq -c ' + map({ + key: (.source_email | ascii_downcase), + value: { + username: .username, + email: .email, + name: .name + } + }) + | from_entries + ' + )" + + oauth_property_mapping_expression="$( + cat </dev/null + else + property_mapping_pk="$( + api POST "/api/v3/propertymappings/source/oauth/" "$oauth_property_mapping_payload" \ + | jq -r '.pk // empty' + )" + fi + + if [[ -z "${property_mapping_pk:-}" ]]; then + echo "error: Google OAuth property mapping did not return a primary key" >&2 + exit 1 + fi + + property_mapping_payload="$(jq -cn --arg property_mapping_pk "$property_mapping_pk" '[$property_mapping_pk]')" +fi + +oauth_source_payload="$( + jq -n \ + --arg name "$source_name" \ + --arg slug "$source_slug" \ + --arg authentication_flow "$flow_pk" \ + --arg enrollment_flow "$enrollment_flow_pk" \ + --arg user_matching_mode "$user_matching_mode" \ + --arg policy_engine_mode "$policy_engine_mode" \ + --argjson user_property_mappings "$property_mapping_payload" \ + --arg consumer_key "$google_client_id" \ + --arg consumer_secret "$google_client_secret" \ + '{ + name: $name, + slug: $slug, + enabled: true, + promoted: true, + authentication_flow: $authentication_flow, + enrollment_flow: $enrollment_flow, + user_property_mappings: $user_property_mappings, + group_property_mappings: [], + policy_engine_mode: $policy_engine_mode, + user_matching_mode: $user_matching_mode, + provider_type: "google", + consumer_key: $consumer_key, + consumer_secret: $consumer_secret + }' +)" + +existing_source="$( + api GET "/api/v3/sources/oauth/?slug=${source_slug}" \ + | jq -c '.results[]?' +)" + +if [[ -n "$existing_source" ]]; then + source_pk="$(printf '%s\n' "$existing_source" | jq -r '.pk')" + api PATCH "/api/v3/sources/oauth/${source_slug}/" "$oauth_source_payload" >/dev/null +else + source_pk="$( + api POST "/api/v3/sources/oauth/" "$oauth_source_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "$source_pk" ]]; then + echo "error: Google OAuth source did not return a primary key" >&2 + exit 1 +fi + +stage_patch="$( + printf '%s\n' "$identification_stage" \ + | jq -c \ + --arg source_pk "$source_pk" \ + --arg login_mode "$login_mode" ' + .sources = ( + if $login_mode == "redirect" then + [$source_pk] + else + ([ $source_pk ] + ((.sources // []) | map(select(. != $source_pk)))) + end + ) + | .show_source_labels = true + | if $login_mode == "redirect" then + .user_fields = [] + else + . + end + | { + sources, + show_source_labels, + user_fields + }' +)" + +api PATCH "/api/v3/stages/identification/${stage_pk}/" "$stage_patch" >/dev/null + +echo "Synced Authentik Google source ${source_slug} (${source_pk}) in ${login_mode} mode." diff --git a/flake.lock b/flake.lock index 599e193..1bafc37 100644 --- a/flake.lock +++ b/flake.lock @@ -52,8 +52,8 @@ ] }, "locked": { - "lastModified": 1773506317, - "narHash": "sha256-qWKbLUJpavIpvOdX1fhHYm0WGerytFHRoh9lVck6Bh0=", + "lastModified": 1773889306, + "narHash": "sha256-PAqwnsBSI9SVC2QugvQ3xeYCB0otOwCacB1ueQj2tgw=", "type": "tarball", "url": "https://codeload.github.com/nix-community/disko/tar.gz/master" }, diff --git a/nixos/README.md b/nixos/README.md index 7924944..acae40f 100644 --- a/nixos/README.md +++ b/nixos/README.md @@ -33,7 +33,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B 4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account. 5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent `. 6. Run `Scripts/provision-forgejo-nsc.sh` locally, then `Scripts/sync-forgejo-nsc-config.sh` to place the Namespace dispatcher/autoscaler runtime inputs under `/var/lib/burrow/intake/`. -7. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age` and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`. +7. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`. 8. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. 9. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace. 10. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`. diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 43f65a3..6d4134c 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -33,6 +33,18 @@ group = "root"; mode = "0400"; }; + age.secrets.burrowAuthentikGoogleClientId = { + file = ../../../secrets/infra/authentik-google-client-id.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; + age.secrets.burrowAuthentikGoogleClientSecret = { + file = ../../../secrets/infra/authentik-google-client-secret.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; networking.extraHosts = '' 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net @@ -69,6 +81,8 @@ enable = true; envFile = config.age.secrets.burrowAuthentikEnv.path; headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; + googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; + googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; }; services.burrow.headscale = { diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 70ef2d7..9e6bf1f 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -8,6 +8,7 @@ let blueprintFile = "${blueprintDir}/burrow-authentik.yaml"; postgresVolume = "burrow-authentik-postgresql:/var/lib/postgresql/data"; dataVolume = "burrow-authentik-data:/data"; + googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' version: 1 metadata: @@ -106,6 +107,33 @@ in default = "/var/lib/burrow/intake/authentik_headscale_client_secret.txt"; description = "Host-local file containing the Authentik Headscale OIDC client secret."; }; + + googleClientIDFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Host-local file containing the Google OAuth client ID for the Authentik source."; + }; + + googleClientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Host-local file containing the Google OAuth client secret for the Authentik source."; + }; + + googleSourceSlug = lib.mkOption { + type = lib.types.str; + default = "google"; + description = "Authentik OAuth source slug used for Google login."; + }; + + googleLoginMode = lib.mkOption { + type = lib.types.enum [ + "promoted" + "redirect" + ]; + default = "redirect"; + description = "Identification-stage behavior for the Google Authentik source."; + }; }; config = lib.mkIf cfg.enable { @@ -263,6 +291,55 @@ EOF ''; }; + systemd.services.burrow-authentik-google-source = lib.mkIf ( + cfg.googleClientIDFile != null && cfg.googleClientSecretFile != null + ) { + description = "Reconcile the Burrow Authentik Google OAuth source"; + after = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + googleSourceSyncScript + cfg.envFile + cfg.googleClientIDFile + cfg.googleClientSecretFile + ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + Restart = "on-failure"; + RestartSec = 5; + }; + script = '' + set -euo pipefail + set -a + source ${lib.escapeShellArg cfg.envFile} + set +a + + export AUTHENTIK_URL=https://${cfg.domain} + export AUTHENTIK_GOOGLE_SOURCE_SLUG=${lib.escapeShellArg cfg.googleSourceSlug} + export AUTHENTIK_GOOGLE_LOGIN_MODE=${lib.escapeShellArg cfg.googleLoginMode} + export AUTHENTIK_GOOGLE_USER_MATCHING_MODE=email_link + export AUTHENTIK_GOOGLE_CLIENT_ID="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.googleClientIDFile})" + export AUTHENTIK_GOOGLE_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.googleClientSecretFile})" + + ${pkgs.bash}/bin/bash ${googleSourceSyncScript} + ''; + }; + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd reverse_proxy 127.0.0.1:${toString cfg.port} diff --git a/secrets.nix b/secrets.nix index 4382fd6..c63d898 100644 --- a/secrets.nix +++ b/secrets.nix @@ -10,5 +10,7 @@ let in { "secrets/infra/authentik.env.age".publicKeys = burrowForgeRecipients; + "secrets/infra/authentik-google-client-id.age".publicKeys = burrowForgeRecipients; + "secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; } diff --git a/secrets/infra/authentik-google-client-id.age b/secrets/infra/authentik-google-client-id.age new file mode 100644 index 0000000000000000000000000000000000000000..f295804f68781d005cf5d43f5fa1e4f1dd49d320 GIT binary patch literal 493 zcmYdHPt{G$OD?J`D9Oyv)5|YP*Do{V(zR14F3!+RO))YxHMCSHtuXPk2vo34D-H`N z4s_2mvMly@_H&EKH!caQFflR>Obqoe$Z#{(Hpt8IHVE={H{o&#^~+0j)z0$D&G0tP za1RSik1Q?CDm2jcjm$Lgj|$JobBhQsDR=cV@I|-HvnVRpFDAPGN%-1)? zJ;kgt-_YHsG$7rv%EKTlJ=NUB(Gz4_1jJiLj%LOssfLCwensUG&gG$jd4(osmgaPg*IIYT5fqcU?aQk>Z-TR0;T@>)0RXXn}51>-pTHJ SSx3#pe;XZ)NG{y;ZY}@@rLS24 literal 0 HcmV?d00001 diff --git a/secrets/infra/authentik-google-client-secret.age b/secrets/infra/authentik-google-client-secret.age new file mode 100644 index 0000000..43ecf0b --- /dev/null +++ b/secrets/infra/authentik-google-client-secret.age @@ -0,0 +1,9 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q 4uq5z93mRUUgcMOxP4+Yfe2Jq4tGYErwtzvtMHUvgi0 +J9DkDeSPkQbOjFM3QoV+1Kz3ZVLfR4PUxCT8Zxz+Wvk +-> ssh-ed25519 IrZmAg uLEVmJ+e9ZiLas5YooR4GfgyspWTsFdMB2WPvluU/VI +7vqqQ/BIDQaOp6VDVLa5ugoRxVZZsMj116cTHY6+8KM +-> X25519 9spF9eLz63UOaBfuG9vTIr6bCKwzFsWMjnaIj1PIR3Y +iGFELg2RQUT9rEal7pblQhfxtwYhxsZdXYxEhvjtHpw +--- 3TDrUnIN826N/n5gc+YY8ilMMc/6K8zGTh6FxzKC/JM +XH#IJGueֹf&1a2BJԎg=̿.*7Fb \ No newline at end of file From fff54759143064dc2305bc7dfd8bb5d5f93929d0 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Tue, 31 Mar 2026 23:28:42 -0700 Subject: [PATCH 042/102] Probe Headscale reachability in Apple UI --- Apple/UI/BurrowView.swift | 136 ++++++++++++++++++++++++++++++-- Apple/UI/Networks/Network.swift | 71 ++++++++++++++++- 2 files changed, 198 insertions(+), 9 deletions(-) diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index ce93231..835510d 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -131,7 +131,10 @@ public struct BurrowView: View { } private func runAutomationIfNeeded() { - guard !didRunAutomation, BurrowAutomationConfig.current?.action == .tailnetLogin else { + guard !didRunAutomation, + let automation = BurrowAutomationConfig.current, + automation.action == .tailnetLogin || automation.action == .headscaleProbe + else { return } didRunAutomation = true @@ -324,6 +327,9 @@ private struct ConfigurationSheetView: View { @State private var errorMessage: String? @State private var loginSessionID: String? @State private var loginStatus: TailnetLoginStatus? + @State private var authorityProbeStatus: TailnetAuthorityProbeStatus? + @State private var authorityProbeError: String? + @State private var isProbingAuthority = false @State private var pollingTask: Task? @State private var didRunAutomation = false @State private var webAuthenticationTask: Task? @@ -437,6 +443,12 @@ private struct ConfigurationSheetView: View { .onAppear { runAutomationIfNeeded() } + .onChange(of: draft.tailnetProvider) { _, _ in + resetAuthorityProbe() + } + .onChange(of: draft.authority) { _, _ in + resetAuthorityProbe() + } .onDisappear { pollingTask?.cancel() webAuthenticationTask?.cancel() @@ -460,6 +472,24 @@ private struct ConfigurationSheetView: View { TextField("Server URL", text: $draft.authority) .burrowLoginField() .autocorrectionDisabled() + + Button { + probeTailnetAuthority() + } label: { + Label { + Text(isProbingAuthority ? "Checking Connection" : "Check Connection") + } icon: { + Image(systemName: isProbingAuthority ? "hourglass" : "bolt.horizontal.circle") + } + } + .buttonStyle(.borderless) + .disabled(isProbingAuthority || normalizedOptional(draft.authority) == nil) + + if let authorityProbeStatus { + tailnetAuthorityProbeCard(status: authorityProbeStatus, failure: nil) + } else if let authorityProbeError { + tailnetAuthorityProbeCard(status: nil, failure: authorityProbeError) + } } else { LabeledContent("Server") { Text("Tailscale managed") @@ -527,6 +557,28 @@ private struct ConfigurationSheetView: View { .foregroundStyle(.secondary) } + if sheet == .tailnet { + if let authorityProbeStatus { + Text(authorityProbeStatus.summary) + .font(.footnote.weight(.medium)) + .foregroundStyle(.primary) + if let detail = authorityProbeStatus.detail { + Text(detail) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } else if let authorityProbeError { + Text("Connection failed") + .font(.footnote.weight(.medium)) + .foregroundStyle(.red) + Text(authorityProbeError) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + if sheet == .tailnet { HStack(spacing: 8) { summaryBadge(draft.tailnetProvider.title) @@ -616,6 +668,34 @@ private struct ConfigurationSheetView: View { ) } + private func tailnetAuthorityProbeCard( + status: TailnetAuthorityProbeStatus?, + failure: String? + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + if let status { + Text(status.summary) + .font(.subheadline.weight(.medium)) + Text(status.detail ?? "HTTP \(status.statusCode) from \(status.authority)") + .font(.footnote) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } else if let failure { + Text("Connection failed") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.red) + Text(failure) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.thinMaterial) + ) + } + private func summaryBadge(_ label: String) -> some View { Text(label) .font(.caption.weight(.medium)) @@ -914,23 +994,30 @@ private struct ConfigurationSheetView: View { guard !didRunAutomation, sheet == .tailnet, let automation = BurrowAutomationConfig.current, - automation.action == .tailnetLogin + automation.action == .tailnetLogin || automation.action == .headscaleProbe else { return } didRunAutomation = true - draft.tailnetProvider = .tailscale draft.title = automation.title ?? draft.title draft.accountName = automation.accountName ?? draft.accountName draft.identityName = automation.identityName ?? draft.identityName draft.hostname = automation.hostname ?? draft.hostname Task { @MainActor in - do { - try await startTailscaleLogin() - } catch { - errorMessage = error.localizedDescription + switch automation.action { + case .tailnetLogin: + draft.tailnetProvider = .tailscale + do { + try await startTailscaleLogin() + } catch { + errorMessage = error.localizedDescription + } + case .headscaleProbe: + applyTailnetProvider(.headscale) + draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority + probeTailnetAuthority() } } } @@ -1085,6 +1172,36 @@ private struct ConfigurationSheetView: View { } } + private func probeTailnetAuthority() { + guard draft.tailnetProvider.requiresControlURL else { return } + guard let authority = normalizedOptional(draft.authority) else { + authorityProbeStatus = nil + authorityProbeError = "Enter a server URL first." + return + } + + isProbingAuthority = true + authorityProbeStatus = nil + authorityProbeError = nil + + Task { @MainActor in + defer { isProbingAuthority = false } + do { + authorityProbeStatus = try await TailnetAuthorityProbeClient.probe( + provider: draft.tailnetProvider, + authority: authority + ) + } catch { + authorityProbeError = error.localizedDescription + } + } + } + + private func resetAuthorityProbe() { + authorityProbeStatus = nil + authorityProbeError = nil + } + private func pasteWireGuardConfiguration() { guard let clipboardString else { return } draft.wireGuardConfig = clipboardString @@ -1228,6 +1345,7 @@ private extension View { private struct BurrowAutomationConfig { enum Action: String { case tailnetLogin = "tailnet-login" + case headscaleProbe = "headscale-probe" } let action: Action @@ -1235,6 +1353,7 @@ private struct BurrowAutomationConfig { let accountName: String? let identityName: String? let hostname: String? + let authority: String? static let current: BurrowAutomationConfig? = { let environment = ProcessInfo.processInfo.environment @@ -1249,7 +1368,8 @@ private struct BurrowAutomationConfig { title: environment["BURROW_UI_AUTOMATION_TITLE"], accountName: environment["BURROW_UI_AUTOMATION_ACCOUNT"], identityName: environment["BURROW_UI_AUTOMATION_IDENTITY"], - hostname: environment["BURROW_UI_AUTOMATION_HOSTNAME"] + hostname: environment["BURROW_UI_AUTOMATION_HOSTNAME"], + authority: environment["BURROW_UI_AUTOMATION_AUTHORITY"] ) }() } diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift index f38ab26..71e5bca 100644 --- a/Apple/UI/Networks/Network.swift +++ b/Apple/UI/Networks/Network.swift @@ -50,6 +50,13 @@ struct TailnetLoginStartResponse: Codable, Sendable { var status: TailnetLoginStatus } +struct TailnetAuthorityProbeStatus: Sendable { + var authority: String + var statusCode: Int + var summary: String + var detail: String? +} + enum TailnetBridgeClient { private static let baseURL = URL(string: "http://127.0.0.1:8080")! @@ -97,6 +104,66 @@ enum TailnetBridgeClient { } } +enum TailnetAuthorityProbeClient { + static func probe(provider: TailnetProvider, authority: String) async throws -> TailnetAuthorityProbeStatus { + let normalizedAuthority = normalizeAuthority(authority) + let baseURL = try validatedBaseURL(normalizedAuthority) + let probeURL = probeURL(for: provider, baseURL: baseURL) + + var request = URLRequest(url: probeURL) + request.timeoutInterval = 10 + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + guard (200..<300).contains(http.statusCode) else { + let message = String(data: data, encoding: .utf8)?.trimmingCharacters( + in: .whitespacesAndNewlines + ) + throw TailnetBridgeError.server(message?.ifEmpty("HTTP \(http.statusCode)") ?? "HTTP \(http.statusCode)") + } + + let body = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let detail = body.flatMap { $0.isEmpty ? nil : $0 } + + return TailnetAuthorityProbeStatus( + authority: normalizedAuthority, + statusCode: http.statusCode, + summary: "\(provider.title) reachable", + detail: detail + ) + } + + private static func normalizeAuthority(_ authority: String) -> String { + let trimmed = authority.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.contains("://") { + return trimmed + } + return "https://\(trimmed)" + } + + private static func validatedBaseURL(_ authority: String) throws -> URL { + guard let url = URL(string: authority), url.host != nil else { + throw TailnetBridgeError.server("Invalid server URL") + } + return url + } + + private static func probeURL(for provider: TailnetProvider, baseURL: URL) -> URL { + switch provider { + case .headscale: + baseURL.appendingPathComponent("health") + case .burrow: + baseURL.appendingPathComponent("healthz") + case .tailscale: + baseURL + } + } +} + enum TailnetBridgeError: LocalizedError { case server(String) @@ -253,7 +320,9 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable { switch self { case .tailscale: "https://controlplane.tailscale.com" - case .headscale, .burrow: + case .headscale: + "https://ts.burrow.net" + case .burrow: nil } } From 7f280c08cfee17c6330c24979a4d4f48f9d75e7b Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Tue, 31 Mar 2026 23:35:36 -0700 Subject: [PATCH 043/102] Commit remaining Burrow platform work --- .cargo/config.toml | 3 - .forgejo/workflows/build-rust.yml | 31 + .forgejo/workflows/build-site.yml | 31 + .github/workflows/build-apple.yml | 3 +- .github/workflows/build-rust.yml | 9 +- .github/workflows/release-apple.yml | 1 + .gitignore | 5 +- CONSTITUTION.md | 38 + Cargo.lock | 1937 ++--------------- Dockerfile | 2 +- Makefile | 14 +- README.md | 11 +- Tools/forwardemail-custom-s3.sh | 171 ++ Tools/forwardemail-hetzner-storage.py | 261 +++ burrow/src/daemon/net/unix.rs | 6 +- burrow/src/daemon/rpc/client.rs | 15 +- burrow/src/daemon/rpc/request.rs | 2 +- burrow/src/lib.rs | 4 +- burrow/src/main.rs | 55 +- burrow/src/tor/dns.rs | 15 +- burrow/src/tor/mod.rs | 2 +- burrow/src/tor/runtime.rs | 5 +- burrow/src/tor/system.rs | 5 +- burrow/src/usernet/mod.rs | 935 ++++++++ burrow/src/wireguard/iface.rs | 2 +- burrow/src/wireguard/noise/handshake.rs | 49 +- burrow/src/wireguard/noise/mod.rs | 20 +- burrow/src/wireguard/noise/rate_limiter.rs | 27 +- burrow/src/wireguard/noise/session.rs | 14 +- burrow/src/wireguard/noise/timers.rs | 10 +- burrow/src/wireguard/pcb.rs | 32 +- docs/FORWARDEMAIL.md | 101 + docs/GETTING_STARTED.md | 8 +- docs/PROTOCOL_ROADMAP.md | 31 + docs/WIREGUARD_LINEAGE.md | 30 + evolution/README.md | 60 + evolution/proposals/0000-template.md | 57 + ...BEP-0001-sovereign-forge-and-governance.md | 61 + ...-control-plane-bootstrap-and-local-auth.md | 60 + ...0003-connect-ip-and-negotiation-roadmap.md | 61 + .../BEP-0004-hosted-mail-and-saas-identity.md | 68 + tun/build.rs | 2 +- tun/src/tokio/mod.rs | 2 +- tun/src/unix/apple/mod.rs | 4 + tun/src/unix/linux/mod.rs | 27 +- tun/src/unix/mod.rs | 26 +- tun/tests/configure.rs | 36 +- tun/tests/tokio.rs | 23 +- 48 files changed, 2508 insertions(+), 1864 deletions(-) create mode 100644 .forgejo/workflows/build-rust.yml create mode 100644 .forgejo/workflows/build-site.yml create mode 100644 CONSTITUTION.md create mode 100755 Tools/forwardemail-custom-s3.sh create mode 100755 Tools/forwardemail-hetzner-storage.py create mode 100644 burrow/src/usernet/mod.rs create mode 100644 docs/FORWARDEMAIL.md create mode 100644 docs/PROTOCOL_ROADMAP.md create mode 100644 docs/WIREGUARD_LINEAGE.md create mode 100644 evolution/README.md create mode 100644 evolution/proposals/0000-template.md create mode 100644 evolution/proposals/BEP-0001-sovereign-forge-and-governance.md create mode 100644 evolution/proposals/BEP-0002-control-plane-bootstrap-and-local-auth.md create mode 100644 evolution/proposals/BEP-0003-connect-ip-and-negotiation-roadmap.md create mode 100644 evolution/proposals/BEP-0004-hosted-mail-and-saas-identity.md diff --git a/.cargo/config.toml b/.cargo/config.toml index 302ce48..767d03a 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,6 +1,3 @@ -[target.'cfg(unix)'] -runner = "sudo -E" - [alias] # command aliases rr = "run --release" bb = "build --release" diff --git a/.forgejo/workflows/build-rust.yml b/.forgejo/workflows/build-rust.yml new file mode 100644 index 0000000..2df1ad3 --- /dev/null +++ b/.forgejo/workflows/build-rust.yml @@ -0,0 +1,31 @@ +name: Build Rust + +on: + push: + branches: + - main + pull_request: + branches: + - "**" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + rust: + name: Cargo Test + runs-on: [self-hosted, linux, x86_64, burrow-forge] + steps: + - name: Checkout + uses: https://code.forgejo.org/actions/checkout@v4 + with: + token: ${{ github.token }} + fetch-depth: 0 + + - name: Test + shell: bash + run: | + set -euo pipefail + nix develop .#ci -c cargo test --workspace --all-features diff --git a/.forgejo/workflows/build-site.yml b/.forgejo/workflows/build-site.yml new file mode 100644 index 0000000..6f7c5e2 --- /dev/null +++ b/.forgejo/workflows/build-site.yml @@ -0,0 +1,31 @@ +name: Build Site + +on: + push: + branches: + - main + pull_request: + branches: + - "**" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + site: + name: Next.js Build + runs-on: [self-hosted, linux, x86_64, burrow-forge] + steps: + - name: Checkout + uses: https://code.forgejo.org/actions/checkout@v4 + with: + token: ${{ github.token }} + fetch-depth: 0 + + - name: Build + shell: bash + run: | + set -euo pipefail + nix develop .#ci -c bash -lc 'cd site && npm install && npm run build' diff --git a/.github/workflows/build-apple.yml b/.github/workflows/build-apple.yml index 7ae8c4c..5a135b4 100644 --- a/.github/workflows/build-apple.yml +++ b/.github/workflows/build-apple.yml @@ -54,6 +54,7 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable with: + toolchain: 1.85.0 targets: ${{ join(matrix.rust-targets, ', ') }} - name: Install Protobuf shell: bash @@ -86,4 +87,4 @@ jobs: destination: ${{ matrix.destination }} test-plan: ${{ matrix.xcode-ui-test }} artifact-prefix: ui-tests-${{ matrix.sdk-name }} - check-name: Xcode UI Tests (${{ matrix.platform }}) \ No newline at end of file + check-name: Xcode UI Tests (${{ matrix.platform }}) diff --git a/.github/workflows/build-rust.yml b/.github/workflows/build-rust.yml index 95fc628..cbbdd81 100644 --- a/.github/workflows/build-rust.yml +++ b/.github/workflows/build-rust.yml @@ -6,6 +6,9 @@ on: pull_request: branches: - "*" +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: build: name: Build Crate (${{ matrix.platform }}) @@ -72,14 +75,14 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable with: - toolchain: stable + toolchain: 1.85.0 components: rustfmt targets: ${{ join(matrix.targets, ', ') }} - name: Setup Rust Cache uses: Swatinem/rust-cache@v2 - name: Build shell: bash - run: cargo build --verbose --workspace --all-features --target ${{ join(matrix.targets, ' --target ') }} --target ${{ join(matrix.test-targets, ' --target ') }} + run: cargo build --locked --verbose --workspace --all-features --target ${{ join(matrix.targets, ' --target ') }} --target ${{ join(matrix.test-targets, ' --target ') }} - name: Test shell: bash - run: cargo test --verbose --workspace --all-features --target ${{ join(matrix.test-targets, ' --target ') }} \ No newline at end of file + run: cargo test --locked --verbose --workspace --all-features --target ${{ join(matrix.test-targets, ' --target ') }} diff --git a/.github/workflows/release-apple.yml b/.github/workflows/release-apple.yml index c869d6a..b36ed73 100644 --- a/.github/workflows/release-apple.yml +++ b/.github/workflows/release-apple.yml @@ -47,6 +47,7 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable with: + toolchain: 1.85.0 targets: ${{ join(matrix.rust-targets, ', ') }} - name: Install Protobuf shell: bash diff --git a/.gitignore b/.gitignore index 1b300b4..7efe903 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Xcode xcuserdata +Apple/build/ # Swift Apple/Package/.swiftpm/ @@ -12,6 +13,8 @@ target/ .idea/ tmp/ +intake/ *.db -*.sock \ No newline at end of file +*.sqlite3 +*.sock diff --git a/CONSTITUTION.md b/CONSTITUTION.md new file mode 100644 index 0000000..f97e683 --- /dev/null +++ b/CONSTITUTION.md @@ -0,0 +1,38 @@ +# Burrow Constitution + +1. Mission + +Burrow exists to build a proper VPN: fast, inspectable, deployable on infrastructure the project controls, and legible enough that future contributors can extend it without guesswork. + +2. Commitments + +- Protocol work must favor correctness over novelty. Burrow does not claim support for a transport or control-plane feature until the wire format, state handling, and recovery behavior are implemented and tested. +- Security is a design constraint, not a cleanup phase. Key material, bootstrap credentials, control-plane tokens, and routing policy must have explicit storage and rotation paths. +- Performance matters. Burrow should avoid needless copies, hidden blocking, and ad hoc process graphs that make packet forwarding or control-plane convergence harder to reason about. +- Source, infrastructure, and release logic live in the repository. If the forge cannot be rebuilt from the tree, the work is incomplete. +- Non-trivial changes require a Burrow Evolution Proposal. Durable rationale belongs in the repository, not only in chat. + +3. Infrastructure + +Burrow controls its own forge, runners, deployment automation, and edge configuration for `burrow.net` and `burrow.rs`. + +- Dedicated compute is preferred over SaaS dependencies when the dependency would hold release, source, or identity authority. +- Secrets may be bootstrapped from local intake for initial bring-up, but long-lived operation must converge on encrypted, versioned secret handling. +- Production access must be attributable. Automation identities, SSH keys, and service accounts must be named and documented. + +4. Contributors + +- Read this constitution before drafting product, protocol, or infrastructure changes. +- Capture intent, testing expectations, and rollback procedures in proposals. +- Prefer reversible migrations. If a change is destructive, document the preconditions and teardown plan first. +- Security-sensitive work requires explicit reviewer attention, even when the implementation is performed by an agent. + +5. Governance + +- Burrow Evolution Proposals (BEPs) are the primary design record for architectural, protocol, forge, and deployment changes. +- Accepted proposals are authoritative until superseded. +- Constitutional changes require a dedicated proposal that quotes the affected text and records the decision. + +6. Origin + +Burrow started as a firewall-burrowing client and now carries its own transport, daemon, mesh, and control-plane work. This constitution exists so the project can finish that evolution coherently. diff --git a/Cargo.lock b/Cargo.lock index b5a929f..2950701 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,21 +23,10 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common 0.1.6", + "crypto-common", "generic-array", ] -[[package]] -name = "aead" -version = "0.6.0-rc.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac8202ab55fcbf46ca829833f347a82a2a4ce0596f0304ac322c2d100030cd56" -dependencies = [ - "bytes", - "crypto-common 0.2.0-rc.4", - "inout 0.2.1", -] - [[package]] name = "aes" version = "0.8.4" @@ -45,7 +34,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher 0.4.4", + "cipher", "cpufeatures", "zeroize", ] @@ -68,12 +57,6 @@ dependencies = [ "cc", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "amplify" version = "4.9.0" @@ -190,9 +173,6 @@ name = "anyhow" version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" -dependencies = [ - "backtrace", -] [[package]] name = "argon2" @@ -206,12 +186,6 @@ dependencies = [ "password-hash 0.5.0", ] -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - [[package]] name = "arrayvec" version = "0.7.6" @@ -228,7 +202,7 @@ dependencies = [ "cfg-if", "derive-deftly", "derive_builder_fork_arti", - "derive_more 2.0.1", + "derive_more", "educe", "fs-mistrust", "futures", @@ -329,19 +303,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-compat" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" -dependencies = [ - "futures-core", - "futures-io", - "once_cell", - "pin-project-lite", - "tokio", -] - [[package]] name = "async-compression" version = "0.4.33" @@ -436,17 +397,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "async_io_stream" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" -dependencies = [ - "futures", - "pharos", - "rustc_version", -] - [[package]] name = "asynchronous-codec" version = "0.7.0" @@ -475,33 +425,12 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "atomic-polyfill" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" -dependencies = [ - "critical-section", -] - [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "attohttpc" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" -dependencies = [ - "base64 0.22.1", - "http 1.3.1", - "log", - "url", -] - [[package]] name = "autocfg" version = "1.5.0" @@ -608,17 +537,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "backon" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" -dependencies = [ - "fastrand", - "gloo-timers", - "tokio", -] - [[package]] name = "backtrace" version = "0.3.75" @@ -640,18 +558,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[package]] -name = "base16ct" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b59d472eab27ade8d770dcb11da7201c11234bef9f82ce7aa517be028d462b" - -[[package]] -name = "base32" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" - [[package]] name = "base64" version = "0.21.7" @@ -755,20 +661,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "blake3" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" -dependencies = [ - "arrayref", - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq 0.3.1", + "digest", ] [[package]] @@ -791,16 +684,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-buffer" -version = "0.11.0-rc.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9ef36a6fcdb072aa548f3da057640ec10859eb4e91ddf526ee648d50c76a949" -dependencies = [ - "hybrid-array", - "zeroize", -] - [[package]] name = "bstr" version = "1.12.1" @@ -812,12 +695,6 @@ dependencies = [ "serde", ] -[[package]] -name = "btparse" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "387e80962b798815a2b5c4bcfdb6bf626fa922ffe9f74e373103b858738e9f31" - [[package]] name = "bumpalo" version = "3.19.0" @@ -828,7 +705,7 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" name = "burrow" version = "0.1.0" dependencies = [ - "aead 0.5.2", + "aead", "anyhow", "argon2", "arti-client", @@ -853,10 +730,10 @@ dependencies = [ "ip_network", "ip_network_table", "ipnetwork", - "iroh", "libc", "libsystemd", "log", + "netstack-smoltcp", "nix 0.27.1", "once_cell", "parking_lot", @@ -968,12 +845,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cexpr" version = "0.6.0" @@ -1002,32 +873,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher 0.4.4", + "cipher", "cpufeatures", ] -[[package]] -name = "chacha20" -version = "0.10.0-rc.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bd162f2b8af3e0639d83f28a637e4e55657b7a74508dba5a9bf4da523d5c9e9" -dependencies = [ - "cfg-if", - "cipher 0.5.0-rc.1", - "cpufeatures", - "zeroize", -] - [[package]] name = "chacha20poly1305" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ - "aead 0.5.2", - "chacha20 0.9.1", - "cipher 0.4.4", - "poly1305 0.8.0", + "aead", + "chacha20", + "cipher", + "poly1305", "zeroize", ] @@ -1076,20 +935,8 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common 0.1.6", - "inout 0.1.4", - "zeroize", -] - -[[package]] -name = "cipher" -version = "0.5.0-rc.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e12a13eb01ded5d32ee9658d94f553a19e804204f2dc811df69ab4d9e0cb8c7" -dependencies = [ - "block-buffer 0.11.0-rc.5", - "crypto-common 0.2.0-rc.4", - "inout 0.2.1", + "crypto-common", + "inout", "zeroize", ] @@ -1155,42 +1002,12 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "cobs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" -dependencies = [ - "thiserror 2.0.16", -] - -[[package]] -name = "color-backtrace" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "308329d5d62e877ba02943db3a8e8c052de9fde7ab48283395ba0e6494efbabd" -dependencies = [ - "backtrace", - "btparse", - "termcolor", -] - [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - [[package]] name = "compression-codecs" version = "0.4.32" @@ -1275,12 +1092,6 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "const-oid" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dabb6555f92fb9ee4140454eb5dcd14c7960e1225c6d1a6cc361f032947713e" - [[package]] name = "const-random" version = "0.1.18" @@ -1307,12 +1118,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" -[[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - [[package]] name = "convert_case" version = "0.7.1" @@ -1331,16 +1136,6 @@ dependencies = [ "futures", ] -[[package]] -name = "cordyceps" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" -dependencies = [ - "loom", - "tracing", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -1351,16 +1146,6 @@ dependencies = [ "libc", ] -[[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" @@ -1508,55 +1293,13 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-common" -version = "0.2.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8235645834fbc6832939736ce2f2d08192652269e11010a6240f61b908a1c6" -dependencies = [ - "hybrid-array", - "rand_core 0.9.3", -] - -[[package]] -name = "crypto_box" -version = "0.10.0-pre.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bda4de3e070830cf3a27a394de135b6709aefcc54d1e16f2f029271254a6ed9" -dependencies = [ - "aead 0.6.0-rc.2", - "chacha20 0.10.0-rc.2", - "crypto_secretbox", - "curve25519-dalek 5.0.0-pre.1", - "salsa20", - "serdect", - "subtle", - "zeroize", -] - -[[package]] -name = "crypto_secretbox" -version = "0.2.0-pre.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54532aae6546084a52cef855593daf9555945719eeeda9974150e0def854873e" -dependencies = [ - "aead 0.6.0-rc.2", - "chacha20 0.10.0-rc.2", - "cipher 0.5.0-rc.1", - "hybrid-array", - "poly1305 0.9.0-rc.2", - "salsa20", - "subtle", - "zeroize", -] - [[package]] name = "ctr" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher 0.4.4", + "cipher", ] [[package]] @@ -1568,31 +1311,13 @@ dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", - "digest 0.10.7", - "fiat-crypto 0.2.9", + "digest", + "fiat-crypto", "rustc_version", "subtle", "zeroize", ] -[[package]] -name = "curve25519-dalek" -version = "5.0.0-pre.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f9200d1d13637f15a6acb71e758f64624048d85b31a5fdbfd8eca1e2687d0b7" -dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "digest 0.11.0-rc.3", - "fiat-crypto 0.3.0", - "rand_core 0.9.3", - "rustc_version", - "serde", - "subtle", - "zeroize", -] - [[package]] name = "curve25519-dalek-derive" version = "0.1.1" @@ -1713,25 +1438,55 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.0.1", +] + +[[package]] +name = "defmt" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.16", +] + [[package]] name = "der" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid 0.9.6", - "pem-rfc7468 0.7.0", - "zeroize", -] - -[[package]] -name = "der" -version = "0.8.0-rc.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9d8dd2f26c86b27a2a8ea2767ec7f9df7a89516e4794e54ac01ee618dda3aa4" -dependencies = [ - "const-oid 0.10.1", - "pem-rfc7468 1.0.0-rc.3", + "const-oid", + "pem-rfc7468", "zeroize", ] @@ -1818,34 +1573,13 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "derive_more" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" -dependencies = [ - "derive_more-impl 1.0.0", -] - [[package]] name = "derive_more" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" dependencies = [ - "derive_more-impl 2.0.1", -] - -[[package]] -name = "derive_more-impl" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", - "unicode-xid", + "derive_more-impl", ] [[package]] @@ -1861,35 +1595,18 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "diatomic-waker" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" - [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", - "const-oid 0.9.6", - "crypto-common 0.1.6", + "block-buffer", + "const-oid", + "crypto-common", "subtle", ] -[[package]] -name = "digest" -version = "0.11.0-rc.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dac89f8a64533a9b0eaa73a68e424db0fb1fd6271c74cc0125336a05f090568d" -dependencies = [ - "block-buffer 0.11.0-rc.5", - "const-oid 0.10.1", - "crypto-common 0.2.0-rc.4", -] - [[package]] name = "directories" version = "6.0.0" @@ -1931,17 +1648,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "dlopen2" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b4f5f101177ff01b8ec4ecc81eead416a8aa42819a2869311b3420fa114ffa" -dependencies = [ - "libc", - "once_cell", - "winapi", -] - [[package]] name = "dlv-list" version = "0.5.2" @@ -1951,15 +1657,6 @@ dependencies = [ "const-random", ] -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - [[package]] name = "dotenv" version = "0.15.0" @@ -1984,12 +1681,12 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der 0.7.10", - "digest 0.10.7", + "der", + "digest", "elliptic-curve", "rfc6979", - "signature 2.2.0", - "spki 0.7.3", + "signature", + "spki", ] [[package]] @@ -1998,19 +1695,8 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8 0.10.2", - "signature 2.2.0", -] - -[[package]] -name = "ed25519" -version = "3.0.0-rc.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ef49c0b20c0ad088893ad2a790a29c06a012b3f05bcfc66661fd22a94b32129" -dependencies = [ - "pkcs8 0.11.0-rc.7", - "serde", - "signature 3.0.0-rc.4", + "pkcs8", + "signature", ] [[package]] @@ -2019,28 +1705,12 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek 4.1.3", - "ed25519 2.2.3", + "curve25519-dalek", + "ed25519", "merlin", "rand_core 0.6.4", "serde", - "sha2 0.10.9", - "subtle", - "zeroize", -] - -[[package]] -name = "ed25519-dalek" -version = "3.0.0-pre.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad207ed88a133091f83224265eac21109930db09bedcad05d5252f2af2de20a1" -dependencies = [ - "curve25519-dalek 5.0.0-pre.1", - "ed25519 3.0.0-rc.1", - "rand_core 0.9.3", - "serde", - "sha2 0.11.0-rc.2", - "signature 3.0.0-rc.4", + "sha2", "subtle", "zeroize", ] @@ -2069,31 +1739,19 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct 0.2.0", + "base16ct", "crypto-bigint", - "digest 0.10.7", + "digest", "ff", "generic-array", "group", - "pkcs8 0.10.2", + "pkcs8", "rand_core 0.6.4", "sec1", "subtle", "zeroize", ] -[[package]] -name = "embedded-io" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" - -[[package]] -name = "embedded-io" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" - [[package]] name = "encode_unicode" version = "1.0.0" @@ -2183,6 +1841,15 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "etherparse" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d8a704b617484e9d867a0423cd45f7577f008c4068e2e33378f8d3860a6d73" +dependencies = [ + "arrayvec", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -2258,12 +1925,6 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" -[[package]] -name = "fiat-crypto" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" - [[package]] name = "figment" version = "0.10.19" @@ -2404,19 +2065,6 @@ dependencies = [ "futures-util", ] -[[package]] -name = "futures-buffered" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8e0e1f38ec07ba4abbde21eed377082f17ccb988be9d988a5adbf4bafc118fd" -dependencies = [ - "cordyceps", - "diatomic-waker", - "futures-core", - "pin-project-lite", - "spin 0.10.0", -] - [[package]] name = "futures-channel" version = "0.3.31" @@ -2450,19 +2098,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - [[package]] name = "futures-macro" version = "0.3.31" @@ -2504,20 +2139,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generator" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" -dependencies = [ - "cc", - "cfg-if", - "libc", - "log", - "rustversion", - "windows 0.61.3", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -2599,18 +2220,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "group" version = "0.13.0" @@ -2673,9 +2282,9 @@ dependencies = [ [[package]] name = "hash32" -version = "0.2.1" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" dependencies = [ "byteorder", ] @@ -2707,8 +2316,6 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.2.0", ] @@ -2736,15 +2343,11 @@ dependencies = [ [[package]] name = "heapless" -version = "0.7.17" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" dependencies = [ - "atomic-polyfill", "hash32", - "rustc_version", - "serde", - "spin 0.9.8", "stable_deref_trait", ] @@ -2767,52 +2370,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" dependencies = [ "async-trait", - "bytes", "cfg-if", "data-encoding", "enum-as-inner", "futures-channel", "futures-io", "futures-util", - "h2 0.4.12", - "http 1.3.1", "idna", "ipnet", "once_cell", "rand 0.9.2", "ring", - "rustls", "thiserror 2.0.16", "tinyvec", "tokio", - "tokio-rustls", "tracing", "url", ] -[[package]] -name = "hickory-resolver" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" -dependencies = [ - "cfg-if", - "futures-util", - "hickory-proto", - "ipconfig", - "moka", - "once_cell", - "parking_lot", - "rand 0.9.2", - "resolv-conf", - "rustls", - "smallvec", - "thiserror 2.0.16", - "tokio", - "tokio-rustls", - "tracing", -] - [[package]] name = "hkdf" version = "0.12.4" @@ -2828,7 +2403,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.7", + "digest", ] [[package]] @@ -2930,16 +2505,6 @@ dependencies = [ "serde", ] -[[package]] -name = "hybrid-array" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f471e0a81b2f90ffc0cb2f951ae04da57de8baa46fa99112b062a5173a5088d0" -dependencies = [ - "typenum", - "zeroize", -] - [[package]] name = "hyper" version = "0.14.32" @@ -3060,7 +2625,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -3209,27 +2774,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "igd-next" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97" -dependencies = [ - "async-trait", - "attohttpc", - "bytes", - "futures", - "http 1.3.1", - "http-body-util", - "hyper 1.7.0", - "hyper-util", - "log", - "rand 0.9.2", - "tokio", - "url", - "xmltree", -] - [[package]] name = "indexmap" version = "1.9.3" @@ -3282,15 +2826,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "inout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7357b6e7aa75618c7864ebd0634b115a7218b0615f4cb1df33ac3eca23943d4" -dependencies = [ - "hybrid-array", -] - [[package]] name = "insta" version = "1.43.2" @@ -3303,18 +2838,6 @@ dependencies = [ "similar", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "inventory" version = "0.3.24" @@ -3357,18 +2880,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e537132deb99c0eb4b752f0346b6a836200eaaa3516dd7e5514b63930a09e5d" -[[package]] -name = "ipconfig" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" -dependencies = [ - "socket2 0.5.10", - "widestring", - "windows-sys 0.48.0", - "winreg", -] - [[package]] name = "ipnet" version = "2.11.0" @@ -3380,6 +2891,9 @@ name = "ipnetwork" version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" +dependencies = [ + "serde", +] [[package]] name = "iri-string" @@ -3391,213 +2905,6 @@ dependencies = [ "serde", ] -[[package]] -name = "iroh" -version = "0.94.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9428cef1eafd2eac584269986d1949e693877ac12065b401dfde69f664b07ac" -dependencies = [ - "aead 0.6.0-rc.2", - "backon", - "bytes", - "cfg_aliases", - "crypto_box", - "data-encoding", - "derive_more 2.0.1", - "ed25519-dalek 3.0.0-pre.1", - "futures-util", - "getrandom 0.3.3", - "hickory-resolver", - "http 1.3.1", - "igd-next", - "instant", - "iroh-base", - "iroh-metrics", - "iroh-quinn", - "iroh-quinn-proto", - "iroh-quinn-udp", - "iroh-relay", - "n0-future", - "n0-snafu", - "n0-watcher", - "nested_enum_utils", - "netdev", - "netwatch", - "pin-project", - "pkarr", - "pkcs8 0.11.0-rc.7", - "portmapper", - "rand 0.9.2", - "reqwest 0.12.23", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "rustls-webpki", - "serde", - "smallvec", - "snafu", - "strum", - "time", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "url", - "wasm-bindgen-futures", - "webpki-roots", - "z32", -] - -[[package]] -name = "iroh-base" -version = "0.94.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7db6dfffe81a58daae02b72c7784c20feef5b5d3849b190ed1c96a8fa0b3cae8" -dependencies = [ - "curve25519-dalek 5.0.0-pre.1", - "data-encoding", - "derive_more 2.0.1", - "ed25519-dalek 3.0.0-pre.1", - "n0-snafu", - "nested_enum_utils", - "rand_core 0.9.3", - "serde", - "snafu", - "url", - "zeroize", - "zeroize_derive", -] - -[[package]] -name = "iroh-metrics" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c84c167b59ae22f940e78eb347ca5f02aa25608e994cb5a7cc016ac2d5eada18" -dependencies = [ - "iroh-metrics-derive", - "itoa", - "postcard", - "ryu", - "serde", - "snafu", - "tracing", -] - -[[package]] -name = "iroh-metrics-derive" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "748d380f26f7c25307c0a7acd181b84b977ddc2a1b7beece1e5998623c323aa1" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "iroh-quinn" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde160ebee7aabede6ae887460cd303c8b809054224815addf1469d54a6fcf7" -dependencies = [ - "bytes", - "cfg_aliases", - "iroh-quinn-proto", - "iroh-quinn-udp", - "pin-project-lite", - "rustc-hash 2.1.1", - "rustls", - "socket2 0.5.10", - "thiserror 2.0.16", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "iroh-quinn-proto" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "929d5d8fa77d5c304d3ee7cae9aede31f13908bd049f9de8c7c0094ad6f7c535" -dependencies = [ - "bytes", - "getrandom 0.2.16", - "rand 0.8.5", - "ring", - "rustc-hash 2.1.1", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.16", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "iroh-quinn-udp" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c53afaa1049f7c83ea1331f5ebb9e6ebc5fdd69c468b7a22dd598b02c9bcc973" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2 0.5.10", - "tracing", - "windows-sys 0.59.0", -] - -[[package]] -name = "iroh-relay" -version = "0.94.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "360e201ab1803201de9a125dd838f7a4d13e6ba3a79aeb46c7fbf023266c062e" -dependencies = [ - "blake3", - "bytes", - "cfg_aliases", - "data-encoding", - "derive_more 2.0.1", - "getrandom 0.3.3", - "hickory-resolver", - "http 1.3.1", - "http-body-util", - "hyper 1.7.0", - "hyper-util", - "iroh-base", - "iroh-metrics", - "iroh-quinn", - "iroh-quinn-proto", - "lru 0.16.2", - "n0-future", - "n0-snafu", - "nested_enum_utils", - "num_enum", - "pin-project", - "pkarr", - "postcard", - "rand 0.9.2", - "reqwest 0.12.23", - "rustls", - "rustls-pki-types", - "serde", - "serde_bytes", - "sha1 0.11.0-rc.2", - "snafu", - "strum", - "tokio", - "tokio-rustls", - "tokio-util", - "tokio-websockets", - "tracing", - "url", - "webpki-roots", - "ws_stream_wasm", - "z32", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -3637,28 +2944,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" -[[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", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" - [[package]] name = "jobserver" version = "0.1.34" @@ -3716,7 +3001,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.8", + "spin", ] [[package]] @@ -3819,7 +3104,7 @@ dependencies = [ "nom 8.0.0", "once_cell", "serde", - "sha2 0.10.9", + "sha2", "thiserror 2.0.16", "uuid", ] @@ -3842,12 +3127,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - [[package]] name = "lock_api" version = "0.4.13" @@ -3864,40 +3143,18 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" -[[package]] -name = "loom" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" -dependencies = [ - "cfg-if", - "generator", - "scoped-tls", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "lru" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "227748d55f2f0ab4735d87fd623798cb6b664512fe979705f829c9f81c934465" - -[[package]] -name = "lru" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96051b46fc183dc9cd4a223960ef37b9af631b55191852a8274bfef064cda20f" -dependencies = [ - "hashbrown 0.16.1", -] - [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + [[package]] name = "matchers" version = "0.2.0" @@ -4014,75 +3271,12 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "moka" -version = "0.12.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" -dependencies = [ - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "parking_lot", - "portable-atomic", - "rustc_version", - "smallvec", - "tagptr", - "uuid", -] - [[package]] name = "multimap" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" -[[package]] -name = "n0-future" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439e746b307c1fd0c08771c3cafcd1746c3ccdb0d9c7b859d3caded366b6da76" -dependencies = [ - "cfg_aliases", - "derive_more 1.0.0", - "futures-buffered", - "futures-lite", - "futures-util", - "js-sys", - "pin-project", - "send_wrapper", - "tokio", - "tokio-util", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-time", -] - -[[package]] -name = "n0-snafu" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1815107e577a95bfccedb4cfabc73d709c0db6d12de3f14e0f284a8c5036dc4f" -dependencies = [ - "anyhow", - "btparse", - "color-backtrace", - "snafu", - "tracing-error", -] - -[[package]] -name = "n0-watcher" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34c65e127e06e5a2781b28df6a33ea474a7bddc0ac0cfea888bd20c79a1b6516" -dependencies = [ - "derive_more 2.0.1", - "n0-future", - "snafu", -] - [[package]] name = "native-tls" version = "0.2.14" @@ -4095,121 +3289,25 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework", "security-framework-sys", "tempfile", ] [[package]] -name = "nested_enum_utils" -version = "0.2.3" +name = "netstack-smoltcp" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1d5475271bdd36a4a2769eac1ef88df0f99428ea43e52dfd8b0ee5cb674695f" +checksum = "ab8eb143b5f4a5907f5ac72a929edf6c9d9454485cf5a3a35ce8fd3c62165adf" dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "netdev" -version = "0.38.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67ab878b4c90faf36dab10ea51d48c69ae9019bcca47c048a7c9b273d5d7a823" -dependencies = [ - "dlopen2", - "ipnet", - "libc", - "netlink-packet-core", - "netlink-packet-route", - "netlink-sys", - "once_cell", - "system-configuration 0.6.1", - "windows-sys 0.59.0", -] - -[[package]] -name = "netlink-packet-core" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" -dependencies = [ - "paste", -] - -[[package]] -name = "netlink-packet-route" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ec2f5b6839be2a19d7fa5aab5bc444380f6311c2b693551cb80f45caaa7b5ef" -dependencies = [ - "bitflags 2.9.4", - "libc", - "log", - "netlink-packet-core", -] - -[[package]] -name = "netlink-proto" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" -dependencies = [ - "bytes", + "etherparse", "futures", - "log", - "netlink-packet-core", - "netlink-sys", - "thiserror 2.0.16", -] - -[[package]] -name = "netlink-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23" -dependencies = [ - "bytes", - "futures", - "libc", - "log", - "tokio", -] - -[[package]] -name = "netwatch" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98d7ec7abdbfe67ee70af3f2002326491178419caea22254b9070e6ff0c83491" -dependencies = [ - "atomic-waker", - "bytes", - "cfg_aliases", - "derive_more 2.0.1", - "iroh-quinn-udp", - "js-sys", - "libc", - "n0-future", - "n0-watcher", - "nested_enum_utils", - "netdev", - "netlink-packet-core", - "netlink-packet-route", - "netlink-proto", - "netlink-sys", - "pin-project-lite", - "serde", - "snafu", - "socket2 0.6.3", - "time", + "rand 0.8.5", + "smoltcp", + "spin", "tokio", "tokio-util", "tracing", - "web-sys", - "windows 0.62.2", - "windows-result 0.4.1", - "wmi", ] [[package]] @@ -4234,6 +3332,7 @@ dependencies = [ "bitflags 2.9.4", "cfg-if", "libc", + "memoffset 0.9.1", ] [[package]] @@ -4309,21 +3408,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "ntimestamp" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c50f94c405726d3e0095e89e72f75ce7f6587b94a8bd8dc8054b73f65c0fd68c" -dependencies = [ - "base32", - "document-features", - "getrandom 0.2.16", - "httpdate", - "js-sys", - "once_cell", - "serde", -] - [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -4569,7 +3653,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -4581,7 +3665,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -4590,12 +3674,12 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" dependencies = [ - "base16ct 0.2.0", + "base16ct", "ecdsa", "elliptic-curve", "primeorder", "rand_core 0.6.4", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -4671,10 +3755,10 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ - "digest 0.10.7", + "digest", "hmac", "password-hash 0.4.2", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -4692,15 +3776,6 @@ dependencies = [ "base64ct", ] -[[package]] -name = "pem-rfc7468" -version = "1.0.0-rc.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8e58fab693c712c0d4e88f8eb3087b6521d060bcaf76aeb20cb192d809115ba" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -4717,16 +3792,6 @@ dependencies = [ "indexmap 2.11.4", ] -[[package]] -name = "pharos" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" -dependencies = [ - "futures", - "rustc_version", -] - [[package]] name = "phf" version = "0.13.1" @@ -4802,46 +3867,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "pkarr" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "792c1328860f6874e90e3b387b4929819cc7783a6bd5a4728e918706eb436a48" -dependencies = [ - "async-compat", - "base32", - "bytes", - "cfg_aliases", - "document-features", - "dyn-clone", - "ed25519-dalek 3.0.0-pre.1", - "futures-buffered", - "futures-lite", - "getrandom 0.3.3", - "log", - "lru 0.13.0", - "ntimestamp", - "reqwest 0.12.23", - "self_cell", - "serde", - "sha1_smol", - "simple-dns", - "thiserror 2.0.16", - "tokio", - "tracing", - "url", - "wasm-bindgen-futures", -] - [[package]] name = "pkcs1" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der 0.7.10", - "pkcs8 0.10.2", - "spki 0.7.3", + "der", + "pkcs8", + "spki", ] [[package]] @@ -4850,18 +3884,8 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der 0.7.10", - "spki 0.7.3", -] - -[[package]] -name = "pkcs8" -version = "0.11.0-rc.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93eac55f10aceed84769df670ea4a32d2ffad7399400d41ee1c13b1cd8e1b478" -dependencies = [ - "der 0.8.0-rc.9", - "spki 0.8.0-rc.4", + "der", + "spki", ] [[package]] @@ -4912,17 +3936,7 @@ checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures", "opaque-debug", - "universal-hash 0.5.1", -] - -[[package]] -name = "poly1305" -version = "0.9.0-rc.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb78a635f75d76d856374961deecf61031c0b6f928c83dc9c0924ab6c019c298" -dependencies = [ - "cpufeatures", - "universal-hash 0.6.0-rc.2", + "universal-hash", ] [[package]] @@ -4931,37 +3945,6 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" -[[package]] -name = "portmapper" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d73aa9bd141e0ff6060fea89a5437883f3b9ceea1cda71c790b90e17d072a3b3" -dependencies = [ - "base64 0.22.1", - "bytes", - "derive_more 2.0.1", - "futures-lite", - "futures-util", - "hyper-util", - "igd-next", - "iroh-metrics", - "libc", - "nested_enum_utils", - "netwatch", - "num_enum", - "rand 0.9.2", - "serde", - "smallvec", - "snafu", - "socket2 0.6.3", - "time", - "tokio", - "tokio-util", - "tower-layer", - "tracing", - "url", -] - [[package]] name = "postage" version = "0.5.0" @@ -4977,31 +3960,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "postcard" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" -dependencies = [ - "cobs", - "embedded-io 0.4.0", - "embedded-io 0.6.1", - "heapless", - "postcard-derive", - "serde", -] - -[[package]] -name = "postcard-derive" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "potential_utf" version = "0.1.3" @@ -5205,7 +4163,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.16", "tokio", "tracing", @@ -5242,7 +4200,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -5480,7 +4438,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper 0.1.2", - "system-configuration 0.5.1", + "system-configuration", "tokio", "tokio-native-tls", "tower-service", @@ -5500,7 +4458,6 @@ dependencies = [ "base64 0.22.1", "bytes", "futures-core", - "futures-util", "http 1.3.1", "http-body 1.0.1", "http-body-util", @@ -5520,24 +4477,16 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-rustls", - "tokio-util", "tower 0.5.2", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", "web-sys", "webpki-roots", ] -[[package]] -name = "resolv-conf" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" - [[package]] name = "retry-error" version = "0.11.0" @@ -5577,17 +4526,17 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid 0.9.6", - "digest 0.10.7", + "const-oid", + "digest", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", - "pkcs8 0.10.2", + "pkcs8", "rand_core 0.6.4", - "sha2 0.10.9", - "signature 2.2.0", - "spki 0.7.3", + "sha2", + "signature", + "spki", "subtle", "zeroize", ] @@ -5696,7 +4645,6 @@ version = "0.23.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" dependencies = [ - "log", "once_cell", "ring", "rustls-pki-types", @@ -5705,18 +4653,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework 3.5.1", -] - [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -5736,33 +4672,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-platform-verifier" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework 3.5.1", - "security-framework-sys", - "webpki-root-certs 0.26.11", - "windows-sys 0.59.0", -] - -[[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.103.8" @@ -5792,23 +4701,13 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee9f10dd250956c65d58a19507dd06ff976f898560fe843580d05134541f0898" dependencies = [ - "derive_more 2.0.1", + "derive_more", "educe", "either", "fluid-let", "thiserror 2.0.16", ] -[[package]] -name = "salsa20" -version = "0.11.0-rc.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3ff3b81c8a6e381bc1673768141383f9328048a60edddcfc752a8291a138443" -dependencies = [ - "cfg-if", - "cipher 0.5.0-rc.1", -] - [[package]] name = "same-file" version = "1.0.6" @@ -5890,12 +4789,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" @@ -5908,10 +4801,10 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct 0.2.0", - "der 0.7.10", + "base16ct", + "der", "generic-array", - "pkcs8 0.10.2", + "pkcs8", "subtle", "zeroize", ] @@ -5923,20 +4816,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.9.4", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" -dependencies = [ - "bitflags 2.9.4", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -5952,24 +4832,12 @@ dependencies = [ "libc", ] -[[package]] -name = "self_cell" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16c2f82143577edb4921b71ede051dac62ca3c16084e918bf7b40c96ae10eb33" - [[package]] name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" -[[package]] -name = "send_wrapper" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" - [[package]] name = "serde" version = "1.0.228" @@ -5990,16 +4858,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -6126,16 +4984,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "serdect" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3ef0e35b322ddfaecbc60f34ab448e157e48531288ee49fafbb053696b8ffe2" -dependencies = [ - "base16ct 0.3.0", - "serde", -] - [[package]] name = "sha-1" version = "0.10.1" @@ -6144,7 +4992,7 @@ checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.7", + "digest", ] [[package]] @@ -6155,26 +5003,9 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.7", + "digest", ] -[[package]] -name = "sha1" -version = "0.11.0-rc.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e046edf639aa2e7afb285589e5405de2ef7e61d4b0ac1e30256e3eab911af9" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.11.0-rc.3", -] - -[[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" @@ -6183,18 +5014,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0-rc.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1e3878ab0f98e35b2df35fe53201d088299b41a6bb63e3e34dada2ac4abd924" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.11.0-rc.3", + "digest", ] [[package]] @@ -6203,7 +5023,7 @@ version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" dependencies = [ - "digest 0.10.7", + "digest", "keccak", ] @@ -6248,37 +5068,16 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest 0.10.7", + "digest", "rand_core 0.6.4", ] -[[package]] -name = "signature" -version = "3.0.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc280a6ff65c79fbd6622f64d7127f32b85563bca8c53cd2e9141d6744a9056d" - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "similar" version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" -[[package]] -name = "simple-dns" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee851d0e5e7af3721faea1843e8015e820a234f81fda3dea9247e15bac9a86a" -dependencies = [ - "bitflags 2.9.4", -] - [[package]] name = "siphasher" version = "1.0.2" @@ -6321,25 +5120,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] -name = "snafu" -version = "0.8.9" +name = "smoltcp" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb" dependencies = [ - "backtrace", - "snafu-derive", -] - -[[package]] -name = "snafu-derive" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.106", + "bitflags 1.3.2", + "byteorder", + "cfg-if", + "defmt 0.3.100", + "heapless", + "log", + "managed", ] [[package]] @@ -6371,12 +5163,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spin" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" - [[package]] name = "spki" version = "0.7.3" @@ -6384,17 +5170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der 0.7.10", -] - -[[package]] -name = "spki" -version = "0.8.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8baeff88f34ed0691978ec34440140e1572b68c7dd4a495fd14a3dc1944daa80" -dependencies = [ - "base64ct", - "der 0.8.0-rc.9", + "der", ] [[package]] @@ -6415,7 +5191,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" dependencies = [ - "cipher 0.4.4", + "cipher", "ssh-encoding", ] @@ -6426,8 +5202,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" dependencies = [ "base64ct", - "pem-rfc7468 0.7.0", - "sha2 0.10.9", + "pem-rfc7468", + "sha2", ] [[package]] @@ -6443,8 +5219,8 @@ dependencies = [ "rand_core 0.6.4", "rsa", "sec1", - "sha2 0.10.9", - "signature 2.2.0", + "sha2", + "signature", "ssh-cipher", "ssh-encoding", "subtle", @@ -6458,11 +5234,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7a2b3c2bc9693bcb40870c4e9b5bf0d79f9cb46273321bf855ec513e919082" dependencies = [ "base64 0.21.7", - "digest 0.10.7", + "digest", "hex", "miette", "sha-1", - "sha2 0.10.9", + "sha2", "thiserror 1.0.69", "xxhash-rust", ] @@ -6587,19 +5363,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", - "core-foundation 0.9.4", - "system-configuration-sys 0.5.0", -] - -[[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags 2.9.4", - "core-foundation 0.9.4", - "system-configuration-sys 0.6.0", + "core-foundation", + "system-configuration-sys", ] [[package]] @@ -6612,22 +5377,6 @@ dependencies = [ "libc", ] -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - [[package]] name = "tap" version = "1.0.1" @@ -6647,15 +5396,6 @@ dependencies = [ "windows-sys 0.61.0", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -6851,7 +5591,6 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", - "tokio-util", ] [[package]] @@ -6864,33 +5603,10 @@ dependencies = [ "futures-core", "futures-io", "futures-sink", - "futures-util", "pin-project-lite", "tokio", ] -[[package]] -name = "tokio-websockets" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1b6348ebfaaecd771cecb69e832961d277f59845d4220a584701f72728152b7" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-sink", - "getrandom 0.3.3", - "http 1.3.1", - "httparse", - "rand 0.9.2", - "ring", - "rustls-pki-types", - "simdutf8", - "tokio", - "tokio-rustls", - "tokio-util", -] - [[package]] name = "toml" version = "0.8.23" @@ -7076,7 +5792,7 @@ version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac6e4d7e131b7d69bc85558383cd4ac61e46b4dd0d4ed51632f28fac98cac0c" dependencies = [ - "derive_more 2.0.1", + "derive_more", "hex", "itertools 0.14.0", "libc", @@ -7098,7 +5814,7 @@ checksum = "64454947258e49f238a5f06a06250a0c54598a1c7409898b5c79505e6a99e7af" dependencies = [ "bytes", "derive-deftly", - "digest 0.10.7", + "digest", "educe", "getrandom 0.4.2", "safelog", @@ -7119,7 +5835,7 @@ dependencies = [ "bytes", "caret", "derive-deftly", - "derive_more 2.0.1", + "derive_more", "educe", "itertools 0.14.0", "paste", @@ -7146,8 +5862,8 @@ checksum = "debc911738298ee801fce4577c36a50c55295b0bb9c5519461b83cc486a1f86e" dependencies = [ "caret", "derive_builder_fork_arti", - "derive_more 2.0.1", - "digest 0.10.7", + "derive_more", + "digest", "thiserror 2.0.16", "tor-bytes", "tor-checkable", @@ -7165,7 +5881,7 @@ dependencies = [ "caret", "cfg-if", "derive-deftly", - "derive_more 2.0.1", + "derive_more", "educe", "futures", "oneshot-fused-workaround", @@ -7202,7 +5918,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15b13a5b50bb55037f2e81b25dde42f420d57c75154216b8ef989006cea3ebee" dependencies = [ "humantime", - "signature 2.2.0", + "signature", "thiserror 2.0.16", "tor-llcrypto", ] @@ -7218,7 +5934,7 @@ dependencies = [ "cfg-if", "derive-deftly", "derive_builder_fork_arti", - "derive_more 2.0.1", + "derive_more", "downcast-rs", "dyn-clone", "educe", @@ -7310,7 +6026,7 @@ version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bfa2b7b71c72830f61c48da4bb3e13191e0c0e1404b9c5168c795e4f5feb4a8" dependencies = [ - "digest 0.10.7", + "digest", "hex", "thiserror 2.0.16", "tor-llcrypto", @@ -7324,7 +6040,7 @@ checksum = "8ccd6fac844ac77c33ccdfcb56bf23ff40ebbb821ea708be79a481ec30e8c39c" dependencies = [ "async-compression", "base64ct", - "derive_more 2.0.1", + "derive_more", "futures", "hex", "http 1.3.1", @@ -7373,8 +6089,8 @@ dependencies = [ "async-trait", "base64ct", "derive_builder_fork_arti", - "derive_more 2.0.1", - "digest 0.10.7", + "derive_more", + "digest", "educe", "event-listener", "fs-mistrust", @@ -7394,7 +6110,7 @@ dependencies = [ "scopeguard", "serde", "serde_json", - "signature 2.2.0", + "signature", "static_assertions", "strum", "thiserror 2.0.16", @@ -7425,7 +6141,7 @@ version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "595b005e6f571ac3890a34a00f361200aab781fd0218f2c528c86fc7af088df5" dependencies = [ - "derive_more 2.0.1", + "derive_more", "futures", "paste", "retry-error", @@ -7442,7 +6158,7 @@ version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727b8c8bc01c1587486055edab5c2cd0d5c960f5bb3fac796fc9911872b8b397" dependencies = [ - "derive_more 2.0.1", + "derive_more", "thiserror 2.0.16", "void", ] @@ -7457,7 +6173,7 @@ dependencies = [ "base64ct", "derive-deftly", "derive_builder_fork_arti", - "derive_more 2.0.1", + "derive_more", "dyn-clone", "educe", "futures", @@ -7498,8 +6214,8 @@ checksum = "a3693cd43f05cd01ac0aaa060dae5c5e53c4364f89e0d769e33cd629a2fd3118" dependencies = [ "data-encoding", "derive-deftly", - "derive_more 2.0.1", - "digest 0.10.7", + "derive_more", + "digest", "hex", "humantime", "itertools 0.14.0", @@ -7507,7 +6223,7 @@ dependencies = [ "rand 0.9.2", "safelog", "serde", - "signature 2.2.0", + "signature", "subtle", "thiserror 2.0.16", "tor-basic-utils", @@ -7526,12 +6242,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ade9065ae49cfe2ab020ca9ca9f2b3c5c9b5fc0d8980fa681d8b3a0668e042f" dependencies = [ "derive-deftly", - "derive_more 2.0.1", + "derive_more", "downcast-rs", "paste", "rand 0.9.2", "rsa", - "signature 2.2.0", + "signature", "ssh-key", "thiserror 2.0.16", "tor-bytes", @@ -7552,7 +6268,7 @@ dependencies = [ "cfg-if", "derive-deftly", "derive_builder_fork_arti", - "derive_more 2.0.1", + "derive_more", "downcast-rs", "dyn-clone", "fs-mistrust", @@ -7563,7 +6279,7 @@ dependencies = [ "rand 0.9.2", "safelog", "serde", - "signature 2.2.0", + "signature", "ssh-key", "thiserror 2.0.16", "tor-basic-utils", @@ -7592,7 +6308,7 @@ dependencies = [ "caret", "derive-deftly", "derive_builder_fork_arti", - "derive_more 2.0.1", + "derive_more", "hex", "itertools 0.14.0", "safelog", @@ -7617,12 +6333,12 @@ dependencies = [ "aes", "base64ct", "ctr", - "curve25519-dalek 4.1.3", + "curve25519-dalek", "der-parser", "derive-deftly", - "derive_more 2.0.1", - "digest 0.10.7", - "ed25519-dalek 2.2.0", + "derive_more", + "digest", + "ed25519-dalek", "educe", "getrandom 0.4.2", "hex", @@ -7635,10 +6351,10 @@ dependencies = [ "rsa", "safelog", "serde", - "sha1 0.10.6", - "sha2 0.10.9", + "sha1", + "sha2", "sha3", - "signature 2.2.0", + "signature", "subtle", "thiserror 2.0.16", "tor-error", @@ -7671,7 +6387,7 @@ checksum = "599daea60fd3272eb72a795d1c593b45bbe15343cbc702340a81db124c06eed5" dependencies = [ "cfg-if", "derive-deftly", - "derive_more 2.0.1", + "derive_more", "dyn-clone", "educe", "futures", @@ -7714,7 +6430,7 @@ checksum = "41be8f47f521fc95206d2ba5facac8fb1a5b5b82169bd41ebeecdf46d1e77246" dependencies = [ "async-trait", "bitflags 2.9.4", - "derive_more 2.0.1", + "derive_more", "futures", "humantime", "itertools 0.14.0", @@ -7742,11 +6458,11 @@ checksum = "ea8bce73d2c78bd78a2a927336ca639cf6bd5d8ad092ebcd0b3fdeaa47dcc77e" dependencies = [ "amplify", "base64ct", - "cipher 0.4.4", + "cipher", "derive-deftly", "derive_builder_fork_arti", - "derive_more 2.0.1", - "digest 0.10.7", + "derive_more", + "digest", "educe", "enumset", "hex", @@ -7758,7 +6474,7 @@ dependencies = [ "saturating-time", "serde", "serde_with", - "signature 2.2.0", + "signature", "smallvec", "strum", "subtle", @@ -7784,7 +6500,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "507ab4b6a3d59ed0df5804eeed66dcacde75e3be13d3694216cdfdb666bce625" dependencies = [ "derive-deftly", - "derive_more 2.0.1", + "derive_more", "filetime", "fs-mistrust", "fslock", @@ -7817,13 +6533,13 @@ dependencies = [ "bytes", "caret", "cfg-if", - "cipher 0.4.4", + "cipher", "coarsetime", "criterion-cycles-per-byte", "derive-deftly", "derive_builder_fork_arti", - "derive_more 2.0.1", - "digest 0.10.7", + "derive_more", + "digest", "educe", "enum_dispatch", "futures", @@ -7891,7 +6607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7e57e9f71b22ae1df63dbccc8e428cb07feec0abd654735109fa563c10bbb90" dependencies = [ "derive-deftly", - "derive_more 2.0.1", + "derive_more", "humantime", "tor-cert", "tor-checkable", @@ -7928,7 +6644,7 @@ dependencies = [ "asynchronous-codec", "cfg-if", "coarsetime", - "derive_more 2.0.1", + "derive_more", "dyn-clone", "educe", "futures", @@ -7958,7 +6674,7 @@ dependencies = [ "assert_matches", "async-trait", "derive-deftly", - "derive_more 2.0.1", + "derive_more", "educe", "futures", "humantime", @@ -8001,7 +6717,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da90e93b4b4aa4ec356ecbe9e19aced36fdd655e94ca459d1915120d873363f0" dependencies = [ "derive-deftly", - "derive_more 2.0.1", + "derive_more", "serde", "thiserror 2.0.16", "tor-memquota", @@ -8106,16 +6822,6 @@ dependencies = [ "valuable", ] -[[package]] -name = "tracing-error" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" -dependencies = [ - "tracing", - "tracing-subscriber", -] - [[package]] name = "tracing-journald" version = "0.3.1" @@ -8297,17 +7003,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common 0.1.6", - "subtle", -] - -[[package]] -name = "universal-hash" -version = "0.6.0-rc.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a55be643b40a21558f44806b53ee9319595bc7ca6896372e4e08e5d7d83c9cd6" -dependencies = [ - "crypto-common 0.2.0-rc.4", + "crypto-common", "subtle", ] @@ -8333,7 +7029,6 @@ dependencies = [ "idna", "percent-encoding", "serde", - "serde_derive", ] [[package]] @@ -8354,7 +7049,6 @@ version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ - "getrandom 0.3.3", "js-sys", "serde", "wasm-bindgen", @@ -8533,19 +7227,6 @@ dependencies = [ "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" @@ -8584,24 +7265,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-root-certs" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" -dependencies = [ - "webpki-root-certs 1.0.3", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05d651ec480de84b762e7be71e6efa7461699c19d9e2c272c8d93455f567786e" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webpki-roots" version = "1.0.3" @@ -8675,23 +7338,11 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections 0.2.0", + "windows-collections", "windows-core 0.61.2", - "windows-future 0.2.1", + "windows-future", "windows-link 0.1.3", - "windows-numerics 0.2.0", -] - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections 0.3.2", - "windows-core 0.62.2", - "windows-future 0.3.2", - "windows-numerics 0.3.1", + "windows-numerics", ] [[package]] @@ -8703,15 +7354,6 @@ dependencies = [ "windows-core 0.61.2", ] -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core 0.62.2", -] - [[package]] name = "windows-core" version = "0.61.2" @@ -8746,18 +7388,7 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading 0.1.0", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", - "windows-threading 0.2.1", + "windows-threading", ] [[package]] @@ -8804,16 +7435,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", -] - [[package]] name = "windows-result" version = "0.3.4" @@ -8850,15 +7471,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[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" @@ -8904,21 +7516,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[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" @@ -8976,21 +7573,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link 0.2.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" @@ -9009,12 +7591,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" -[[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" @@ -9033,12 +7609,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" -[[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" @@ -9069,12 +7639,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" -[[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" @@ -9093,12 +7657,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" -[[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" @@ -9117,12 +7675,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" -[[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" @@ -9141,12 +7693,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" -[[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" @@ -9284,46 +7830,12 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "wmi" -version = "0.17.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120d8c2b6a7c96c27bf4a7947fd7f02d73ca7f5958b8bd72a696e46cb5521ee6" -dependencies = [ - "chrono", - "futures", - "log", - "serde", - "thiserror 2.0.16", - "windows 0.62.2", - "windows-core 0.62.2", -] - [[package]] name = "writeable" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" -[[package]] -name = "ws_stream_wasm" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" -dependencies = [ - "async_io_stream", - "futures", - "js-sys", - "log", - "pharos", - "rustc_version", - "send_wrapper", - "thiserror 2.0.16", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wyz" version = "0.5.1" @@ -9339,27 +7851,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ - "curve25519-dalek 4.1.3", + "curve25519-dalek", "rand_core 0.6.4", "serde", "zeroize", ] -[[package]] -name = "xml-rs" -version = "0.8.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" - -[[package]] -name = "xmltree" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" -dependencies = [ - "xml-rs", -] - [[package]] name = "xxhash-rust" version = "0.8.15" @@ -9390,12 +7887,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "z32" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2164e798d9e3d84ee2c91139ace54638059a3b23e361f5c11781c2c6459bde0f" - [[package]] name = "zerocopy" version = "0.8.27" @@ -9499,13 +7990,13 @@ dependencies = [ "aes", "byteorder", "bzip2", - "constant_time_eq 0.1.5", + "constant_time_eq", "crc32fast", "crossbeam-utils", "flate2", "hmac", "pbkdf2", - "sha1 0.10.6", + "sha1", "time", "zstd 0.11.2+zstd.1.5.2", ] diff --git a/Dockerfile b/Dockerfile index 404179b..3497e22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM docker.io/library/rust:1.79-slim-bookworm AS builder +FROM docker.io/library/rust:1.85-slim-bookworm AS builder ARG TARGETPLATFORM ARG LLVM_VERSION=16 diff --git a/Makefile b/Makefile index 6563ab1..f927f5f 100644 --- a/Makefile +++ b/Makefile @@ -1,21 +1,23 @@ tun := $(shell ifconfig -l | sed 's/ /\n/g' | grep utun | tail -n 1) -cargo_console := RUST_BACKTRACE=1 RUST_LOG=debug RUSTFLAGS='--cfg tokio_unstable' cargo run --all-features -cargo_norm := RUST_BACKTRACE=1 RUST_LOG=debug cargo run +cargo_console := env RUST_BACKTRACE=1 RUST_LOG=debug RUSTFLAGS='--cfg tokio_unstable' cargo run --all-features -- +cargo_norm := env RUST_BACKTRACE=1 RUST_LOG=debug cargo run -- +sudo_cargo_console := sudo -E env RUST_BACKTRACE=1 RUST_LOG=debug RUSTFLAGS='--cfg tokio_unstable' cargo run --all-features -- +sudo_cargo_norm := sudo -E env RUST_BACKTRACE=1 RUST_LOG=debug cargo run -- check: @cargo check build: - @cargo run build + @cargo build daemon-console: - @$(cargo_console) daemon + @$(sudo_cargo_console) daemon daemon: - @$(cargo_norm) daemon + @$(sudo_cargo_norm) daemon start: - @$(cargo_norm) start + @$(sudo_cargo_norm) start stop: @$(cargo_norm) stop diff --git a/README.md b/README.md index 89914d0..b8684c3 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,19 @@ Burrow is an open source tool for burrowing through firewalls, built by teenagers at [Hack Club](https://hackclub.com/). `burrow` provides a simple command-line tool to open virtual interfaces and direct traffic through them. +Routine verification now runs unprivileged with `cargo test --workspace --all-features`; only tunnel startup needs elevation. + +The repository now carries its own design and deployment record: + +- [Constitution](./CONSTITUTION.md) +- [Burrow Evolution](./evolution/README.md) +- [WireGuard Rust Lineage](./docs/WIREGUARD_LINEAGE.md) +- [Protocol Roadmap](./docs/PROTOCOL_ROADMAP.md) +- [Forward Email Runbook](./docs/FORWARDEMAIL.md) ## Contributing -Burrow is fully open source, you can fork the repo and start contributing easily. For more information and in-depth discussions, visit the `#burrow` channel on the [Hack Club Slack](https://hackclub.com/slack/), here you can ask for help and talk with other people interested in burrow! Checkout [GETTING_STARTED.md](./docs/GETTING_STARTED.md) for build instructions and [GTK_APP.md](./docs/GTK_APP.md) for the Linux app. +Burrow is fully open source, you can fork the repo and start contributing easily. For more information and in-depth discussions, visit the `#burrow` channel on the [Hack Club Slack](https://hackclub.com/slack/), here you can ask for help and talk with other people interested in burrow. Checkout [GETTING_STARTED.md](./docs/GETTING_STARTED.md) for build instructions and [GTK_APP.md](./docs/GTK_APP.md) for the Linux app. Forge and deployment scaffolding live in [`flake.nix`](./flake.nix), [`nixos/`](./nixos), and [`.forgejo/workflows/`](./.forgejo/workflows/). Hosted mail backup operations live in [`docs/FORWARDEMAIL.md`](./docs/FORWARDEMAIL.md) and [`Tools/forwardemail-custom-s3.sh`](./Tools/forwardemail-custom-s3.sh). The project structure is divided in the following folders: diff --git a/Tools/forwardemail-custom-s3.sh b/Tools/forwardemail-custom-s3.sh new file mode 100755 index 0000000..5f39ddd --- /dev/null +++ b/Tools/forwardemail-custom-s3.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash + +set -euo pipefail +umask 077 + +usage() { + cat <<'EOF' +Usage: + Tools/forwardemail-custom-s3.sh \ + --domain burrow.net \ + --api-token-file intake/forwardemail_api_token.txt \ + --s3-endpoint https:// \ + --s3-region \ + --s3-bucket \ + --s3-access-key-file intake/hetzner-s3-user.txt \ + --s3-secret-key-file intake/hetzner-s3-secret.txt + +Options: + --domain Forward Email domain to update. + --api-token-file File containing the Forward Email API token. + --s3-endpoint S3-compatible endpoint URL. + --s3-region S3 region string expected by Forward Email. + --s3-bucket Bucket used for alias backup uploads. + --s3-access-key-file File containing the S3 access key id. + --s3-secret-key-file File containing the S3 secret access key. + --test-only Skip the update call and only test the saved connection. + --help Show this help text. + +Notes: + - Secrets are passed to curl through a temporary config file to avoid putting + them in the process list. + - By default the script updates the domain settings and then calls + /test-s3-connection. + - For Hetzner Object Storage, use the regional S3 endpoint such as + https://hel1.your-objectstorage.com, not an account alias endpoint. +EOF +} + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +require_file() { + local path="$1" + [[ -f "$path" ]] || fail "missing file: $path" +} + +read_secret() { + local path="$1" + local value + value="$(tr -d '\r\n' < "$path")" + [[ -n "$value" ]] || fail "empty secret file: $path" + printf '%s' "$value" +} + +domain="" +api_token_file="" +s3_endpoint="" +s3_region="" +s3_bucket="" +s3_access_key_file="" +s3_secret_key_file="" +test_only=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --domain) + domain="${2:-}" + shift 2 + ;; + --api-token-file) + api_token_file="${2:-}" + shift 2 + ;; + --s3-endpoint) + s3_endpoint="${2:-}" + shift 2 + ;; + --s3-region) + s3_region="${2:-}" + shift 2 + ;; + --s3-bucket) + s3_bucket="${2:-}" + shift 2 + ;; + --s3-access-key-file) + s3_access_key_file="${2:-}" + shift 2 + ;; + --s3-secret-key-file) + s3_secret_key_file="${2:-}" + shift 2 + ;; + --test-only) + test_only=true + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + fail "unknown argument: $1" + ;; + esac +done + +[[ -n "$domain" ]] || fail "--domain is required" +[[ -n "$api_token_file" ]] || fail "--api-token-file is required" +[[ -n "$s3_endpoint" || "$test_only" == true ]] || fail "--s3-endpoint is required unless --test-only is set" +[[ -n "$s3_region" || "$test_only" == true ]] || fail "--s3-region is required unless --test-only is set" +[[ -n "$s3_bucket" || "$test_only" == true ]] || fail "--s3-bucket is required unless --test-only is set" +[[ -n "$s3_access_key_file" || "$test_only" == true ]] || fail "--s3-access-key-file is required unless --test-only is set" +[[ -n "$s3_secret_key_file" || "$test_only" == true ]] || fail "--s3-secret-key-file is required unless --test-only is set" + +require_file "$api_token_file" +api_token="$(read_secret "$api_token_file")" + +if [[ "$test_only" == false ]]; then + require_file "$s3_access_key_file" + require_file "$s3_secret_key_file" + s3_access_key_id="$(read_secret "$s3_access_key_file")" + s3_secret_access_key="$(read_secret "$s3_secret_key_file")" + + case "$s3_endpoint" in + http://*|https://*) + ;; + *) + fail "--s3-endpoint must start with http:// or https://" + ;; + esac +fi + +curl_config="$(mktemp)" +trap 'rm -f "$curl_config"' EXIT + +if [[ "$test_only" == false ]]; then + cat >"$curl_config" <&2 + curl --config "$curl_config" + printf '\n' >&2 +fi + +cat >"$curl_config" <&2 +curl --config "$curl_config" +printf '\n' >&2 diff --git a/Tools/forwardemail-hetzner-storage.py b/Tools/forwardemail-hetzner-storage.py new file mode 100755 index 0000000..3a2a941 --- /dev/null +++ b/Tools/forwardemail-hetzner-storage.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import hmac +import sys +import textwrap +from pathlib import Path +from urllib.parse import urlencode, urlparse + +import requests + + +def read_secret(path: str) -> str: + value = Path(path).read_text(encoding="utf-8").strip() + if not value: + raise SystemExit(f"error: empty secret file: {path}") + return value + + +def sign(key: bytes, msg: str) -> bytes: + return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest() + + +def request( + *, + method: str, + endpoint: str, + region: str, + access_key: str, + secret_key: str, + bucket: str, + query: dict[str, str] | None = None, + body: bytes = b"", + content_type: str | None = None, +) -> requests.Response: + parsed = urlparse(endpoint) + if parsed.scheme != "https": + raise SystemExit("error: endpoint must use https") + + host = parsed.netloc + canonical_uri = f"/{bucket}" + query = query or {} + canonical_querystring = urlencode(sorted(query.items()), doseq=True, safe="~") + + now = dt.datetime.now(dt.timezone.utc) + amz_date = now.strftime("%Y%m%dT%H%M%SZ") + date_stamp = now.strftime("%Y%m%d") + payload_hash = hashlib.sha256(body).hexdigest() + + headers = { + "host": host, + "x-amz-content-sha256": payload_hash, + "x-amz-date": amz_date, + } + if content_type: + headers["content-type"] = content_type + + signed_headers = ";".join(sorted(headers.keys())) + canonical_headers = "".join(f"{name}:{headers[name]}\n" for name in sorted(headers.keys())) + canonical_request = "\n".join( + [ + method, + canonical_uri, + canonical_querystring, + canonical_headers, + signed_headers, + payload_hash, + ] + ) + + algorithm = "AWS4-HMAC-SHA256" + credential_scope = f"{date_stamp}/{region}/s3/aws4_request" + string_to_sign = "\n".join( + [ + algorithm, + amz_date, + credential_scope, + hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(), + ] + ) + + k_date = sign(("AWS4" + secret_key).encode("utf-8"), date_stamp) + k_region = sign(k_date, region) + k_service = sign(k_region, "s3") + signing_key = sign(k_service, "aws4_request") + signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest() + + auth_header = ( + f"{algorithm} Credential={access_key}/{credential_scope}, " + f"SignedHeaders={signed_headers}, Signature={signature}" + ) + + url = f"{parsed.scheme}://{host}{canonical_uri}" + if canonical_querystring: + url = f"{url}?{canonical_querystring}" + + response = requests.request( + method, + url, + headers={**headers, "Authorization": auth_header}, + data=body, + timeout=30, + ) + return response + + +def ensure_bucket(args: argparse.Namespace, bucket: str) -> None: + head = request( + method="HEAD", + endpoint=args.endpoint, + region=args.region, + access_key=args.access_key, + secret_key=args.secret_key, + bucket=bucket, + ) + if head.status_code == 200: + print(f"{bucket}: exists") + return + if head.status_code != 404: + raise SystemExit(f"error: HEAD {bucket} returned {head.status_code}: {head.text[:200]}") + + body = textwrap.dedent( + f"""\ + + + {args.region} + + """ + ).encode("utf-8") + create = request( + method="PUT", + endpoint=args.endpoint, + region=args.region, + access_key=args.access_key, + secret_key=args.secret_key, + bucket=bucket, + body=body, + content_type="application/xml", + ) + if create.status_code not in (200, 204): + raise SystemExit(f"error: PUT {bucket} returned {create.status_code}: {create.text[:200]}") + print(f"{bucket}: created") + + +def put_lifecycle(args: argparse.Namespace, bucket: str) -> None: + body = textwrap.dedent( + f"""\ + + + + expire-forwardemail-backups-after-{args.expire_days}-days + Enabled + + + + + {args.expire_days} + + + + """ + ).encode("utf-8") + response = request( + method="PUT", + endpoint=args.endpoint, + region=args.region, + access_key=args.access_key, + secret_key=args.secret_key, + bucket=bucket, + query={"lifecycle": ""}, + body=body, + content_type="application/xml", + ) + if response.status_code not in (200, 204): + raise SystemExit( + f"error: PUT lifecycle for {bucket} returned {response.status_code}: {response.text[:200]}" + ) + print(f"{bucket}: lifecycle set to {args.expire_days} days") + + +def get_lifecycle(args: argparse.Namespace, bucket: str) -> None: + response = request( + method="GET", + endpoint=args.endpoint, + region=args.region, + access_key=args.access_key, + secret_key=args.secret_key, + bucket=bucket, + query={"lifecycle": ""}, + ) + if response.status_code != 200: + raise SystemExit( + f"error: GET lifecycle for {bucket} returned {response.status_code}: {response.text[:200]}" + ) + print(f"=== {bucket} lifecycle ===") + print(response.text.strip()) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Provision Hetzner object-storage buckets for Forward Email backups." + ) + parser.add_argument( + "--endpoint", + default="https://hel1.your-objectstorage.com", + help="Public S3-compatible endpoint URL. For Hetzner, use the regional endpoint, not the account alias.", + ) + parser.add_argument("--region", default="hel1", help="S3 region.") + parser.add_argument( + "--access-key-file", + default="intake/hetzner-s3-user.txt", + help="File containing the S3 access key id.", + ) + parser.add_argument( + "--secret-key-file", + default="intake/hetzner-s3-secret.txt", + help="File containing the S3 secret key.", + ) + parser.add_argument( + "--bucket", + action="append", + required=True, + help="Bucket to provision. Repeat for multiple buckets.", + ) + parser.add_argument( + "--expire-days", + type=int, + default=90, + help="Lifecycle expiry window in days.", + ) + parser.add_argument( + "--verify-only", + action="store_true", + help="Skip create/update and only read the current lifecycle.", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + args.access_key = read_secret(args.access_key_file) + args.secret_key = read_secret(args.secret_key_file) + + for bucket in args.bucket: + if args.verify_only: + get_lifecycle(args, bucket) + continue + ensure_bucket(args, bucket) + put_lifecycle(args, bucket) + get_lifecycle(args, bucket) + + +if __name__ == "__main__": + try: + main() + except requests.RequestException as err: + raise SystemExit(f"error: request failed: {err}") from err diff --git a/burrow/src/daemon/net/unix.rs b/burrow/src/daemon/net/unix.rs index 975c470..f7f9433 100644 --- a/burrow/src/daemon/net/unix.rs +++ b/burrow/src/daemon/net/unix.rs @@ -11,11 +11,7 @@ use tokio::{ use tracing::{debug, error, info}; use crate::daemon::rpc::{ - DaemonCommand, - DaemonMessage, - DaemonNotification, - DaemonRequest, - DaemonResponse, + DaemonCommand, DaemonMessage, DaemonNotification, DaemonRequest, DaemonResponse, DaemonResponseData, }; diff --git a/burrow/src/daemon/rpc/client.rs b/burrow/src/daemon/rpc/client.rs index 862e34c..06a9b45 100644 --- a/burrow/src/daemon/rpc/client.rs +++ b/burrow/src/daemon/rpc/client.rs @@ -1,5 +1,6 @@ use anyhow::Result; use hyper_util::rt::TokioIo; +use std::path::Path; use tokio::net::UnixStream; use tonic::transport::{Endpoint, Uri}; use tower::service_fn; @@ -15,10 +16,18 @@ pub struct BurrowClient { impl BurrowClient { #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub async fn from_uds() -> Result { + Self::from_uds_path(get_socket_path()).await + } + + #[cfg(any(target_os = "linux", target_vendor = "apple"))] + pub async fn from_uds_path(path: impl AsRef) -> Result { + let socket_path = path.as_ref().to_owned(); let channel = Endpoint::try_from("http://[::]:50051")? // NOTE: this is a hack(?) - .connect_with_connector(service_fn(|_: Uri| async { - let sock_path = get_socket_path(); - Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(sock_path).await?)) + .connect_with_connector(service_fn(move |_: Uri| { + let socket_path = socket_path.clone(); + async move { + Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(&socket_path).await?)) + } })) .await?; let nw_client = NetworksClient::new(channel.clone()); diff --git a/burrow/src/daemon/rpc/request.rs b/burrow/src/daemon/rpc/request.rs index e9480aa..91562cc 100644 --- a/burrow/src/daemon/rpc/request.rs +++ b/burrow/src/daemon/rpc/request.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use tun::TunOptions; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(tag="method", content="params")] +#[serde(tag = "method", content = "params")] pub enum DaemonCommand { Start(DaemonStartOptions), ServerInfo, diff --git a/burrow/src/lib.rs b/burrow/src/lib.rs index bbc8c17..15b6a19 100644 --- a/burrow/src/lib.rs +++ b/burrow/src/lib.rs @@ -10,11 +10,11 @@ mod auth; mod daemon; #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub mod database; -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -pub mod mesh; #[cfg(target_os = "linux")] pub mod tor; pub(crate) mod tracing; +#[cfg(target_os = "linux")] +pub mod usernet; #[cfg(target_vendor = "apple")] pub use daemon::apple::spawn_in_process; diff --git a/burrow/src/main.rs b/burrow/src/main.rs index c1f512b..c91f36f 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -11,11 +11,10 @@ mod wireguard; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod auth; - -#[cfg(any(target_os = "linux", target_vendor = "apple"))] -mod mesh; #[cfg(target_os = "linux")] mod tor; +#[cfg(target_os = "linux")] +mod usernet; #[cfg(any(target_os = "linux", target_vendor = "apple"))] use daemon::{DaemonClient, DaemonCommand}; @@ -74,6 +73,9 @@ enum Commands { /// Delete Network NetworkDelete(NetworkDeleteArgs), #[cfg(target_os = "linux")] + /// Run a command in an unshared Linux namespace using a Burrow backend + Exec(ExecArgs), + #[cfg(target_os = "linux")] /// Run a command in a Linux user namespace with Tor-backed networking TorExec(TorExecArgs), } @@ -116,6 +118,17 @@ struct TorExecArgs { command: Vec, } +#[cfg(target_os = "linux")] +#[derive(Args)] +struct ExecArgs { + #[arg(long, value_enum)] + backend: usernet::ExecBackendKind, + #[arg(long)] + payload: Option, + #[arg(required = true, num_args = 1.., trailing_var_arg = true)] + command: Vec, +} + #[cfg(any(target_os = "linux", target_vendor = "apple"))] async fn try_start() -> Result<()> { let mut client = BurrowClient::from_uds().await?; @@ -229,9 +242,30 @@ async fn try_network_delete(id: i32) -> Result<()> { #[cfg(target_os = "linux")] async fn try_tor_exec(payload_path: &str, command: Vec) -> Result<()> { - let payload = tokio::fs::read(payload_path).await?; - let config = tor::Config::from_payload(&payload)?; - let exit_code = tor::run_exec(config, command).await?; + let exit_code = usernet::run_exec(usernet::ExecInvocation { + backend: usernet::ExecBackendKind::Tor, + payload_path: Some(payload_path.into()), + command, + }) + .await?; + if exit_code != 0 { + std::process::exit(exit_code); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +async fn try_exec( + backend: usernet::ExecBackendKind, + payload: Option, + command: Vec, +) -> Result<()> { + let exit_code = usernet::run_exec(usernet::ExecInvocation { + backend, + payload_path: payload.map(Into::into), + command, + }) + .await?; if exit_code != 0 { std::process::exit(exit_code); } @@ -315,6 +349,15 @@ async fn main() -> Result<()> { Commands::NetworkReorder(args) => try_network_reorder(args.id, args.index).await?, Commands::NetworkDelete(args) => try_network_delete(args.id).await?, #[cfg(target_os = "linux")] + Commands::Exec(args) => { + try_exec( + args.backend.clone(), + args.payload.clone(), + args.command.clone(), + ) + .await? + } + #[cfg(target_os = "linux")] Commands::TorExec(args) => try_tor_exec(&args.payload_path, args.command.clone()).await?, } diff --git a/burrow/src/tor/dns.rs b/burrow/src/tor/dns.rs index 46ba96e..d918fc4 100644 --- a/burrow/src/tor/dns.rs +++ b/burrow/src/tor/dns.rs @@ -76,13 +76,10 @@ pub async fn spawn( } }); - Ok(TorDnsHandle { - shutdown: shutdown_tx, - task, - }) + Ok(TorDnsHandle { shutdown: shutdown_tx, task }) } -async fn build_response( +pub(crate) async fn build_response( packet: &[u8], tor_client: &TorClient, ) -> Result> { @@ -133,9 +130,11 @@ fn record_for_address( addr: IpAddr, ) -> Option { match (record_type, addr) { - (RecordType::A, IpAddr::V4(ip)) => { - Some(Record::from_rdata(name, DNS_TTL_SECS, RData::A(A::from(ip)))) - } + (RecordType::A, IpAddr::V4(ip)) => Some(Record::from_rdata( + name, + DNS_TTL_SECS, + RData::A(A::from(ip)), + )), (RecordType::AAAA, IpAddr::V6(ip)) => Some(Record::from_rdata( name, DNS_TTL_SECS, diff --git a/burrow/src/tor/mod.rs b/burrow/src/tor/mod.rs index 3c936f7..635c355 100644 --- a/burrow/src/tor/mod.rs +++ b/burrow/src/tor/mod.rs @@ -1,5 +1,5 @@ mod config; -mod dns; +pub(crate) mod dns; mod exec; mod runtime; mod system; diff --git a/burrow/src/tor/runtime.rs b/burrow/src/tor/runtime.rs index e583b83..45690ee 100644 --- a/burrow/src/tor/runtime.rs +++ b/burrow/src/tor/runtime.rs @@ -118,10 +118,7 @@ pub async fn spawn_with_client( }), }; - Ok(TorHandle { - shutdown: shutdown_tx, - task, - }) + Ok(TorHandle { shutdown: shutdown_tx, task }) } fn join_error(err: JoinError) -> anyhow::Error { diff --git a/burrow/src/tor/system.rs b/burrow/src/tor/system.rs index db00e3c..74f8157 100644 --- a/burrow/src/tor/system.rs +++ b/burrow/src/tor/system.rs @@ -118,7 +118,10 @@ mod tests { }; let parsed = socket_addr_from_storage(&storage, size_of::()).unwrap(); - assert_eq!(parsed, SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 9040))); + assert_eq!( + parsed, + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 9040)) + ); } #[test] diff --git a/burrow/src/usernet/mod.rs b/burrow/src/usernet/mod.rs new file mode 100644 index 0000000..12de810 --- /dev/null +++ b/burrow/src/usernet/mod.rs @@ -0,0 +1,935 @@ +use std::{ + collections::HashMap, + env, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + os::fd::{AsRawFd, FromRawFd, RawFd}, + os::unix::net::UnixStream as StdUnixStream, + os::unix::process::ExitStatusExt, + path::{Path, PathBuf}, + process::{Command as StdCommand, ExitStatus}, + str, + sync::Arc, + time::Duration, +}; + +use anyhow::{anyhow, bail, Context, Result}; +use clap::ValueEnum; +use futures::{SinkExt, StreamExt}; +use ipnetwork::IpNetwork; +use netstack_smoltcp::{ + StackBuilder, TcpListener as StackTcpListener, TcpStream as StackTcpStream, + UdpSocket as StackUdpSocket, +}; +use nix::{ + cmsg_space, + fcntl::{fcntl, FcntlArg, FdFlag}, + sys::socket::{recvmsg, sendmsg, ControlMessage, ControlMessageOwned, MsgFlags}, +}; +use serde::{Deserialize, Serialize}; +use tokio::{ + io::copy_bidirectional, + net::{TcpStream, UdpSocket}, + process::{Child, Command}, + sync::{mpsc, Mutex, RwLock}, + task::JoinSet, +}; +use tokio_util::compat::FuturesAsyncReadCompatExt; +use tracing::{debug, warn}; +use tun::{tokio::TunInterface as TokioTunInterface, TunOptions}; + +use crate::{ + tor::{bootstrap_client, dns::build_response as build_tor_dns_response, Config as TorConfig}, + wireguard::{Config as WireGuardConfig, Interface as WireGuardInterface}, +}; + +const INNER_ENV: &str = "BURROW_USERNET_INNER"; +const INNER_CONTROL_FD_ENV: &str = "BURROW_USERNET_CONTROL_FD"; +const INNER_TUN_CONFIG_ENV: &str = "BURROW_USERNET_TUN_CONFIG"; +const DEFAULT_MTU: u32 = 1500; +const DEFAULT_TUN_V4: &str = "100.64.0.2/24"; +const DEFAULT_TUN_V6: &str = "fd00:64::2/64"; +const UDP_IDLE_TIMEOUT: Duration = Duration::from_secs(30); +const READY_ACK: &[u8; 1] = b"1"; + +#[derive(Clone, Debug, Eq, PartialEq, ValueEnum)] +pub enum ExecBackendKind { + Direct, + Tor, + Wireguard, +} + +impl ExecBackendKind { + fn cli_name(&self) -> &'static str { + match self { + Self::Direct => "direct", + Self::Tor => "tor", + Self::Wireguard => "wireguard", + } + } +} + +#[derive(Clone, Debug)] +pub struct ExecInvocation { + pub backend: ExecBackendKind, + pub payload_path: Option, + pub command: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirectConfig { + #[serde(default)] + pub address: Vec, + #[serde(default)] + pub dns: Vec, + #[serde(default)] + pub mtu: Option, + #[serde(default)] + pub tun_name: Option, +} + +impl DirectConfig { + pub fn from_payload(payload: &[u8]) -> Result { + if payload.is_empty() { + return Ok(Self::default()); + } + + if let Ok(config) = serde_json::from_slice(payload) { + return Ok(config); + } + + let payload = str::from_utf8(payload).context("direct payload must be valid UTF-8")?; + toml::from_str(payload).context("failed to parse direct payload as JSON or TOML") + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TunNetworkConfig { + tun_name: String, + addresses: Vec, + mtu: u32, +} + +enum PreparedBackend { + Socket { + backend: SocketBackend, + tun_config: TunNetworkConfig, + }, + Wireguard { + config: WireGuardConfig, + tun_config: TunNetworkConfig, + }, +} + +impl PreparedBackend { + fn tun_config(&self) -> &TunNetworkConfig { + match self { + Self::Socket { tun_config, .. } => tun_config, + Self::Wireguard { tun_config, .. } => tun_config, + } + } +} + +struct NamespaceChild { + child: Child, + control: StdUnixStream, +} + +#[derive(Clone)] +enum SocketBackend { + Direct, + Tor(Arc>), +} + +#[derive(Debug)] +struct UdpReply { + payload: Vec, + source: SocketAddr, + destination: SocketAddr, +} + +#[derive(Debug, Clone, Eq, Hash, PartialEq)] +struct UdpFlowKey { + local: SocketAddr, + remote: SocketAddr, +} + +pub async fn run_exec(invocation: ExecInvocation) -> Result { + if invocation.command.is_empty() { + bail!("exec requires a command to run"); + } + + if env::var_os(INNER_ENV).is_some() { + run_inner(invocation.command).await + } else { + run_supervisor(invocation).await + } +} + +async fn run_supervisor(invocation: ExecInvocation) -> Result { + let prepared = prepare_backend(&invocation).await?; + let mut child = spawn_namespaced_child(&invocation, prepared.tun_config())?; + let tun = child.receive_tun().await?; + + match prepared { + PreparedBackend::Socket { backend, .. } => run_socket_backend(backend, tun, child).await, + PreparedBackend::Wireguard { config, .. } => { + run_wireguard_backend(config, tun, child).await + } + } +} + +async fn prepare_backend(invocation: &ExecInvocation) -> Result { + match invocation.backend { + ExecBackendKind::Direct => { + let payload = read_optional_payload(invocation.payload_path.as_deref()).await?; + let config = DirectConfig::from_payload(&payload)?; + let tun_config = socket_tun_config( + &config.address, + config.mtu, + config.tun_name.as_deref(), + "burrow-direct", + )?; + Ok(PreparedBackend::Socket { + backend: SocketBackend::Direct, + tun_config, + }) + } + ExecBackendKind::Tor => { + let payload = read_required_payload(invocation.payload_path.as_deref(), "tor").await?; + let mut config = TorConfig::from_payload(&payload)?; + let (state_dir, cache_dir) = config.runtime_dirs(std::process::id() as i32); + config.arti.state_dir = state_dir; + config.arti.cache_dir = cache_dir; + let tun_config = socket_tun_config( + &config.address, + config.mtu, + config.tun_name.as_deref(), + "burrow-tor", + )?; + let tor_client = bootstrap_client(&config).await?; + Ok(PreparedBackend::Socket { + backend: SocketBackend::Tor(tor_client), + tun_config, + }) + } + ExecBackendKind::Wireguard => { + let payload = + read_required_payload(invocation.payload_path.as_deref(), "wireguard").await?; + let config = parse_wireguard_payload(&payload, invocation.payload_path.as_deref())?; + let tun_config = wireguard_tun_config(&config)?; + Ok(PreparedBackend::Wireguard { config, tun_config }) + } + } +} + +fn spawn_namespaced_child( + invocation: &ExecInvocation, + tun_config: &TunNetworkConfig, +) -> Result { + ensure_tool("unshare")?; + ensure_tool("ip")?; + + let (parent_control, child_control) = + StdUnixStream::pair().context("failed to create namespace control socket")?; + set_inheritable(child_control.as_raw_fd())?; + + let current_exe = env::current_exe().context("failed to locate current burrow binary")?; + let mut cmd = Command::new("unshare"); + cmd.args([ + "--user", + "--map-root-user", + "--net", + "--mount", + "--pid", + "--fork", + "--kill-child", + "--mount-proc", + ]); + cmd.env(INNER_ENV, "1"); + cmd.env(INNER_CONTROL_FD_ENV, child_control.as_raw_fd().to_string()); + cmd.env( + INNER_TUN_CONFIG_ENV, + serde_json::to_string(tun_config).context("failed to encode namespace tun config")?, + ); + cmd.arg(current_exe); + cmd.arg("exec"); + cmd.args(["--backend", invocation.backend.cli_name()]); + if let Some(payload_path) = &invocation.payload_path { + cmd.arg("--payload"); + cmd.arg(payload_path); + } + cmd.arg("--"); + cmd.args(&invocation.command); + + let child = cmd + .spawn() + .context("failed to enter unshared Linux namespace")?; + drop(child_control); + + Ok(NamespaceChild { child, control: parent_control }) +} + +async fn run_inner(command: Vec) -> Result { + run_ip(["link", "set", "lo", "up"])?; + let tun_config = read_inner_tun_config()?; + let tun = open_tun_device(&tun_config)?; + configure_tun_addresses(&tun, &tun_config.addresses, tun_config.mtu)?; + let name = tun.name().context("failed to retrieve tun device name")?; + run_ip(["link", "set", "dev", &name, "up"])?; + install_default_routes(&name, &tun_config.addresses)?; + + let control_fd = env::var(INNER_CONTROL_FD_ENV) + .context("missing namespace control fd")? + .parse::() + .context("invalid namespace control fd")?; + send_tun_fd(control_fd, tun.as_raw_fd())?; + await_parent_ready(control_fd).await?; + drop(tun); + + let status = spawn_child(&command).await?; + child_exit_code(status) +} + +impl NamespaceChild { + async fn receive_tun(&mut self) -> Result { + let control = self + .control + .try_clone() + .context("failed to clone namespace control socket")?; + let fd = tokio::task::spawn_blocking(move || recv_tun_fd(&control)) + .await + .context("failed to join namespace tun receive task")??; + tokio_tun_from_fd(fd) + } + + async fn signal_ready(&self) -> Result<()> { + let mut control = self + .control + .try_clone() + .context("failed to clone namespace control socket")?; + tokio::task::spawn_blocking(move || -> Result<()> { + std::io::Write::write_all(&mut control, READY_ACK) + .context("failed to acknowledge namespace readiness")?; + Ok(()) + }) + .await + .context("failed to join namespace ready task")??; + Ok(()) + } + + async fn wait(mut self) -> Result { + self.child + .wait() + .await + .context("failed to wait for namespace child") + } +} + +async fn run_socket_backend( + backend: SocketBackend, + tun: TokioTunInterface, + child: NamespaceChild, +) -> Result { + let tun = Arc::new(tun); + let (stack, runner, udp_socket, tcp_listener) = StackBuilder::default() + .stack_buffer_size(1024) + .udp_buffer_size(1024) + .tcp_buffer_size(1024) + .enable_udp(true) + .enable_tcp(true) + .enable_icmp(true) + .build() + .context("failed to build userspace netstack")?; + let (mut stack_sink, mut stack_stream) = stack.split(); + + let mut tasks = JoinSet::new(); + if let Some(runner) = runner { + tasks.spawn(async move { runner.await.map_err(anyhow::Error::from) }); + } + + { + let tun = tun.clone(); + tasks.spawn(async move { + let mut buf = vec![0u8; 65_535]; + loop { + let len = tun + .recv(&mut buf) + .await + .context("failed to read packet from tun")?; + if len == 0 { + continue; + } + stack_sink + .send(buf[..len].to_vec()) + .await + .context("failed to send tun packet into userspace stack")?; + } + #[allow(unreachable_code)] + Result::<()>::Ok(()) + }); + } + + { + let tun = tun.clone(); + tasks.spawn(async move { + while let Some(packet) = stack_stream.next().await { + let packet = packet.context("failed to receive packet from userspace stack")?; + tun.send(&packet) + .await + .context("failed to write userspace stack packet to tun")?; + } + Result::<()>::Ok(()) + }); + } + + if let Some(tcp_listener) = tcp_listener { + let backend = backend.clone(); + tasks.spawn(async move { tcp_dispatch_loop(tcp_listener, backend).await }); + } + + if let Some(udp_socket) = udp_socket { + tasks.spawn(async move { udp_dispatch_loop(udp_socket, backend).await }); + } + + child.signal_ready().await?; + let status = child.wait().await?; + + tasks.abort_all(); + while let Some(joined) = tasks.join_next().await { + match joined { + Ok(Ok(())) => {} + Ok(Err(err)) => debug!(?err, "usernet background task exited with error"), + Err(err) if err.is_cancelled() => {} + Err(err) => debug!(?err, "usernet background task panicked"), + } + } + + child_exit_code(status) +} + +async fn run_wireguard_backend( + config: WireGuardConfig, + tun: TokioTunInterface, + child: NamespaceChild, +) -> Result { + let interface: WireGuardInterface = config.try_into()?; + interface.set_tun(tun).await; + let interface = Arc::new(interface); + let runner = { + let interface = interface.clone(); + tokio::spawn(async move { interface.run().await }) + }; + + child.signal_ready().await?; + let status = child.wait().await?; + + interface.remove_tun().await; + match runner.await { + Ok(Ok(())) => {} + Ok(Err(err)) => debug!(?err, "wireguard exec runtime exited with error"), + Err(err) if err.is_cancelled() => {} + Err(err) => debug!(?err, "wireguard exec runtime panicked"), + } + + child_exit_code(status) +} + +async fn tcp_dispatch_loop(mut listener: StackTcpListener, backend: SocketBackend) -> Result<()> { + let mut tasks = JoinSet::new(); + loop { + tokio::select! { + Some(result) = tasks.join_next(), if !tasks.is_empty() => { + match result { + Ok(Ok(())) => {} + Ok(Err(err)) => warn!(?err, "tcp bridge task failed"), + Err(err) if err.is_cancelled() => {} + Err(err) => warn!(?err, "tcp bridge task panicked"), + } + } + next = listener.next() => match next { + Some((stream, local_addr, remote_addr)) => { + debug!(%local_addr, %remote_addr, "accepted userspace tcp stream"); + let backend = backend.clone(); + tasks.spawn(async move { + bridge_tcp(backend, stream, local_addr, remote_addr).await + }); + } + None => break, + } + } + } + + tasks.abort_all(); + while let Some(result) = tasks.join_next().await { + match result { + Ok(Ok(())) => {} + Ok(Err(err)) => debug!(?err, "tcp bridge task exited during shutdown"), + Err(err) if err.is_cancelled() => {} + Err(err) => debug!(?err, "tcp bridge task panicked during shutdown"), + } + } + Ok(()) +} + +async fn bridge_tcp( + backend: SocketBackend, + mut inbound: StackTcpStream, + _local_addr: SocketAddr, + remote_addr: SocketAddr, +) -> Result<()> { + match backend { + SocketBackend::Direct => { + debug!(%remote_addr, "dialing direct outbound tcp"); + let mut outbound = TcpStream::connect(remote_addr) + .await + .with_context(|| format!("failed to connect to {remote_addr}"))?; + copy_bidirectional(&mut inbound, &mut outbound) + .await + .with_context(|| format!("failed to bridge tcp stream for {remote_addr}"))?; + } + SocketBackend::Tor(tor_client) => { + debug!(%remote_addr, "dialing tor outbound tcp"); + let tor_stream = tor_client + .connect((remote_addr.ip().to_string(), remote_addr.port())) + .await + .with_context(|| format!("failed to connect to {remote_addr} over tor"))?; + let mut tor_stream = tor_stream.compat(); + copy_bidirectional(&mut inbound, &mut tor_stream) + .await + .with_context(|| format!("failed to bridge tor stream for {remote_addr}"))?; + } + } + Ok(()) +} + +async fn udp_dispatch_loop(socket: StackUdpSocket, backend: SocketBackend) -> Result<()> { + let (mut udp_reader, mut udp_writer) = socket.split(); + let (reply_tx, mut reply_rx) = mpsc::channel::(128); + let direct_sessions = Arc::new(Mutex::new( + HashMap::>>::new(), + )); + let mut session_tasks = JoinSet::new(); + + loop { + tokio::select! { + Some(result) = session_tasks.join_next(), if !session_tasks.is_empty() => { + match result { + Ok(Ok(())) => {} + Ok(Err(err)) => warn!(?err, "udp session task failed"), + Err(err) if err.is_cancelled() => {} + Err(err) => warn!(?err, "udp session task panicked"), + } + } + maybe_reply = reply_rx.recv() => match maybe_reply { + Some(reply) => { + udp_writer + .send((reply.payload, reply.source, reply.destination)) + .await + .context("failed to write udp reply into userspace stack")?; + } + None => break, + }, + maybe_datagram = udp_reader.next() => match maybe_datagram { + Some((payload, local_addr, remote_addr)) => { + match &backend { + SocketBackend::Direct => { + dispatch_direct_udp( + payload, + local_addr, + remote_addr, + reply_tx.clone(), + direct_sessions.clone(), + &mut session_tasks, + ).await?; + } + SocketBackend::Tor(tor_client) => { + if remote_addr.port() != 53 { + debug!(%remote_addr, "dropping non-DNS UDP datagram for tor backend"); + continue; + } + let response = build_tor_dns_response(&payload, tor_client.as_ref()).await?; + reply_tx + .send(UdpReply { + payload: response, + source: remote_addr, + destination: local_addr, + }) + .await + .context("failed to enqueue tor dns response")?; + } + } + } + None => break, + } + } + } + + session_tasks.abort_all(); + while let Some(result) = session_tasks.join_next().await { + match result { + Ok(Ok(())) => {} + Ok(Err(err)) => debug!(?err, "udp session task exited during shutdown"), + Err(err) if err.is_cancelled() => {} + Err(err) => debug!(?err, "udp session task panicked during shutdown"), + } + } + Ok(()) +} + +async fn dispatch_direct_udp( + payload: Vec, + local_addr: SocketAddr, + remote_addr: SocketAddr, + reply_tx: mpsc::Sender, + sessions: Arc>>>>, + session_tasks: &mut JoinSet>, +) -> Result<()> { + let key = UdpFlowKey { + local: local_addr, + remote: remote_addr, + }; + let existing = { sessions.lock().await.get(&key).cloned() }; + if let Some(sender) = existing { + if sender.send(payload.clone()).await.is_ok() { + return Ok(()); + } + sessions.lock().await.remove(&key); + } + + let (tx, rx) = mpsc::channel::>(32); + tx.send(payload) + .await + .context("failed to enqueue outbound udp payload")?; + sessions.lock().await.insert(key.clone(), tx); + + session_tasks.spawn(async move { run_direct_udp_session(key, rx, reply_tx, sessions).await }); + Ok(()) +} + +async fn run_direct_udp_session( + key: UdpFlowKey, + mut outbound_rx: mpsc::Receiver>, + reply_tx: mpsc::Sender, + sessions: Arc>>>>, +) -> Result<()> { + let bind_addr = match key.remote { + SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0), + }; + let socket = UdpSocket::bind(bind_addr) + .await + .with_context(|| format!("failed to bind udp socket for {}", key.remote))?; + socket + .connect(key.remote) + .await + .with_context(|| format!("failed to connect udp socket to {}", key.remote))?; + + let mut buf = vec![0u8; 65_535]; + loop { + tokio::select! { + maybe_payload = outbound_rx.recv() => match maybe_payload { + Some(payload) => { + socket + .send(&payload) + .await + .with_context(|| format!("failed to send udp payload to {}", key.remote))?; + } + None => break, + }, + recv = tokio::time::timeout(UDP_IDLE_TIMEOUT, socket.recv(&mut buf)) => match recv { + Ok(Ok(len)) => { + reply_tx + .send(UdpReply { + payload: buf[..len].to_vec(), + source: key.remote, + destination: key.local, + }) + .await + .context("failed to enqueue inbound udp reply")?; + } + Ok(Err(err)) => return Err(err).with_context(|| format!("failed to receive udp response from {}", key.remote)), + Err(_) => break, + } + } + } + + sessions.lock().await.remove(&key); + Ok(()) +} + +fn wireguard_tun_config(config: &WireGuardConfig) -> Result { + parse_tun_config( + &config.interface.address, + config.interface.mtu, + Some("burrow-wireguard"), + ) +} + +fn socket_tun_config( + addresses: &[String], + mtu: Option, + tun_name: Option<&str>, + default_name: &str, +) -> Result { + let default_addresses; + let addresses = if addresses.is_empty() { + default_addresses = vec![DEFAULT_TUN_V4.to_string(), DEFAULT_TUN_V6.to_string()]; + default_addresses.as_slice() + } else { + addresses + }; + parse_tun_config(addresses, mtu, Some(tun_name.unwrap_or(default_name))) +} + +fn parse_tun_config( + addresses: &[String], + mtu: Option, + tun_name: Option<&str>, +) -> Result { + let addresses = addresses + .iter() + .map(|addr| { + addr.parse::() + .with_context(|| format!("invalid tunnel address '{addr}'")) + }) + .collect::>>()?; + + Ok(TunNetworkConfig { + tun_name: tun_name.unwrap_or("burrow-exec").to_string(), + addresses, + mtu: mtu.unwrap_or(DEFAULT_MTU), + }) +} + +fn open_tun_device(config: &TunNetworkConfig) -> Result { + let tun = TunOptions::new() + .name(&config.tun_name) + .no_pi(true) + .tun_excl(true) + .open() + .context("failed to create tun device")?; + Ok(tun.inner.into_inner()) +} + +fn tokio_tun_from_fd(fd: RawFd) -> Result { + let tun = unsafe { tun::TunInterface::from_raw_fd(fd) }; + TokioTunInterface::new(tun).context("failed to wrap tun fd in tokio interface") +} + +fn read_inner_tun_config() -> Result { + let raw = env::var(INNER_TUN_CONFIG_ENV).context("missing namespace tun config")?; + serde_json::from_str(&raw).context("invalid namespace tun config") +} + +fn configure_tun_addresses( + iface: &tun::TunInterface, + networks: &[IpNetwork], + mtu: u32, +) -> Result<()> { + for network in networks { + match network { + IpNetwork::V4(net) => { + iface.set_ipv4_addr(net.ip())?; + let netmask = prefix_to_netmask_v4(net.prefix()); + iface.set_netmask(netmask)?; + iface.set_broadcast_addr(broadcast_v4(net.ip(), netmask))?; + } + IpNetwork::V6(net) => iface.add_ipv6_addr(net.ip(), net.prefix())?, + } + } + iface.set_mtu(mtu as i32)?; + Ok(()) +} + +fn install_default_routes(name: &str, networks: &[IpNetwork]) -> Result<()> { + if networks + .iter() + .any(|network| matches!(network, IpNetwork::V4(_))) + { + run_ip(["route", "replace", "default", "dev", name])?; + } + if networks + .iter() + .any(|network| matches!(network, IpNetwork::V6(_))) + { + run_ip(["-6", "route", "replace", "default", "dev", name])?; + } + Ok(()) +} + +fn run_ip(args: [&str; N]) -> Result<()> { + let status = StdCommand::new("ip") + .args(args) + .status() + .context("failed to execute ip command")?; + if !status.success() { + bail!("ip {} failed with status {}", args.join(" "), status); + } + Ok(()) +} + +fn set_inheritable(fd: RawFd) -> Result<()> { + let flags = FdFlag::from_bits_truncate( + fcntl(fd, FcntlArg::F_GETFD).context("failed to query descriptor flags")?, + ); + let flags = flags & !FdFlag::FD_CLOEXEC; + fcntl(fd, FcntlArg::F_SETFD(flags)).context("failed to clear close-on-exec")?; + Ok(()) +} + +async fn await_parent_ready(control_fd: RawFd) -> Result<()> { + tokio::task::spawn_blocking(move || -> Result<()> { + let mut control = unsafe { StdUnixStream::from_raw_fd(control_fd) }; + let mut ack = [0u8; 1]; + std::io::Read::read_exact(&mut control, &mut ack) + .context("failed to read namespace ready ack")?; + if ack != *READY_ACK { + bail!("unexpected namespace ready ack"); + } + Ok(()) + }) + .await + .context("failed to join namespace ready wait task")??; + Ok(()) +} + +fn send_tun_fd(control_fd: RawFd, tun_fd: RawFd) -> Result<()> { + let buf = [0u8; 1]; + let iov = [std::io::IoSlice::new(&buf)]; + let fds = [tun_fd]; + sendmsg::<()>( + control_fd, + &iov, + &[ControlMessage::ScmRights(&fds)], + MsgFlags::empty(), + None, + ) + .context("failed to send tun fd to parent")?; + Ok(()) +} + +fn recv_tun_fd(control: &StdUnixStream) -> Result { + let mut buf = [0u8; 1]; + let mut iov = [std::io::IoSliceMut::new(&mut buf)]; + let mut cmsgspace = cmsg_space!([RawFd; 1]); + let msg = recvmsg::<()>( + control.as_raw_fd(), + &mut iov, + Some(&mut cmsgspace), + MsgFlags::empty(), + ) + .context("failed to receive tun fd from namespace child")?; + for cmsg in msg.cmsgs() { + if let ControlMessageOwned::ScmRights(fds) = cmsg { + if let Some(fd) = fds.first() { + return Ok(*fd); + } + } + } + bail!("namespace child did not send a tun fd") +} + +fn ensure_tool(tool: &str) -> Result<()> { + let status = StdCommand::new("sh") + .args(["-lc", &format!("command -v {tool} >/dev/null")]) + .status() + .with_context(|| format!("failed to probe required tool '{tool}'"))?; + if !status.success() { + bail!("required host tool '{tool}' is not available"); + } + Ok(()) +} + +async fn read_optional_payload(path: Option<&Path>) -> Result> { + match path { + Some(path) => tokio::fs::read(path) + .await + .with_context(|| format!("failed to read payload from {}", path.display())), + None => Ok(Vec::new()), + } +} + +async fn read_required_payload(path: Option<&Path>, backend: &str) -> Result> { + let path = path.ok_or_else(|| anyhow!("{backend} exec requires --payload"))?; + tokio::fs::read(path) + .await + .with_context(|| format!("failed to read payload from {}", path.display())) +} + +fn parse_wireguard_payload(payload: &[u8], path: Option<&Path>) -> Result { + let payload = str::from_utf8(payload).context("wireguard payload must be valid UTF-8")?; + if let Some(path) = path { + if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) { + return WireGuardConfig::from_content_fmt(payload, ext); + } + } + + WireGuardConfig::from_toml(payload).or_else(|_| WireGuardConfig::from_ini(payload)) +} + +async fn spawn_child(command: &[String]) -> Result { + let mut cmd = Command::new(&command[0]); + if command.len() > 1 { + cmd.args(&command[1..]); + } + cmd.stdin(std::process::Stdio::inherit()); + cmd.stdout(std::process::Stdio::inherit()); + cmd.stderr(std::process::Stdio::inherit()); + cmd.kill_on_drop(true); + cmd.status() + .await + .with_context(|| format!("failed to spawn '{}'", command[0])) +} + +fn child_exit_code(status: ExitStatus) -> Result { + if let Some(code) = status.code() { + return Ok(code); + } + if let Some(signal) = status.signal() { + return Ok(128 + signal); + } + bail!("child process terminated without an exit code"); +} + +fn prefix_to_netmask_v4(prefix: u8) -> Ipv4Addr { + if prefix == 0 { + Ipv4Addr::new(0, 0, 0, 0) + } else { + let mask = (!0u32) << (32 - prefix); + Ipv4Addr::from(mask) + } +} + +fn broadcast_v4(ip: Ipv4Addr, netmask: Ipv4Addr) -> Ipv4Addr { + let ip_u32 = u32::from(ip); + let mask = u32::from(netmask); + Ipv4Addr::from(ip_u32 | !mask) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_direct_json_payload() { + let payload = br#"{"address":["10.0.0.2/24"],"mtu":1400,"tun_name":"burrow0"}"#; + let config = DirectConfig::from_payload(payload).unwrap(); + assert_eq!(config.address, vec!["10.0.0.2/24"]); + assert_eq!(config.mtu, Some(1400)); + assert_eq!(config.tun_name.as_deref(), Some("burrow0")); + } + + #[test] + fn socket_tun_config_uses_dual_stack_defaults() { + let config = socket_tun_config(&[], None, None, "burrow-test").unwrap(); + assert_eq!(config.tun_name, "burrow-test"); + assert!(config + .addresses + .iter() + .any(|network| matches!(network, IpNetwork::V4(_)))); + assert!(config + .addresses + .iter() + .any(|network| matches!(network, IpNetwork::V6(_)))); + } +} diff --git a/burrow/src/wireguard/iface.rs b/burrow/src/wireguard/iface.rs index 321801b..5b61861 100755 --- a/burrow/src/wireguard/iface.rs +++ b/burrow/src/wireguard/iface.rs @@ -148,7 +148,7 @@ impl Interface { debug!("Routing packet to {}", dst_addr); let Some(idx) = pcbs.find(dst_addr) else { - continue + continue; }; debug!("Found peer:{}", idx); diff --git a/burrow/src/wireguard/noise/handshake.rs b/burrow/src/wireguard/noise/handshake.rs index 2ec0c6a..65136bc 100755 --- a/burrow/src/wireguard/noise/handshake.rs +++ b/burrow/src/wireguard/noise/handshake.rs @@ -9,20 +9,15 @@ use std::{ use aead::{Aead, Payload}; use blake2::{ digest::{FixedOutput, KeyInit}, - Blake2s256, - Blake2sMac, - Digest, + Blake2s256, Blake2sMac, Digest, }; use chacha20poly1305::XChaCha20Poly1305; use rand_core::OsRng; use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, CHACHA20_POLY1305}; +use subtle::ConstantTimeEq; use super::{ - errors::WireGuardError, - session::Session, - x25519, - HandshakeInit, - HandshakeResponse, + errors::WireGuardError, session::Session, x25519, HandshakeInit, HandshakeResponse, PacketCookieReply, }; @@ -209,7 +204,7 @@ impl Tai64N { /// Parse a timestamp from a 12 byte u8 slice fn parse(buf: &[u8; 12]) -> Result { if buf.len() < 12 { - return Err(WireGuardError::InvalidTai64nTimestamp) + return Err(WireGuardError::InvalidTai64nTimestamp); } let (sec_bytes, nano_bytes) = buf.split_at(std::mem::size_of::()); @@ -534,11 +529,14 @@ impl Handshake { &hash, )?; - ring::constant_time::verify_slices_are_equal( - self.params.peer_static_public.as_bytes(), - &peer_static_public_decrypted, - ) - .map_err(|_| WireGuardError::WrongKey)?; + if !bool::from( + self.params + .peer_static_public + .as_bytes() + .ct_eq(&peer_static_public_decrypted), + ) { + return Err(WireGuardError::WrongKey); + } // initiator.hash = HASH(initiator.hash || msg.encrypted_static) hash = b2s_hash(&hash, packet.encrypted_static); @@ -556,19 +554,22 @@ impl Handshake { let timestamp = Tai64N::parse(×tamp)?; if !timestamp.after(&self.last_handshake_timestamp) { // Possibly a replay - return Err(WireGuardError::WrongTai64nTimestamp) + return Err(WireGuardError::WrongTai64nTimestamp); } self.last_handshake_timestamp = timestamp; // initiator.hash = HASH(initiator.hash || msg.encrypted_timestamp) hash = b2s_hash(&hash, packet.encrypted_timestamp); - self.previous = std::mem::replace(&mut self.state, HandshakeState::InitReceived { - chaining_key, - hash, - peer_ephemeral_public, - peer_index, - }); + self.previous = std::mem::replace( + &mut self.state, + HandshakeState::InitReceived { + chaining_key, + hash, + peer_ephemeral_public, + peer_index, + }, + ); self.format_handshake_response(dst) } @@ -669,7 +670,7 @@ impl Handshake { let local_index = self.cookies.index; if packet.receiver_idx != local_index { - return Err(WireGuardError::WrongIndex) + return Err(WireGuardError::WrongIndex); } // msg.encrypted_cookie = XAEAD(HASH(LABEL_COOKIE || responder.static_public), // msg.nonce, cookie, last_received_msg.mac1) @@ -725,7 +726,7 @@ impl Handshake { dst: &'a mut [u8], ) -> Result<&'a mut [u8], WireGuardError> { if dst.len() < super::HANDSHAKE_INIT_SZ { - return Err(WireGuardError::DestinationBufferTooSmall) + return Err(WireGuardError::DestinationBufferTooSmall); } let (message_type, rest) = dst.split_at_mut(4); @@ -808,7 +809,7 @@ impl Handshake { dst: &'a mut [u8], ) -> Result<(&'a mut [u8], Session), WireGuardError> { if dst.len() < super::HANDSHAKE_RESP_SZ { - return Err(WireGuardError::DestinationBufferTooSmall) + return Err(WireGuardError::DestinationBufferTooSmall); } let state = std::mem::replace(&mut self.state, HandshakeState::None); diff --git a/burrow/src/wireguard/noise/mod.rs b/burrow/src/wireguard/noise/mod.rs index aa06652..86bcc73 100755 --- a/burrow/src/wireguard/noise/mod.rs +++ b/burrow/src/wireguard/noise/mod.rs @@ -133,9 +133,9 @@ pub enum Packet<'a> { impl Tunnel { #[inline(always)] - pub fn parse_incoming_packet(src: &[u8]) -> Result { + pub fn parse_incoming_packet(src: &[u8]) -> Result, WireGuardError> { if src.len() < 4 { - return Err(WireGuardError::InvalidPacket) + return Err(WireGuardError::InvalidPacket); } // Checks the type, as well as the reserved zero fields @@ -177,7 +177,7 @@ impl Tunnel { pub fn dst_address(packet: &[u8]) -> Option { if packet.is_empty() { - return None + return None; } match packet[0] >> 4 { @@ -201,7 +201,7 @@ impl Tunnel { pub fn src_address(packet: &[u8]) -> Option { if packet.is_empty() { - return None + return None; } match packet[0] >> 4 { @@ -296,7 +296,7 @@ impl Tunnel { self.timer_tick(TimerName::TimeLastDataPacketSent); } self.tx_bytes += src.len(); - return TunnResult::WriteToNetwork(packet) + return TunnResult::WriteToNetwork(packet); } // If there is no session, queue the packet for future retry @@ -320,7 +320,7 @@ impl Tunnel { ) -> TunnResult<'a> { if datagram.is_empty() { // Indicates a repeated call - return self.send_queued_packet(dst) + return self.send_queued_packet(dst); } let mut cookie = [0u8; COOKIE_REPLY_SZ]; @@ -331,7 +331,7 @@ impl Tunnel { Ok(packet) => packet, Err(TunnResult::WriteToNetwork(cookie)) => { dst[..cookie.len()].copy_from_slice(cookie); - return TunnResult::WriteToNetwork(&mut dst[..cookie.len()]) + return TunnResult::WriteToNetwork(&mut dst[..cookie.len()]); } Err(TunnResult::Err(e)) => return TunnResult::Err(e), _ => unreachable!(), @@ -435,7 +435,7 @@ impl Tunnel { let cur_idx = self.current; if cur_idx == new_idx { // There is nothing to do, already using this session, this is the common case - return + return; } if self.sessions[cur_idx % N_SESSIONS].is_none() || self.timers.session_timers[new_idx % N_SESSIONS] @@ -481,7 +481,7 @@ impl Tunnel { force_resend: bool, ) -> TunnResult<'a> { if self.handshake.is_in_progress() && !force_resend { - return TunnResult::Done + return TunnResult::Done; } if self.handshake.is_expired() { @@ -540,7 +540,7 @@ impl Tunnel { }; if computed_len > packet.len() { - return TunnResult::Err(WireGuardError::InvalidPacket) + return TunnResult::Err(WireGuardError::InvalidPacket); } self.timer_tick(TimerName::TimeLastDataPacketReceived); diff --git a/burrow/src/wireguard/noise/rate_limiter.rs b/burrow/src/wireguard/noise/rate_limiter.rs index ff19efd..e4fde02 100755 --- a/burrow/src/wireguard/noise/rate_limiter.rs +++ b/burrow/src/wireguard/noise/rate_limiter.rs @@ -8,23 +8,13 @@ use aead::{generic_array::GenericArray, AeadInPlace, KeyInit}; use chacha20poly1305::{Key, XChaCha20Poly1305}; use parking_lot::Mutex; use rand_core::{OsRng, RngCore}; -use ring::constant_time::verify_slices_are_equal; +use subtle::ConstantTimeEq; use super::{ handshake::{ - b2s_hash, - b2s_keyed_mac_16, - b2s_keyed_mac_16_2, - b2s_mac_24, - LABEL_COOKIE, - LABEL_MAC1, + b2s_hash, b2s_keyed_mac_16, b2s_keyed_mac_16_2, b2s_mac_24, LABEL_COOKIE, LABEL_MAC1, }, - HandshakeInit, - HandshakeResponse, - Packet, - TunnResult, - Tunnel, - WireGuardError, + HandshakeInit, HandshakeResponse, Packet, TunnResult, Tunnel, WireGuardError, }; const COOKIE_REFRESH: u64 = 128; // Use 128 and not 120 so the compiler can optimize out the division @@ -136,7 +126,7 @@ impl RateLimiter { dst: &'a mut [u8], ) -> Result<&'a mut [u8], WireGuardError> { if dst.len() < super::COOKIE_REPLY_SZ { - return Err(WireGuardError::DestinationBufferTooSmall) + return Err(WireGuardError::DestinationBufferTooSmall); } let (message_type, rest) = dst.split_at_mut(4); @@ -185,8 +175,9 @@ impl RateLimiter { let (mac1, mac2) = macs.split_at(16); let computed_mac1 = b2s_keyed_mac_16(&self.mac1_key, msg); - verify_slices_are_equal(&computed_mac1[..16], mac1) - .map_err(|_| TunnResult::Err(WireGuardError::InvalidMac))?; + if !bool::from(computed_mac1[..16].ct_eq(mac1)) { + return Err(TunnResult::Err(WireGuardError::InvalidMac)); + } if self.is_under_load() { let addr = match src_addr { @@ -198,11 +189,11 @@ impl RateLimiter { let cookie = self.current_cookie(addr); let computed_mac2 = b2s_keyed_mac_16_2(&cookie, msg, mac1); - if verify_slices_are_equal(&computed_mac2[..16], mac2).is_err() { + if !bool::from(computed_mac2[..16].ct_eq(mac2)) { let cookie_packet = self .format_cookie_reply(sender_idx, cookie, mac1, dst) .map_err(TunnResult::Err)?; - return Err(TunnResult::WriteToNetwork(cookie_packet)) + return Err(TunnResult::WriteToNetwork(cookie_packet)); } } } diff --git a/burrow/src/wireguard/noise/session.rs b/burrow/src/wireguard/noise/session.rs index 8988728..14c191b 100755 --- a/burrow/src/wireguard/noise/session.rs +++ b/burrow/src/wireguard/noise/session.rs @@ -88,11 +88,11 @@ impl ReceivingKeyCounterValidator { fn will_accept(&self, counter: u64) -> Result<(), WireGuardError> { if counter >= self.next { // As long as the counter is growing no replay took place for sure - return Ok(()) + return Ok(()); } if counter + N_BITS < self.next { // Drop if too far back - return Err(WireGuardError::InvalidCounter) + return Err(WireGuardError::InvalidCounter); } if !self.check_bit(counter) { Ok(()) @@ -107,22 +107,22 @@ impl ReceivingKeyCounterValidator { fn mark_did_receive(&mut self, counter: u64) -> Result<(), WireGuardError> { if counter + N_BITS < self.next { // Drop if too far back - return Err(WireGuardError::InvalidCounter) + return Err(WireGuardError::InvalidCounter); } if counter == self.next { // Usually the packets arrive in order, in that case we simply mark the bit and // increment the counter self.set_bit(counter); self.next += 1; - return Ok(()) + return Ok(()); } if counter < self.next { // A packet arrived out of order, check if it is valid, and mark if self.check_bit(counter) { - return Err(WireGuardError::InvalidCounter) + return Err(WireGuardError::InvalidCounter); } self.set_bit(counter); - return Ok(()) + return Ok(()); } // Packets where dropped, or maybe reordered, skip them and mark unused if counter - self.next >= N_BITS { @@ -247,7 +247,7 @@ impl Session { panic!("The destination buffer is too small"); } if packet.receiver_idx != self.receiving_index { - return Err(WireGuardError::WrongIndex) + return Err(WireGuardError::WrongIndex); } // Don't reuse counters, in case this is a replay attack we want to quickly // check the counter without running expensive decryption diff --git a/burrow/src/wireguard/noise/timers.rs b/burrow/src/wireguard/noise/timers.rs index 1d0cf1f..f713e6f 100755 --- a/burrow/src/wireguard/noise/timers.rs +++ b/burrow/src/wireguard/noise/timers.rs @@ -190,7 +190,7 @@ impl Tunnel { { if self.handshake.is_expired() { - return TunnResult::Err(WireGuardError::ConnectionExpired) + return TunnResult::Err(WireGuardError::ConnectionExpired); } // Clear cookie after COOKIE_EXPIRATION_TIME @@ -206,7 +206,7 @@ impl Tunnel { tracing::error!("CONNECTION_EXPIRED(REJECT_AFTER_TIME * 3)"); self.handshake.set_expired(); self.clear_all(); - return TunnResult::Err(WireGuardError::ConnectionExpired) + return TunnResult::Err(WireGuardError::ConnectionExpired); } if let Some(time_init_sent) = self.handshake.timer() { @@ -219,7 +219,7 @@ impl Tunnel { tracing::error!("CONNECTION_EXPIRED(REKEY_ATTEMPT_TIME)"); self.handshake.set_expired(); self.clear_all(); - return TunnResult::Err(WireGuardError::ConnectionExpired) + return TunnResult::Err(WireGuardError::ConnectionExpired); } if time_init_sent.elapsed() >= REKEY_TIMEOUT { @@ -299,11 +299,11 @@ impl Tunnel { } if handshake_initiation_required { - return self.format_handshake_initiation(dst, true) + return self.format_handshake_initiation(dst, true); } if keepalive_required { - return self.encapsulate(&[], dst) + return self.encapsulate(&[], dst); } TunnResult::Done diff --git a/burrow/src/wireguard/pcb.rs b/burrow/src/wireguard/pcb.rs index 974d84e..6e5e6c0 100755 --- a/burrow/src/wireguard/pcb.rs +++ b/burrow/src/wireguard/pcb.rs @@ -64,7 +64,7 @@ impl PeerPcb { let guard = self.socket.read().await; let Some(socket) = guard.as_ref() else { self.open_if_closed().await?; - continue + continue; }; let mut res_buf = [0; 1500]; // tracing::debug!("{} : waiting for readability on {:?}", rid, socket); @@ -72,7 +72,7 @@ impl PeerPcb { Ok(l) => l, Err(e) => { log::error!("{}: error reading from socket: {:?}", rid, e); - continue + continue; } }; let mut res_dat = &res_buf[..len]; @@ -88,7 +88,7 @@ impl PeerPcb { TunnResult::Done => break, TunnResult::Err(e) => { tracing::error!(message = "Decapsulate error", error = ?e); - break + break; } TunnResult::WriteToNetwork(packet) => { tracing::debug!("WriteToNetwork: {:?}", packet); @@ -102,17 +102,29 @@ impl PeerPcb { .await?; tracing::debug!("WriteToNetwork done"); res_dat = &[]; - continue + continue; } TunnResult::WriteToTunnelV4(packet, addr) => { tracing::debug!("WriteToTunnelV4: {:?}, {:?}", packet, addr); - tun_interface.read().await.as_ref().ok_or(anyhow::anyhow!("tun interface does not exist"))?.send(packet).await?; - break + tun_interface + .read() + .await + .as_ref() + .ok_or(anyhow::anyhow!("tun interface does not exist"))? + .send(packet) + .await?; + break; } TunnResult::WriteToTunnelV6(packet, addr) => { tracing::debug!("WriteToTunnelV6: {:?}, {:?}", packet, addr); - tun_interface.read().await.as_ref().ok_or(anyhow::anyhow!("tun interface does not exist"))?.send(packet).await?; - break + tun_interface + .read() + .await + .as_ref() + .ok_or(anyhow::anyhow!("tun interface does not exist"))? + .send(packet) + .await?; + break; } } } @@ -134,7 +146,7 @@ impl PeerPcb { let handle = self.socket.read().await; let Some(socket) = handle.as_ref() else { tracing::error!("No socket for peer"); - return Ok(()) + return Ok(()); }; tracing::debug!("Our Encapsulated packet: {:?}", packet); socket.send(packet).await?; @@ -157,7 +169,7 @@ impl PeerPcb { let handle = self.socket.read().await; let Some(socket) = handle.as_ref() else { tracing::error!("No socket for peer"); - return Ok(()) + return Ok(()); }; socket.send(packet).await?; tracing::debug!("Sent Packet for timer update"); diff --git a/docs/FORWARDEMAIL.md b/docs/FORWARDEMAIL.md new file mode 100644 index 0000000..798f3e5 --- /dev/null +++ b/docs/FORWARDEMAIL.md @@ -0,0 +1,101 @@ +# Forward Email Backups + +Burrow's mail direction is hosted mail on [Forward Email](https://forwardemail.net/), with domain-owned backup retention in our own S3-compatible object storage. + +This is the first mail path to operationalize for `burrow.net` and `burrow.rs`. It keeps SMTP/IMAP hosting off the first forge host while still giving Burrow control over backup retention and object ownership. + +## What Forward Email Requires + +Forward Email exposes custom backup storage per domain. The documented API shape is: + +- `PUT /v1/domains/{domain}` with: + - `has_custom_s3=true` + - `s3_endpoint` + - `s3_access_key_id` + - `s3_secret_access_key` + - `s3_region` + - `s3_bucket` +- `POST /v1/domains/{domain}/test-s3-connection` + +Forward Email also documents these operational constraints: + +- the bucket must remain private +- credentials are validated with `HeadBucket` +- failed or public-bucket configurations fall back to Forward Email's default storage and notify domain administrators +- custom S3 keeps every backup version, so lifecycle expiration is our responsibility + +## Burrow Secret Layout + +Present in `intake/` today: + +- `intake/forwardemail_api_token.txt` +- `intake/hetzner-s3-user.txt` +- `intake/hetzner-s3-secret.txt` +- Hetzner public S3 endpoint for Forward Email: `https://hel1.your-objectstorage.com` +- Hetzner object storage region: `hel1` +- Hetzner bucket used for Forward Email backups: `burrow` + +## Verified Storage State + +As of March 15, 2026, Burrow's Forward Email custom S3 configuration is live: + +- endpoint: `https://hel1.your-objectstorage.com` +- region: `hel1` +- bucket: `burrow` +- `burrow.net` has `has_custom_s3=true` +- `burrow.rs` has `has_custom_s3=true` +- Forward Email's `/test-s3-connection` succeeded for both domains +- the `burrow` bucket enforces lifecycle expiration after `90` days + +Forward Email performs bucket validation with bucket-style addressing. For Hetzner Object Storage, this means the working endpoint is the regional S3 endpoint (`https://hel1.your-objectstorage.com`), not the account alias (`https://burrow.hel1.your-objectstorage.com`). Using the account alias causes TLS hostname mismatches when the vendor prepends the bucket name. + +## Helper + +Use [`Tools/forwardemail-custom-s3.sh`](../Tools/forwardemail-custom-s3.sh) to configure or retest the domain setting without putting secrets on the process list. + +Use [`Tools/forwardemail-hetzner-storage.py`](../Tools/forwardemail-hetzner-storage.py) to ensure the Hetzner backup bucket exists and to apply lifecycle expiry before enabling custom S3 on the Forward Email side. + +Bucket bootstrap example: + +```sh +Tools/forwardemail-hetzner-storage.py \ + --endpoint https://hel1.your-objectstorage.com \ + --bucket burrow \ + --expire-days 90 +``` + +Example: + +```sh +Tools/forwardemail-custom-s3.sh \ + --domain burrow.net \ + --api-token-file intake/forwardemail_api_token.txt \ + --s3-endpoint https://hel1.your-objectstorage.com \ + --s3-region hel1 \ + --s3-bucket burrow \ + --s3-access-key-file intake/hetzner-s3-user.txt \ + --s3-secret-key-file intake/hetzner-s3-secret.txt +``` + +Retest an existing domain configuration without rewriting it: + +```sh +Tools/forwardemail-custom-s3.sh \ + --domain burrow.net \ + --api-token-file intake/forwardemail_api_token.txt \ + --test-only +``` + +## Retention + +Forward Email preserves every backup object when custom S3 is enabled. Configure lifecycle expiration on the bucket itself. A 30-day or 90-day expiry window is the baseline recommendation from the vendor docs; Burrow should choose explicitly per domain instead of letting the bucket grow without bound. The current Burrow bootstrap helper defaults to `90` days. + +## Identity Direction + +Hosted mail and SaaS identity are separate concerns: + +- mail hosting/backups: Forward Email + Burrow-owned S3-compatible storage +- interactive identity: Authentik as the long-term IdP +- future SaaS SSO target: Linear via SAML once the workspace and plan are ready + +This means the forge host does not need to become the first mail server just to give Burrow mailboxes or retention control. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 764c219..346f7e7 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -98,10 +98,14 @@ code burrow You can run burrow on the command line with cargo: ``` -cargo run +sudo -E cargo run -- start ``` -Cargo will ask for your password because burrow needs permission in order to create a tunnel. +Creating the tunnel requires elevated privileges. Regular checks and tests can run without `sudo`: + +``` +cargo test --workspace --all-features +``` diff --git a/docs/PROTOCOL_ROADMAP.md b/docs/PROTOCOL_ROADMAP.md new file mode 100644 index 0000000..37c7228 --- /dev/null +++ b/docs/PROTOCOL_ROADMAP.md @@ -0,0 +1,31 @@ +# Protocol Roadmap + +Burrow currently has two tunnel paths in-tree: + +- a WireGuard data plane +- a Tor-backed userspace TCP path + +What it does not have yet is a transport-neutral control plane that can honestly claim full MASQUE `CONNECT-IP` or full Tailscale-style negotiation parity. This repository now contains the beginnings of that layer: + +- control-plane data structures in `burrow/src/control/mod.rs` +- local auth bootstrap and persistent node/session storage in `burrow/src/auth/server/` +- governance documents under `evolution/` for the bigger protocol work + +## `CONNECT-IP` + +Full RFC 9484 support requires more than packet forwarding. It needs HTTP/3 session management, Capsule handling, HTTP Datagram context identifiers, address assignment, route advertisement, and request-scope enforcement. Burrow does not implement those end to end yet. + +## Tailscale-Style Negotiation + +Burrow now has register/map request and response types plus persistent node records, but it does not yet implement the full Tailscale capability surface, peer delta protocol, DERP coordination, or Noise-based control transport. + +## Current Direction + +The intended sequence is: + +1. Stabilize the control-plane data model and bootstrap auth. +2. Introduce transport-neutral route and address abstractions. +3. Add MASQUE framing and HTTP/3 transport support. +4. Expand policy, relay, and interoperability testing. + +This keeps Burrow honest about what is running today while creating a clean path for the rest. diff --git a/docs/WIREGUARD_LINEAGE.md b/docs/WIREGUARD_LINEAGE.md new file mode 100644 index 0000000..63e8839 --- /dev/null +++ b/docs/WIREGUARD_LINEAGE.md @@ -0,0 +1,30 @@ +# WireGuard Rust Lineage + +Burrow's in-tree WireGuard engine is not a greenfield implementation. It was lifted from the Rust WireGuard lineage around Cloudflare's BoringTun, then cut down and reshaped to fit Burrow's own daemon and tunnel abstractions. + +## What Was Lifted + +- The repository history includes `1b39eca` (`boringtun wip`) and `28af9003` (`merge boringtun into burrow`). +- The current `burrow/src/wireguard/noise/*` files still carry the original Cloudflare copyright and SPDX headers. +- Core protocol machinery such as the Noise handshake, session state, rate limiter, and timer logic came from that imported body of work. + +## What Changed in Burrow + +Burrow does not embed BoringTun unchanged. + +- The original device layer was replaced with Burrow-specific interface and peer control blocks in `burrow/src/wireguard/iface.rs` and `burrow/src/wireguard/pcb.rs`. +- Configuration handling was rewritten around Burrow's own INI parser and config model in `burrow/src/wireguard/config.rs`. +- The daemon now resolves the active runtime from the database-backed network list rather than from a single static WireGuard payload. +- Burrow added its own runtime switching path so WireGuard can share one daemon lifecycle with the rest of the managed runtime system. + +## What Was Improved + +The lifted code has been tightened further in-repo. + +- Deprecated constant-time comparisons were replaced with `subtle`. +- Network ordering and runtime selection are now deterministic and test-covered. +- The Burrow runtime can swap between WireGuard configurations without restarting the daemon process itself. + +## Why This Matters + +This project should be explicit about lineage. Burrow benefits from proven Rust WireGuard work, but it owns the integration surface, runtime behavior, and future maintenance burden. That is why the code should be documented as lifted, modified, and improved rather than described as wholly original. diff --git a/evolution/README.md b/evolution/README.md new file mode 100644 index 0000000..e55a347 --- /dev/null +++ b/evolution/README.md @@ -0,0 +1,60 @@ +# Burrow Evolution + +Burrow Evolution Proposals (BEPs) are the repository's durable design record for protocol work, control-plane changes, forge infrastructure, and operational policy. + +## Goals + +1. Capture intent before implementation outruns the architecture. +2. Give contributors and agents enough context to work safely without re-discovering prior decisions. +3. Tie ambitious work to concrete validation, rollout, and rollback criteria. + +## When a BEP is required + +Open a BEP for: + +- new transports or protocol families +- control-plane and identity changes +- deployment, forge, runner, or secrets changes +- data model migrations +- user-visible behavior that changes security or routing semantics + +Small bug fixes and isolated refactors do not need a BEP unless they materially change one of the areas above. + +## Lifecycle + +1. Pitch + Capture the problem and why it matters now. +2. Draft + Copy `evolution/proposals/0000-template.md` to `evolution/proposals/BEP-XXXX-short-slug.md`. +3. Review + Collect feedback, tighten the design, and document unresolved concerns. +4. Decision + Mark the proposal `Accepted`, `Rejected`, or `Returned for Revision`. +5. Implementation + Link code changes, tests, and rollout evidence. +6. Supersession + Keep historical proposals in-tree and point forward to the replacing BEP. + +## Status Values + +- `Pitch` +- `Draft` +- `In Review` +- `Accepted` +- `Implemented` +- `Rejected` +- `Returned for Revision` +- `Superseded` +- `Archived` + +## Layout + +```text +evolution/ + README.md + proposals/ + 0000-template.md + BEP-0001-... +``` + +Use ASCII Markdown. Keep metadata at the top of each proposal so tooling and future agents can parse it quickly. diff --git a/evolution/proposals/0000-template.md b/evolution/proposals/0000-template.md new file mode 100644 index 0000000..66954c6 --- /dev/null +++ b/evolution/proposals/0000-template.md @@ -0,0 +1,57 @@ +# `BEP-XXXX` - Title Case Summary + +```text +Status: Draft | In Review | Accepted | Implemented | Rejected | Returned for Revision | Superseded | Archived +Proposal: BEP-XXXX +Authors: +Coordinator: +Reviewers: +Constitution Sections: +Implementation PRs: (optional while drafting) +Decision Date: +``` + +## Summary + +One or two paragraphs that state the desired outcome and why it matters. + +## Motivation + +- What problem exists today? +- Why should Burrow solve it now? +- Which issues, incidents, or constraints support the change? + +## Detailed Design + +- Architecture and boundaries +- Data model and migration plan +- Protocol or API changes +- Observability, testing, and failure handling + +## Security and Operational Considerations + +- Access and secret handling +- Abuse, downgrade, or supply-chain risks +- Rollback and kill-switch plans + +## Contributor Playbook + +Give the concrete steps, commands, checks, and evidence a contributor should produce while implementing or rolling out the change. + +## Alternatives Considered + +List alternatives and why they were rejected. + +## Impact on Other Work + +- follow-up tasks +- dependencies +- compatibility constraints + +## Decision + +Record the final call, who made it, and any conditions. + +## References + +Link relevant issues, specs, transcripts, and external research. diff --git a/evolution/proposals/BEP-0001-sovereign-forge-and-governance.md b/evolution/proposals/BEP-0001-sovereign-forge-and-governance.md new file mode 100644 index 0000000..f48a7a9 --- /dev/null +++ b/evolution/proposals/BEP-0001-sovereign-forge-and-governance.md @@ -0,0 +1,61 @@ +# `BEP-0001` - Sovereign Forge and Governance Bootstrap + +```text +Status: Draft +Proposal: BEP-0001 +Authors: gpt-5.4 +Coordinator: gpt-5.4 +Reviewers: Pending +Constitution Sections: II, III, V +Implementation PRs: Pending +Decision Date: Pending +``` + +## Summary + +Burrow should own its forge, deployment logic, and operational context under `burrow.net`. This proposal establishes the repository-local governance and forge bootstrap required to move build, release, and infrastructure control out of GitHub-centric assumptions and into a self-hosted operating model. + +## Motivation + +- The repository currently keeps CI definitions under `.github/workflows/` but has no first-class self-hosted forge layout. +- Infrastructure changes and protocol work are already entangled; without a design record, the project risks landing irreversible operations without enough context. +- A self-hosted forge is a prerequisite for durable autonomy over source, runners, and release pipelines. + +## Detailed Design + +- Add a project constitution and BEP process under `evolution/`. +- Introduce a Nix flake and NixOS host/module layout for `burrow-forge`. +- Add Forgejo-native workflows under `.forgejo/workflows/` for repository-local CI. +- Bootstrap the initial forge identity around `contact@burrow.net` and an agent-owned SSH workflow. + +## Security and Operational Considerations + +- Initial bootstrap may read credentials from local intake, but production must converge on encrypted secret handling. +- The first forge host replacement must preserve rollback information before deleting any existing VM. +- DNS for `burrow.net` is currently pending activation; the forge rollout must not assume public reachability until nameserver cutover completes. + +## Contributor Playbook + +- Keep destructive host operations behind explicit verification of the current Hetzner state. +- Build and test repository-local workflows before using them for deployment. +- Record the active server id, image, IPs, and SSH path before replacement. + +## Alternatives Considered + +- Continue relying on GitHub Actions while separately hosting services. Rejected because it leaves source authority and CI policy split across systems. +- Stand up Forgejo without a repository-local operating model. Rejected because the repo would still be missing deployment truth. + +## Impact on Other Work + +- Blocks long-term migration of workflows away from GitHub. +- Provides the governance anchor for protocol and control-plane proposals. + +## Decision + +Pending. + +## References + +- `CONSTITUTION.md` +- `.github/workflows/` +- `.forgejo/workflows/` diff --git a/evolution/proposals/BEP-0002-control-plane-bootstrap-and-local-auth.md b/evolution/proposals/BEP-0002-control-plane-bootstrap-and-local-auth.md new file mode 100644 index 0000000..2558d09 --- /dev/null +++ b/evolution/proposals/BEP-0002-control-plane-bootstrap-and-local-auth.md @@ -0,0 +1,60 @@ +# `BEP-0002` - Control-Plane Bootstrap and Local Auth + +```text +Status: Draft +Proposal: BEP-0002 +Authors: gpt-5.4 +Coordinator: gpt-5.4 +Reviewers: Pending +Constitution Sections: I, II, III, V +Implementation PRs: Pending +Decision Date: Pending +``` + +## Summary + +Burrow needs a repository-owned control-plane model instead of ad hoc network payload storage plus third-party-only auth. This proposal introduces a local username/password bootstrap for `contact@burrow.net`, plus a register/map data model shaped to support a Tailscale-style control server without claiming full parity yet. + +## Motivation + +- Current auth support is limited and does not provide a plain local bootstrap path for the project's own operator identity. +- The existing database stores network payloads, but not a durable model for users, nodes, sessions, or control-plane negotiation state. +- Future work on route policy, device coordination, and richer negotiation needs a real data model now. + +## Detailed Design + +- Add control-plane types for users, nodes, register requests, and map responses. +- Extend the auth server schema with local credentials, sessions, provider logins, and control nodes. +- Expose JSON endpoints for local login, node registration, and map retrieval. +- Seed the initial operator account from intake-backed bootstrap credentials. + +## Security and Operational Considerations + +- Passwords are stored with Argon2id hashes only. +- Session tokens are bearer credentials and must be treated as sensitive. +- The bootstrap credential path is a short-term path; follow-up work should move it into encrypted secret management before public deployment. + +## Contributor Playbook + +- Verify bootstrap account creation in an isolated test database. +- Exercise login, register, and map end to end with integration tests. +- Do not advertise protocol parity beyond the implemented request/response contract. + +## Alternatives Considered + +- Wait for full external identity-provider integration first. Rejected because the forge needs an operator account now. +- Keep control-plane state implicit in daemon-local configuration. Rejected because it cannot express multi-device coordination. + +## Impact on Other Work + +- Unblocks forge bootstrap and future device control-plane work. +- Creates the storage model needed for richer policy and transport proposals. + +## Decision + +Pending. + +## References + +- `burrow/src/auth/server/` +- `burrow/src/control/` diff --git a/evolution/proposals/BEP-0003-connect-ip-and-negotiation-roadmap.md b/evolution/proposals/BEP-0003-connect-ip-and-negotiation-roadmap.md new file mode 100644 index 0000000..99ddedf --- /dev/null +++ b/evolution/proposals/BEP-0003-connect-ip-and-negotiation-roadmap.md @@ -0,0 +1,61 @@ +# `BEP-0003` - CONNECT-IP and Negotiation Roadmap + +```text +Status: Draft +Proposal: BEP-0003 +Authors: gpt-5.4 +Coordinator: gpt-5.4 +Reviewers: Pending +Constitution Sections: I, II, V +Implementation PRs: Pending +Decision Date: Pending +``` + +## Summary + +Burrow should grow from a WireGuard-first tunnel runner into a transport stack that can support HTTP/3 MASQUE `CONNECT-IP` and a richer node negotiation model. This proposal stages that work so Burrow can adopt the right abstractions instead of stapling QUIC-era semantics onto a WireGuard-only daemon. + +## Motivation + +- `CONNECT-IP` introduces HTTP/3 sessions, context identifiers, address assignment, and route advertisements that do not fit the current daemon model. +- A Tailscale-style control plane requires explicit node, endpoint, and session state rather than raw network blobs. +- The project needs a roadmap that distinguishes data-model work, control-plane work, and actual transport implementation. + +## Detailed Design + +- Stage 1: land control-plane types and persistent auth/session/node storage. +- Stage 2: add transport-agnostic route, address-assignment, and policy abstractions in Burrow. +- Stage 3: implement MASQUE `CONNECT-IP` framing and HTTP Datagram handling. +- Stage 4: connect the transport layer to real relay, policy, and observability paths. + +## Security and Operational Considerations + +- `CONNECT-IP` changes the trust boundary from WireGuard peers to HTTP/3 peers and relays; authentication, replay handling, and scope restriction must be explicit. +- Route advertisements and delegated prefixes must be validated before touching the data plane. +- Control-plane capability claims must not imply support that the transport layer does not yet implement. + +## Contributor Playbook + +- Keep protocol codecs independently testable before integrating them into live transports. +- Add interoperability tests for every new capsule or datagram type. +- Separate request parsing, policy validation, and packet forwarding so regressions stay localized. + +## Alternatives Considered + +- Implement MASQUE directly in the daemon without control-plane refactoring. Rejected because the current daemon has no transport-neutral contract for routes or prefixes. +- Treat Tailscale negotiation as a one-off compatibility shim. Rejected because Burrow needs first-class control-plane concepts either way. + +## Impact on Other Work + +- Depends on BEP-0002. +- Informs future relay, policy, and node coordination work. + +## Decision + +Pending. + +## References + +- RFC 9484 +- `burrow/src/daemon/` +- `burrow/src/control/` diff --git a/evolution/proposals/BEP-0004-hosted-mail-and-saas-identity.md b/evolution/proposals/BEP-0004-hosted-mail-and-saas-identity.md new file mode 100644 index 0000000..d633f37 --- /dev/null +++ b/evolution/proposals/BEP-0004-hosted-mail-and-saas-identity.md @@ -0,0 +1,68 @@ +# `BEP-0004` - Hosted Mail Backups and SaaS Identity + +```text +Status: Draft +Proposal: BEP-0004 +Authors: gpt-5.4 +Coordinator: gpt-5.4 +Reviewers: Pending +Constitution Sections: II, III, V +Implementation PRs: Pending +Decision Date: Pending +``` + +## Summary + +Burrow should start with hosted mail on Forward Email instead of self-hosting SMTP and IMAP on the first forge machine. Backup retention should still be controlled by Burrow through custom S3-compatible storage backed by Burrow-owned object storage. In parallel, Burrow should treat SaaS identity as a separate track and converge on Authentik as the long-term IdP, with Linear SAML SSO as a planned downstream integration rather than an immediate bootstrap dependency. + +## Motivation + +- The first forge host already carries source control, CI, and deployment bootstrap risk. Adding a self-hosted mail stack increases operational scope before the forge is stable. +- Forward Email already exposes SMTP and IMAP while allowing per-domain custom S3 backup storage, which preserves Burrow's data ownership over mailbox backups. +- The repository needs a durable decision record that separates hosted mail operations from future SaaS SSO work. + +## Detailed Design + +- Use Forward Email as the operational mail provider for `burrow.net` and `burrow.rs`. +- Configure custom S3-compatible storage per domain using Burrow-controlled object storage credentials. +- Keep one backup bucket per domain and enforce lifecycle expiration at the bucket layer. +- Add repository-owned tooling and documentation for applying and testing the Forward Email custom S3 configuration. +- Treat Authentik as the future identity authority for SaaS applications, but keep Linear SAML as a later rollout once the workspace and vendor prerequisites are available. Linear's current docs place SAML and SCIM behind higher-tier workspace security settings, so Burrow should treat plan availability as an explicit precondition. + +## Security and Operational Considerations + +- Forward Email API tokens and S3 credentials must stay in secret files and must not be passed directly on the shell command line. +- Buckets must remain private. Public bucket detection by the vendor should be treated as a hard failure, not a warning. +- Backup growth is unbounded without lifecycle rules. Retention policy is part of the rollout, not optional cleanup. +- Hosted mail reduces MTA attack surface on the forge host, but it adds third-party dependency risk; keeping backups in Burrow-owned storage limits that blast radius. + +## Contributor Playbook + +- Put the Forward Email API token in `intake/forwardemail_api_token.txt`. +- Use `Tools/forwardemail-custom-s3.sh` to configure `burrow.net` and `burrow.rs`. +- Run the helper again with `--test-only` after any credential rotation. +- Record the chosen endpoint, region, bucket names, and lifecycle policy alongside rollout evidence. +- Do not claim Linear SAML is live until the Authentik app, Linear workspace settings, workspace plan prerequisites, and end-to-end login flow are verified. + +## Alternatives Considered + +- Self-host Stalwart on the forge host immediately. Rejected for the first rollout because it expands host scope before source control and CI are stable. +- Rely on Forward Email default backup storage only. Rejected because it gives Burrow less control over retention and data location. +- Delay all SaaS identity planning until after forge cutover. Rejected because Linear and other SaaS integrations will otherwise accrete without an agreed authority. + +## Impact on Other Work + +- Narrows the first forge host scope. +- Creates a clean mail path for `contact@burrow.net` without requiring self-hosted SMTP and IMAP. +- Leaves Authentik and Linear SAML as explicit follow-up work instead of hidden assumptions. + +## Decision + +Pending. + +## References + +- `docs/FORWARDEMAIL.md` +- `Tools/forwardemail-custom-s3.sh` +- Forward Email FAQ: custom S3-compatible storage for backups +- Linear docs: SAML SSO diff --git a/tun/build.rs b/tun/build.rs index 8da8a40..03ee131 100644 --- a/tun/build.rs +++ b/tun/build.rs @@ -26,7 +26,7 @@ async fn generate(out_dir: &std::path::Path) -> anyhow::Result<()> { println!("cargo:rerun-if-changed={}", binary_path.to_str().unwrap()); if let (Ok(..), Ok(..)) = (File::open(&bindings_path), File::open(&binary_path)) { - return Ok(()) + return Ok(()); }; let archive = download(out_dir) diff --git a/tun/src/tokio/mod.rs b/tun/src/tokio/mod.rs index bd27109..f56f3d2 100644 --- a/tun/src/tokio/mod.rs +++ b/tun/src/tokio/mod.rs @@ -33,7 +33,7 @@ impl TunInterface { Ok(result) => return result, Err(_would_block) => { tracing::debug!("WouldBlock"); - continue + continue; } } } diff --git a/tun/src/unix/apple/mod.rs b/tun/src/unix/apple/mod.rs index 0fc701e..66a2f15 100644 --- a/tun/src/unix/apple/mod.rs +++ b/tun/src/unix/apple/mod.rs @@ -114,6 +114,10 @@ impl TunInterface { ifname_to_string(buf) } + pub(crate) fn packet_information_size(&self) -> usize { + 4 + } + #[throws] #[instrument] fn ifreq(&self) -> sys::ifreq { diff --git a/tun/src/unix/linux/mod.rs b/tun/src/unix/linux/mod.rs index 03b6f09..9fc963a 100644 --- a/tun/src/unix/linux/mod.rs +++ b/tun/src/unix/linux/mod.rs @@ -73,6 +73,21 @@ impl TunInterface { ifname_to_string(iff.ifr_name) } + pub(crate) fn packet_information_size(&self) -> usize { + let mut iff = unsafe { mem::zeroed::() }; + match unsafe { sys::tun_get_iff(self.socket.as_raw_fd(), &mut iff) } { + Ok(_) => { + let flags = unsafe { iff.ifr_ifru.ifru_flags }; + if flags & libc::IFF_NO_PI as i16 != 0 { + 0 + } else { + 4 + } + } + Err(_) => 4, + } + } + #[throws] #[instrument] fn ifreq(&self) -> sys::ifreq { @@ -283,6 +298,16 @@ impl TunInterface { #[throws] #[instrument] pub fn send(&self, buf: &[u8]) -> usize { - self.socket.send(buf)? + let len = unsafe { + libc::write( + self.as_raw_fd(), + buf.as_ptr().cast::(), + buf.len(), + ) + }; + if len < 0 { + Err(Error::last_os_error())?; + } + len as usize } } diff --git a/tun/src/unix/mod.rs b/tun/src/unix/mod.rs index f1d7da1..ad25667 100644 --- a/tun/src/unix/mod.rs +++ b/tun/src/unix/mod.rs @@ -48,12 +48,26 @@ impl TunInterface { #[throws] #[instrument] pub fn recv(&self, buf: &mut [u8]) -> usize { - // Use IoVec to read directly into target buffer - let mut tmp_buf = [MaybeUninit::uninit(); 1500]; - let len = self.socket.recv(&mut tmp_buf)?; - let result_buf = unsafe { assume_init(&tmp_buf[4..len]) }; - buf[..len - 4].copy_from_slice(result_buf); - len - 4 + let packet_information_size = self.packet_information_size(); + let mut tmp_buf = [MaybeUninit::uninit(); 1504]; + let len = unsafe { + libc::read( + self.as_raw_fd(), + tmp_buf.as_mut_ptr().cast::(), + tmp_buf.len(), + ) + }; + if len < 0 { + Err(Error::last_os_error())?; + } + let len = len as usize; + if len < packet_information_size { + return 0; + } + + let result_buf = unsafe { assume_init(&tmp_buf[packet_information_size..len]) }; + buf[..len - packet_information_size].copy_from_slice(result_buf); + len - packet_information_size } #[throws] diff --git a/tun/tests/configure.rs b/tun/tests/configure.rs index 7c05959..bfa56ef 100644 --- a/tun/tests/configure.rs +++ b/tun/tests/configure.rs @@ -3,17 +3,33 @@ use std::{io::Error, net::Ipv4Addr}; use fehler::throws; use tun::TunInterface; +fn open_tun() -> Result, Error> { + match TunInterface::new() { + Ok(tun) => Ok(Some(tun)), + Err(err) + if err.kind() == std::io::ErrorKind::PermissionDenied + || matches!(err.raw_os_error(), Some(1 | 13)) => + { + eprintln!("skipping tun test without tunnel privileges: {err}"); + Ok(None) + } + Err(err) => Err(err), + } +} + #[test] #[throws] fn test_create() { - TunInterface::new()?; + let _ = open_tun()?; } #[test] #[throws] #[cfg(not(any(target_os = "windows", target_vendor = "apple")))] fn test_set_get_broadcast_addr() { - let tun = TunInterface::new()?; + let Some(tun) = open_tun()? else { + return Ok(()); + }; let addr = Ipv4Addr::new(10, 0, 0, 1); tun.set_ipv4_addr(addr)?; @@ -28,7 +44,9 @@ fn test_set_get_broadcast_addr() { #[throws] #[cfg(not(target_os = "windows"))] fn test_set_get_ipv4() { - let tun = TunInterface::new()?; + let Some(tun) = open_tun()? else { + return Ok(()); + }; let addr = Ipv4Addr::new(10, 0, 0, 1); tun.set_ipv4_addr(addr)?; @@ -43,7 +61,9 @@ fn test_set_get_ipv4() { fn test_set_get_ipv6() { use std::net::Ipv6Addr; - let tun = TunInterface::new()?; + let Some(tun) = open_tun()? else { + return Ok(()); + }; let addr = Ipv6Addr::new(1, 1, 1, 1, 1, 1, 1, 1); tun.add_ipv6_addr(addr, 128)?; @@ -56,7 +76,9 @@ fn test_set_get_ipv6() { #[throws] #[cfg(not(target_os = "windows"))] fn test_set_get_mtu() { - let interf = TunInterface::new()?; + let Some(interf) = open_tun()? else { + return Ok(()); + }; interf.set_mtu(500)?; @@ -67,7 +89,9 @@ fn test_set_get_mtu() { #[throws] #[cfg(not(target_os = "windows"))] fn test_set_get_netmask() { - let interf = TunInterface::new()?; + let Some(interf) = open_tun()? else { + return Ok(()); + }; let netmask = Ipv4Addr::new(255, 0, 0, 0); let addr = Ipv4Addr::new(192, 168, 1, 1); diff --git a/tun/tests/tokio.rs b/tun/tests/tokio.rs index 097387c..3b89777 100644 --- a/tun/tests/tokio.rs +++ b/tun/tests/tokio.rs @@ -1,10 +1,27 @@ #[cfg(all(feature = "tokio", not(target_os = "windows")))] use std::net::Ipv4Addr; +#[cfg(all(feature = "tokio", not(target_os = "windows")))] +fn open_tun() -> Option { + match tun::TunInterface::new() { + Ok(tun) => Some(tun), + Err(err) + if err.kind() == std::io::ErrorKind::PermissionDenied + || matches!(err.raw_os_error(), Some(1 | 13)) => + { + eprintln!("skipping tokio tun test without tunnel privileges: {err}"); + None + } + Err(err) => panic!("failed to create tun interface: {err}"), + } +} + #[tokio::test] #[cfg(all(feature = "tokio", not(target_os = "windows")))] async fn test_create() { - let tun = tun::TunInterface::new().unwrap(); + let Some(tun) = open_tun() else { + return; + }; let _ = tun::tokio::TunInterface::new(tun).unwrap(); } @@ -12,7 +29,9 @@ async fn test_create() { #[ignore = "requires interactivity"] #[cfg(all(feature = "tokio", not(target_os = "windows")))] async fn test_write() { - let tun = tun::TunInterface::new().unwrap(); + let Some(tun) = open_tun() else { + return; + }; tun.set_ipv4_addr(Ipv4Addr::from([192, 168, 1, 10])) .unwrap(); let async_tun = tun::tokio::TunInterface::new(tun).unwrap(); From 0e68c25a994a1b0d046912021d2da9d025e4b3fc Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Wed, 1 Apr 2026 01:12:15 -0700 Subject: [PATCH 044/102] Wire Forgejo sign-in through Authentik --- Scripts/authentik-sync-forgejo-oidc.sh | 203 +++++++++++++++++++ nixos/README.md | 2 +- nixos/hosts/burrow-forge/default.nix | 8 + nixos/modules/burrow-authentik.nix | 81 +++++++- nixos/modules/burrow-forge.nix | 132 ++++++++++++ secrets.nix | 1 + secrets/infra/forgejo-oidc-client-secret.age | 10 + 7 files changed, 434 insertions(+), 3 deletions(-) create mode 100644 Scripts/authentik-sync-forgejo-oidc.sh create mode 100644 secrets/infra/forgejo-oidc-client-secret.age diff --git a/Scripts/authentik-sync-forgejo-oidc.sh b/Scripts/authentik-sync-forgejo-oidc.sh new file mode 100644 index 0000000..f354633 --- /dev/null +++ b/Scripts/authentik-sync-forgejo-oidc.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +application_slug="${AUTHENTIK_FORGEJO_APPLICATION_SLUG:-git}" +application_name="${AUTHENTIK_FORGEJO_APPLICATION_NAME:-burrow.net}" +provider_name="${AUTHENTIK_FORGEJO_PROVIDER_NAME:-burrow.net}" +client_id="${AUTHENTIK_FORGEJO_CLIENT_ID:-git.burrow.net}" +client_secret="${AUTHENTIK_FORGEJO_CLIENT_SECRET:-}" +launch_url="${AUTHENTIK_FORGEJO_LAUNCH_URL:-https://git.burrow.net/}" +redirect_uris_json="${AUTHENTIK_FORGEJO_REDIRECT_URIS_JSON:-[ + \"https://git.burrow.net/user/oauth2/burrow.net/callback\", + \"https://git.burrow.net/user/oauth2/authentik/callback\", + \"https://git.burrow.net/user/oauth2/GitHub/callback\" +]}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-forgejo-oidc.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + AUTHENTIK_FORGEJO_CLIENT_SECRET + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_FORGEJO_APPLICATION_SLUG + AUTHENTIK_FORGEJO_APPLICATION_NAME + AUTHENTIK_FORGEJO_PROVIDER_NAME + AUTHENTIK_FORGEJO_CLIENT_ID + AUTHENTIK_FORGEJO_LAUNCH_URL + AUTHENTIK_FORGEJO_REDIRECT_URIS_JSON +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ -z "$bootstrap_token" ]]; then + echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 + exit 1 +fi + +if [[ -z "$client_secret" || "$client_secret" == PENDING* ]]; then + echo "Forgejo OIDC client secret is not configured; skipping Authentik Forgejo sync." >&2 + exit 0 +fi + +if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then + echo "error: AUTHENTIK_FORGEJO_REDIRECT_URIS_JSON must be a non-empty JSON array" >&2 + exit 1 +fi + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + + if [[ -n "$data" ]]; then + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + else + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + fi +} + +wait_for_authentik() { + for _ in $(seq 1 90); do + if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +wait_for_authentik + +template_provider="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -c '.results[]? | select(.assigned_application_slug == "ts")' \ + | head -n1 +)" + +if [[ -z "$template_provider" ]]; then + echo "error: could not resolve the Burrow Tailnet OAuth provider template" >&2 + exit 1 +fi + +authorization_flow="$(printf '%s\n' "$template_provider" | jq -r '.authorization_flow')" +invalidation_flow="$(printf '%s\n' "$template_provider" | jq -r '.invalidation_flow')" +property_mappings="$(printf '%s\n' "$template_provider" | jq -c '.property_mappings')" +signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')" + +provider_payload="$( + jq -n \ + --arg name "$provider_name" \ + --arg slug "$application_slug" \ + --arg authorization_flow "$authorization_flow" \ + --arg invalidation_flow "$invalidation_flow" \ + --arg client_id "$client_id" \ + --arg client_secret "$client_secret" \ + --arg signing_key "$signing_key" \ + --argjson property_mappings "$property_mappings" \ + --argjson redirect_uris "$redirect_uris_json" \ + '{ + name: $name, + slug: $slug, + authorization_flow: $authorization_flow, + invalidation_flow: $invalidation_flow, + client_type: "confidential", + client_id: $client_id, + client_secret: $client_secret, + include_claims_in_id_token: true, + redirect_uris: ($redirect_uris | map({matching_mode: "strict", url: .})), + property_mappings: $property_mappings, + signing_key: $signing_key, + issuer_mode: "per_provider", + sub_mode: "hashed_user_id" + }' +)" + +existing_provider="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -c \ + --arg application_slug "$application_slug" \ + --arg provider_name "$provider_name" \ + '.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \ + | head -n1 +)" + +if [[ -n "$existing_provider" ]]; then + provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')" + api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$provider_payload" >/dev/null +else + provider_pk="$( + api POST "/api/v3/providers/oauth2/" "$provider_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "${provider_pk:-}" ]]; then + echo "error: Forgejo OIDC provider did not return a primary key" >&2 + exit 1 +fi + +application_payload="$( + jq -n \ + --arg name "$application_name" \ + --arg slug "$application_slug" \ + --arg provider "$provider_pk" \ + --arg launch_url "$launch_url" \ + '{ + name: $name, + slug: $slug, + provider: ($provider | tonumber), + meta_launch_url: $launch_url, + open_in_new_tab: false, + policy_engine_mode: "any" + }' +)" + +existing_application="$( + api GET "/api/v3/core/applications/?slug=${application_slug}" \ + | jq -c '.results[]? | select(.slug != null)' \ + | head -n1 +)" + +if [[ -n "$existing_application" ]]; then + application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" +else + application_pk="$( + api POST "/api/v3/core/applications/" "$application_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "${application_pk:-}" ]]; then + echo "error: Forgejo OIDC application did not return a primary key" >&2 + exit 1 +fi + +for _ in $(seq 1 30); do + if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then + echo "Synced Authentik Forgejo OIDC application ${application_slug} (${application_name})." + exit 0 + fi + sleep 2 +done + +echo "warning: Forgejo OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2 +echo "Synced Authentik Forgejo OIDC application ${application_slug} (${application_name})." diff --git a/nixos/README.md b/nixos/README.md index acae40f..07b421d 100644 --- a/nixos/README.md +++ b/nixos/README.md @@ -33,7 +33,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B 4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account. 5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent `. 6. Run `Scripts/provision-forgejo-nsc.sh` locally, then `Scripts/sync-forgejo-nsc-config.sh` to place the Namespace dispatcher/autoscaler runtime inputs under `/var/lib/burrow/intake/`. -7. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`. +7. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`. 8. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. 9. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace. 10. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`. diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 6d4134c..314d6f1 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -33,6 +33,12 @@ group = "root"; mode = "0400"; }; + age.secrets.burrowForgejoOidcClientSecret = { + file = ../../../secrets/infra/forgejo-oidc-client-secret.age; + owner = "forgejo"; + group = "forgejo"; + mode = "0440"; + }; age.secrets.burrowAuthentikGoogleClientId = { file = ../../../secrets/infra/authentik-google-client-id.age; owner = "root"; @@ -54,6 +60,7 @@ services.burrow.forge = { enable = true; adminPasswordFile = "/var/lib/burrow/intake/forgejo_pass_contact_at_burrow_net.txt"; + oidcClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; authorizedKeys = [ (builtins.readFile ../../keys/contact_at_burrow_net.pub) (builtins.readFile ../../keys/agent_at_burrow_net.pub) @@ -80,6 +87,7 @@ services.burrow.authentik = { enable = true; envFile = config.age.secrets.burrowAuthentikEnv.path; + forgejoClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 9e6bf1f..78a305a 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -8,6 +8,7 @@ let blueprintFile = "${blueprintDir}/burrow-authentik.yaml"; postgresVolume = "burrow-authentik-postgresql:/var/lib/postgresql/data"; dataVolume = "burrow-authentik-data:/data"; + forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' version: 1 @@ -102,6 +103,30 @@ in description = "Authentik provider slug for Headscale."; }; + forgejoDomain = lib.mkOption { + type = lib.types.str; + default = "git.burrow.net"; + description = "Forgejo public domain used for the bundled OIDC client."; + }; + + forgejoProviderSlug = lib.mkOption { + type = lib.types.str; + default = "git"; + description = "Authentik application slug for Forgejo."; + }; + + forgejoClientId = lib.mkOption { + type = lib.types.str; + default = "git.burrow.net"; + description = "Client ID Authentik should present to Forgejo."; + }; + + forgejoClientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Host-local file containing the Authentik Forgejo OIDC client secret."; + }; + headscaleClientSecretFile = lib.mkOption { type = lib.types.str; default = "/var/lib/burrow/intake/authentik_headscale_client_secret.txt"; @@ -182,6 +207,13 @@ in exit 1 fi + ${lib.optionalString (cfg.forgejoClientSecretFile != null) '' + if [ ! -s ${lib.escapeShellArg cfg.forgejoClientSecretFile} ]; then + echo "Forgejo client secret missing: ${cfg.forgejoClientSecretFile}" >&2 + exit 1 + fi + ''} + install -d -m 0750 -o root -g root ${runtimeDir} ${blueprintDir} install -m 0644 -o root -g root ${authentikBlueprint} ${blueprintFile} @@ -208,6 +240,7 @@ AUTHENTIK_SECRET_KEY=$AUTHENTIK_SECRET_KEY AUTHENTIK_BOOTSTRAP_PASSWORD=$AUTHENTIK_BOOTSTRAP_PASSWORD AUTHENTIK_BOOTSTRAP_TOKEN=$AUTHENTIK_BOOTSTRAP_TOKEN AUTHENTIK_BURROW_TS_CLIENT_SECRET=$(read_secret ${lib.escapeShellArg cfg.headscaleClientSecretFile}) +${lib.optionalString (cfg.forgejoClientSecretFile != null) "AUTHENTIK_BURROW_FORGEJO_CLIENT_SECRET=$(read_secret ${lib.escapeShellArg cfg.forgejoClientSecretFile})"} EOF chown root:root ${envFile} chmod 0600 ${envFile} @@ -320,8 +353,6 @@ EOF Type = "oneshot"; User = "root"; Group = "root"; - Restart = "on-failure"; - RestartSec = 5; }; script = '' set -euo pipefail @@ -340,6 +371,52 @@ EOF ''; }; + systemd.services.burrow-authentik-forgejo-oidc = lib.mkIf (cfg.forgejoClientSecretFile != null) { + description = "Reconcile the Burrow Authentik Forgejo OIDC application"; + after = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + forgejoOidcSyncScript + cfg.envFile + cfg.forgejoClientSecretFile + ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + set -a + source ${lib.escapeShellArg cfg.envFile} + set +a + + export AUTHENTIK_URL=https://${cfg.domain} + export AUTHENTIK_FORGEJO_APPLICATION_SLUG=${lib.escapeShellArg cfg.forgejoProviderSlug} + export AUTHENTIK_FORGEJO_APPLICATION_NAME=burrow.net + export AUTHENTIK_FORGEJO_PROVIDER_NAME=burrow.net + export AUTHENTIK_FORGEJO_CLIENT_ID=${lib.escapeShellArg cfg.forgejoClientId} + export AUTHENTIK_FORGEJO_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.forgejoClientSecretFile})" + export AUTHENTIK_FORGEJO_LAUNCH_URL=https://${cfg.forgejoDomain}/ + export AUTHENTIK_FORGEJO_REDIRECT_URIS_JSON='["https://${cfg.forgejoDomain}/user/oauth2/burrow.net/callback","https://${cfg.forgejoDomain}/user/oauth2/authentik/callback","https://${cfg.forgejoDomain}/user/oauth2/GitHub/callback"]' + + ${pkgs.bash}/bin/bash ${forgejoOidcSyncScript} + ''; + }; + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd reverse_proxy 127.0.0.1:${toString cfg.port} diff --git a/nixos/modules/burrow-forge.nix b/nixos/modules/burrow-forge.nix index e02475f..edf5538 100644 --- a/nixos/modules/burrow-forge.nix +++ b/nixos/modules/burrow-forge.nix @@ -68,6 +68,30 @@ in description = "Host-local path to the plaintext bootstrap password file for the initial Forgejo admin."; }; + oidcDisplayName = lib.mkOption { + type = lib.types.str; + default = "burrow.net"; + description = "Login button label for the Forgejo OIDC provider."; + }; + + oidcClientId = lib.mkOption { + type = lib.types.str; + default = "git.burrow.net"; + description = "OIDC client ID that Forgejo should use against Authentik."; + }; + + oidcClientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Host-local path to the Forgejo OIDC client secret."; + }; + + oidcDiscoveryUrl = lib.mkOption { + type = lib.types.str; + default = "https://auth.burrow.net/application/o/git/.well-known/openid-configuration"; + description = "OpenID Connect discovery URL for the Forgejo login source."; + }; + authorizedKeys = lib.mkOption { type = with lib.types; listOf str; default = [ ]; @@ -243,5 +267,113 @@ in fi ''; }; + + systemd.services.burrow-forgejo-oidc-bootstrap = lib.mkIf (cfg.oidcClientSecretFile != null) { + description = "Seed the Burrow Forgejo OIDC login source"; + after = [ + "forgejo.service" + "postgresql.service" + ] ++ lib.optionals config.services.burrow.authentik.enable [ + "burrow-authentik-ready.service" + ]; + wants = lib.optionals config.services.burrow.authentik.enable [ + "burrow-authentik-ready.service" + ]; + requires = [ + "forgejo.service" + "postgresql.service" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + cfg.oidcClientSecretFile + ]; + path = [ + pkgs.coreutils + pkgs.gnugrep + pkgs.jq + pkgs.postgresql + ]; + serviceConfig = { + Type = "oneshot"; + User = forgejoCfg.user; + Group = forgejoCfg.group; + WorkingDirectory = forgejoCfg.stateDir; + }; + script = '' + set -euo pipefail + + if [ ! -s ${lib.escapeShellArg cfg.oidcClientSecretFile} ]; then + echo "Forgejo OIDC client secret missing: ${cfg.oidcClientSecretFile}" >&2 + exit 1 + fi + + ready=0 + for attempt in $(seq 1 60); do + if ${pkgs.postgresql}/bin/psql -h /run/postgresql -U forgejo forgejo -tAc \ + "SELECT 1 FROM pg_tables WHERE schemaname='public' AND tablename='login_source';" \ + | grep -q 1; then + ready=1 + break + fi + sleep 1 + done + + if [ "$ready" -ne 1 ]; then + echo "Forgejo login_source table did not become ready" >&2 + exit 1 + fi + + oidc_secret="$(${pkgs.coreutils}/bin/tr -d '\r\n' < ${lib.escapeShellArg cfg.oidcClientSecretFile})" + if [ -z "$oidc_secret" ]; then + echo "Forgejo OIDC client secret is empty" >&2 + exit 1 + fi + + cfg_json="$(${pkgs.jq}/bin/jq -nc \ + --arg client_id ${lib.escapeShellArg cfg.oidcClientId} \ + --arg client_secret "$oidc_secret" \ + --arg discovery_url ${lib.escapeShellArg cfg.oidcDiscoveryUrl} \ + '{ + Provider: "openidConnect", + ClientID: $client_id, + ClientSecret: $client_secret, + OpenIDConnectAutoDiscoveryURL: $discovery_url, + CustomURLMapping: null, + IconURL: "", + Scopes: ["openid", "profile", "email"], + AttributeSSHPublicKey: "", + RequiredClaimName: "", + RequiredClaimValue: "", + GroupClaimName: "", + AdminGroup: "", + GroupTeamMap: "", + GroupTeamMapRemoval: false, + RestrictedGroup: "" + }')" + + ${pkgs.postgresql}/bin/psql -v ON_ERROR_STOP=1 \ + -h /run/postgresql -U forgejo forgejo \ + -v oidc_name=${lib.escapeShellArg cfg.oidcDisplayName} \ + -v cfg_json="$cfg_json" <<'SQL' + INSERT INTO login_source ( + type, name, is_active, is_sync_enabled, cfg, created_unix, updated_unix + ) VALUES ( + 6, + :'oidc_name', + TRUE, + FALSE, + :'cfg_json', + EXTRACT(EPOCH FROM NOW())::BIGINT, + EXTRACT(EPOCH FROM NOW())::BIGINT + ) + ON CONFLICT (name) DO UPDATE SET + type = EXCLUDED.type, + is_active = TRUE, + is_sync_enabled = FALSE, + cfg = EXCLUDED.cfg, + updated_unix = EXCLUDED.updated_unix; + SQL + ''; + }; }; } diff --git a/secrets.nix b/secrets.nix index c63d898..909b929 100644 --- a/secrets.nix +++ b/secrets.nix @@ -12,5 +12,6 @@ in "secrets/infra/authentik.env.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-google-client-id.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients; + "secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; } diff --git a/secrets/infra/forgejo-oidc-client-secret.age b/secrets/infra/forgejo-oidc-client-secret.age new file mode 100644 index 0000000..ce6c440 --- /dev/null +++ b/secrets/infra/forgejo-oidc-client-secret.age @@ -0,0 +1,10 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q eaJ7I0AyitRWPLXnTbaazTiQ0qv2DRKOBNwx++QVrGk +1ScGy1EN80pr6QjJCToe/YRb0yHuFDR9pjoaWI/GlW8 +-> ssh-ed25519 IrZmAg AQIz2iWOSu+ewmasAa0nRFV17grA5/IRi4NEBinKaQ8 +8QIufDokWybbiRWV/OJle7kOdomyOnXSnxJeKF+5YI8 +-> X25519 9pO0rjF27QSQ6ZOgLiWAzbCBIP3MVZSapB+udiuz400 +74Ws3sCw4O3HvoCX96UhZd6b1SMptE82z9OIuEisOu8 +--- 8UR5iYLjAo6k1A3hpwiG+/mi2ZweMDvTbvi+XMWiimA +*Z(єQ ^ܯu+.nhs=0VRF +=Ge;zm_VMark4hݑ~Y<#:> \ No newline at end of file From 1ff8270a0128d1210f559b13b97e927b14150379 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Wed, 1 Apr 2026 01:26:08 -0700 Subject: [PATCH 045/102] Advertise OIDC discovery on burrow.net --- nixos/modules/burrow-forge.nix | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nixos/modules/burrow-forge.nix b/nixos/modules/burrow-forge.nix index edf5538..890e1d3 100644 --- a/nixos/modules/burrow-forge.nix +++ b/nixos/modules/burrow-forge.nix @@ -199,6 +199,12 @@ in reverse_proxy 127.0.0.1:${toString config.services.forgejo.settings.server.HTTP_PORT} ''; "${cfg.siteDomain}".extraConfig = '' + encode gzip zstd + @oidcConfig path /.well-known/openid-configuration + redir @oidcConfig https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.forgejoProviderSlug}/.well-known/openid-configuration 308 + @webfinger path /.well-known/webfinger + header @webfinger Content-Type application/jrd+json + respond @webfinger "{\"subject\":\"{query.resource}\",\"links\":[{\"rel\":\"http://openid.net/specs/connect/1.0/issuer\",\"href\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.forgejoProviderSlug}/\"}]}" 200 @root path / redir @root ${homeRepoUrl} 308 respond 404 From bb05bd9014aa7244ccf825f22ed17ffbb9fff8fe Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Wed, 1 Apr 2026 11:39:29 -0700 Subject: [PATCH 046/102] Add Burrow Authentik admin directory sync --- Scripts/authentik-sync-burrow-directory.sh | 249 +++++++++++++++++++++ Scripts/authentik-sync-forgejo-oidc.sh | 61 ++++- nixos/hosts/burrow-forge/default.nix | 16 ++ nixos/modules/burrow-authentik.nix | 128 +++++++++++ nixos/modules/burrow-forge.nix | 41 +++- 5 files changed, 484 insertions(+), 11 deletions(-) create mode 100644 Scripts/authentik-sync-burrow-directory.sh diff --git a/Scripts/authentik-sync-burrow-directory.sh b/Scripts/authentik-sync-burrow-directory.sh new file mode 100644 index 0000000..656b738 --- /dev/null +++ b/Scripts/authentik-sync-burrow-directory.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +directory_json="${AUTHENTIK_BURROW_DIRECTORY_JSON:-[]}" +users_group="${AUTHENTIK_BURROW_USERS_GROUP:-burrow-users}" +admins_group="${AUTHENTIK_BURROW_ADMINS_GROUP:-burrow-admins}" +forgejo_application_slug="${AUTHENTIK_FORGEJO_APPLICATION_SLUG:-}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-burrow-directory.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + AUTHENTIK_BURROW_DIRECTORY_JSON + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_BURROW_USERS_GROUP + AUTHENTIK_BURROW_ADMINS_GROUP + AUTHENTIK_FORGEJO_APPLICATION_SLUG +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ -z "$bootstrap_token" ]]; then + echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 + exit 1 +fi + +if ! printf '%s' "$directory_json" | jq -e 'type == "array"' >/dev/null; then + echo "error: AUTHENTIK_BURROW_DIRECTORY_JSON must be a JSON array" >&2 + exit 1 +fi + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + + if [[ -n "$data" ]]; then + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + else + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + fi +} + +wait_for_authentik() { + for _ in $(seq 1 90); do + if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +lookup_group_pk() { + local group_name="$1" + + api GET "/api/v3/core/groups/?page_size=200&search=${group_name}" \ + | jq -r --arg name "$group_name" '.results[]? | select(.name == $name) | .pk // empty' \ + | head -n1 +} + +ensure_group() { + local group_name="$1" + local payload group_pk + + payload="$( + jq -cn \ + --arg name "$group_name" \ + '{name: $name}' + )" + + group_pk="$(lookup_group_pk "$group_name")" + if [[ -n "$group_pk" ]]; then + api PATCH "/api/v3/core/groups/${group_pk}/" "$payload" >/dev/null + else + group_pk="$( + api POST "/api/v3/core/groups/" "$payload" \ + | jq -r '.pk // empty' + )" + fi + + if [[ -z "$group_pk" ]]; then + echo "error: could not create Authentik group ${group_name}" >&2 + exit 1 + fi + + printf '%s\n' "$group_pk" +} + +lookup_user_pk() { + local username="$1" + + api GET "/api/v3/core/users/?page_size=200&search=${username}" \ + | jq -r --arg username "$username" '.results[]? | select(.username == $username) | .pk // empty' \ + | head -n1 +} + +ensure_user() { + local user_spec="$1" + local username name email is_admin groups_json effective_groups_json group_name + local group_pks_json payload user_pk + + username="$(printf '%s\n' "$user_spec" | jq -r '.username')" + name="$(printf '%s\n' "$user_spec" | jq -r '.name')" + email="$(printf '%s\n' "$user_spec" | jq -r '.email')" + is_admin="$(printf '%s\n' "$user_spec" | jq -r '.isAdmin // false')" + groups_json="$(printf '%s\n' "$user_spec" | jq -c '.groups // []')" + + if [[ -z "$username" || "$username" == "null" || -z "$email" || "$email" == "null" ]]; then + echo "error: each Burrow Authentik user requires username and email" >&2 + exit 1 + fi + + effective_groups_json="$( + printf '%s\n' "$groups_json" \ + | jq -c --arg users_group "$users_group" --arg admins_group "$admins_group" --argjson is_admin "$is_admin" ' + . + [$users_group] + (if $is_admin then [$admins_group] else [] end) | unique + ' + )" + + group_pks_json='[]' + while IFS= read -r group_name; do + group_pk="$(ensure_group "$group_name")" + group_pks_json="$( + jq -cn \ + --argjson current "$group_pks_json" \ + --arg next "$group_pk" \ + '$current + [$next]' + )" + done < <(printf '%s\n' "$effective_groups_json" | jq -r '.[]') + + payload="$( + jq -cn \ + --arg username "$username" \ + --arg name "$name" \ + --arg email "$email" \ + --argjson groups "$group_pks_json" \ + '{ + username: $username, + name: $name, + email: $email, + is_active: true, + path: "users", + groups: $groups + }' + )" + + user_pk="$(lookup_user_pk "$username")" + if [[ -n "$user_pk" ]]; then + api PATCH "/api/v3/core/users/${user_pk}/" "$payload" >/dev/null + else + user_pk="$( + api POST "/api/v3/core/users/" "$payload" \ + | jq -r '.pk // empty' + )" + fi + + if [[ -z "$user_pk" ]]; then + echo "error: could not create Authentik user ${username}" >&2 + exit 1 + fi +} + +lookup_application_pk() { + local slug="$1" + + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \ + | head -n1 +} + +ensure_application_group_binding() { + local application_slug="$1" + local group_name="$2" + local application_pk group_pk existing payload binding_pk + + application_pk="$(lookup_application_pk "$application_slug")" + if [[ -z "$application_pk" ]]; then + echo "warning: could not resolve Authentik application ${application_slug}; skipping application group binding" >&2 + return 0 + fi + + group_pk="$(lookup_group_pk "$group_name")" + if [[ -z "$group_pk" ]]; then + echo "error: could not resolve Authentik group ${group_name}" >&2 + exit 1 + fi + + existing="$( + api GET "/api/v3/policies/bindings/?page_size=200&target=${application_pk}" \ + | jq -c --arg group_pk "$group_pk" '.results[]? | select(.group == $group_pk)' \ + | head -n1 + )" + + payload="$( + jq -cn \ + --arg target "$application_pk" \ + --arg group "$group_pk" \ + '{ + group: $group, + target: $target, + negate: false, + enabled: true, + order: 100, + timeout: 30, + failure_result: false + }' + )" + + if [[ -n "$existing" ]]; then + binding_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" + api PATCH "/api/v3/policies/bindings/${binding_pk}/" "$payload" >/dev/null + else + api POST "/api/v3/policies/bindings/" "$payload" >/dev/null + fi +} + +wait_for_authentik +ensure_group "$users_group" >/dev/null +ensure_group "$admins_group" >/dev/null + +while IFS= read -r user_spec; do + ensure_user "$user_spec" +done < <(printf '%s\n' "$directory_json" | jq -c '.[]') + +if [[ -n "$forgejo_application_slug" ]]; then + ensure_application_group_binding "$forgejo_application_slug" "$users_group" +fi + +echo "Synced Burrow Authentik directory." diff --git a/Scripts/authentik-sync-forgejo-oidc.sh b/Scripts/authentik-sync-forgejo-oidc.sh index f354633..7b292dc 100644 --- a/Scripts/authentik-sync-forgejo-oidc.sh +++ b/Scripts/authentik-sync-forgejo-oidc.sh @@ -74,6 +74,41 @@ api() { fi } +api_with_status() { + local method="$1" + local path="$2" + local data="${3:-}" + local response_file status + + response_file="$(mktemp)" + trap 'rm -f "$response_file"' RETURN + + if [[ -n "$data" ]]; then + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + )" + else + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + )" + fi + + printf '%s\n' "$status" + cat "$response_file" +} + wait_for_authentik() { for _ in $(seq 1 90); do if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then @@ -106,7 +141,6 @@ signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')" provider_payload="$( jq -n \ --arg name "$provider_name" \ - --arg slug "$application_slug" \ --arg authorization_flow "$authorization_flow" \ --arg invalidation_flow "$invalidation_flow" \ --arg client_id "$client_id" \ @@ -116,7 +150,6 @@ provider_payload="$( --argjson redirect_uris "$redirect_uris_json" \ '{ name: $name, - slug: $slug, authorization_flow: $authorization_flow, invalidation_flow: $invalidation_flow, client_type: "confidential", @@ -172,18 +205,32 @@ application_payload="$( )" existing_application="$( - api GET "/api/v3/core/applications/?slug=${application_slug}" \ - | jq -c '.results[]? | select(.slug != null)' \ + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \ | head -n1 )" if [[ -n "$existing_application" ]]; then application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" else - application_pk="$( - api POST "/api/v3/core/applications/" "$application_payload" \ - | jq -r '.pk // empty' + create_application_result="$( + api_with_status POST "/api/v3/core/applications/" "$application_payload" )" + create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')" + create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')" + + if [[ "$create_application_status" =~ ^20[01]$ ]]; then + application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')" + elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e ' + (.slug // [] | index("Application with this slug already exists.")) != null + or (.provider // [] | index("Application with this provider already exists.")) != null + ' >/dev/null; then + application_pk="existing-duplicate" + else + printf '%s\n' "$create_application_body" >&2 + echo "error: could not reconcile Authentik application ${application_slug}" >&2 + exit 1 + fi fi if [[ -z "${application_pk:-}" ]]; then diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 314d6f1..76b0ef5 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -91,6 +91,22 @@ headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; + bootstrapUsers = [ + { + username = "contact"; + name = "Burrow"; + email = "contact@burrow.net"; + sourceEmail = "net.burrow@gmail.com"; + isAdmin = true; + } + { + username = "conrad"; + name = "Conrad Kramer"; + email = "conrad@burrow.net"; + sourceEmail = "ckrames1234@gmail.com"; + isAdmin = true; + } + ]; }; services.burrow.headscale = { diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 78a305a..4e31d43 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -8,6 +8,7 @@ let blueprintFile = "${blueprintDir}/burrow-authentik.yaml"; postgresVolume = "burrow-authentik-postgresql:/var/lib/postgresql/data"; dataVolume = "burrow-authentik-data:/data"; + directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh; forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' @@ -31,6 +32,19 @@ let "email_verified": True, } + - model: authentik_providers_oauth2.scopemapping + id: burrow-oidc-groups + identifiers: + name: Burrow OIDC Groups + attrs: + name: Burrow OIDC Groups + scope_name: groups + description: Group membership mapping for Burrow + expression: | + return { + "groups": [group.name for group in request.user.ak_groups.all()], + } + - model: authentik_providers_oauth2.oauth2provider id: burrow-oidc-provider-ts identifiers: @@ -50,6 +64,7 @@ let property_mappings: - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] - !KeyOf burrow-oidc-email + - !KeyOf burrow-oidc-groups - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]] signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] @@ -159,6 +174,54 @@ in default = "redirect"; description = "Identification-stage behavior for the Google Authentik source."; }; + + userGroupName = lib.mkOption { + type = lib.types.str; + default = "burrow-users"; + description = "Authentik group granted baseline Burrow access."; + }; + + adminGroupName = lib.mkOption { + type = lib.types.str; + default = "burrow-admins"; + description = "Authentik group granted Burrow administrator access."; + }; + + bootstrapUsers = lib.mkOption { + type = with lib.types; listOf (submodule { + options = { + username = lib.mkOption { + type = str; + description = "Authentik username."; + }; + name = lib.mkOption { + type = str; + description = "Display name for the user."; + }; + email = lib.mkOption { + type = str; + description = "Canonical email stored in Authentik."; + }; + sourceEmail = lib.mkOption { + type = nullOr str; + default = null; + description = "External Google account email that should map onto this Authentik user."; + }; + groups = lib.mkOption { + type = listOf str; + default = [ ]; + description = "Additional Authentik groups for this user."; + }; + isAdmin = lib.mkOption { + type = bool; + default = false; + description = "Whether this user should be in the Burrow admin group."; + }; + }; + }); + default = [ ]; + description = "Declarative Burrow users to create in Authentik."; + }; }; config = lib.mkIf cfg.enable { @@ -295,6 +358,16 @@ EOF ]; }; + systemd.services.podman-burrow-authentik-server.restartTriggers = [ + blueprintFile + envFile + ]; + + systemd.services.podman-burrow-authentik-worker.restartTriggers = [ + blueprintFile + envFile + ]; + systemd.services.burrow-authentik-ready = { description = "Wait for Burrow Authentik to become ready"; after = [ "podman-burrow-authentik-server.service" ]; @@ -366,11 +439,66 @@ EOF export AUTHENTIK_GOOGLE_USER_MATCHING_MODE=email_link export AUTHENTIK_GOOGLE_CLIENT_ID="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.googleClientIDFile})" export AUTHENTIK_GOOGLE_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.googleClientSecretFile})" + export AUTHENTIK_GOOGLE_ACCOUNT_MAP_JSON='${builtins.toJSON (map (user: { + source_email = user.sourceEmail; + username = user.username; + email = user.email; + name = user.name; + }) (lib.filter (user: user.sourceEmail != null) cfg.bootstrapUsers))}' ${pkgs.bash}/bin/bash ${googleSourceSyncScript} ''; }; + systemd.services.burrow-authentik-directory = lib.mkIf (cfg.bootstrapUsers != [ ]) { + description = "Reconcile Burrow Authentik users and groups"; + after = + [ + "burrow-authentik-ready.service" + "network-online.target" + ] + ++ lib.optionals (cfg.forgejoClientSecretFile != null) [ "burrow-authentik-forgejo-oidc.service" ]; + wants = + [ + "burrow-authentik-ready.service" + "network-online.target" + ] + ++ lib.optionals (cfg.forgejoClientSecretFile != null) [ "burrow-authentik-forgejo-oidc.service" ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + directorySyncScript + cfg.envFile + ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + set -a + source ${lib.escapeShellArg cfg.envFile} + set +a + + export AUTHENTIK_URL=https://${cfg.domain} + export AUTHENTIK_BURROW_USERS_GROUP=${lib.escapeShellArg cfg.userGroupName} + export AUTHENTIK_BURROW_ADMINS_GROUP=${lib.escapeShellArg cfg.adminGroupName} + export AUTHENTIK_FORGEJO_APPLICATION_SLUG=${lib.escapeShellArg cfg.forgejoProviderSlug} + export AUTHENTIK_BURROW_DIRECTORY_JSON='${builtins.toJSON (map (user: { + inherit (user) username name email isAdmin; + groups = user.groups; + }) cfg.bootstrapUsers)}' + + ${pkgs.bash}/bin/bash ${directorySyncScript} + ''; + }; + systemd.services.burrow-authentik-forgejo-oidc = lib.mkIf (cfg.forgejoClientSecretFile != null) { description = "Reconcile the Burrow Authentik Forgejo OIDC application"; after = [ diff --git a/nixos/modules/burrow-forge.nix b/nixos/modules/burrow-forge.nix index 890e1d3..e2a57e0 100644 --- a/nixos/modules/burrow-forge.nix +++ b/nixos/modules/burrow-forge.nix @@ -92,6 +92,35 @@ in description = "OpenID Connect discovery URL for the Forgejo login source."; }; + oidcScopes = lib.mkOption { + type = with lib.types; listOf str; + default = [ + "openid" + "profile" + "email" + "groups" + ]; + description = "OIDC scopes requested from Authentik."; + }; + + oidcGroupClaimName = lib.mkOption { + type = lib.types.str; + default = "groups"; + description = "OIDC claim name that carries group membership."; + }; + + oidcAdminGroup = lib.mkOption { + type = lib.types.str; + default = "burrow-admins"; + description = "OIDC group that should grant Forgejo admin access."; + }; + + oidcRestrictedGroup = lib.mkOption { + type = lib.types.str; + default = "burrow-users"; + description = "OIDC group that is required to log into Forgejo."; + }; + authorizedKeys = lib.mkOption { type = with lib.types; listOf str; default = [ ]; @@ -339,6 +368,10 @@ in --arg client_id ${lib.escapeShellArg cfg.oidcClientId} \ --arg client_secret "$oidc_secret" \ --arg discovery_url ${lib.escapeShellArg cfg.oidcDiscoveryUrl} \ + --argjson scopes '${builtins.toJSON cfg.oidcScopes}' \ + --arg group_claim_name ${lib.escapeShellArg cfg.oidcGroupClaimName} \ + --arg admin_group ${lib.escapeShellArg cfg.oidcAdminGroup} \ + --arg restricted_group ${lib.escapeShellArg cfg.oidcRestrictedGroup} \ '{ Provider: "openidConnect", ClientID: $client_id, @@ -346,15 +379,15 @@ in OpenIDConnectAutoDiscoveryURL: $discovery_url, CustomURLMapping: null, IconURL: "", - Scopes: ["openid", "profile", "email"], + Scopes: $scopes, AttributeSSHPublicKey: "", RequiredClaimName: "", RequiredClaimValue: "", - GroupClaimName: "", - AdminGroup: "", + GroupClaimName: $group_claim_name, + AdminGroup: $admin_group, GroupTeamMap: "", GroupTeamMapRemoval: false, - RestrictedGroup: "" + RestrictedGroup: $restricted_group }')" ${pkgs.postgresql}/bin/psql -v ON_ERROR_STOP=1 \ From 3332bf5c53c244eae3867449c7b2ec8908798231 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Wed, 1 Apr 2026 13:43:47 -0700 Subject: [PATCH 047/102] Fix Forgejo OIDC account linking --- nixos/modules/burrow-forge.nix | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/nixos/modules/burrow-forge.nix b/nixos/modules/burrow-forge.nix index e2a57e0..d238f2e 100644 --- a/nixos/modules/burrow-forge.nix +++ b/nixos/modules/burrow-forge.nix @@ -121,6 +121,24 @@ in description = "OIDC group that is required to log into Forgejo."; }; + oidcAutoRegistration = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether Forgejo should automatically create users for new OIDC sign-ins."; + }; + + oidcAccountLinking = lib.mkOption { + type = lib.types.enum [ "disabled" "login" "auto" ]; + default = "auto"; + description = "How Forgejo should link existing local accounts for OIDC sign-ins."; + }; + + oidcUsernameSource = lib.mkOption { + type = lib.types.enum [ "userid" "nickname" "email" ]; + default = "email"; + description = "Which OIDC claim Forgejo should use to derive usernames for auto-registration."; + }; + authorizedKeys = lib.mkOption { type = with lib.types; listOf str; default = [ ]; @@ -201,6 +219,13 @@ in ENABLE_OPENID_SIGNUP = false; }; + oauth2_client = { + OPENID_CONNECT_SCOPES = lib.concatStringsSep " " (lib.subtractLists [ "openid" ] cfg.oidcScopes); + ENABLE_AUTO_REGISTRATION = cfg.oidcAutoRegistration; + ACCOUNT_LINKING = cfg.oidcAccountLinking; + USERNAME = cfg.oidcUsernameSource; + }; + actions = { ENABLED = true; }; From 72b7f1467b18bd1bb376134ad13fa54e2f041c7b Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Thu, 2 Apr 2026 21:44:10 -0700 Subject: [PATCH 048/102] Disable Forgejo local password sign-in --- nixos/hosts/burrow-forge/default.nix | 1 + nixos/modules/burrow-forge.nix | 3 +++ 2 files changed, 4 insertions(+) diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 76b0ef5..d612ea8 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -91,6 +91,7 @@ headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; + googleLoginMode = "redirect"; bootstrapUsers = [ { username = "contact"; diff --git a/nixos/modules/burrow-forge.nix b/nixos/modules/burrow-forge.nix index d238f2e..51af7eb 100644 --- a/nixos/modules/burrow-forge.nix +++ b/nixos/modules/burrow-forge.nix @@ -203,6 +203,9 @@ in service = { DISABLE_REGISTRATION = true; + ENABLE_INTERNAL_SIGNIN = false; + ENABLE_BASIC_AUTHENTICATION = false; + SHOW_REGISTRATION_BUTTON = false; REQUIRE_SIGNIN_VIEW = false; DEFAULT_ALLOW_CREATE_ORGANIZATION = false; ENABLE_NOTIFY_MAIL = false; From baf1408060597229ff3cf0082cf783cbe3ff064f Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Fri, 3 Apr 2026 00:17:12 -0700 Subject: [PATCH 049/102] Add Tailnet landing page --- nixos/modules/burrow-headscale.nix | 134 ++++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 1 deletion(-) diff --git a/nixos/modules/burrow-headscale.nix b/nixos/modules/burrow-headscale.nix index ad5ec68..98cf5ba 100644 --- a/nixos/modules/burrow-headscale.nix +++ b/nixos/modules/burrow-headscale.nix @@ -3,6 +3,131 @@ let cfg = config.services.burrow.headscale; policyFile = ./burrow-headscale-policy.hujson; + landingPage = pkgs.writeTextDir "index.html" '' + + + + + + Burrow Tailnet + + + +
+
Burrow Tailnet
+
+

Sign-in starts from your client, not this page.

+

+ ts.burrow.net is the Burrow Headscale control plane. Headscale does not provide a built-in web UI, + so browser authentication starts only after a Tailscale-compatible client initiates login. +

+
+
tailscale up --login-server https://ts.burrow.net
+ +
+ + + ''; in { options.services.burrow.headscale = { @@ -221,7 +346,14 @@ in services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd - reverse_proxy 127.0.0.1:${toString cfg.port} + @root path / + handle @root { + root * ${landingPage} + file_server + } + handle { + reverse_proxy 127.0.0.1:${toString cfg.port} + } ''; }; } From 1da00ecdf3126cc33bd718efd00a72deb39d610f Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Fri, 3 Apr 2026 00:42:39 -0700 Subject: [PATCH 050/102] Add email-based tailnet discovery to Apple app --- Apple/Burrow.xcodeproj/project.pbxproj | 36 +- Apple/Core/Client/Generated/burrow.grpc.swift | 761 ++++++++++++++++++ Apple/Core/Client/Generated/burrow.pb.swift | 566 +++++++++++++ Apple/Core/Client/grpc-swift-config.json | 11 - Apple/Core/Client/swift-protobuf-config.json | 10 - Apple/UI/BurrowView.swift | 175 +++- Apple/UI/Networks/Network.swift | 46 +- burrow/src/auth/server/mod.rs | 39 +- burrow/src/control/discovery.rs | 212 +++++ burrow/src/control/mod.rs | 2 + nixos/modules/burrow-forge.nix | 5 +- nixos/modules/burrow-headscale.nix | 134 +-- 12 files changed, 1784 insertions(+), 213 deletions(-) create mode 100644 Apple/Core/Client/Generated/burrow.grpc.swift create mode 100644 Apple/Core/Client/Generated/burrow.pb.swift delete mode 100644 Apple/Core/Client/grpc-swift-config.json delete mode 100644 Apple/Core/Client/swift-protobuf-config.json create mode 100644 burrow/src/control/discovery.rs diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index 995af28..9897f79 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -42,8 +42,8 @@ D0D4E5A62C8D9E65007F820A /* BurrowCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; }; D0F4FAD32C8DC79C0068730A /* BurrowCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; }; D0F7594E2C8DAB6B00126CF3 /* GRPC in Frameworks */ = {isa = PBXBuildFile; productRef = D078F7E02C8DA375008A8CEC /* GRPC */; }; - D0F759612C8DB24B00126CF3 /* grpc-swift-config.json in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4962C8D921A007F820A /* grpc-swift-config.json */; }; - D0F759622C8DB24B00126CF3 /* swift-protobuf-config.json in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4972C8D921A007F820A /* swift-protobuf-config.json */; }; + D0FA10012D10200100112233 /* burrow.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA10032D10200100112233 /* burrow.pb.swift */; }; + D0FA10022D10200100112233 /* burrow.grpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA10042D10200100112233 /* burrow.grpc.swift */; }; D0F7597E2C8DB30500126CF3 /* CGRPCZlib in Frameworks */ = {isa = PBXBuildFile; productRef = D0F7597D2C8DB30500126CF3 /* CGRPCZlib */; }; D0F7598D2C8DB3DA00126CF3 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4992C8D921A007F820A /* Client.swift */; }; /* End PBXBuildFile section */ @@ -154,8 +154,6 @@ D0BCC6032A09535900AD070D /* libburrow.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libburrow.a; sourceTree = BUILT_PRODUCTS_DIR; }; D0BF09582C8E6789000D8DEC /* UI.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = UI.xcconfig; sourceTree = ""; }; D0D4E4952C8D921A007F820A /* burrow.proto */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.protobuf; path = burrow.proto; sourceTree = ""; }; - D0D4E4962C8D921A007F820A /* grpc-swift-config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "grpc-swift-config.json"; sourceTree = ""; }; - D0D4E4972C8D921A007F820A /* swift-protobuf-config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "swift-protobuf-config.json"; sourceTree = ""; }; D0D4E4992C8D921A007F820A /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = ""; }; D0D4E49A2C8D921A007F820A /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; }; D0D4E49E2C8D921A007F820A /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = ""; }; @@ -179,6 +177,8 @@ D0D4E58E2C8D9D0A007F820A /* Constants.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Constants.h; sourceTree = ""; }; D0D4E58F2C8D9D0A007F820A /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; D0D4E5902C8D9D0A007F820A /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = ""; }; + D0FA10032D10200100112233 /* burrow.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/burrow.pb.swift; sourceTree = ""; }; + D0FA10042D10200100112233 /* burrow.grpc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/burrow.grpc.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -317,8 +317,8 @@ isa = PBXGroup; children = ( D0D4E4952C8D921A007F820A /* burrow.proto */, - D0D4E4962C8D921A007F820A /* grpc-swift-config.json */, - D0D4E4972C8D921A007F820A /* swift-protobuf-config.json */, + D0FA10032D10200100112233 /* burrow.pb.swift */, + D0FA10042D10200100112233 /* burrow.grpc.swift */, ); path = Client; sourceTree = ""; @@ -428,8 +428,6 @@ ); dependencies = ( D0F7598A2C8DB34200126CF3 /* PBXTargetDependency */, - D0F7595E2C8DB24400126CF3 /* PBXTargetDependency */, - D0F759602C8DB24400126CF3 /* PBXTargetDependency */, ); name = Core; packageProductDependencies = ( @@ -617,8 +615,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D0F759612C8DB24B00126CF3 /* grpc-swift-config.json in Sources */, - D0F759622C8DB24B00126CF3 /* swift-protobuf-config.json in Sources */, + D0FA10012D10200100112233 /* burrow.pb.swift in Sources */, + D0FA10022D10200100112233 /* burrow.grpc.swift in Sources */, D0F7598D2C8DB3DA00126CF3 /* Client.swift in Sources */, D0D4E56B2C8D9C2F007F820A /* Logging.swift in Sources */, ); @@ -689,14 +687,6 @@ target = D0D4E5302C8D996F007F820A /* Core */; targetProxy = D0F4FAD12C8DC7960068730A /* PBXContainerItemProxy */; }; - D0F7595E2C8DB24400126CF3 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - productRef = D0F7595D2C8DB24400126CF3 /* GRPCSwiftPlugin */; - }; - D0F759602C8DB24400126CF3 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - productRef = D0F7595F2C8DB24400126CF3 /* SwiftProtobufPlugin */; - }; D0F7598A2C8DB34200126CF3 /* PBXTargetDependency */ = { isa = PBXTargetDependency; productRef = D0F759892C8DB34200126CF3 /* GRPC */; @@ -921,16 +911,6 @@ package = D0B1D10E2C436152004B7823 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */; productName = AsyncAlgorithms; }; - D0F7595D2C8DB24400126CF3 /* GRPCSwiftPlugin */ = { - isa = XCSwiftPackageProductDependency; - package = D0D4E4822C8D8EF6007F820A /* XCRemoteSwiftPackageReference "grpc-swift" */; - productName = "plugin:GRPCSwiftPlugin"; - }; - D0F7595F2C8DB24400126CF3 /* SwiftProtobufPlugin */ = { - isa = XCSwiftPackageProductDependency; - package = D0D4E4852C8D8F29007F820A /* XCRemoteSwiftPackageReference "swift-protobuf" */; - productName = "plugin:SwiftProtobufPlugin"; - }; D0F7597D2C8DB30500126CF3 /* CGRPCZlib */ = { isa = XCSwiftPackageProductDependency; package = D0D4E4822C8D8EF6007F820A /* XCRemoteSwiftPackageReference "grpc-swift" */; diff --git a/Apple/Core/Client/Generated/burrow.grpc.swift b/Apple/Core/Client/Generated/burrow.grpc.swift new file mode 100644 index 0000000..d1f848c --- /dev/null +++ b/Apple/Core/Client/Generated/burrow.grpc.swift @@ -0,0 +1,761 @@ +// +// DO NOT EDIT. +// swift-format-ignore-file +// +// Generated by the protocol buffer compiler. +// Source: burrow.proto +// +import GRPC +import NIO +import NIOConcurrencyHelpers +import SwiftProtobuf + + +/// Usage: instantiate `Burrow_TunnelClient`, then call methods of this protocol to make API calls. +public protocol Burrow_TunnelClientProtocol: GRPCClient { + var serviceName: String { get } + var interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? { get } + + func tunnelConfiguration( + _ request: Burrow_Empty, + callOptions: CallOptions?, + handler: @escaping (Burrow_TunnelConfigurationResponse) -> Void + ) -> ServerStreamingCall + + func tunnelStart( + _ request: Burrow_Empty, + callOptions: CallOptions? + ) -> UnaryCall + + func tunnelStop( + _ request: Burrow_Empty, + callOptions: CallOptions? + ) -> UnaryCall + + func tunnelStatus( + _ request: Burrow_Empty, + callOptions: CallOptions?, + handler: @escaping (Burrow_TunnelStatusResponse) -> Void + ) -> ServerStreamingCall +} + +extension Burrow_TunnelClientProtocol { + public var serviceName: String { + return "burrow.Tunnel" + } + + /// Server streaming call to TunnelConfiguration + /// + /// - Parameters: + /// - request: Request to send to TunnelConfiguration. + /// - callOptions: Call options. + /// - handler: A closure called when each response is received from the server. + /// - Returns: A `ServerStreamingCall` with futures for the metadata and status. + public func tunnelConfiguration( + _ request: Burrow_Empty, + callOptions: CallOptions? = nil, + handler: @escaping (Burrow_TunnelConfigurationResponse) -> Void + ) -> ServerStreamingCall { + return self.makeServerStreamingCall( + path: Burrow_TunnelClientMetadata.Methods.tunnelConfiguration.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeTunnelConfigurationInterceptors() ?? [], + handler: handler + ) + } + + /// Unary call to TunnelStart + /// + /// - Parameters: + /// - request: Request to send to TunnelStart. + /// - callOptions: Call options. + /// - Returns: A `UnaryCall` with futures for the metadata, status and response. + public func tunnelStart( + _ request: Burrow_Empty, + callOptions: CallOptions? = nil + ) -> UnaryCall { + return self.makeUnaryCall( + path: Burrow_TunnelClientMetadata.Methods.tunnelStart.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeTunnelStartInterceptors() ?? [] + ) + } + + /// Unary call to TunnelStop + /// + /// - Parameters: + /// - request: Request to send to TunnelStop. + /// - callOptions: Call options. + /// - Returns: A `UnaryCall` with futures for the metadata, status and response. + public func tunnelStop( + _ request: Burrow_Empty, + callOptions: CallOptions? = nil + ) -> UnaryCall { + return self.makeUnaryCall( + path: Burrow_TunnelClientMetadata.Methods.tunnelStop.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeTunnelStopInterceptors() ?? [] + ) + } + + /// Server streaming call to TunnelStatus + /// + /// - Parameters: + /// - request: Request to send to TunnelStatus. + /// - callOptions: Call options. + /// - handler: A closure called when each response is received from the server. + /// - Returns: A `ServerStreamingCall` with futures for the metadata and status. + public func tunnelStatus( + _ request: Burrow_Empty, + callOptions: CallOptions? = nil, + handler: @escaping (Burrow_TunnelStatusResponse) -> Void + ) -> ServerStreamingCall { + return self.makeServerStreamingCall( + path: Burrow_TunnelClientMetadata.Methods.tunnelStatus.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeTunnelStatusInterceptors() ?? [], + handler: handler + ) + } +} + +@available(*, deprecated) +extension Burrow_TunnelClient: @unchecked Sendable {} + +@available(*, deprecated, renamed: "Burrow_TunnelNIOClient") +public final class Burrow_TunnelClient: Burrow_TunnelClientProtocol { + private let lock = Lock() + private var _defaultCallOptions: CallOptions + private var _interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? + public let channel: GRPCChannel + public var defaultCallOptions: CallOptions { + get { self.lock.withLock { return self._defaultCallOptions } } + set { self.lock.withLockVoid { self._defaultCallOptions = newValue } } + } + public var interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? { + get { self.lock.withLock { return self._interceptors } } + set { self.lock.withLockVoid { self._interceptors = newValue } } + } + + /// Creates a client for the burrow.Tunnel service. + /// + /// - Parameters: + /// - channel: `GRPCChannel` to the service host. + /// - defaultCallOptions: Options to use for each service call if the user doesn't provide them. + /// - interceptors: A factory providing interceptors for each RPC. + public init( + channel: GRPCChannel, + defaultCallOptions: CallOptions = CallOptions(), + interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? = nil + ) { + self.channel = channel + self._defaultCallOptions = defaultCallOptions + self._interceptors = interceptors + } +} + +public struct Burrow_TunnelNIOClient: Burrow_TunnelClientProtocol { + public var channel: GRPCChannel + public var defaultCallOptions: CallOptions + public var interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? + + /// Creates a client for the burrow.Tunnel service. + /// + /// - Parameters: + /// - channel: `GRPCChannel` to the service host. + /// - defaultCallOptions: Options to use for each service call if the user doesn't provide them. + /// - interceptors: A factory providing interceptors for each RPC. + public init( + channel: GRPCChannel, + defaultCallOptions: CallOptions = CallOptions(), + interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? = nil + ) { + self.channel = channel + self.defaultCallOptions = defaultCallOptions + self.interceptors = interceptors + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +public protocol Burrow_TunnelAsyncClientProtocol: GRPCClient { + static var serviceDescriptor: GRPCServiceDescriptor { get } + var interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? { get } + + func makeTunnelConfigurationCall( + _ request: Burrow_Empty, + callOptions: CallOptions? + ) -> GRPCAsyncServerStreamingCall + + func makeTunnelStartCall( + _ request: Burrow_Empty, + callOptions: CallOptions? + ) -> GRPCAsyncUnaryCall + + func makeTunnelStopCall( + _ request: Burrow_Empty, + callOptions: CallOptions? + ) -> GRPCAsyncUnaryCall + + func makeTunnelStatusCall( + _ request: Burrow_Empty, + callOptions: CallOptions? + ) -> GRPCAsyncServerStreamingCall +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension Burrow_TunnelAsyncClientProtocol { + public static var serviceDescriptor: GRPCServiceDescriptor { + return Burrow_TunnelClientMetadata.serviceDescriptor + } + + public var interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? { + return nil + } + + public func makeTunnelConfigurationCall( + _ request: Burrow_Empty, + callOptions: CallOptions? = nil + ) -> GRPCAsyncServerStreamingCall { + return self.makeAsyncServerStreamingCall( + path: Burrow_TunnelClientMetadata.Methods.tunnelConfiguration.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeTunnelConfigurationInterceptors() ?? [] + ) + } + + public func makeTunnelStartCall( + _ request: Burrow_Empty, + callOptions: CallOptions? = nil + ) -> GRPCAsyncUnaryCall { + return self.makeAsyncUnaryCall( + path: Burrow_TunnelClientMetadata.Methods.tunnelStart.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeTunnelStartInterceptors() ?? [] + ) + } + + public func makeTunnelStopCall( + _ request: Burrow_Empty, + callOptions: CallOptions? = nil + ) -> GRPCAsyncUnaryCall { + return self.makeAsyncUnaryCall( + path: Burrow_TunnelClientMetadata.Methods.tunnelStop.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeTunnelStopInterceptors() ?? [] + ) + } + + public func makeTunnelStatusCall( + _ request: Burrow_Empty, + callOptions: CallOptions? = nil + ) -> GRPCAsyncServerStreamingCall { + return self.makeAsyncServerStreamingCall( + path: Burrow_TunnelClientMetadata.Methods.tunnelStatus.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeTunnelStatusInterceptors() ?? [] + ) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension Burrow_TunnelAsyncClientProtocol { + public func tunnelConfiguration( + _ request: Burrow_Empty, + callOptions: CallOptions? = nil + ) -> GRPCAsyncResponseStream { + return self.performAsyncServerStreamingCall( + path: Burrow_TunnelClientMetadata.Methods.tunnelConfiguration.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeTunnelConfigurationInterceptors() ?? [] + ) + } + + public func tunnelStart( + _ request: Burrow_Empty, + callOptions: CallOptions? = nil + ) async throws -> Burrow_Empty { + return try await self.performAsyncUnaryCall( + path: Burrow_TunnelClientMetadata.Methods.tunnelStart.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeTunnelStartInterceptors() ?? [] + ) + } + + public func tunnelStop( + _ request: Burrow_Empty, + callOptions: CallOptions? = nil + ) async throws -> Burrow_Empty { + return try await self.performAsyncUnaryCall( + path: Burrow_TunnelClientMetadata.Methods.tunnelStop.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeTunnelStopInterceptors() ?? [] + ) + } + + public func tunnelStatus( + _ request: Burrow_Empty, + callOptions: CallOptions? = nil + ) -> GRPCAsyncResponseStream { + return self.performAsyncServerStreamingCall( + path: Burrow_TunnelClientMetadata.Methods.tunnelStatus.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeTunnelStatusInterceptors() ?? [] + ) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +public struct Burrow_TunnelAsyncClient: Burrow_TunnelAsyncClientProtocol { + public var channel: GRPCChannel + public var defaultCallOptions: CallOptions + public var interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? + + public init( + channel: GRPCChannel, + defaultCallOptions: CallOptions = CallOptions(), + interceptors: Burrow_TunnelClientInterceptorFactoryProtocol? = nil + ) { + self.channel = channel + self.defaultCallOptions = defaultCallOptions + self.interceptors = interceptors + } +} + +public protocol Burrow_TunnelClientInterceptorFactoryProtocol: Sendable { + + /// - Returns: Interceptors to use when invoking 'tunnelConfiguration'. + func makeTunnelConfigurationInterceptors() -> [ClientInterceptor] + + /// - Returns: Interceptors to use when invoking 'tunnelStart'. + func makeTunnelStartInterceptors() -> [ClientInterceptor] + + /// - Returns: Interceptors to use when invoking 'tunnelStop'. + func makeTunnelStopInterceptors() -> [ClientInterceptor] + + /// - Returns: Interceptors to use when invoking 'tunnelStatus'. + func makeTunnelStatusInterceptors() -> [ClientInterceptor] +} + +public enum Burrow_TunnelClientMetadata { + public static let serviceDescriptor = GRPCServiceDescriptor( + name: "Tunnel", + fullName: "burrow.Tunnel", + methods: [ + Burrow_TunnelClientMetadata.Methods.tunnelConfiguration, + Burrow_TunnelClientMetadata.Methods.tunnelStart, + Burrow_TunnelClientMetadata.Methods.tunnelStop, + Burrow_TunnelClientMetadata.Methods.tunnelStatus, + ] + ) + + public enum Methods { + public static let tunnelConfiguration = GRPCMethodDescriptor( + name: "TunnelConfiguration", + path: "/burrow.Tunnel/TunnelConfiguration", + type: GRPCCallType.serverStreaming + ) + + public static let tunnelStart = GRPCMethodDescriptor( + name: "TunnelStart", + path: "/burrow.Tunnel/TunnelStart", + type: GRPCCallType.unary + ) + + public static let tunnelStop = GRPCMethodDescriptor( + name: "TunnelStop", + path: "/burrow.Tunnel/TunnelStop", + type: GRPCCallType.unary + ) + + public static let tunnelStatus = GRPCMethodDescriptor( + name: "TunnelStatus", + path: "/burrow.Tunnel/TunnelStatus", + type: GRPCCallType.serverStreaming + ) + } +} + +/// Usage: instantiate `Burrow_NetworksClient`, then call methods of this protocol to make API calls. +public protocol Burrow_NetworksClientProtocol: GRPCClient { + var serviceName: String { get } + var interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? { get } + + func networkAdd( + _ request: Burrow_Network, + callOptions: CallOptions? + ) -> UnaryCall + + func networkList( + _ request: Burrow_Empty, + callOptions: CallOptions?, + handler: @escaping (Burrow_NetworkListResponse) -> Void + ) -> ServerStreamingCall + + func networkReorder( + _ request: Burrow_NetworkReorderRequest, + callOptions: CallOptions? + ) -> UnaryCall + + func networkDelete( + _ request: Burrow_NetworkDeleteRequest, + callOptions: CallOptions? + ) -> UnaryCall +} + +extension Burrow_NetworksClientProtocol { + public var serviceName: String { + return "burrow.Networks" + } + + /// Unary call to NetworkAdd + /// + /// - Parameters: + /// - request: Request to send to NetworkAdd. + /// - callOptions: Call options. + /// - Returns: A `UnaryCall` with futures for the metadata, status and response. + public func networkAdd( + _ request: Burrow_Network, + callOptions: CallOptions? = nil + ) -> UnaryCall { + return self.makeUnaryCall( + path: Burrow_NetworksClientMetadata.Methods.networkAdd.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeNetworkAddInterceptors() ?? [] + ) + } + + /// Server streaming call to NetworkList + /// + /// - Parameters: + /// - request: Request to send to NetworkList. + /// - callOptions: Call options. + /// - handler: A closure called when each response is received from the server. + /// - Returns: A `ServerStreamingCall` with futures for the metadata and status. + public func networkList( + _ request: Burrow_Empty, + callOptions: CallOptions? = nil, + handler: @escaping (Burrow_NetworkListResponse) -> Void + ) -> ServerStreamingCall { + return self.makeServerStreamingCall( + path: Burrow_NetworksClientMetadata.Methods.networkList.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeNetworkListInterceptors() ?? [], + handler: handler + ) + } + + /// Unary call to NetworkReorder + /// + /// - Parameters: + /// - request: Request to send to NetworkReorder. + /// - callOptions: Call options. + /// - Returns: A `UnaryCall` with futures for the metadata, status and response. + public func networkReorder( + _ request: Burrow_NetworkReorderRequest, + callOptions: CallOptions? = nil + ) -> UnaryCall { + return self.makeUnaryCall( + path: Burrow_NetworksClientMetadata.Methods.networkReorder.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeNetworkReorderInterceptors() ?? [] + ) + } + + /// Unary call to NetworkDelete + /// + /// - Parameters: + /// - request: Request to send to NetworkDelete. + /// - callOptions: Call options. + /// - Returns: A `UnaryCall` with futures for the metadata, status and response. + public func networkDelete( + _ request: Burrow_NetworkDeleteRequest, + callOptions: CallOptions? = nil + ) -> UnaryCall { + return self.makeUnaryCall( + path: Burrow_NetworksClientMetadata.Methods.networkDelete.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeNetworkDeleteInterceptors() ?? [] + ) + } +} + +@available(*, deprecated) +extension Burrow_NetworksClient: @unchecked Sendable {} + +@available(*, deprecated, renamed: "Burrow_NetworksNIOClient") +public final class Burrow_NetworksClient: Burrow_NetworksClientProtocol { + private let lock = Lock() + private var _defaultCallOptions: CallOptions + private var _interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? + public let channel: GRPCChannel + public var defaultCallOptions: CallOptions { + get { self.lock.withLock { return self._defaultCallOptions } } + set { self.lock.withLockVoid { self._defaultCallOptions = newValue } } + } + public var interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? { + get { self.lock.withLock { return self._interceptors } } + set { self.lock.withLockVoid { self._interceptors = newValue } } + } + + /// Creates a client for the burrow.Networks service. + /// + /// - Parameters: + /// - channel: `GRPCChannel` to the service host. + /// - defaultCallOptions: Options to use for each service call if the user doesn't provide them. + /// - interceptors: A factory providing interceptors for each RPC. + public init( + channel: GRPCChannel, + defaultCallOptions: CallOptions = CallOptions(), + interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? = nil + ) { + self.channel = channel + self._defaultCallOptions = defaultCallOptions + self._interceptors = interceptors + } +} + +public struct Burrow_NetworksNIOClient: Burrow_NetworksClientProtocol { + public var channel: GRPCChannel + public var defaultCallOptions: CallOptions + public var interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? + + /// Creates a client for the burrow.Networks service. + /// + /// - Parameters: + /// - channel: `GRPCChannel` to the service host. + /// - defaultCallOptions: Options to use for each service call if the user doesn't provide them. + /// - interceptors: A factory providing interceptors for each RPC. + public init( + channel: GRPCChannel, + defaultCallOptions: CallOptions = CallOptions(), + interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? = nil + ) { + self.channel = channel + self.defaultCallOptions = defaultCallOptions + self.interceptors = interceptors + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +public protocol Burrow_NetworksAsyncClientProtocol: GRPCClient { + static var serviceDescriptor: GRPCServiceDescriptor { get } + var interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? { get } + + func makeNetworkAddCall( + _ request: Burrow_Network, + callOptions: CallOptions? + ) -> GRPCAsyncUnaryCall + + func makeNetworkListCall( + _ request: Burrow_Empty, + callOptions: CallOptions? + ) -> GRPCAsyncServerStreamingCall + + func makeNetworkReorderCall( + _ request: Burrow_NetworkReorderRequest, + callOptions: CallOptions? + ) -> GRPCAsyncUnaryCall + + func makeNetworkDeleteCall( + _ request: Burrow_NetworkDeleteRequest, + callOptions: CallOptions? + ) -> GRPCAsyncUnaryCall +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension Burrow_NetworksAsyncClientProtocol { + public static var serviceDescriptor: GRPCServiceDescriptor { + return Burrow_NetworksClientMetadata.serviceDescriptor + } + + public var interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? { + return nil + } + + public func makeNetworkAddCall( + _ request: Burrow_Network, + callOptions: CallOptions? = nil + ) -> GRPCAsyncUnaryCall { + return self.makeAsyncUnaryCall( + path: Burrow_NetworksClientMetadata.Methods.networkAdd.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeNetworkAddInterceptors() ?? [] + ) + } + + public func makeNetworkListCall( + _ request: Burrow_Empty, + callOptions: CallOptions? = nil + ) -> GRPCAsyncServerStreamingCall { + return self.makeAsyncServerStreamingCall( + path: Burrow_NetworksClientMetadata.Methods.networkList.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeNetworkListInterceptors() ?? [] + ) + } + + public func makeNetworkReorderCall( + _ request: Burrow_NetworkReorderRequest, + callOptions: CallOptions? = nil + ) -> GRPCAsyncUnaryCall { + return self.makeAsyncUnaryCall( + path: Burrow_NetworksClientMetadata.Methods.networkReorder.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeNetworkReorderInterceptors() ?? [] + ) + } + + public func makeNetworkDeleteCall( + _ request: Burrow_NetworkDeleteRequest, + callOptions: CallOptions? = nil + ) -> GRPCAsyncUnaryCall { + return self.makeAsyncUnaryCall( + path: Burrow_NetworksClientMetadata.Methods.networkDelete.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeNetworkDeleteInterceptors() ?? [] + ) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension Burrow_NetworksAsyncClientProtocol { + public func networkAdd( + _ request: Burrow_Network, + callOptions: CallOptions? = nil + ) async throws -> Burrow_Empty { + return try await self.performAsyncUnaryCall( + path: Burrow_NetworksClientMetadata.Methods.networkAdd.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeNetworkAddInterceptors() ?? [] + ) + } + + public func networkList( + _ request: Burrow_Empty, + callOptions: CallOptions? = nil + ) -> GRPCAsyncResponseStream { + return self.performAsyncServerStreamingCall( + path: Burrow_NetworksClientMetadata.Methods.networkList.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeNetworkListInterceptors() ?? [] + ) + } + + public func networkReorder( + _ request: Burrow_NetworkReorderRequest, + callOptions: CallOptions? = nil + ) async throws -> Burrow_Empty { + return try await self.performAsyncUnaryCall( + path: Burrow_NetworksClientMetadata.Methods.networkReorder.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeNetworkReorderInterceptors() ?? [] + ) + } + + public func networkDelete( + _ request: Burrow_NetworkDeleteRequest, + callOptions: CallOptions? = nil + ) async throws -> Burrow_Empty { + return try await self.performAsyncUnaryCall( + path: Burrow_NetworksClientMetadata.Methods.networkDelete.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeNetworkDeleteInterceptors() ?? [] + ) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +public struct Burrow_NetworksAsyncClient: Burrow_NetworksAsyncClientProtocol { + public var channel: GRPCChannel + public var defaultCallOptions: CallOptions + public var interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? + + public init( + channel: GRPCChannel, + defaultCallOptions: CallOptions = CallOptions(), + interceptors: Burrow_NetworksClientInterceptorFactoryProtocol? = nil + ) { + self.channel = channel + self.defaultCallOptions = defaultCallOptions + self.interceptors = interceptors + } +} + +public protocol Burrow_NetworksClientInterceptorFactoryProtocol: Sendable { + + /// - Returns: Interceptors to use when invoking 'networkAdd'. + func makeNetworkAddInterceptors() -> [ClientInterceptor] + + /// - Returns: Interceptors to use when invoking 'networkList'. + func makeNetworkListInterceptors() -> [ClientInterceptor] + + /// - Returns: Interceptors to use when invoking 'networkReorder'. + func makeNetworkReorderInterceptors() -> [ClientInterceptor] + + /// - Returns: Interceptors to use when invoking 'networkDelete'. + func makeNetworkDeleteInterceptors() -> [ClientInterceptor] +} + +public enum Burrow_NetworksClientMetadata { + public static let serviceDescriptor = GRPCServiceDescriptor( + name: "Networks", + fullName: "burrow.Networks", + methods: [ + Burrow_NetworksClientMetadata.Methods.networkAdd, + Burrow_NetworksClientMetadata.Methods.networkList, + Burrow_NetworksClientMetadata.Methods.networkReorder, + Burrow_NetworksClientMetadata.Methods.networkDelete, + ] + ) + + public enum Methods { + public static let networkAdd = GRPCMethodDescriptor( + name: "NetworkAdd", + path: "/burrow.Networks/NetworkAdd", + type: GRPCCallType.unary + ) + + public static let networkList = GRPCMethodDescriptor( + name: "NetworkList", + path: "/burrow.Networks/NetworkList", + type: GRPCCallType.serverStreaming + ) + + public static let networkReorder = GRPCMethodDescriptor( + name: "NetworkReorder", + path: "/burrow.Networks/NetworkReorder", + type: GRPCCallType.unary + ) + + public static let networkDelete = GRPCMethodDescriptor( + name: "NetworkDelete", + path: "/burrow.Networks/NetworkDelete", + type: GRPCCallType.unary + ) + } +} + diff --git a/Apple/Core/Client/Generated/burrow.pb.swift b/Apple/Core/Client/Generated/burrow.pb.swift new file mode 100644 index 0000000..bba0f16 --- /dev/null +++ b/Apple/Core/Client/Generated/burrow.pb.swift @@ -0,0 +1,566 @@ +// DO NOT EDIT. +// swift-format-ignore-file +// swiftlint:disable all +// +// Generated by the Swift generator plugin for the protocol buffer compiler. +// Source: burrow.proto +// +// For information on using the generated types, please see the documentation: +// https://github.com/apple/swift-protobuf/ + +import Foundation +import SwiftProtobuf + +// If the compiler emits an error on this type, it is because this file +// was generated by a version of the `protoc` Swift plug-in that is +// incompatible with the version of SwiftProtobuf to which you are linking. +// Please ensure that you are building against the same version of the API +// that was used to generate this file. +fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { + struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} + typealias Version = _2 +} + +public enum Burrow_NetworkType: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int + case wireGuard // = 0 + case tailnet // = 1 + case UNRECOGNIZED(Int) + + public init() { + self = .wireGuard + } + + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .wireGuard + case 1: self = .tailnet + default: self = .UNRECOGNIZED(rawValue) + } + } + + public var rawValue: Int { + switch self { + case .wireGuard: return 0 + case .tailnet: return 1 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [Burrow_NetworkType] = [ + .wireGuard, + .tailnet, + ] + +} + +public enum Burrow_State: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int + case stopped // = 0 + case running // = 1 + case UNRECOGNIZED(Int) + + public init() { + self = .stopped + } + + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .stopped + case 1: self = .running + default: self = .UNRECOGNIZED(rawValue) + } + } + + public var rawValue: Int { + switch self { + case .stopped: return 0 + case .running: return 1 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [Burrow_State] = [ + .stopped, + .running, + ] + +} + +public struct Burrow_NetworkReorderRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var id: Int32 = 0 + + public var index: Int32 = 0 + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_WireGuardPeer: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var endpoint: String = String() + + public var subnet: [String] = [] + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_WireGuardNetwork: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var address: String = String() + + public var dns: String = String() + + public var peer: [Burrow_WireGuardPeer] = [] + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_NetworkDeleteRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var id: Int32 = 0 + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_Network: @unchecked Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var id: Int32 = 0 + + public var type: Burrow_NetworkType = .wireGuard + + public var payload: Data = Data() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_NetworkListResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var network: [Burrow_Network] = [] + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_Empty: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_TunnelStatusResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var state: Burrow_State = .stopped + + public var start: SwiftProtobuf.Google_Protobuf_Timestamp { + get {return _start ?? SwiftProtobuf.Google_Protobuf_Timestamp()} + set {_start = newValue} + } + /// Returns true if `start` has been explicitly set. + public var hasStart: Bool {return self._start != nil} + /// Clears the value of `start`. Subsequent reads from it will return its default value. + public mutating func clearStart() {self._start = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _start: SwiftProtobuf.Google_Protobuf_Timestamp? = nil +} + +public struct Burrow_TunnelConfigurationResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var addresses: [String] = [] + + public var mtu: Int32 = 0 + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +// MARK: - Code below here is support for the SwiftProtobuf runtime. + +fileprivate let _protobuf_package = "burrow" + +extension Burrow_NetworkType: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 0: .same(proto: "WireGuard"), + 1: .same(proto: "Tailnet"), + ] +} + +extension Burrow_State: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 0: .same(proto: "Stopped"), + 1: .same(proto: "Running"), + ] +} + +extension Burrow_NetworkReorderRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".NetworkReorderRequest" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "id"), + 2: .same(proto: "index"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularInt32Field(value: &self.id) }() + case 2: try { try decoder.decodeSingularInt32Field(value: &self.index) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.id != 0 { + try visitor.visitSingularInt32Field(value: self.id, fieldNumber: 1) + } + if self.index != 0 { + try visitor.visitSingularInt32Field(value: self.index, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Burrow_NetworkReorderRequest, rhs: Burrow_NetworkReorderRequest) -> Bool { + if lhs.id != rhs.id {return false} + if lhs.index != rhs.index {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Burrow_WireGuardPeer: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".WireGuardPeer" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "endpoint"), + 2: .same(proto: "subnet"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.endpoint) }() + case 2: try { try decoder.decodeRepeatedStringField(value: &self.subnet) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.endpoint.isEmpty { + try visitor.visitSingularStringField(value: self.endpoint, fieldNumber: 1) + } + if !self.subnet.isEmpty { + try visitor.visitRepeatedStringField(value: self.subnet, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Burrow_WireGuardPeer, rhs: Burrow_WireGuardPeer) -> Bool { + if lhs.endpoint != rhs.endpoint {return false} + if lhs.subnet != rhs.subnet {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Burrow_WireGuardNetwork: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".WireGuardNetwork" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "address"), + 2: .same(proto: "dns"), + 3: .same(proto: "peer"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.address) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.dns) }() + case 3: try { try decoder.decodeRepeatedMessageField(value: &self.peer) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.address.isEmpty { + try visitor.visitSingularStringField(value: self.address, fieldNumber: 1) + } + if !self.dns.isEmpty { + try visitor.visitSingularStringField(value: self.dns, fieldNumber: 2) + } + if !self.peer.isEmpty { + try visitor.visitRepeatedMessageField(value: self.peer, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Burrow_WireGuardNetwork, rhs: Burrow_WireGuardNetwork) -> Bool { + if lhs.address != rhs.address {return false} + if lhs.dns != rhs.dns {return false} + if lhs.peer != rhs.peer {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Burrow_NetworkDeleteRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".NetworkDeleteRequest" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "id"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularInt32Field(value: &self.id) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.id != 0 { + try visitor.visitSingularInt32Field(value: self.id, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Burrow_NetworkDeleteRequest, rhs: Burrow_NetworkDeleteRequest) -> Bool { + if lhs.id != rhs.id {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Burrow_Network: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".Network" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "id"), + 2: .same(proto: "type"), + 3: .same(proto: "payload"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularInt32Field(value: &self.id) }() + case 2: try { try decoder.decodeSingularEnumField(value: &self.type) }() + case 3: try { try decoder.decodeSingularBytesField(value: &self.payload) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.id != 0 { + try visitor.visitSingularInt32Field(value: self.id, fieldNumber: 1) + } + if self.type != .wireGuard { + try visitor.visitSingularEnumField(value: self.type, fieldNumber: 2) + } + if !self.payload.isEmpty { + try visitor.visitSingularBytesField(value: self.payload, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Burrow_Network, rhs: Burrow_Network) -> Bool { + if lhs.id != rhs.id {return false} + if lhs.type != rhs.type {return false} + if lhs.payload != rhs.payload {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Burrow_NetworkListResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".NetworkListResponse" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "network"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeRepeatedMessageField(value: &self.network) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.network.isEmpty { + try visitor.visitRepeatedMessageField(value: self.network, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Burrow_NetworkListResponse, rhs: Burrow_NetworkListResponse) -> Bool { + if lhs.network != rhs.network {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Burrow_Empty: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".Empty" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Burrow_Empty, rhs: Burrow_Empty) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Burrow_TunnelStatusResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".TunnelStatusResponse" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "state"), + 2: .same(proto: "start"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.state) }() + case 2: try { try decoder.decodeSingularMessageField(value: &self._start) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if self.state != .stopped { + try visitor.visitSingularEnumField(value: self.state, fieldNumber: 1) + } + try { if let v = self._start { + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Burrow_TunnelStatusResponse, rhs: Burrow_TunnelStatusResponse) -> Bool { + if lhs.state != rhs.state {return false} + if lhs._start != rhs._start {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".TunnelConfigurationResponse" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "addresses"), + 2: .same(proto: "mtu"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeRepeatedStringField(value: &self.addresses) }() + case 2: try { try decoder.decodeSingularInt32Field(value: &self.mtu) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.addresses.isEmpty { + try visitor.visitRepeatedStringField(value: self.addresses, fieldNumber: 1) + } + if self.mtu != 0 { + try visitor.visitSingularInt32Field(value: self.mtu, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Burrow_TunnelConfigurationResponse, rhs: Burrow_TunnelConfigurationResponse) -> Bool { + if lhs.addresses != rhs.addresses {return false} + if lhs.mtu != rhs.mtu {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/Apple/Core/Client/grpc-swift-config.json b/Apple/Core/Client/grpc-swift-config.json deleted file mode 100644 index 2d89698..0000000 --- a/Apple/Core/Client/grpc-swift-config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "invocations": [ - { - "protoFiles": [ - "burrow.proto", - ], - "server": false, - "visibility": "public" - } - ] -} diff --git a/Apple/Core/Client/swift-protobuf-config.json b/Apple/Core/Client/swift-protobuf-config.json deleted file mode 100644 index 87aaec3..0000000 --- a/Apple/Core/Client/swift-protobuf-config.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "invocations": [ - { - "protoFiles": [ - "burrow.proto", - ], - "visibility": "public" - } - ] -} diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index 835510d..b4fa7d8 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -284,6 +284,7 @@ private struct AccountDraft { var identityName = "" var wireGuardConfig = "" + var discoveryEmail = "" var tailnetProvider: TailnetProvider = .tailscale var authority = "" var tailnet = "" @@ -327,6 +328,9 @@ private struct ConfigurationSheetView: View { @State private var errorMessage: String? @State private var loginSessionID: String? @State private var loginStatus: TailnetLoginStatus? + @State private var discoveryStatus: TailnetDiscoveryResponse? + @State private var discoveryError: String? + @State private var isDiscoveringTailnet = false @State private var authorityProbeStatus: TailnetAuthorityProbeStatus? @State private var authorityProbeError: String? @State private var isProbingAuthority = false @@ -449,6 +453,9 @@ private struct ConfigurationSheetView: View { .onChange(of: draft.authority) { _, _ in resetAuthorityProbe() } + .onChange(of: draft.discoveryEmail) { _, _ in + resetTailnetDiscoveryFeedback() + } .onDisappear { pollingTask?.cancel() webAuthenticationTask?.cancel() @@ -459,7 +466,37 @@ private struct ConfigurationSheetView: View { @ViewBuilder private var tailnetSections: some View { Section("Connection") { - Picker("Provider", selection: $draft.tailnetProvider) { + TextField("Email address", text: $draft.discoveryEmail) + .textInputAutocapitalization(.never) + .keyboardType(.emailAddress) + .burrowLoginField() + .autocorrectionDisabled() + + Button { + discoverTailnetAuthority() + } label: { + Label { + Text(isDiscoveringTailnet ? "Finding Server" : "Find Server") + } icon: { + Image(systemName: isDiscoveringTailnet ? "hourglass" : "at.circle") + } + } + .buttonStyle(.borderless) + .disabled(isDiscoveringTailnet || normalizedOptional(draft.discoveryEmail) == nil) + + if let discoveryStatus { + tailnetDiscoveryCard(status: discoveryStatus, failure: nil) + } else if let discoveryError { + tailnetDiscoveryCard(status: nil, failure: discoveryError) + } + + Picker( + "Provider", + selection: Binding( + get: { draft.tailnetProvider }, + set: { applyTailnetProvider($0) } + ) + ) { ForEach(TailnetProvider.allCases) { provider in Text(provider.title).tag(provider) } @@ -503,14 +540,14 @@ private struct ConfigurationSheetView: View { } Section("Authentication") { - if draft.tailnetProvider.usesWebLogin { + if tailnetUsesWebLogin { tailnetWebLoginCard } else { TextField("Username", text: $draft.username) .burrowLoginField() .autocorrectionDisabled() Picker("Authentication", selection: $draft.authMode) { - ForEach([AccountAuthMode.none, .password, .preauthKey]) { mode in + ForEach(availableTailnetAuthModes) { mode in Text(mode.title).tag(mode) } } @@ -583,7 +620,7 @@ private struct ConfigurationSheetView: View { HStack(spacing: 8) { summaryBadge(draft.tailnetProvider.title) summaryBadge( - draft.tailnetProvider.usesWebLogin ? "Web Sign-In" : draft.authMode.title + tailnetUsesWebLogin ? "Web Sign-In" : draft.authMode.title ) } } @@ -656,7 +693,7 @@ private struct ConfigurationSheetView: View { .foregroundStyle(.secondary) } } else { - Text("Burrow launches the local bridge, then opens the real Tailscale sign-in page in-app.") + Text("Burrow launches the local bridge, then opens the real provider sign-in page in-app.") .font(.footnote) .foregroundStyle(.secondary) } @@ -696,6 +733,41 @@ private struct ConfigurationSheetView: View { ) } + private func tailnetDiscoveryCard( + status: TailnetDiscoveryResponse?, + failure: String? + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + if let status { + Text("Discovered \(status.provider.title)") + .font(.subheadline.weight(.medium)) + Text(status.authority) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + if let oidcIssuer = status.oidcIssuer { + Text("OIDC: \(oidcIssuer)") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(3) + .textSelection(.enabled) + } + } else if let failure { + Text("Discovery failed") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.red) + Text(failure) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.thinMaterial) + ) + } + private func summaryBadge(_ label: String) -> some View { Text(label) .font(.caption.weight(.medium)) @@ -762,12 +834,12 @@ private struct ConfigurationSheetView: View { } } - if !draft.tailnetProvider.usesWebLogin { + if availableTailnetAuthModes.count > 1 { Menu("Authentication") { - ForEach([AccountAuthMode.none, .password, .preauthKey]) { mode in + ForEach(availableTailnetAuthModes) { mode in Button(mode.title) { draft.authMode = mode - if mode == .none { + if mode == .none || mode == .web { draft.secret = "" } } @@ -848,7 +920,7 @@ private struct ConfigurationSheetView: View { case .tor: return "Save Account" case .tailnet: - if draft.tailnetProvider.usesWebLogin { + if tailnetUsesWebLogin { return loginStatus?.running == true ? "Save Account" : "Start Sign-In" } return "Save Account" @@ -865,12 +937,12 @@ private struct ConfigurationSheetView: View { if normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil { return true } - if draft.tailnetProvider.usesWebLogin { - return false - } if draft.tailnetProvider.requiresControlURL && normalizedOptional(draft.authority) == nil { return true } + if tailnetUsesWebLogin { + return false + } if draft.authMode != .none && normalizedOptional(draft.secret) == nil { return true } @@ -955,14 +1027,14 @@ private struct ConfigurationSheetView: View { } private func submitTailnet() async throws { - if draft.tailnetProvider.usesWebLogin { + if tailnetUsesWebLogin { if loginStatus?.running == true { webAuthenticationTask?.cancel() webAuthenticationTask = nil try await saveTailnetAccount(secret: nil, username: nil) dismiss() } else { - try await startTailscaleLogin() + try await startTailnetLogin() } return } @@ -973,13 +1045,13 @@ private struct ConfigurationSheetView: View { dismiss() } - private func startTailscaleLogin() async throws { + private func startTailnetLogin() async throws { let response = try await TailnetBridgeClient.startLogin( TailnetLoginStartRequest( accountName: normalized(draft.accountName, fallback: "default"), identityName: normalized(draft.identityName, fallback: "apple"), hostname: normalizedOptional(draft.hostname), - controlURL: draft.tailnetProvider.defaultAuthority + controlURL: normalizedOptional(draft.authority) ?? draft.tailnetProvider.defaultAuthority ) ) loginSessionID = response.sessionID @@ -1010,7 +1082,7 @@ private struct ConfigurationSheetView: View { case .tailnetLogin: draft.tailnetProvider = .tailscale do { - try await startTailscaleLogin() + try await startTailnetLogin() } catch { errorMessage = error.localizedDescription } @@ -1078,14 +1150,14 @@ private struct ConfigurationSheetView: View { let provider = draft.tailnetProvider let title = titleOrFallback( hostnameFallback( - from: provider.usesWebLogin ? (loginStatus?.tailnetName ?? "") : draft.authority, + from: tailnetUsesWebLogin ? (loginStatus?.tailnetName ?? "") : draft.authority, fallback: provider.title ) ) let payload = TailnetNetworkPayload( provider: provider, - authority: normalizedOptional(provider.defaultAuthority ?? draft.authority), + authority: normalizedOptional(draft.authority) ?? normalizedOptional(provider.defaultAuthority ?? ""), account: normalized(draft.accountName, fallback: "default"), identity: normalized(draft.identityName, fallback: "apple"), tailnet: normalizedOptional(loginStatus?.tailnetName ?? draft.tailnet), @@ -1094,7 +1166,7 @@ private struct ConfigurationSheetView: View { var noteParts: [String] = [ provider.title, - provider.usesWebLogin + tailnetUsesWebLogin ? "State: \(loginStatus?.backendState ?? "NeedsLogin")" : "Auth: \(draft.authMode.title)", ] @@ -1123,7 +1195,7 @@ private struct ConfigurationSheetView: View { hostname: payload.hostname, username: username, tailnet: payload.tailnet, - authMode: provider.usesWebLogin ? .web : draft.authMode, + authMode: tailnetUsesWebLogin ? .web : draft.authMode, note: noteParts.joined(separator: " • "), createdAt: .now, updatedAt: .now @@ -1155,18 +1227,25 @@ private struct ConfigurationSheetView: View { } private func applyTailnetProvider(_ provider: TailnetProvider) { + resetTailnetDiscoveryFeedback() draft.tailnetProvider = provider applyTailnetDefaults(for: provider) } private func applyTailnetDefaults(for provider: TailnetProvider) { draft.authority = provider.defaultAuthority ?? "" - if provider.usesWebLogin { + loginStatus = nil + loginSessionID = nil + pollingTask?.cancel() + if provider == .tailscale { draft.authMode = .web draft.username = "" draft.secret = "" } else { - if draft.authMode == .web { + if !availableTailnetAuthModes.contains(draft.authMode) { + draft.authMode = provider.supportsWebLogin ? .web : .none + } + if draft.authMode == .web && !provider.supportsWebLogin { draft.authMode = .none } } @@ -1202,6 +1281,41 @@ private struct ConfigurationSheetView: View { authorityProbeError = nil } + private func resetTailnetDiscoveryFeedback() { + discoveryStatus = nil + discoveryError = nil + } + + private func discoverTailnetAuthority() { + guard let email = normalizedOptional(draft.discoveryEmail) else { + discoveryStatus = nil + discoveryError = "Enter an email address first." + return + } + + isDiscoveringTailnet = true + discoveryStatus = nil + discoveryError = nil + + Task { @MainActor in + defer { isDiscoveringTailnet = false } + do { + let discovery = try await TailnetDiscoveryClient.discover(email: email) + discoveryStatus = discovery + draft.tailnetProvider = discovery.provider + draft.authority = discovery.authority + if discovery.provider.supportsWebLogin, discovery.oidcIssuer != nil { + draft.authMode = .web + draft.username = "" + draft.secret = "" + } + probeTailnetAuthority() + } catch { + discoveryError = error.localizedDescription + } + } + } + private func pasteWireGuardConfiguration() { guard let clipboardString else { return } draft.wireGuardConfig = clipboardString @@ -1247,6 +1361,21 @@ private struct ConfigurationSheetView: View { return host } + private var tailnetUsesWebLogin: Bool { + draft.authMode == .web && draft.tailnetProvider.supportsWebLogin + } + + private var availableTailnetAuthModes: [AccountAuthMode] { + switch draft.tailnetProvider { + case .tailscale: + [.web] + case .headscale: + [.web, .none, .password, .preauthKey] + case .burrow: + [.none, .password, .preauthKey] + } + } + @ViewBuilder private func labeledValue(_ label: String, _ value: String) -> some View { VStack(alignment: .leading, spacing: 2) { diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift index 71e5bca..9a534ce 100644 --- a/Apple/UI/Networks/Network.swift +++ b/Apple/UI/Networks/Network.swift @@ -33,6 +33,13 @@ struct TailnetLoginStartRequest: Codable, Sendable { var controlURL: String? } +struct TailnetDiscoveryResponse: Codable, Sendable { + var domain: String + var provider: TailnetProvider + var authority: String + var oidcIssuer: String? +} + struct TailnetLoginStatus: Codable, Sendable { var backendState: String var authURL: String? @@ -91,7 +98,7 @@ enum TailnetBridgeClient { return try decoder.decode(TailnetLoginStatus.self, from: data) } - private static func validate(response: URLResponse, data: Data) throws { + fileprivate static func validate(response: URLResponse, data: Data) throws { guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } @@ -104,6 +111,32 @@ enum TailnetBridgeClient { } } +enum TailnetDiscoveryClient { + private static let baseURL = URL(string: "http://127.0.0.1:8080")! + + static func discover(email: String) async throws -> TailnetDiscoveryResponse { + guard var components = URLComponents( + url: baseURL.appendingPathComponent("v1/tailnet/discover"), + resolvingAgainstBaseURL: false + ) else { + throw URLError(.badURL) + } + components.queryItems = [ + URLQueryItem(name: "email", value: email) + ] + guard let url = components.url else { + throw URLError(.badURL) + } + + let (data, response) = try await URLSession.shared.data(from: url) + try TailnetBridgeClient.validate(response: response, data: data) + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(TailnetDiscoveryResponse.self, from: data) + } +} + enum TailnetAuthorityProbeClient { static func probe(provider: TailnetProvider, authority: String) async throws -> TailnetAuthorityProbeStatus { let normalizedAuthority = normalizeAuthority(authority) @@ -308,8 +341,13 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable { } } - var usesWebLogin: Bool { - self == .tailscale + var supportsWebLogin: Bool { + switch self { + case .tailscale, .headscale: + true + case .burrow: + false + } } var requiresControlURL: Bool { @@ -332,7 +370,7 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable { case .tailscale: "Use Tailscale's real browser login flow." case .headscale: - "Store a Headscale control-plane endpoint and credentials." + "Use your Headscale control plane with browser or key-based sign-in." case .burrow: "Store Burrow control-plane credentials." } diff --git a/burrow/src/auth/server/mod.rs b/burrow/src/auth/server/mod.rs index b0c0522..fdffce3 100644 --- a/burrow/src/auth/server/mod.rs +++ b/burrow/src/auth/server/mod.rs @@ -5,17 +5,18 @@ use std::{env, path::Path}; use anyhow::{Context, Result}; use axum::{ - extract::{Json, Path as AxumPath, State}, + extract::{Json, Path as AxumPath, Query, State}, http::{header::AUTHORIZATION, HeaderMap, StatusCode}, response::IntoResponse, routing::{get, post}, Router, }; +use serde::Deserialize; use tokio::signal; use crate::control::{ - LocalAuthRequest, LocalAuthResponse, MapRequest, MapResponse, RegisterRequest, - RegisterResponse, BURROW_TAILNET_DOMAIN, + discovery, LocalAuthRequest, LocalAuthResponse, MapRequest, MapResponse, RegisterRequest, + RegisterResponse, TailnetDiscovery, BURROW_TAILNET_DOMAIN, }; #[derive(Clone, Debug)] @@ -105,6 +106,11 @@ struct AppState { tailscale: tailscale::TailscaleBridgeManager, } +#[derive(Debug, Deserialize)] +struct TailnetDiscoveryQuery { + email: String, +} + type AppResult = Result; pub async fn serve() -> Result<()> { @@ -139,6 +145,7 @@ pub fn build_router(config: AuthServerConfig) -> Router { .route("/v1/auth/login", post(login_local)) .route("/v1/control/register", post(control_register)) .route("/v1/control/map", post(control_map)) + .route("/v1/tailnet/discover", get(tailnet_discover)) .route("/v1/tailscale/login/start", post(tailscale_login_start)) .route("/v1/tailscale/login/:session_id", get(tailscale_login_status)) .with_state(AppState { @@ -205,6 +212,19 @@ async fn control_map( Ok(Json(response)) } +async fn tailnet_discover( + Query(query): Query, +) -> AppResult> { + if query.email.trim().is_empty() { + return Err((StatusCode::BAD_REQUEST, "email is required".to_owned())); + } + + let discovery = discovery::discover_tailnet(&query.email) + .await + .map_err(|err| (StatusCode::BAD_GATEWAY, err.to_string()))?; + Ok(Json(discovery)) +} + async fn tailscale_login_start( State(state): State, Json(request): Json, @@ -394,4 +414,17 @@ mod tests { assert!(map.dns.expect("dns").magic_dns); Ok(()) } + + #[tokio::test] + async fn tailnet_discover_requires_email() -> Result<()> { + let app = build_router(AuthServerConfig::default()); + let response = app + .oneshot( + Request::get("/v1/tailnet/discover?email=") + .body(Body::empty())?, + ) + .await?; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + Ok(()) + } } diff --git a/burrow/src/control/discovery.rs b/burrow/src/control/discovery.rs new file mode 100644 index 0000000..28b48bb --- /dev/null +++ b/burrow/src/control/discovery.rs @@ -0,0 +1,212 @@ +use anyhow::{anyhow, Context, Result}; +use reqwest::{Client, StatusCode, Url}; +use serde::{Deserialize, Serialize}; + +use super::TailnetProvider; + +pub const TAILNET_DISCOVERY_REL: &str = "https://burrow.net/rel/tailnet-control-server"; +const TAILNET_DISCOVERY_PATH: &str = "/.well-known/burrow-tailnet"; +const WEBFINGER_PATH: &str = "/.well-known/webfinger"; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct TailnetDiscovery { + pub domain: String, + pub provider: TailnetProvider, + pub authority: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oidc_issuer: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct WebFingerDocument { + #[serde(default)] + links: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct WebFingerLink { + #[serde(default)] + rel: String, + #[serde(default)] + href: Option, +} + +pub async fn discover_tailnet(email: &str) -> Result { + let domain = email_domain(email)?; + let base_url = Url::parse(&format!("https://{domain}")) + .with_context(|| format!("invalid discovery domain {domain}"))?; + let client = Client::builder() + .user_agent("burrow-tailnet-discovery") + .timeout(std::time::Duration::from_secs(10)) + .build() + .context("failed to build tailnet discovery client")?; + discover_tailnet_at(&client, email, &base_url).await +} + +pub async fn discover_tailnet_at( + client: &Client, + email: &str, + base_url: &Url, +) -> Result { + let domain = email_domain(email)?; + + if let Some(discovery) = discover_well_known(client, base_url).await? { + return Ok(TailnetDiscovery { domain, ..discovery }); + } + + if let Some(authority) = discover_webfinger(client, email, base_url).await? { + return Ok(TailnetDiscovery { + domain, + provider: TailnetProvider::Headscale, + authority, + oidc_issuer: None, + }); + } + + Err(anyhow!("no tailnet discovery metadata found for {domain}")) +} + +pub fn email_domain(email: &str) -> Result { + let trimmed = email.trim(); + let (_, domain) = trimmed + .rsplit_once('@') + .ok_or_else(|| anyhow!("email address must include a domain"))?; + let domain = domain.trim().trim_matches('.').to_ascii_lowercase(); + if domain.is_empty() { + return Err(anyhow!("email address must include a domain")); + } + Ok(domain) +} + +async fn discover_well_known(client: &Client, base_url: &Url) -> Result> { + let url = base_url + .join(TAILNET_DISCOVERY_PATH) + .context("failed to build tailnet discovery URL")?; + let response = client + .get(url) + .header("accept", "application/json") + .send() + .await + .context("tailnet well-known request failed")?; + + match response.status() { + StatusCode::OK => response + .json::() + .await + .context("invalid tailnet discovery document") + .map(Some), + StatusCode::NOT_FOUND => Ok(None), + status => Err(anyhow!("tailnet well-known lookup failed with HTTP {status}")), + } +} + +async fn discover_webfinger(client: &Client, email: &str, base_url: &Url) -> Result> { + let mut url = base_url + .join(WEBFINGER_PATH) + .context("failed to build webfinger URL")?; + url.query_pairs_mut() + .append_pair("resource", &format!("acct:{email}")) + .append_pair("rel", TAILNET_DISCOVERY_REL); + + let response = client + .get(url) + .header("accept", "application/jrd+json, application/json") + .send() + .await + .context("tailnet webfinger request failed")?; + + match response.status() { + StatusCode::OK => { + let document = response + .json::() + .await + .context("invalid webfinger document")?; + Ok(document + .links + .into_iter() + .find(|link| link.rel == TAILNET_DISCOVERY_REL) + .and_then(|link| link.href) + .filter(|href| !href.trim().is_empty())) + } + StatusCode::NOT_FOUND => Ok(None), + status => Err(anyhow!("tailnet webfinger lookup failed with HTTP {status}")), + } +} + +#[cfg(test)] +mod tests { + use axum::{routing::get, Router}; + use serde_json::json; + use tokio::net::TcpListener; + + use super::*; + + #[test] + fn extracts_domain_from_email() { + assert_eq!(email_domain("Contact@Burrow.net").unwrap(), "burrow.net"); + assert!(email_domain("contact").is_err()); + } + + #[tokio::test] + async fn discovers_from_well_known_document() -> Result<()> { + let router = Router::new().route( + TAILNET_DISCOVERY_PATH, + get(|| async { + axum::Json(json!({ + "domain": "burrow.net", + "provider": "headscale", + "authority": "https://ts.burrow.net", + "oidc_issuer": "https://auth.burrow.net/application/o/ts/" + })) + }), + ); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let base_url = Url::parse(&format!("http://{}", listener.local_addr()?))?; + let server = tokio::spawn(async move { axum::serve(listener, router).await }); + + let client = Client::builder().build()?; + let discovery = discover_tailnet_at(&client, "contact@burrow.net", &base_url).await?; + assert_eq!(discovery.provider, TailnetProvider::Headscale); + assert_eq!(discovery.authority, "https://ts.burrow.net"); + assert_eq!(discovery.domain, "burrow.net"); + + server.abort(); + Ok(()) + } + + #[tokio::test] + async fn falls_back_to_webfinger_authority() -> Result<()> { + let router = Router::new() + .route( + TAILNET_DISCOVERY_PATH, + get(|| async { (StatusCode::NOT_FOUND, "") }), + ) + .route( + WEBFINGER_PATH, + get(|| async { + axum::Json(json!({ + "subject": "acct:contact@burrow.net", + "links": [ + { + "rel": TAILNET_DISCOVERY_REL, + "href": "https://ts.burrow.net" + } + ] + })) + }), + ); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let base_url = Url::parse(&format!("http://{}", listener.local_addr()?))?; + let server = tokio::spawn(async move { axum::serve(listener, router).await }); + + let client = Client::builder().build()?; + let discovery = discover_tailnet_at(&client, "contact@burrow.net", &base_url).await?; + assert_eq!(discovery.provider, TailnetProvider::Headscale); + assert_eq!(discovery.authority, "https://ts.burrow.net"); + + server.abort(); + Ok(()) + } +} diff --git a/burrow/src/control/mod.rs b/burrow/src/control/mod.rs index 331a7d2..472f673 100644 --- a/burrow/src/control/mod.rs +++ b/burrow/src/control/mod.rs @@ -1,4 +1,5 @@ pub mod config; +pub mod discovery; use std::collections::BTreeMap; @@ -6,6 +7,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; pub use config::{TailnetConfig, TailnetProvider}; +pub use discovery::{TailnetDiscovery, TAILNET_DISCOVERY_REL}; pub const BURROW_CAPABILITY_VERSION: i32 = 1; pub const BURROW_TAILNET_DOMAIN: &str = "burrow.net"; diff --git a/nixos/modules/burrow-forge.nix b/nixos/modules/burrow-forge.nix index 51af7eb..0d0f5c8 100644 --- a/nixos/modules/burrow-forge.nix +++ b/nixos/modules/burrow-forge.nix @@ -259,9 +259,12 @@ in encode gzip zstd @oidcConfig path /.well-known/openid-configuration redir @oidcConfig https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.forgejoProviderSlug}/.well-known/openid-configuration 308 + @tailnetConfig path /.well-known/burrow-tailnet + header @tailnetConfig Content-Type application/json + respond @tailnetConfig "{\"domain\":\"${cfg.siteDomain}\",\"provider\":\"headscale\",\"authority\":\"https://${config.services.burrow.headscale.domain}\",\"oidc_issuer\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.headscaleProviderSlug}/\"}" 200 @webfinger path /.well-known/webfinger header @webfinger Content-Type application/jrd+json - respond @webfinger "{\"subject\":\"{query.resource}\",\"links\":[{\"rel\":\"http://openid.net/specs/connect/1.0/issuer\",\"href\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.forgejoProviderSlug}/\"}]}" 200 + respond @webfinger "{\"subject\":\"{query.resource}\",\"links\":[{\"rel\":\"http://openid.net/specs/connect/1.0/issuer\",\"href\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.forgejoProviderSlug}/\"},{\"rel\":\"https://burrow.net/rel/tailnet-control-server\",\"href\":\"https://${config.services.burrow.headscale.domain}\"}]}" 200 @root path / redir @root ${homeRepoUrl} 308 respond 404 diff --git a/nixos/modules/burrow-headscale.nix b/nixos/modules/burrow-headscale.nix index 98cf5ba..ad5ec68 100644 --- a/nixos/modules/burrow-headscale.nix +++ b/nixos/modules/burrow-headscale.nix @@ -3,131 +3,6 @@ let cfg = config.services.burrow.headscale; policyFile = ./burrow-headscale-policy.hujson; - landingPage = pkgs.writeTextDir "index.html" '' - - - - - - Burrow Tailnet - - - -
-
Burrow Tailnet
-
-

Sign-in starts from your client, not this page.

-

- ts.burrow.net is the Burrow Headscale control plane. Headscale does not provide a built-in web UI, - so browser authentication starts only after a Tailscale-compatible client initiates login. -

-
-
tailscale up --login-server https://ts.burrow.net
- -
- - - ''; in { options.services.burrow.headscale = { @@ -346,14 +221,7 @@ in services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd - @root path / - handle @root { - root * ${landingPage} - file_server - } - handle { - reverse_proxy 127.0.0.1:${toString cfg.port} - } + reverse_proxy 127.0.0.1:${toString cfg.port} ''; }; } From f6a7f0922d14107a5dbeec9f3eaf605dde041155 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Fri, 3 Apr 2026 01:36:10 -0700 Subject: [PATCH 051/102] Add governance and identity registry scaffolding --- .forgejo/workflows/lint-governance.yml | 27 ++++ .github/workflows/lint-governance.yml | 23 +++ AGENTS.md | 14 ++ Makefile | 6 + README.md | 3 + Scripts/bep | 133 ++++++++++++++++++ Scripts/check-bep-metadata.py | 94 +++++++++++++ contributors.nix | 47 +++++++ evolution/README.md | 14 ++ .../BEP-0005-daemon-ipc-and-apple-boundary.md | 78 ++++++++++ ...6-tailnet-authority-first-control-plane.md | 71 ++++++++++ ...dentity-registry-and-operator-bootstrap.md | 73 ++++++++++ nixos/hosts/burrow-forge/default.nix | 50 ++++--- 13 files changed, 612 insertions(+), 21 deletions(-) create mode 100644 .forgejo/workflows/lint-governance.yml create mode 100644 .github/workflows/lint-governance.yml create mode 100644 AGENTS.md create mode 100755 Scripts/bep create mode 100755 Scripts/check-bep-metadata.py create mode 100644 contributors.nix create mode 100644 evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md create mode 100644 evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md create mode 100644 evolution/proposals/BEP-0007-identity-registry-and-operator-bootstrap.md diff --git a/.forgejo/workflows/lint-governance.yml b/.forgejo/workflows/lint-governance.yml new file mode 100644 index 0000000..490702e --- /dev/null +++ b/.forgejo/workflows/lint-governance.yml @@ -0,0 +1,27 @@ +name: Lint Governance + +on: + push: + branches: + - main + pull_request: + branches: + - "**" + workflow_dispatch: + +jobs: + governance: + name: BEP Metadata + runs-on: [self-hosted, linux, x86_64, burrow-forge] + steps: + - name: Checkout + uses: https://code.forgejo.org/actions/checkout@v4 + with: + token: ${{ github.token }} + fetch-depth: 0 + + - name: Validate BEP metadata + shell: bash + run: | + set -euo pipefail + python3 Scripts/check-bep-metadata.py diff --git a/.github/workflows/lint-governance.yml b/.github/workflows/lint-governance.yml new file mode 100644 index 0000000..08b665c --- /dev/null +++ b/.github/workflows/lint-governance.yml @@ -0,0 +1,23 @@ +name: Governance Lint + +on: + pull_request: + branches: + - "*" + +jobs: + governance: + name: BEP Metadata + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Validate BEP metadata + shell: bash + run: | + set -euo pipefail + python3 Scripts/check-bep-metadata.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0ca7ced --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +# instructions for agents + +1. Spell the project name as `Burrow` in user-facing copy and `burrow` in code, package, and protocol identifiers unless an existing integration requires a different literal. +2. Read [CONSTITUTION.md](CONSTITUTION.md) before changing Apple clients, the daemon, the control plane, forge infrastructure, identity, or security-sensitive code. +3. Anchor non-trivial changes in a Burrow Evolution Proposal (BEP) under [evolution/](evolution/README.md) so future contributors can inherit the rationale, safeguards, and rollout shape. +4. Before touching the Apple app, daemon IPC, or Tailnet flows, review: + - [evolution/proposals/BEP-0002-control-plane-bootstrap-and-local-auth.md](evolution/proposals/BEP-0002-control-plane-bootstrap-and-local-auth.md) + - [evolution/proposals/BEP-0003-connect-ip-and-negotiation-roadmap.md](evolution/proposals/BEP-0003-connect-ip-and-negotiation-roadmap.md) + - [evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md](evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md) + - [evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md](evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md) +5. Apple clients must talk only to the daemon over gRPC. Do not add direct HTTP, control-plane, or helper-process calls from Swift UI code. +6. Treat Tailnet as one protocol family. Tailscale-managed and self-hosted Headscale-style deployments differ by authority, policy, and auth details, not by a separate user-facing protocol surface. +7. Maintain canonical identity and operator metadata in [contributors.nix](contributors.nix). If Burrow forge, Authentik, Headscale, or admin/group mappings need to change, edit that registry first and derive runtime configuration from it. +8. When process or architecture is unclear, stop and draft or update a BEP instead of improvising durable behavior in code. diff --git a/Makefile b/Makefile index f927f5f..1a0488c 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,12 @@ check: build: @cargo build +bep-check: + @python3 Scripts/check-bep-metadata.py + +bep-list: + @Scripts/bep list + daemon-console: @$(sudo_cargo_console) daemon diff --git a/README.md b/README.md index b8684c3..ba4f50c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Routine verification now runs unprivileged with `cargo test --workspace --all-fe The repository now carries its own design and deployment record: - [Constitution](./CONSTITUTION.md) +- [Agent Instructions](./AGENTS.md) - [Burrow Evolution](./evolution/README.md) - [WireGuard Rust Lineage](./docs/WIREGUARD_LINEAGE.md) - [Protocol Roadmap](./docs/PROTOCOL_ROADMAP.md) @@ -19,6 +20,8 @@ The repository now carries its own design and deployment record: Burrow is fully open source, you can fork the repo and start contributing easily. For more information and in-depth discussions, visit the `#burrow` channel on the [Hack Club Slack](https://hackclub.com/slack/), here you can ask for help and talk with other people interested in burrow. Checkout [GETTING_STARTED.md](./docs/GETTING_STARTED.md) for build instructions and [GTK_APP.md](./docs/GTK_APP.md) for the Linux app. Forge and deployment scaffolding live in [`flake.nix`](./flake.nix), [`nixos/`](./nixos), and [`.forgejo/workflows/`](./.forgejo/workflows/). Hosted mail backup operations live in [`docs/FORWARDEMAIL.md`](./docs/FORWARDEMAIL.md) and [`Tools/forwardemail-custom-s3.sh`](./Tools/forwardemail-custom-s3.sh). +Agent and governance-sensitive work should start with [AGENTS.md](./AGENTS.md), [CONSTITUTION.md](./CONSTITUTION.md), and the relevant BEPs under [`evolution/proposals/`](./evolution/proposals/). Identity and bootstrap metadata now live in [`contributors.nix`](./contributors.nix). + The project structure is divided in the following folders: ``` diff --git a/Scripts/bep b/Scripts/bep new file mode 100755 index 0000000..1c6bd64 --- /dev/null +++ b/Scripts/bep @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(git rev-parse --show-toplevel) +proposals_dir="$repo_root/evolution/proposals" + +auto_browse() { + if command -v wisu >/dev/null 2>&1; then + exec wisu -i -g --icons "$repo_root/evolution" + fi + exec ls -la "$repo_root/evolution" +} + +usage() { + cat <<'USAGE' +Usage: bep [command] + +Commands: + list [--status ] List BEPs, optionally filtered by status. + open Open a BEP in $EDITOR. + help Show this help. + +If no command is provided, bep launches a simple browser for evolution/. +USAGE +} + +normalize_id() { + local raw="$1" + if [[ "$raw" =~ ^BEP-[0-9]+$ ]]; then + printf '%s' "$raw" + return 0 + fi + if [[ "$raw" =~ ^[0-9]+$ ]]; then + printf 'BEP-%04d' "$raw" + return 0 + fi + return 1 +} + +read_status() { + local file="$1" + awk -F ': ' '/^Status:/ {print $2; exit}' "$file" +} + +read_title() { + local file="$1" + local line + line=$(head -n 1 "$file" || true) + printf '%s' "$line" | sed -E 's/^# `[^`]+`[[:space:]]+//; s/^[^A-Za-z0-9]+//' +} + +list_bep() { + local filter="${1:-}" + local filter_lower="" + if [[ -n "$filter" ]]; then + filter_lower=$(printf '%s' "$filter" | tr '[:upper:]' '[:lower:]') + fi + + printf '%-10s %-18s %s\n' "BEP" "Status" "Title" + local file + local entries=() + for file in "$proposals_dir"/BEP-*.md; do + [[ -e "$file" ]] || continue + local base + base=$(basename "$file") + local id + id=$(printf '%s' "$base" | cut -d- -f1-2) + local status + status=$(read_status "$file") + local status_lower + status_lower=$(printf '%s' "$status" | tr '[:upper:]' '[:lower:]') + if [[ -n "$filter_lower" && "$status_lower" != "$filter_lower" ]]; then + continue + fi + local title + title=$(read_title "$file") + entries+=("$(printf '%-10s %-18s %s' "$id" "$status" "$title")") + done + if [[ ${#entries[@]} -gt 0 ]]; then + printf '%s\n' "${entries[@]}" | sort + fi +} + +open_bep() { + local raw="$1" + local id + if ! id=$(normalize_id "$raw"); then + echo "Unknown BEP id: $raw" >&2 + exit 1 + fi + local matches + matches=("$proposals_dir"/"$id"-*.md) + if [[ ${#matches[@]} -eq 0 || ! -e "${matches[0]}" ]]; then + echo "No proposal found for $id" >&2 + exit 1 + fi + if [[ ${#matches[@]} -gt 1 ]]; then + echo "Multiple proposals match $id:" >&2 + printf ' %s\n' "${matches[@]}" >&2 + exit 1 + fi + local editor="${EDITOR:-vi}" + exec "$editor" "${matches[0]}" +} + +command=${1:-} +case "$command" in + "") + auto_browse + ;; + list) + if [[ ${2:-} == "--status" && -n ${3:-} ]]; then + list_bep "$3" + else + list_bep + fi + ;; + open) + if [[ -z ${2:-} ]]; then + echo "bep open requires an id" >&2 + exit 1 + fi + open_bep "$2" + ;; + help|-h|--help) + usage + ;; + *) + echo "Unknown command: $command" >&2 + usage + exit 1 + ;; +esac diff --git a/Scripts/check-bep-metadata.py b/Scripts/check-bep-metadata.py new file mode 100755 index 0000000..d054934 --- /dev/null +++ b/Scripts/check-bep-metadata.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import pathlib +import re +import sys + + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +PROPOSALS_DIR = REPO_ROOT / "evolution" / "proposals" +ALLOWED_STATUSES = { + "Pitch", + "Draft", + "In Review", + "Accepted", + "Implemented", + "Rejected", + "Returned for Revision", + "Superseded", + "Archived", +} +REQUIRED_FIELDS = [ + "Status", + "Proposal", + "Authors", + "Coordinator", + "Reviewers", + "Constitution Sections", + "Implementation PRs", + "Decision Date", +] + + +def text_block_lines(path: pathlib.Path) -> list[str]: + content = path.read_text(encoding="utf-8") + match = re.search(r"```text\n(.*?)\n```", content, re.DOTALL) + if not match: + raise ValueError("missing leading ```text metadata block") + return [line.rstrip() for line in match.group(1).splitlines() if line.strip()] + + +def validate(path: pathlib.Path) -> list[str]: + errors: list[str] = [] + proposal_id = path.name.split("-", 2)[:2] + expected_id = "-".join(proposal_id).removesuffix(".md") + + try: + lines = text_block_lines(path) + except ValueError as exc: + return [f"{path}: {exc}"] + + field_names = [line.split(":", 1)[0] for line in lines] + if field_names != REQUIRED_FIELDS: + errors.append( + f"{path}: metadata fields must appear in order {', '.join(REQUIRED_FIELDS)}" + ) + return errors + + fields = dict(line.split(":", 1) for line in lines) + fields = {key.strip(): value.strip() for key, value in fields.items()} + + if fields["Status"] not in ALLOWED_STATUSES: + errors.append(f"{path}: invalid Status {fields['Status']!r}") + + if fields["Proposal"] != expected_id: + errors.append( + f"{path}: Proposal field {fields['Proposal']!r} does not match filename id {expected_id!r}" + ) + + if fields["Status"] in {"Accepted", "Implemented", "Superseded", "Rejected", "Archived"} and fields["Decision Date"] == "Pending": + errors.append( + f"{path}: Decision Date must not be Pending once status is {fields['Status']}" + ) + + return errors + + +def main() -> int: + errors: list[str] = [] + for path in sorted(PROPOSALS_DIR.glob("BEP-*.md")): + errors.extend(validate(path)) + + if errors: + for error in errors: + print(error, file=sys.stderr) + return 1 + + print(f"checked {len(list(PROPOSALS_DIR.glob('BEP-*.md')))} BEPs") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/contributors.nix b/contributors.nix new file mode 100644 index 0000000..f6cc014 --- /dev/null +++ b/contributors.nix @@ -0,0 +1,47 @@ +{ + groups = { + users = "burrow-users"; + admins = "burrow-admins"; + }; + + identities = { + contact = { + displayName = "Burrow"; + canonicalEmail = "contact@burrow.net"; + sourceEmail = "net.burrow@gmail.com"; + isAdmin = true; + forgeAuthorized = true; + bootstrapAuthentik = true; + sshPublicKeyPath = ./nixos/keys/contact_at_burrow_net.pub; + roles = [ + "operator" + "forge-admin" + ]; + }; + + conrad = { + displayName = "Conrad Kramer"; + canonicalEmail = "conrad@burrow.net"; + sourceEmail = "ckrames1234@gmail.com"; + isAdmin = true; + forgeAuthorized = false; + bootstrapAuthentik = true; + roles = [ + "operator" + "founder" + ]; + }; + + agent = { + displayName = "Burrow Agent"; + canonicalEmail = "agent@burrow.net"; + isAdmin = false; + forgeAuthorized = true; + bootstrapAuthentik = false; + sshPublicKeyPath = ./nixos/keys/agent_at_burrow_net.pub; + roles = [ + "automation" + ]; + }; + }; +} diff --git a/evolution/README.md b/evolution/README.md index e55a347..794b1fe 100644 --- a/evolution/README.md +++ b/evolution/README.md @@ -58,3 +58,17 @@ evolution/ ``` Use ASCII Markdown. Keep metadata at the top of each proposal so tooling and future agents can parse it quickly. + +## BEP Helper + +Use the `bep` helper under `Scripts/` to browse or list proposals: + +- `Scripts/bep` opens a quick browser for `evolution/`. +- `Scripts/bep list --status Draft` lists proposals by status. +- `Scripts/bep open BEP-0005` opens a proposal in `$EDITOR`. + +Validate proposal metadata with: + +```bash +python3 Scripts/check-bep-metadata.py +``` diff --git a/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md b/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md new file mode 100644 index 0000000..1227444 --- /dev/null +++ b/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md @@ -0,0 +1,78 @@ +# `BEP-0005` - Daemon IPC and Apple Boundary + +```text +Status: Draft +Proposal: BEP-0005 +Authors: gpt-5.4 +Coordinator: gpt-5.4 +Reviewers: Pending +Constitution Sections: II, III, IV, V +Implementation PRs: Pending +Decision Date: Pending +``` + +## Summary + +Burrow should formalize one Apple/runtime boundary: Apple clients speak only to the daemon over gRPC on the app-group Unix socket, and the daemon owns all external control-plane, helper-process, and runtime coordination work. This prevents UI code from accreting side HTTP paths or ad hoc control-plane integrations that bypass the system Burrow is supposed to own. + +## Motivation + +- The current Tailnet work already showed the failure mode: Swift UI code started reaching around the daemon boundary to talk to helper HTTP endpoints directly. +- Apple-specific process ownership is easy to blur between the app, the network extension, and helper daemons unless the contract is explicit. +- If Burrow wants a durable multi-runtime architecture, the daemon must remain the only orchestration boundary between clients and control/data-plane behavior. + +## Detailed Design + +- Apple UI and Apple support libraries may call only daemon gRPC methods over the declared Burrow Unix socket. +- Direct Swift calls to external control-plane HTTP APIs, localhost helper HTTP servers, or runtime-specific subprocesses are forbidden. +- The daemon is responsible for: + - discovery of Tailnet authorities and related metadata + - control-plane session setup and tracking + - login/session lifecycle brokering + - runtime start/stop/reconcile + - translating helper or bridge processes into stable daemon RPCs +- `burrow/src/control/` owns transport-neutral control-plane semantics such as discovery, authority normalization, and request/response shaping. +- Apple UI owns presentation only: + - forms + - local state + - presenting returned auth URLs or statuses + - surfacing daemon availability and errors +- Any new Apple-facing runtime capability requires a daemon RPC first. + +## Security and Operational Considerations + +- Keeping control-plane I/O out of Swift UI reduces accidental secret, token, and callback sprawl across app code. +- The daemon boundary makes testing and kill-switch behavior tractable because runtime integration is localized. +- Apple daemon lifecycle ownership must be explicit: either the app ensures the daemon is running before RPC or the extension owns it and the UI surfaces daemon-unavailable state clearly. + +## Contributor Playbook + +- Before adding a new Apple-side workflow, identify the daemon RPC that should own it. +- If the RPC does not exist, add the protocol shape in `proto/burrow.proto`, implement it in the daemon, and only then wire Swift UI. +- Verify that no Swift UI or support code calls external control-plane HTTP endpoints directly. +- For Tailnet and similar flows, test: + - daemon unavailable behavior + - successful RPC path + - error propagation through the UI + +## Alternatives Considered + +- Let Apple UI call control-plane endpoints directly for convenience. Rejected because it creates parallel orchestration paths and breaks the daemon contract. +- Allow one-off exceptions for login helpers. Rejected because those exceptions become the architecture. + +## Impact on Other Work + +- Governs the Tailnet refactor and future Apple runtime work. +- Interacts with BEP-0002 control-plane bootstrap and BEP-0003 transport refactoring. + +## Decision + +Pending. + +## References + +- `Apple/UI/` +- `Apple/Core/` +- `Apple/NetworkExtension/` +- `burrow/src/daemon/` +- `burrow/src/control/` diff --git a/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md b/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md new file mode 100644 index 0000000..fea4aba --- /dev/null +++ b/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md @@ -0,0 +1,71 @@ +# `BEP-0006` - Tailnet Authority-First Control Plane + +```text +Status: Draft +Proposal: BEP-0006 +Authors: gpt-5.4 +Coordinator: gpt-5.4 +Reviewers: Pending +Constitution Sections: I, II, IV, V +Implementation PRs: Pending +Decision Date: Pending +``` + +## Summary + +Burrow should treat Tailnet as one protocol family. Tailscale-managed and self-hosted Headscale-style deployments differ by authority, policy, and auth details, not by a distinct user-facing protocol. Burrow’s config and UI should therefore be authority-first rather than provider-first. + +## Motivation + +- Splitting Tailscale and Headscale into separate user-facing providers causes fake architectural divergence. +- Discovery already naturally returns an authority and optional issuer; that is the stable contract users actually need. +- Future managed or enterprise deployments should fit the same model without requiring another protocol picker. + +## Detailed Design + +- Tailnet configuration is centered on: + - account + - identity + - authority/login server URL + - optional tailnet name + - optional hostname + - auth method/material +- User-facing surfaces should not force a protocol choice between Tailscale and Headscale. +- Provider inference may remain internal metadata for compatibility and diagnostics: + - default managed Tailscale authority + - custom self-hosted authority + - Burrow-owned authority when explicitly applicable +- Discovery returns authority and related metadata; editing the authority is the mechanism that moves a configuration from managed default to custom control server. +- The daemon and control layer own provider inference; the UI should primarily present “Tailnet” plus the selected authority. + +## Security and Operational Considerations + +- Authority-first config reduces UI complexity and makes misconfiguration easier to reason about. +- Provider-specific assumptions must not leak into packet or control-plane semantics unless the authority actually requires them. +- Auth material must remain authority-scoped and identity-scoped in daemon storage. + +## Contributor Playbook + +- Remove provider pickers from Tailnet UI unless a concrete protocol difference requires one. +- Store the authority explicitly in payloads and infer provider internally only when needed. +- Prefer tests that validate authority normalization and discovery behavior over UI-provider branching. + +## Alternatives Considered + +- Keep separate user-facing providers for Tailscale and Headscale. Rejected because it models deployment shape as protocol shape. +- Collapse all control planes into one opaque Burrow provider. Rejected because the authority still matters operationally and diagnostically. + +## Impact on Other Work + +- Refines BEP-0002’s Tailscale-shaped control-plane work. +- Constrains the Tailnet Apple refactor and future daemon control-plane storage. + +## Decision + +Pending. + +## References + +- `burrow/src/control/` +- `Apple/UI/Networks/` +- `proto/burrow.proto` diff --git a/evolution/proposals/BEP-0007-identity-registry-and-operator-bootstrap.md b/evolution/proposals/BEP-0007-identity-registry-and-operator-bootstrap.md new file mode 100644 index 0000000..1fde0fb --- /dev/null +++ b/evolution/proposals/BEP-0007-identity-registry-and-operator-bootstrap.md @@ -0,0 +1,73 @@ +# `BEP-0007` - Identity Registry and Operator Bootstrap + +```text +Status: Draft +Proposal: BEP-0007 +Authors: gpt-5.4 +Coordinator: gpt-5.4 +Reviewers: Pending +Constitution Sections: II, III, IV, V +Implementation PRs: Pending +Decision Date: Pending +``` + +## Summary + +Burrow should maintain one canonical registry for project identities, aliases, bootstrap users, SSH keys, and admin-group mappings. Forgejo, Authentik, and related bootstrap configuration should derive from that registry instead of hardcoding overlapping identity facts in multiple modules. + +## Motivation + +- Burrow currently hardcodes operator and admin/bootstrap user facts directly in host configuration. +- Multi-account and self-hosted identity are becoming core architecture, not incidental infra details. +- A single registry reduces drift across Forgejo, Authentik, Headscale, SSH authorization, and future control-plane bootstrap. + +## Detailed Design + +- Add a root-level identity registry (`contributors.nix`) as the canonical source of truth for: + - usernames + - display names + - canonical emails + - external source emails or aliases + - admin scope + - bootstrap eligibility + - forge authorized SSH keys + - named roles +- Consume that registry from host configuration for: + - Forgejo authorized keys + - Forgejo bootstrap admin defaults + - Authentik bootstrap users + - Burrow user/admin group names +- Future work may derive contributor docs, OIDC bootstrap, and additional runtime configuration from the same registry. + +## Security and Operational Considerations + +- Identity drift is a security bug when it affects admin groups, bootstrap accounts, or SSH authorization. +- The registry stores metadata only; secrets remain in agenix or other declared secret paths. +- Changes to the registry should receive explicit review because they affect access and governance. + +## Contributor Playbook + +- Edit `contributors.nix` first when changing operator, admin, alias, or bootstrap identity state. +- Derive runtime configuration from the registry instead of duplicating the same facts elsewhere. +- Keep secret references separate from identity metadata. + +## Alternatives Considered + +- Continue hardcoding users in module options. Rejected because drift is inevitable once Forgejo, Authentik, and Headscale all depend on the same identities. +- Create separate per-service user lists. Rejected because it duplicates governance facts and weakens review. + +## Impact on Other Work + +- Supports forge auth, Authentik group sync, and future multi-account Burrow control-plane work. +- Creates the basis for stronger contributor and operator provenance later. + +## Decision + +Pending. + +## References + +- `contributors.nix` +- `nixos/hosts/burrow-forge/default.nix` +- `nixos/modules/burrow-authentik.nix` +- `nixos/modules/burrow-forge.nix` diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index d612ea8..fb5b8ae 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -1,4 +1,23 @@ -{ config, self, ... }: +{ config, lib, self, ... }: + +let + contributors = import ../../../contributors.nix; + identities = contributors.identities; + bootstrapUsers = lib.mapAttrsToList + ( + username: identity: { + inherit username; + name = identity.displayName; + email = identity.canonicalEmail; + sourceEmail = identity.sourceEmail or null; + isAdmin = identity.isAdmin or false; + } + ) + (lib.filterAttrs (_: identity: identity.bootstrapAuthentik or false) identities); + forgeAuthorizedKeys = map + (username: builtins.readFile identities.${username}.sshPublicKeyPath) + (builtins.attrNames (lib.filterAttrs (_: identity: identity.forgeAuthorized or false) identities)); +in { imports = [ @@ -59,12 +78,14 @@ services.burrow.forge = { enable = true; + contactEmail = identities.contact.canonicalEmail; + adminUsername = "contact"; + adminEmail = identities.contact.canonicalEmail; adminPasswordFile = "/var/lib/burrow/intake/forgejo_pass_contact_at_burrow_net.txt"; + oidcAdminGroup = contributors.groups.admins; + oidcRestrictedGroup = contributors.groups.users; oidcClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; - authorizedKeys = [ - (builtins.readFile ../../keys/contact_at_burrow_net.pub) - (builtins.readFile ../../keys/agent_at_burrow_net.pub) - ]; + authorizedKeys = forgeAuthorizedKeys; }; services.burrow.forgeRunner = { @@ -92,22 +113,9 @@ googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; googleLoginMode = "redirect"; - bootstrapUsers = [ - { - username = "contact"; - name = "Burrow"; - email = "contact@burrow.net"; - sourceEmail = "net.burrow@gmail.com"; - isAdmin = true; - } - { - username = "conrad"; - name = "Conrad Kramer"; - email = "conrad@burrow.net"; - sourceEmail = "ckrames1234@gmail.com"; - isAdmin = true; - } - ]; + userGroupName = contributors.groups.users; + adminGroupName = contributors.groups.admins; + bootstrapUsers = bootstrapUsers; }; services.burrow.headscale = { From d1e28b881775967fa696294bc4d3c18ebebde757 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Fri, 3 Apr 2026 01:36:55 -0700 Subject: [PATCH 052/102] Route Tailnet Apple flows through daemon gRPC --- Apple/Core/Client.swift | 198 ++++++++++++++++ Apple/UI/BurrowView.swift | 406 ++++++-------------------------- Apple/UI/Networks/Network.swift | 254 ++++++-------------- burrow/src/control/discovery.rs | 136 ++++++++++- burrow/src/daemon/instance.rs | 48 +++- burrow/src/daemon/mod.rs | 7 +- burrow/src/daemon/rpc/client.rs | 8 +- proto/burrow.proto | 28 +++ 8 files changed, 565 insertions(+), 520 deletions(-) diff --git a/Apple/Core/Client.swift b/Apple/Core/Client.swift index 8874e3b..c426fe7 100644 --- a/Apple/Core/Client.swift +++ b/Apple/Core/Client.swift @@ -1,5 +1,7 @@ +import Foundation import GRPC import NIOTransportServices +import SwiftProtobuf public typealias TunnelClient = Burrow_TunnelAsyncClient public typealias NetworksClient = Burrow_NetworksAsyncClient @@ -30,3 +32,199 @@ extension NetworksClient: Client { self.init(channel: channel, defaultCallOptions: .init(), interceptors: .none) } } + +public struct Burrow_TailnetDiscoverRequest: Sendable { + public var email: String = "" + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_TailnetDiscoverResponse: Sendable { + public var domain: String = "" + public var authority: String = "" + public var oidcIssuer: String = "" + public var managed: Bool = false + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_TailnetProbeRequest: Sendable { + public var authority: String = "" + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_TailnetProbeResponse: Sendable { + public var authority: String = "" + public var statusCode: Int32 = 0 + public var summary: String = "" + public var detail: String = "" + public var reachable: Bool = false + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +extension Burrow_TailnetDiscoverRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.TailnetDiscoverRequest" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "email") + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularStringField(value: &self.email) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.email.isEmpty { + try visitor.visitSingularStringField(value: self.email, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_TailnetDiscoverResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.TailnetDiscoverResponse" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "domain"), + 2: .same(proto: "authority"), + 3: .same(proto: "oidc_issuer"), + 4: .same(proto: "managed"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularStringField(value: &self.domain) + case 2: try decoder.decodeSingularStringField(value: &self.authority) + case 3: try decoder.decodeSingularStringField(value: &self.oidcIssuer) + case 4: try decoder.decodeSingularBoolField(value: &self.managed) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.domain.isEmpty { + try visitor.visitSingularStringField(value: self.domain, fieldNumber: 1) + } + if !self.authority.isEmpty { + try visitor.visitSingularStringField(value: self.authority, fieldNumber: 2) + } + if !self.oidcIssuer.isEmpty { + try visitor.visitSingularStringField(value: self.oidcIssuer, fieldNumber: 3) + } + if self.managed { + try visitor.visitSingularBoolField(value: self.managed, fieldNumber: 4) + } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_TailnetProbeRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.TailnetProbeRequest" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "authority") + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularStringField(value: &self.authority) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.authority.isEmpty { + try visitor.visitSingularStringField(value: self.authority, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_TailnetProbeResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.TailnetProbeResponse" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "authority"), + 2: .same(proto: "status_code"), + 3: .same(proto: "summary"), + 4: .same(proto: "detail"), + 5: .same(proto: "reachable"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularStringField(value: &self.authority) + case 2: try decoder.decodeSingularInt32Field(value: &self.statusCode) + case 3: try decoder.decodeSingularStringField(value: &self.summary) + case 4: try decoder.decodeSingularStringField(value: &self.detail) + case 5: try decoder.decodeSingularBoolField(value: &self.reachable) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.authority.isEmpty { + try visitor.visitSingularStringField(value: self.authority, fieldNumber: 1) + } + if self.statusCode != 0 { + try visitor.visitSingularInt32Field(value: self.statusCode, fieldNumber: 2) + } + if !self.summary.isEmpty { + try visitor.visitSingularStringField(value: self.summary, fieldNumber: 3) + } + if !self.detail.isEmpty { + try visitor.visitSingularStringField(value: self.detail, fieldNumber: 4) + } + if self.reachable { + try visitor.visitSingularBoolField(value: self.reachable, fieldNumber: 5) + } + try unknownFields.traverse(visitor: &visitor) + } +} + +public struct TailnetClient: Client, GRPCClient { + public let channel: GRPCChannel + public var defaultCallOptions: CallOptions + + public init(channel: any GRPCChannel) { + self.channel = channel + self.defaultCallOptions = .init() + } + + public func discover( + _ request: Burrow_TailnetDiscoverRequest, + callOptions: CallOptions? = nil + ) async throws -> Burrow_TailnetDiscoverResponse { + try await self.performAsyncUnaryCall( + path: "/burrow.TailnetControl/Discover", + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: [] + ) + } + + public func probe( + _ request: Burrow_TailnetProbeRequest, + callOptions: CallOptions? = nil + ) async throws -> Burrow_TailnetProbeResponse { + try await self.performAsyncUnaryCall( + path: "/burrow.TailnetControl/Probe", + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: [] + ) + } +} diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index b4fa7d8..9938eef 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -1,4 +1,3 @@ -import AuthenticationServices import BurrowConfiguration import Foundation import SwiftUI @@ -204,7 +203,7 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable { switch self { case .wireGuard: .wireGuard case .tor: .tor - case .tailnet: .headscale + case .tailnet: .tailnet } } @@ -285,13 +284,12 @@ private struct AccountDraft { var wireGuardConfig = "" var discoveryEmail = "" - var tailnetProvider: TailnetProvider = .tailscale var authority = "" var tailnet = "" var hostname = ProcessInfo.processInfo.hostName var username = "" var secret = "" - var authMode: AccountAuthMode = .web + var authMode: AccountAuthMode = .none var torAddresses = "100.64.0.2/32" var torDNS = "1.1.1.1, 1.0.0.1" @@ -317,7 +315,6 @@ private struct AccountDraft { private struct ConfigurationSheetView: View { @Environment(\.dismiss) private var dismiss - @Environment(\.webAuthenticationSession) private var webAuthenticationSession let sheet: ConfigurationSheet let networkViewModel: NetworkViewModel @@ -326,17 +323,13 @@ private struct ConfigurationSheetView: View { @State private var draft: AccountDraft @State private var isSubmitting = false @State private var errorMessage: String? - @State private var loginSessionID: String? - @State private var loginStatus: TailnetLoginStatus? @State private var discoveryStatus: TailnetDiscoveryResponse? @State private var discoveryError: String? @State private var isDiscoveringTailnet = false @State private var authorityProbeStatus: TailnetAuthorityProbeStatus? @State private var authorityProbeError: String? @State private var isProbingAuthority = false - @State private var pollingTask: Task? @State private var didRunAutomation = false - @State private var webAuthenticationTask: Task? init( sheet: ConfigurationSheet, @@ -447,20 +440,12 @@ private struct ConfigurationSheetView: View { .onAppear { runAutomationIfNeeded() } - .onChange(of: draft.tailnetProvider) { _, _ in - resetAuthorityProbe() - } .onChange(of: draft.authority) { _, _ in resetAuthorityProbe() } .onChange(of: draft.discoveryEmail) { _, _ in resetTailnetDiscoveryFeedback() } - .onDisappear { - pollingTask?.cancel() - webAuthenticationTask?.cancel() - webAuthenticationTask = nil - } } @ViewBuilder @@ -490,48 +475,30 @@ private struct ConfigurationSheetView: View { tailnetDiscoveryCard(status: nil, failure: discoveryError) } - Picker( - "Provider", - selection: Binding( - get: { draft.tailnetProvider }, - set: { applyTailnetProvider($0) } - ) - ) { - ForEach(TailnetProvider.allCases) { provider in - Text(provider.title).tag(provider) + TextField("Authority URL", text: $draft.authority) + .burrowLoginField() + .autocorrectionDisabled() + + Text("Use the managed Tailnet authority or enter a custom Tailnet control server.") + .font(.footnote) + .foregroundStyle(.secondary) + + Button { + probeTailnetAuthority() + } label: { + Label { + Text(isProbingAuthority ? "Checking Connection" : "Check Connection") + } icon: { + Image(systemName: isProbingAuthority ? "hourglass" : "bolt.horizontal.circle") } } - .pickerStyle(.menu) + .buttonStyle(.borderless) + .disabled(isProbingAuthority || normalizedOptional(draft.authority) == nil) - tailnetProviderCard - - if draft.tailnetProvider.requiresControlURL { - TextField("Server URL", text: $draft.authority) - .burrowLoginField() - .autocorrectionDisabled() - - Button { - probeTailnetAuthority() - } label: { - Label { - Text(isProbingAuthority ? "Checking Connection" : "Check Connection") - } icon: { - Image(systemName: isProbingAuthority ? "hourglass" : "bolt.horizontal.circle") - } - } - .buttonStyle(.borderless) - .disabled(isProbingAuthority || normalizedOptional(draft.authority) == nil) - - if let authorityProbeStatus { - tailnetAuthorityProbeCard(status: authorityProbeStatus, failure: nil) - } else if let authorityProbeError { - tailnetAuthorityProbeCard(status: nil, failure: authorityProbeError) - } - } else { - LabeledContent("Server") { - Text("Tailscale managed") - .foregroundStyle(.secondary) - } + if let authorityProbeStatus { + tailnetAuthorityProbeCard(status: authorityProbeStatus, failure: nil) + } else if let authorityProbeError { + tailnetAuthorityProbeCard(status: nil, failure: authorityProbeError) } TextField("Tailnet", text: $draft.tailnet) @@ -540,28 +507,24 @@ private struct ConfigurationSheetView: View { } Section("Authentication") { - if tailnetUsesWebLogin { - tailnetWebLoginCard - } else { - TextField("Username", text: $draft.username) - .burrowLoginField() - .autocorrectionDisabled() - Picker("Authentication", selection: $draft.authMode) { - ForEach(availableTailnetAuthModes) { mode in - Text(mode.title).tag(mode) - } + TextField("Username", text: $draft.username) + .burrowLoginField() + .autocorrectionDisabled() + Picker("Authentication", selection: $draft.authMode) { + ForEach(availableTailnetAuthModes) { mode in + Text(mode.title).tag(mode) } - .pickerStyle(.menu) - if draft.authMode != .none { - SecureField( - draft.authMode == .password ? "Password" : "Preauth Key", - text: $draft.secret - ) - } - Text("Credentials stay on-device. Burrow uses them when it needs to register or refresh this identity.") - .font(.footnote) - .foregroundStyle(.secondary) } + .pickerStyle(.menu) + if draft.authMode != .none { + SecureField( + draft.authMode == .password ? "Password" : "Preauth Key", + text: $draft.secret + ) + } + Text("Tailnet account material stays on-device. Burrow stores the authority and credentials for daemon-managed registration and refresh.") + .font(.footnote) + .foregroundStyle(.secondary) } } @@ -618,10 +581,8 @@ private struct ConfigurationSheetView: View { if sheet == .tailnet { HStack(spacing: 8) { - summaryBadge(draft.tailnetProvider.title) - summaryBadge( - tailnetUsesWebLogin ? "Web Sign-In" : draft.authMode.title - ) + summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom") + summaryBadge(draft.authMode.title) } } } @@ -632,79 +593,6 @@ private struct ConfigurationSheetView: View { ) } - private var tailnetProviderCard: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 10) { - Image(systemName: tailnetProviderIconName) - .font(.headline) - .foregroundStyle(sheetAccentColor) - .frame(width: 28, height: 28) - .background( - Circle() - .fill(sheetAccentColor.opacity(0.14)) - ) - - VStack(alignment: .leading, spacing: 2) { - Text(draft.tailnetProvider.title) - .font(.headline) - Text(draft.tailnetProvider.subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - } - - Spacer() - } - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(.thinMaterial) - ) - } - - @ViewBuilder - private var tailnetWebLoginCard: some View { - VStack(alignment: .leading, spacing: 10) { - Text("Sign in with the shared browser session.") - .font(.subheadline.weight(.medium)) - - if let loginStatus { - labeledValue("State", loginStatus.backendState) - if let tailnetName = loginStatus.tailnetName { - labeledValue("Tailnet", tailnetName) - } - if let dnsName = loginStatus.selfDNSName { - labeledValue("Device", dnsName) - } - if !loginStatus.tailscaleIPs.isEmpty { - labeledValue("Addresses", loginStatus.tailscaleIPs.joined(separator: ", ")) - } - if let authURL = loginStatus.authURL { - Button("Resume Sign-In") { - if let url = URL(string: authURL) { - openLoginURL(url) - } - } - .buttonStyle(.borderless) - } - if !loginStatus.health.isEmpty { - Text(loginStatus.health.joined(separator: " • ")) - .font(.footnote) - .foregroundStyle(.secondary) - } - } else { - Text("Burrow launches the local bridge, then opens the real provider sign-in page in-app.") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(.thinMaterial) - ) - } - private func tailnetAuthorityProbeCard( status: TailnetAuthorityProbeStatus?, failure: String? @@ -739,12 +627,15 @@ private struct ConfigurationSheetView: View { ) -> some View { VStack(alignment: .leading, spacing: 6) { if let status { - Text("Discovered \(status.provider.title)") + Text("Discovered Tailnet Server") .font(.subheadline.weight(.medium)) Text(status.authority) .font(.footnote.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) + Text(status.provider == .tailscale ? "Managed authority" : "Custom authority") + .font(.footnote) + .foregroundStyle(.secondary) if let oidcIssuer = status.oidcIssuer { Text("OIDC: \(oidcIssuer)") .font(.footnote) @@ -826,12 +717,8 @@ private struct ConfigurationSheetView: View { } case .tailnet: - Menu("Provider") { - ForEach(TailnetProvider.allCases) { provider in - Button(provider.title) { - applyTailnetProvider(provider) - } - } + Button("Use Tailscale Managed Server") { + applyTailnetDefaults(for: .tailscale) } if availableTailnetAuthModes.count > 1 { @@ -839,7 +726,7 @@ private struct ConfigurationSheetView: View { ForEach(availableTailnetAuthModes) { mode in Button(mode.title) { draft.authMode = mode - if mode == .none || mode == .web { + if mode == .none { draft.secret = "" } } @@ -847,8 +734,8 @@ private struct ConfigurationSheetView: View { } } - Button("Restore Provider Defaults") { - applyTailnetDefaults(for: draft.tailnetProvider) + Button("Clear Discovery Result") { + resetTailnetDiscoveryFeedback() } } } @@ -886,17 +773,6 @@ private struct ConfigurationSheetView: View { } } - private var tailnetProviderIconName: String { - switch draft.tailnetProvider { - case .tailscale: - "globe.badge.chevron.backward" - case .headscale: - "server.rack" - case .burrow: - "shield" - } - } - private var showsBottomActionButton: Bool { #if os(iOS) true @@ -920,9 +796,6 @@ private struct ConfigurationSheetView: View { case .tor: return "Save Account" case .tailnet: - if tailnetUsesWebLogin { - return loginStatus?.running == true ? "Save Account" : "Start Sign-In" - } return "Save Account" } } @@ -937,12 +810,9 @@ private struct ConfigurationSheetView: View { if normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil { return true } - if draft.tailnetProvider.requiresControlURL && normalizedOptional(draft.authority) == nil { + if normalizedOptional(draft.authority) == nil { return true } - if tailnetUsesWebLogin { - return false - } if draft.authMode != .none && normalizedOptional(draft.secret) == nil { return true } @@ -1027,41 +897,12 @@ private struct ConfigurationSheetView: View { } private func submitTailnet() async throws { - if tailnetUsesWebLogin { - if loginStatus?.running == true { - webAuthenticationTask?.cancel() - webAuthenticationTask = nil - try await saveTailnetAccount(secret: nil, username: nil) - dismiss() - } else { - try await startTailnetLogin() - } - return - } - let secret = draft.authMode == .none ? nil : draft.secret let username = normalizedOptional(draft.username) try await saveTailnetAccount(secret: secret, username: username) dismiss() } - private func startTailnetLogin() async throws { - let response = try await TailnetBridgeClient.startLogin( - TailnetLoginStartRequest( - accountName: normalized(draft.accountName, fallback: "default"), - identityName: normalized(draft.identityName, fallback: "apple"), - hostname: normalizedOptional(draft.hostname), - controlURL: normalizedOptional(draft.authority) ?? draft.tailnetProvider.defaultAuthority - ) - ) - loginSessionID = response.sessionID - loginStatus = response.status - if let authURL = response.status.authURL, let url = URL(string: authURL) { - openLoginURL(url) - } - startPollingTailscaleLogin() - } - private func runAutomationIfNeeded() { guard !didRunAutomation, sheet == .tailnet, @@ -1080,79 +921,19 @@ private struct ConfigurationSheetView: View { Task { @MainActor in switch automation.action { case .tailnetLogin: - draft.tailnetProvider = .tailscale - do { - try await startTailnetLogin() - } catch { - errorMessage = error.localizedDescription - } + applyTailnetDefaults(for: .tailscale) + probeTailnetAuthority() case .headscaleProbe: - applyTailnetProvider(.headscale) draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority probeTailnetAuthority() } } } - private func startPollingTailscaleLogin() { - pollingTask?.cancel() - guard let loginSessionID else { return } - pollingTask = Task { @MainActor in - while !Task.isCancelled { - do { - let status = try await TailnetBridgeClient.status(sessionID: loginSessionID) - let previousAuthURL = loginStatus?.authURL - loginStatus = status - if previousAuthURL == nil, - let authURL = status.authURL, - let url = URL(string: authURL) - { - openLoginURL(url) - } - if status.running { - webAuthenticationTask?.cancel() - webAuthenticationTask = nil - return - } - } catch { - errorMessage = error.localizedDescription - return - } - try? await Task.sleep(for: .seconds(2)) - } - } - } - - private func openLoginURL(_ url: URL) { - webAuthenticationTask?.cancel() - webAuthenticationTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(300)) - do { - _ = try await webAuthenticationSession.authenticate( - using: url, - callbackURLScheme: "burrow", - preferredBrowserSession: .shared - ) - } catch is CancellationError { - return - } catch let error as ASWebAuthenticationSessionError - where error.code == .canceledLogin - { - return - } catch { - errorMessage = error.localizedDescription - } - webAuthenticationTask = nil - } - } - private func saveTailnetAccount(secret: String?, username: String?) async throws { - let provider = draft.tailnetProvider + let provider = inferredTailnetProvider let title = titleOrFallback( - hostnameFallback( - from: tailnetUsesWebLogin ? (loginStatus?.tailnetName ?? "") : draft.authority, - fallback: provider.title - ) + hostnameFallback(from: draft.authority, fallback: "Tailnet") ) let payload = TailnetNetworkPayload( @@ -1160,22 +941,14 @@ private struct ConfigurationSheetView: View { authority: normalizedOptional(draft.authority) ?? normalizedOptional(provider.defaultAuthority ?? ""), account: normalized(draft.accountName, fallback: "default"), identity: normalized(draft.identityName, fallback: "apple"), - tailnet: normalizedOptional(loginStatus?.tailnetName ?? draft.tailnet), + tailnet: normalizedOptional(draft.tailnet), hostname: normalizedOptional(draft.hostname) ) var noteParts: [String] = [ - provider.title, - tailnetUsesWebLogin - ? "State: \(loginStatus?.backendState ?? "NeedsLogin")" - : "Auth: \(draft.authMode.title)", + isManagedTailnetAuthority ? "Managed Tailnet" : "Custom Tailnet", + "Auth: \(draft.authMode.title)", ] - if let dnsName = loginStatus?.selfDNSName { - noteParts.append("Device: \(dnsName)") - } - if let magicDNSSuffix = loginStatus?.magicDNSSuffix { - noteParts.append("MagicDNS: \(magicDNSSuffix)") - } do { let networkID = try await networkViewModel.addTailnetNetwork(payload: payload) @@ -1186,7 +959,7 @@ private struct ConfigurationSheetView: View { let record = NetworkAccountRecord( id: UUID(), - kind: .headscale, + kind: .tailnet, title: title, authority: payload.authority, provider: provider, @@ -1195,7 +968,7 @@ private struct ConfigurationSheetView: View { hostname: payload.hostname, username: username, tailnet: payload.tailnet, - authMode: tailnetUsesWebLogin ? .web : draft.authMode, + authMode: draft.authMode, note: noteParts.joined(separator: " • "), createdAt: .now, updatedAt: .now @@ -1226,33 +999,15 @@ private struct ConfigurationSheetView: View { draft.torListen = defaults.torListen } - private func applyTailnetProvider(_ provider: TailnetProvider) { - resetTailnetDiscoveryFeedback() - draft.tailnetProvider = provider - applyTailnetDefaults(for: provider) - } - private func applyTailnetDefaults(for provider: TailnetProvider) { + resetTailnetDiscoveryFeedback() draft.authority = provider.defaultAuthority ?? "" - loginStatus = nil - loginSessionID = nil - pollingTask?.cancel() - if provider == .tailscale { - draft.authMode = .web - draft.username = "" - draft.secret = "" - } else { - if !availableTailnetAuthModes.contains(draft.authMode) { - draft.authMode = provider.supportsWebLogin ? .web : .none - } - if draft.authMode == .web && !provider.supportsWebLogin { - draft.authMode = .none - } + if !availableTailnetAuthModes.contains(draft.authMode) { + draft.authMode = .none } } private func probeTailnetAuthority() { - guard draft.tailnetProvider.requiresControlURL else { return } guard let authority = normalizedOptional(draft.authority) else { authorityProbeStatus = nil authorityProbeError = "Enter a server URL first." @@ -1266,10 +1021,7 @@ private struct ConfigurationSheetView: View { Task { @MainActor in defer { isProbingAuthority = false } do { - authorityProbeStatus = try await TailnetAuthorityProbeClient.probe( - provider: draft.tailnetProvider, - authority: authority - ) + authorityProbeStatus = try await networkViewModel.probeTailnetAuthority(authority) } catch { authorityProbeError = error.localizedDescription } @@ -1300,15 +1052,9 @@ private struct ConfigurationSheetView: View { Task { @MainActor in defer { isDiscoveringTailnet = false } do { - let discovery = try await TailnetDiscoveryClient.discover(email: email) + let discovery = try await networkViewModel.discoverTailnet(email: email) discoveryStatus = discovery - draft.tailnetProvider = discovery.provider draft.authority = discovery.authority - if discovery.provider.supportsWebLogin, discovery.oidcIssuer != nil { - draft.authMode = .web - draft.username = "" - draft.secret = "" - } probeTailnetAuthority() } catch { discoveryError = error.localizedDescription @@ -1361,19 +1107,19 @@ private struct ConfigurationSheetView: View { return host } - private var tailnetUsesWebLogin: Bool { - draft.authMode == .web && draft.tailnetProvider.supportsWebLogin + private var availableTailnetAuthModes: [AccountAuthMode] { + [.none, .password, .preauthKey] } - private var availableTailnetAuthModes: [AccountAuthMode] { - switch draft.tailnetProvider { - case .tailscale: - [.web] - case .headscale: - [.web, .none, .password, .preauthKey] - case .burrow: - [.none, .password, .preauthKey] - } + private var inferredTailnetProvider: TailnetProvider { + TailnetProvider.inferred( + authority: normalizedOptional(draft.authority), + explicit: discoveryStatus?.provider + ) + } + + private var isManagedTailnetAuthority: Bool { + TailnetProvider.isManagedTailscaleAuthority(normalizedOptional(draft.authority)) } @ViewBuilder diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift index 9a534ce..b048add 100644 --- a/Apple/UI/Networks/Network.swift +++ b/Apple/UI/Networks/Network.swift @@ -26,13 +26,6 @@ struct TailnetNetworkPayload: Codable, Sendable { } } -struct TailnetLoginStartRequest: Codable, Sendable { - var accountName: String - var identityName: String - var hostname: String? - var controlURL: String? -} - struct TailnetDiscoveryResponse: Codable, Sendable { var domain: String var provider: TailnetProvider @@ -40,23 +33,6 @@ struct TailnetDiscoveryResponse: Codable, Sendable { var oidcIssuer: String? } -struct TailnetLoginStatus: Codable, Sendable { - var backendState: String - var authURL: String? - var running: Bool - var needsLogin: Bool - var tailnetName: String? - var magicDNSSuffix: String? - var selfDNSName: String? - var tailscaleIPs: [String] - var health: [String] -} - -struct TailnetLoginStartResponse: Codable, Sendable { - var sessionID: String - var status: TailnetLoginStatus -} - struct TailnetAuthorityProbeStatus: Sendable { var authority: String var statusCode: Int @@ -64,148 +40,38 @@ struct TailnetAuthorityProbeStatus: Sendable { var detail: String? } -enum TailnetBridgeClient { - private static let baseURL = URL(string: "http://127.0.0.1:8080")! - - static func startLogin(_ request: TailnetLoginStartRequest) async throws -> TailnetLoginStartResponse { - var urlRequest = URLRequest( - url: baseURL.appendingPathComponent("v1/tailscale/login/start") - ) - urlRequest.httpMethod = "POST" - urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - urlRequest.httpBody = try encoder.encode(request) - - let (data, response) = try await URLSession.shared.data(for: urlRequest) - try validate(response: response, data: data) - - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - return try decoder.decode(TailnetLoginStartResponse.self, from: data) - } - - static func status(sessionID: String) async throws -> TailnetLoginStatus { - let url = baseURL - .appendingPathComponent("v1/tailscale/login") - .appendingPathComponent(sessionID) - let (data, response) = try await URLSession.shared.data(from: url) - try validate(response: response, data: data) - - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - return try decoder.decode(TailnetLoginStatus.self, from: data) - } - - fileprivate static func validate(response: URLResponse, data: Data) throws { - guard let http = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - guard (200..<300).contains(http.statusCode) else { - let message = String(data: data, encoding: .utf8)?.trimmingCharacters( - in: .whitespacesAndNewlines - ) - throw TailnetBridgeError.server(message?.ifEmpty("HTTP \(http.statusCode)") ?? "HTTP \(http.statusCode)") - } - } -} - enum TailnetDiscoveryClient { - private static let baseURL = URL(string: "http://127.0.0.1:8080")! + static func discover(email: String, socketURL: URL) async throws -> TailnetDiscoveryResponse { + var request = Burrow_TailnetDiscoverRequest() + request.email = email - static func discover(email: String) async throws -> TailnetDiscoveryResponse { - guard var components = URLComponents( - url: baseURL.appendingPathComponent("v1/tailnet/discover"), - resolvingAgainstBaseURL: false - ) else { - throw URLError(.badURL) - } - components.queryItems = [ - URLQueryItem(name: "email", value: email) - ] - guard let url = components.url else { - throw URLError(.badURL) - } - - let (data, response) = try await URLSession.shared.data(from: url) - try TailnetBridgeClient.validate(response: response, data: data) - - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - return try decoder.decode(TailnetDiscoveryResponse.self, from: data) + let response = try await TailnetClient.unix(socketURL: socketURL).discover(request) + return TailnetDiscoveryResponse( + domain: response.domain, + provider: response.managed ? .tailscale : .headscale, + authority: response.authority, + oidcIssuer: response.oidcIssuer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : response.oidcIssuer + ) } } enum TailnetAuthorityProbeClient { - static func probe(provider: TailnetProvider, authority: String) async throws -> TailnetAuthorityProbeStatus { - let normalizedAuthority = normalizeAuthority(authority) - let baseURL = try validatedBaseURL(normalizedAuthority) - let probeURL = probeURL(for: provider, baseURL: baseURL) - - var request = URLRequest(url: probeURL) - request.timeoutInterval = 10 - request.setValue("application/json", forHTTPHeaderField: "Accept") - - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - guard (200..<300).contains(http.statusCode) else { - let message = String(data: data, encoding: .utf8)?.trimmingCharacters( - in: .whitespacesAndNewlines - ) - throw TailnetBridgeError.server(message?.ifEmpty("HTTP \(http.statusCode)") ?? "HTTP \(http.statusCode)") - } - - let body = String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let detail = body.flatMap { $0.isEmpty ? nil : $0 } + static func probe(authority: String, socketURL: URL) async throws -> TailnetAuthorityProbeStatus { + var request = Burrow_TailnetProbeRequest() + request.authority = authority + let response = try await TailnetClient.unix(socketURL: socketURL).probe(request) return TailnetAuthorityProbeStatus( - authority: normalizedAuthority, - statusCode: http.statusCode, - summary: "\(provider.title) reachable", - detail: detail + authority: response.authority, + statusCode: Int(response.statusCode), + summary: response.summary, + detail: response.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : response.detail ) } - - private static func normalizeAuthority(_ authority: String) -> String { - let trimmed = authority.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.contains("://") { - return trimmed - } - return "https://\(trimmed)" - } - - private static func validatedBaseURL(_ authority: String) throws -> URL { - guard let url = URL(string: authority), url.host != nil else { - throw TailnetBridgeError.server("Invalid server URL") - } - return url - } - - private static func probeURL(for provider: TailnetProvider, baseURL: URL) -> URL { - switch provider { - case .headscale: - baseURL.appendingPathComponent("health") - case .burrow: - baseURL.appendingPathComponent("healthz") - case .tailscale: - baseURL - } - } -} - -enum TailnetBridgeError: LocalizedError { - case server(String) - - var errorDescription: String? { - switch self { - case .server(let message): - message - } - } } @Observable @@ -215,7 +81,7 @@ final class NetworkViewModel: Sendable { private(set) var connectionError: String? private let socketURLResult: Result - nonisolated(unsafe) private var task: Task? + @ObservationIgnored private var task: Task? init(socketURLResult: Result) { self.socketURLResult = socketURLResult @@ -242,6 +108,16 @@ final class NetworkViewModel: Sendable { try await addNetwork(type: .tailnet, payload: payload.encoded()) } + func discoverTailnet(email: String) async throws -> TailnetDiscoveryResponse { + let socketURL = try socketURLResult.get() + return try await TailnetDiscoveryClient.discover(email: email, socketURL: socketURL) + } + + func probeTailnetAuthority(_ authority: String) async throws -> TailnetAuthorityProbeStatus { + let socketURL = try socketURLResult.get() + return try await TailnetAuthorityProbeClient.probe(authority: authority, socketURL: socketURL) + } + private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 { let socketURL = try socketURLResult.get() let networkID = nextNetworkID @@ -341,19 +217,6 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable { } } - var supportsWebLogin: Bool { - switch self { - case .tailscale, .headscale: - true - case .burrow: - false - } - } - - var requiresControlURL: Bool { - self != .tailscale - } - var defaultAuthority: String? { switch self { case .tailscale: @@ -368,19 +231,44 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable { var subtitle: String { switch self { case .tailscale: - "Use Tailscale's real browser login flow." + "Managed Tailnet authority." case .headscale: - "Use your Headscale control plane with browser or key-based sign-in." + "Custom Tailnet control server." case .burrow: - "Store Burrow control-plane credentials." + "Burrow-native Tailnet authority." } } + + static func inferred(authority: String?, explicit: TailnetProvider?) -> TailnetProvider { + if explicit == .burrow { + return .burrow + } + if isManagedTailscaleAuthority(authority) { + return .tailscale + } + return .headscale + } + + static func isManagedTailscaleAuthority(_ authority: String?) -> Bool { + guard let normalized = authority? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: "/")), + !normalized.isEmpty + else { + return false + } + + return normalized == "https://controlplane.tailscale.com" + || normalized == "http://controlplane.tailscale.com" + || normalized == "controlplane.tailscale.com" + } } enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { case wireGuard case tor - case headscale + case tailnet var id: String { rawValue } @@ -388,7 +276,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { switch self { case .wireGuard: "WireGuard" case .tor: "Tor" - case .headscale: "Tailnet" + case .tailnet: "Tailnet" } } @@ -396,7 +284,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { switch self { case .wireGuard: "Import a tunnel and optional account metadata." case .tor: "Store Arti account and identity preferences." - case .headscale: "Save Tailscale, Headscale, or Burrow control-plane identities." + case .tailnet: "Save Tailnet authority, identity, and login material." } } @@ -404,7 +292,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { switch self { case .wireGuard: .init("WireGuard") case .tor: .orange - case .headscale: .mint + case .tailnet: .mint } } @@ -412,7 +300,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { switch self { case .wireGuard: "Add Network" case .tor: "Save Account" - case .headscale: "Save Account" + case .tailnet: "Save Account" } } @@ -422,7 +310,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { nil case .tor: "Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet." - case .headscale: + case .tailnet: "Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can be stored in the daemon." } } @@ -430,7 +318,6 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { case none - case web case password case preauthKey @@ -439,7 +326,6 @@ enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { var title: String { switch self { case .none: "None" - case .web: "Web Login" case .password: "Password" case .preauthKey: "Preauth Key" } @@ -465,17 +351,15 @@ struct NetworkAccountRecord: Codable, Identifiable, Hashable, Sendable { struct TailnetCard { var id: Int32 - var provider: String var title: String var detail: String init(network: Burrow_Network) { let payload = (try? JSONDecoder().decode(TailnetNetworkPayload.self, from: network.payload)) id = network.id - provider = payload?.provider.title ?? "Tailnet" title = payload?.tailnet ?? payload?.hostname ?? "Tailnet" detail = [ - payload?.provider.title, + payload?.authority.flatMap { URL(string: $0)?.host } ?? payload?.authority, payload?.authority, payload.map { "Account: \($0.account)" }, ] @@ -492,7 +376,7 @@ struct TailnetCard { VStack(alignment: .leading, spacing: 12) { HStack { VStack(alignment: .leading, spacing: 4) { - Text(provider) + Text("Tailnet") .font(.headline) .foregroundStyle(.white.opacity(0.85)) Text(title) diff --git a/burrow/src/control/discovery.rs b/burrow/src/control/discovery.rs index 28b48bb..5fc7add 100644 --- a/burrow/src/control/discovery.rs +++ b/burrow/src/control/discovery.rs @@ -7,6 +7,7 @@ use super::TailnetProvider; pub const TAILNET_DISCOVERY_REL: &str = "https://burrow.net/rel/tailnet-control-server"; const TAILNET_DISCOVERY_PATH: &str = "/.well-known/burrow-tailnet"; const WEBFINGER_PATH: &str = "/.well-known/webfinger"; +const MANAGED_TAILSCALE_AUTHORITY: &str = "controlplane.tailscale.com"; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct TailnetDiscovery { @@ -17,6 +18,15 @@ pub struct TailnetDiscovery { pub oidc_issuer: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct TailnetAuthorityProbe { + pub authority: String, + pub status_code: i32, + pub summary: String, + pub detail: String, + pub reachable: bool, +} + #[derive(Clone, Debug, Default, Deserialize)] struct WebFingerDocument { #[serde(default)] @@ -43,6 +53,63 @@ pub async fn discover_tailnet(email: &str) -> Result { discover_tailnet_at(&client, email, &base_url).await } +pub fn normalize_authority(authority: &str) -> String { + let trimmed = authority.trim(); + if trimmed.contains("://") { + trimmed.to_owned() + } else { + format!("https://{trimmed}") + } +} + +pub fn is_managed_tailscale_authority(authority: &str) -> bool { + let normalized = normalize_authority(authority) + .trim_end_matches('/') + .to_ascii_lowercase(); + normalized == format!("https://{MANAGED_TAILSCALE_AUTHORITY}") + || normalized == format!("http://{MANAGED_TAILSCALE_AUTHORITY}") +} + +pub async fn probe_tailnet_authority(authority: &str) -> Result { + let authority = normalize_authority(authority); + if is_managed_tailscale_authority(&authority) { + return Ok(TailnetAuthorityProbe { + authority, + status_code: 200, + summary: "Tailscale-managed control plane".to_owned(), + detail: "Using Tailscale's default login server.".to_owned(), + reachable: true, + }); + } + + let base_url = + Url::parse(&authority).with_context(|| format!("invalid tailnet authority {authority}"))?; + let client = Client::builder() + .user_agent("burrow-tailnet-probe") + .timeout(std::time::Duration::from_secs(10)) + .build() + .context("failed to build tailnet authority probe client")?; + + if let Some(status) = + probe_url(&client, base_url.join("/health")?, &authority, "Tailnet server reachable").await? + { + return Ok(status); + } + + if let Some(status) = probe_url( + &client, + base_url.clone(), + &authority, + "Tailnet server reachable", + ) + .await? + { + return Ok(status); + } + + Err(anyhow!("could not connect to the server")) +} + pub async fn discover_tailnet_at( client: &Client, email: &str, @@ -57,7 +124,7 @@ pub async fn discover_tailnet_at( if let Some(authority) = discover_webfinger(client, email, base_url).await? { return Ok(TailnetDiscovery { domain, - provider: TailnetProvider::Headscale, + provider: inferred_provider(Some(&authority), None), authority, oidc_issuer: None, }); @@ -78,6 +145,19 @@ pub fn email_domain(email: &str) -> Result { Ok(domain) } +pub fn inferred_provider( + authority: Option<&str>, + explicit: Option<&TailnetProvider>, +) -> TailnetProvider { + if matches!(explicit, Some(TailnetProvider::Burrow)) { + return TailnetProvider::Burrow; + } + if authority.is_some_and(is_managed_tailscale_authority) { + return TailnetProvider::Tailscale; + } + TailnetProvider::Headscale +} + async fn discover_well_known(client: &Client, base_url: &Url) -> Result> { let url = base_url .join(TAILNET_DISCOVERY_PATH) @@ -133,6 +213,37 @@ async fn discover_webfinger(client: &Client, email: &str, base_url: &Url) -> Res } } +async fn probe_url( + client: &Client, + url: Url, + authority: &str, + summary: &str, +) -> Result> { + let response = match client + .get(url) + .header("accept", "application/json") + .send() + .await + { + Ok(response) => response, + Err(_) => return Ok(None), + }; + + let status = response.status(); + if !status.is_success() { + return Ok(None); + } + + let detail = response.text().await.unwrap_or_default().trim().to_owned(); + Ok(Some(TailnetAuthorityProbe { + authority: authority.to_owned(), + status_code: i32::from(status.as_u16()), + summary: summary.to_owned(), + detail, + reachable: true, + })) +} + #[cfg(test)] mod tests { use axum::{routing::get, Router}; @@ -147,6 +258,13 @@ mod tests { assert!(email_domain("contact").is_err()); } + #[test] + fn detects_managed_tailscale_authority() { + assert!(is_managed_tailscale_authority("controlplane.tailscale.com")); + assert!(is_managed_tailscale_authority("https://controlplane.tailscale.com/")); + assert!(!is_managed_tailscale_authority("https://ts.burrow.net")); + } + #[tokio::test] async fn discovers_from_well_known_document() -> Result<()> { let router = Router::new().route( @@ -209,4 +327,20 @@ mod tests { server.abort(); Ok(()) } + + #[tokio::test] + async fn probes_custom_authority() -> Result<()> { + let router = Router::new().route("/health", get(|| async { "ok" })); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let authority = format!("http://{}", listener.local_addr()?); + let server = tokio::spawn(async move { axum::serve(listener, router).await }); + + let status = probe_tailnet_authority(&authority).await?; + assert_eq!(status.authority, authority); + assert_eq!(status.status_code, 200); + assert!(status.reachable); + + server.abort(); + Ok(()) + } } diff --git a/burrow/src/daemon/instance.rs b/burrow/src/daemon/instance.rs index 1eb0629..e4e6d96 100644 --- a/burrow/src/daemon/instance.rs +++ b/burrow/src/daemon/instance.rs @@ -13,13 +13,16 @@ use tun::tokio::TunInterface; use super::{ rpc::grpc_defs::{ - networks_server::Networks, tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest, - NetworkListResponse, NetworkReorderRequest, State as RPCTunnelState, + networks_server::Networks, tailnet_control_server::TailnetControl, + tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest, NetworkListResponse, + NetworkReorderRequest, State as RPCTunnelState, TailnetDiscoverRequest, + TailnetDiscoverResponse, TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, TunnelStatusResponse, }, runtime::{ActiveTunnel, ResolvedTunnel}, }; use crate::{ + control::discovery, daemon::rpc::ServerConfig, database::{add_network, delete_network, get_connection, list_networks, reorder_network}, }; @@ -266,6 +269,47 @@ impl Networks for DaemonRPCServer { } } +#[tonic::async_trait] +impl TailnetControl for DaemonRPCServer { + async fn discover( + &self, + request: Request, + ) -> Result, RspStatus> { + let request = request.into_inner(); + let discovery = discovery::discover_tailnet(&request.email) + .await + .map_err(proc_err)?; + + Ok(Response::new(TailnetDiscoverResponse { + domain: discovery.domain, + authority: discovery.authority.clone(), + oidc_issuer: discovery.oidc_issuer.unwrap_or_default(), + managed: matches!( + discovery::inferred_provider(Some(&discovery.authority), Some(&discovery.provider)), + crate::control::TailnetProvider::Tailscale + ), + })) + } + + async fn probe( + &self, + request: Request, + ) -> Result, RspStatus> { + let request = request.into_inner(); + let status = discovery::probe_tailnet_authority(&request.authority) + .await + .map_err(proc_err)?; + + Ok(Response::new(TailnetProbeResponse { + authority: status.authority, + status_code: status.status_code, + summary: status.summary, + detail: status.detail, + reachable: status.reachable, + })) + } +} + fn proc_err(err: impl ToString) -> RspStatus { RspStatus::internal(err.to_string()) } diff --git a/burrow/src/daemon/mod.rs b/burrow/src/daemon/mod.rs index a016788..724e3bb 100644 --- a/burrow/src/daemon/mod.rs +++ b/burrow/src/daemon/mod.rs @@ -16,7 +16,10 @@ use tonic::transport::Server; use tracing::info; use crate::{ - daemon::rpc::grpc_defs::{networks_server::NetworksServer, tunnel_server::TunnelServer}, + daemon::rpc::grpc_defs::{ + networks_server::NetworksServer, tailnet_control_server::TailnetControlServer, + tunnel_server::TunnelServer, + }, database::get_connection, }; @@ -36,9 +39,11 @@ pub async fn daemon_main( let uds = UnixListener::bind(sock_path)?; let serve_job = tokio::spawn(async move { let uds_stream = UnixListenerStream::new(uds); + let tailnet_server = burrow_server.clone(); let _srv = Server::builder() .add_service(TunnelServer::new(burrow_server.clone())) .add_service(NetworksServer::new(burrow_server)) + .add_service(TailnetControlServer::new(tailnet_server)) .serve_with_incoming(uds_stream) .await?; Ok::<(), AhError>(()) diff --git a/burrow/src/daemon/rpc/client.rs b/burrow/src/daemon/rpc/client.rs index 06a9b45..aa84c64 100644 --- a/burrow/src/daemon/rpc/client.rs +++ b/burrow/src/daemon/rpc/client.rs @@ -5,11 +5,15 @@ use tokio::net::UnixStream; use tonic::transport::{Endpoint, Uri}; use tower::service_fn; -use super::grpc_defs::{networks_client::NetworksClient, tunnel_client::TunnelClient}; +use super::grpc_defs::{ + networks_client::NetworksClient, tailnet_control_client::TailnetControlClient, + tunnel_client::TunnelClient, +}; use crate::daemon::get_socket_path; pub struct BurrowClient { pub networks_client: NetworksClient, + pub tailnet_client: TailnetControlClient, pub tunnel_client: TunnelClient, } @@ -31,9 +35,11 @@ impl BurrowClient { })) .await?; let nw_client = NetworksClient::new(channel.clone()); + let tailnet_client = TailnetControlClient::new(channel.clone()); let tun_client = TunnelClient::new(channel.clone()); Ok(BurrowClient { networks_client: nw_client, + tailnet_client, tunnel_client: tun_client, }) } diff --git a/proto/burrow.proto b/proto/burrow.proto index 5b5a30b..79e8976 100644 --- a/proto/burrow.proto +++ b/proto/burrow.proto @@ -17,6 +17,11 @@ service Networks { rpc NetworkDelete (NetworkDeleteRequest) returns (Empty); } +service TailnetControl { + rpc Discover (TailnetDiscoverRequest) returns (TailnetDiscoverResponse); + rpc Probe (TailnetProbeRequest) returns (TailnetProbeResponse); +} + message NetworkReorderRequest { int32 id = 1; int32 index = 2; @@ -56,6 +61,29 @@ message Empty { } +message TailnetDiscoverRequest { + string email = 1; +} + +message TailnetDiscoverResponse { + string domain = 1; + string authority = 2; + string oidc_issuer = 3; + bool managed = 4; +} + +message TailnetProbeRequest { + string authority = 1; +} + +message TailnetProbeResponse { + string authority = 1; + int32 status_code = 2; + string summary = 3; + string detail = 4; + bool reachable = 5; +} + enum State { Stopped = 0; Running = 1; From 0c660acd1e0b61dde4a3ea80643b5df9ae381623 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Fri, 3 Apr 2026 02:09:58 -0700 Subject: [PATCH 053/102] Add daemon-owned Tailnet login flow --- Apple/Core/Client.swift | 228 ++++++++++++++++++++ Apple/UI/BurrowView.swift | 318 ++++++++++++++++++++++++++-- Apple/UI/Networks/Network.swift | 93 ++++++++ burrow/src/auth/server/tailscale.rs | 77 ++++++- burrow/src/daemon/instance.rs | 93 +++++++- proto/burrow.proto | 31 +++ 6 files changed, 812 insertions(+), 28 deletions(-) diff --git a/Apple/Core/Client.swift b/Apple/Core/Client.swift index c426fe7..e44ebcd 100644 --- a/Apple/Core/Client.swift +++ b/Apple/Core/Client.swift @@ -68,6 +68,46 @@ public struct Burrow_TailnetProbeResponse: Sendable { public init() {} } +public struct Burrow_TailnetLoginStartRequest: Sendable { + public var accountName: String = "" + public var identityName: String = "" + public var hostname: String = "" + public var authority: String = "" + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_TailnetLoginStatusRequest: Sendable { + public var sessionID: String = "" + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_TailnetLoginCancelRequest: Sendable { + public var sessionID: String = "" + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_TailnetLoginStatusResponse: Sendable { + public var sessionID: String = "" + public var backendState: String = "" + public var authURL: String = "" + public var running: Bool = false + public var needsLogin: Bool = false + public var tailnetName: String = "" + public var magicDNSSuffix: String = "" + public var selfDNSName: String = "" + public var tailnetIPs: [String] = [] + public var health: [String] = [] + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + extension Burrow_TailnetDiscoverRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "burrow.TailnetDiscoverRequest" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ @@ -195,6 +235,158 @@ extension Burrow_TailnetProbeResponse: SwiftProtobuf.Message, SwiftProtobuf._Mes } } +extension Burrow_TailnetLoginStartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.TailnetLoginStartRequest" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .standard(proto: "account_name"), + 2: .standard(proto: "identity_name"), + 3: .same(proto: "hostname"), + 4: .same(proto: "authority"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularStringField(value: &self.accountName) + case 2: try decoder.decodeSingularStringField(value: &self.identityName) + case 3: try decoder.decodeSingularStringField(value: &self.hostname) + case 4: try decoder.decodeSingularStringField(value: &self.authority) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.accountName.isEmpty { + try visitor.visitSingularStringField(value: self.accountName, fieldNumber: 1) + } + if !self.identityName.isEmpty { + try visitor.visitSingularStringField(value: self.identityName, fieldNumber: 2) + } + if !self.hostname.isEmpty { + try visitor.visitSingularStringField(value: self.hostname, fieldNumber: 3) + } + if !self.authority.isEmpty { + try visitor.visitSingularStringField(value: self.authority, fieldNumber: 4) + } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_TailnetLoginStatusRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.TailnetLoginStatusRequest" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .standard(proto: "session_id") + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularStringField(value: &self.sessionID) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.sessionID.isEmpty { + try visitor.visitSingularStringField(value: self.sessionID, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_TailnetLoginCancelRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.TailnetLoginCancelRequest" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .standard(proto: "session_id") + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularStringField(value: &self.sessionID) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.sessionID.isEmpty { + try visitor.visitSingularStringField(value: self.sessionID, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_TailnetLoginStatusResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.TailnetLoginStatusResponse" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .standard(proto: "session_id"), + 2: .standard(proto: "backend_state"), + 3: .standard(proto: "auth_url"), + 4: .same(proto: "running"), + 5: .standard(proto: "needs_login"), + 6: .standard(proto: "tailnet_name"), + 7: .standard(proto: "magic_dns_suffix"), + 8: .standard(proto: "self_dns_name"), + 9: .standard(proto: "tailnet_ips"), + 10: .same(proto: "health"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularStringField(value: &self.sessionID) + case 2: try decoder.decodeSingularStringField(value: &self.backendState) + case 3: try decoder.decodeSingularStringField(value: &self.authURL) + case 4: try decoder.decodeSingularBoolField(value: &self.running) + case 5: try decoder.decodeSingularBoolField(value: &self.needsLogin) + case 6: try decoder.decodeSingularStringField(value: &self.tailnetName) + case 7: try decoder.decodeSingularStringField(value: &self.magicDNSSuffix) + case 8: try decoder.decodeSingularStringField(value: &self.selfDNSName) + case 9: try decoder.decodeRepeatedStringField(value: &self.tailnetIPs) + case 10: try decoder.decodeRepeatedStringField(value: &self.health) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.sessionID.isEmpty { + try visitor.visitSingularStringField(value: self.sessionID, fieldNumber: 1) + } + if !self.backendState.isEmpty { + try visitor.visitSingularStringField(value: self.backendState, fieldNumber: 2) + } + if !self.authURL.isEmpty { + try visitor.visitSingularStringField(value: self.authURL, fieldNumber: 3) + } + if self.running { + try visitor.visitSingularBoolField(value: self.running, fieldNumber: 4) + } + if self.needsLogin { + try visitor.visitSingularBoolField(value: self.needsLogin, fieldNumber: 5) + } + if !self.tailnetName.isEmpty { + try visitor.visitSingularStringField(value: self.tailnetName, fieldNumber: 6) + } + if !self.magicDNSSuffix.isEmpty { + try visitor.visitSingularStringField(value: self.magicDNSSuffix, fieldNumber: 7) + } + if !self.selfDNSName.isEmpty { + try visitor.visitSingularStringField(value: self.selfDNSName, fieldNumber: 8) + } + if !self.tailnetIPs.isEmpty { + try visitor.visitRepeatedStringField(value: self.tailnetIPs, fieldNumber: 9) + } + if !self.health.isEmpty { + try visitor.visitRepeatedStringField(value: self.health, fieldNumber: 10) + } + try unknownFields.traverse(visitor: &visitor) + } +} + public struct TailnetClient: Client, GRPCClient { public let channel: GRPCChannel public var defaultCallOptions: CallOptions @@ -227,4 +419,40 @@ public struct TailnetClient: Client, GRPCClient { interceptors: [] ) } + + public func loginStart( + _ request: Burrow_TailnetLoginStartRequest, + callOptions: CallOptions? = nil + ) async throws -> Burrow_TailnetLoginStatusResponse { + try await self.performAsyncUnaryCall( + path: "/burrow.TailnetControl/LoginStart", + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: [] + ) + } + + public func loginStatus( + _ request: Burrow_TailnetLoginStatusRequest, + callOptions: CallOptions? = nil + ) async throws -> Burrow_TailnetLoginStatusResponse { + try await self.performAsyncUnaryCall( + path: "/burrow.TailnetControl/LoginStatus", + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: [] + ) + } + + public func loginCancel( + _ request: Burrow_TailnetLoginCancelRequest, + callOptions: CallOptions? = nil + ) async throws -> Burrow_Empty { + try await self.performAsyncUnaryCall( + path: "/burrow.TailnetControl/LoginCancel", + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: [] + ) + } } diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index 9938eef..b95e904 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -1,6 +1,9 @@ import BurrowConfiguration import Foundation import SwiftUI +#if canImport(AuthenticationServices) +import AuthenticationServices +#endif #if canImport(UIKit) import UIKit #elseif canImport(AppKit) @@ -309,6 +312,7 @@ private struct AccountDraft { accountName = "default" identityName = "apple" authority = TailnetProvider.tailscale.defaultAuthority ?? "" + authMode = .web } } } @@ -329,6 +333,14 @@ private struct ConfigurationSheetView: View { @State private var authorityProbeStatus: TailnetAuthorityProbeStatus? @State private var authorityProbeError: String? @State private var isProbingAuthority = false + @State private var tailnetLoginStatus: TailnetLoginStatus? + @State private var tailnetLoginError: String? + @State private var tailnetLoginSessionID: String? + @State private var isStartingTailnetLogin = false + @State private var tailnetPresentedAuthURL: URL? + @State private var preserveTailnetLoginSession = false + @State private var browserAuthenticator = TailnetBrowserAuthenticator() + @State private var tailnetLoginPollTask: Task? @State private var didRunAutomation = false init( @@ -397,7 +409,10 @@ private struct ConfigurationSheetView: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { - dismiss() + Task { @MainActor in + await cancelTailnetLoginIfNeeded() + dismiss() + } } } #if os(iOS) @@ -446,14 +461,28 @@ private struct ConfigurationSheetView: View { .onChange(of: draft.discoveryEmail) { _, _ in resetTailnetDiscoveryFeedback() } + .onChange(of: draft.authMode) { _, newMode in + guard newMode != .web else { return } + Task { @MainActor in + await cancelTailnetLoginIfNeeded() + } + } + .onDisappear { + tailnetLoginPollTask?.cancel() + browserAuthenticator.cancel() + if !preserveTailnetLoginSession { + Task { @MainActor in + await cancelTailnetLoginIfNeeded() + } + } + } } @ViewBuilder private var tailnetSections: some View { Section("Connection") { TextField("Email address", text: $draft.discoveryEmail) - .textInputAutocapitalization(.never) - .keyboardType(.emailAddress) + .burrowEmailField() .burrowLoginField() .autocorrectionDisabled() @@ -507,22 +536,44 @@ private struct ConfigurationSheetView: View { } Section("Authentication") { - TextField("Username", text: $draft.username) - .burrowLoginField() - .autocorrectionDisabled() Picker("Authentication", selection: $draft.authMode) { ForEach(availableTailnetAuthModes) { mode in Text(mode.title).tag(mode) } } .pickerStyle(.menu) - if draft.authMode != .none { - SecureField( - draft.authMode == .password ? "Password" : "Preauth Key", - text: $draft.secret - ) + + if draft.authMode == .web { + Button { + startTailnetLogin() + } label: { + Label { + Text(isStartingTailnetLogin ? "Starting Sign-In" : tailnetSignInActionTitle) + } icon: { + Image(systemName: isStartingTailnetLogin ? "hourglass" : "person.badge.key") + } + } + .buttonStyle(.borderless) + .disabled(isStartingTailnetLogin || normalizedOptional(draft.authority) == nil) + + if let tailnetLoginStatus { + tailnetLoginCard(status: tailnetLoginStatus, failure: nil) + } else if let tailnetLoginError { + tailnetLoginCard(status: nil, failure: tailnetLoginError) + } + } else { + TextField("Username", text: $draft.username) + .burrowLoginField() + .autocorrectionDisabled() + if draft.authMode != .none { + SecureField( + draft.authMode == .password ? "Password" : "Preauth Key", + text: $draft.secret + ) + } } - Text("Tailnet account material stays on-device. Burrow stores the authority and credentials for daemon-managed registration and refresh.") + + Text(tailnetAuthenticationFootnote) .font(.footnote) .foregroundStyle(.secondary) } @@ -583,6 +634,9 @@ private struct ConfigurationSheetView: View { HStack(spacing: 8) { summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom") summaryBadge(draft.authMode.title) + if tailnetLoginStatus?.running == true { + summaryBadge("Signed In") + } } } } @@ -659,6 +713,52 @@ private struct ConfigurationSheetView: View { ) } + private func tailnetLoginCard( + status: TailnetLoginStatus?, + failure: String? + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + if let status { + Text(status.running ? "Signed In" : status.needsLogin ? "Browser Sign-In Required" : "Checking Sign-In") + .font(.subheadline.weight(.medium)) + if let tailnetName = status.tailnetName, !tailnetName.isEmpty { + Text("Tailnet: \(tailnetName)") + .font(.footnote) + .foregroundStyle(.secondary) + } + if let selfDNSName = status.selfDNSName, !selfDNSName.isEmpty { + Text(selfDNSName) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + if !status.tailnetIPs.isEmpty { + Text(status.tailnetIPs.joined(separator: ", ")) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + if !status.health.isEmpty { + Text(status.health.joined(separator: " • ")) + .font(.footnote) + .foregroundStyle(.secondary) + } + } else if let failure { + Text("Sign-In failed") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.red) + Text(failure) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.thinMaterial) + ) + } + private func summaryBadge(_ label: String) -> some View { Text(label) .font(.caption.weight(.medium)) @@ -813,6 +913,9 @@ private struct ConfigurationSheetView: View { if normalizedOptional(draft.authority) == nil { return true } + if draft.authMode == .web { + return tailnetLoginStatus?.running != true + } if draft.authMode != .none && normalizedOptional(draft.secret) == nil { return true } @@ -897,8 +1000,9 @@ private struct ConfigurationSheetView: View { } private func submitTailnet() async throws { - let secret = draft.authMode == .none ? nil : draft.secret + let secret = (draft.authMode == .none || draft.authMode == .web) ? nil : draft.secret let username = normalizedOptional(draft.username) + preserveTailnetLoginSession = draft.authMode == .web && tailnetLoginStatus?.running == true try await saveTailnetAccount(secret: secret, username: username) dismiss() } @@ -922,7 +1026,7 @@ private struct ConfigurationSheetView: View { switch automation.action { case .tailnetLogin: applyTailnetDefaults(for: .tailscale) - probeTailnetAuthority() + startTailnetLogin() case .headscaleProbe: draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority probeTailnetAuthority() @@ -950,6 +1054,10 @@ private struct ConfigurationSheetView: View { "Auth: \(draft.authMode.title)", ] + if draft.authMode == .web, tailnetLoginStatus?.running == true { + noteParts.append("Browser sign-in complete") + } + do { let networkID = try await networkViewModel.addTailnetNetwork(payload: payload) noteParts.append("Linked to daemon network #\(networkID)") @@ -1003,7 +1111,36 @@ private struct ConfigurationSheetView: View { resetTailnetDiscoveryFeedback() draft.authority = provider.defaultAuthority ?? "" if !availableTailnetAuthModes.contains(draft.authMode) { - draft.authMode = .none + draft.authMode = .web + } + } + + private func startTailnetLogin() { + guard let authority = normalizedOptional(draft.authority) else { + tailnetLoginStatus = nil + tailnetLoginError = "Enter a server URL first." + return + } + + isStartingTailnetLogin = true + tailnetLoginError = nil + preserveTailnetLoginSession = false + + Task { @MainActor in + defer { isStartingTailnetLogin = false } + do { + let status = try await networkViewModel.startTailnetLogin( + accountName: normalized(draft.accountName, fallback: "default"), + identityName: normalized(draft.identityName, fallback: "apple"), + hostname: normalizedOptional(draft.hostname), + authority: authority + ) + tailnetLoginSessionID = status.sessionID + updateTailnetLoginStatus(status) + beginTailnetLoginPolling(sessionID: status.sessionID) + } catch { + tailnetLoginError = error.localizedDescription + } } } @@ -1031,6 +1168,7 @@ private struct ConfigurationSheetView: View { private func resetAuthorityProbe() { authorityProbeStatus = nil authorityProbeError = nil + tailnetLoginError = nil } private func resetTailnetDiscoveryFeedback() { @@ -1062,6 +1200,76 @@ private struct ConfigurationSheetView: View { } } + private func beginTailnetLoginPolling(sessionID: String) { + tailnetLoginPollTask?.cancel() + tailnetLoginPollTask = Task { @MainActor in + while !Task.isCancelled { + do { + let status = try await networkViewModel.tailnetLoginStatus(sessionID: sessionID) + updateTailnetLoginStatus(status) + if status.running { + tailnetLoginPollTask = nil + return + } + } catch { + tailnetLoginError = error.localizedDescription + tailnetLoginPollTask = nil + return + } + try? await Task.sleep(for: .seconds(1)) + } + } + } + + private func updateTailnetLoginStatus(_ status: TailnetLoginStatus) { + tailnetLoginStatus = status + tailnetLoginError = nil + tailnetLoginSessionID = status.sessionID + + if status.running { + browserAuthenticator.cancel() + tailnetPresentedAuthURL = nil + return + } + + guard let authURL = status.authURL else { + return + } + + if tailnetPresentedAuthURL != authURL { + tailnetPresentedAuthURL = authURL + browserAuthenticator.start(url: authURL) { [sessionID = status.sessionID] in + Task { @MainActor in + if tailnetLoginStatus?.running != true { + tailnetLoginSessionID = sessionID + } + } + } + } + } + + private func cancelTailnetLoginIfNeeded() async { + tailnetLoginPollTask?.cancel() + tailnetLoginPollTask = nil + browserAuthenticator.cancel() + tailnetPresentedAuthURL = nil + + guard tailnetLoginStatus?.running != true, + let sessionID = tailnetLoginSessionID + else { + return + } + + do { + try await networkViewModel.cancelTailnetLogin(sessionID: sessionID) + } catch { + tailnetLoginError = error.localizedDescription + } + + tailnetLoginStatus = nil + tailnetLoginSessionID = nil + } + private func pasteWireGuardConfiguration() { guard let clipboardString else { return } draft.wireGuardConfig = clipboardString @@ -1108,7 +1316,28 @@ private struct ConfigurationSheetView: View { } private var availableTailnetAuthModes: [AccountAuthMode] { - [.none, .password, .preauthKey] + [.web, .none, .password, .preauthKey] + } + + private var tailnetSignInActionTitle: String { + if tailnetLoginStatus?.running == true { + return "Signed In" + } + if tailnetLoginSessionID != nil { + return "Resume Sign-In" + } + return "Start Sign-In" + } + + private var tailnetAuthenticationFootnote: String { + switch draft.authMode { + case .web: + return "Burrow asks the daemon to start a Tailnet browser sign-in session, then closes it locally once the daemon reports the device is running." + case .none: + return "Save the authority only. Useful when the control plane handles authentication elsewhere." + case .password, .preauthKey: + return "Tailnet account material stays on-device. Burrow stores the authority and credentials for daemon-managed registration and refresh." + } } private var inferredTailnetProvider: TailnetProvider { @@ -1215,8 +1444,65 @@ private extension View { self #endif } + + @ViewBuilder + func burrowEmailField() -> some View { + #if os(iOS) + textInputAutocapitalization(.never) + .keyboardType(.emailAddress) + #else + self + #endif + } } +#if canImport(AuthenticationServices) +@MainActor +private final class TailnetBrowserAuthenticator: NSObject { + private var session: ASWebAuthenticationSession? + + func start(url: URL, onDismiss: @escaping @Sendable () -> Void) { + cancel() + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: nil) { _, _ in + onDismiss() + } + session.presentationContextProvider = self + session.prefersEphemeralWebBrowserSession = false + self.session = session + _ = session.start() + } + + func cancel() { + session?.cancel() + session = nil + } +} + +extension TailnetBrowserAuthenticator: ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + #if canImport(AppKit) + return NSApplication.shared.keyWindow + ?? NSApplication.shared.windows.first + ?? ASPresentationAnchor() + #elseif canImport(UIKit) + return ASPresentationAnchor() + #else + return ASPresentationAnchor() + #endif + } +} +#else +@MainActor +private final class TailnetBrowserAuthenticator { + func start(url: URL, onDismiss: @escaping @Sendable () -> Void) { + _ = url + onDismiss() + } + + func cancel() {} +} +#endif + private struct BurrowAutomationConfig { enum Action: String { case tailnetLogin = "tailnet-login" diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift index b048add..32f0b8c 100644 --- a/Apple/UI/Networks/Network.swift +++ b/Apple/UI/Networks/Network.swift @@ -40,6 +40,19 @@ struct TailnetAuthorityProbeStatus: Sendable { var detail: String? } +struct TailnetLoginStatus: Sendable { + var sessionID: String + var backendState: String + var authURL: URL? + var running: Bool + var needsLogin: Bool + var tailnetName: String? + var magicDNSSuffix: String? + var selfDNSName: String? + var tailnetIPs: [String] + var health: [String] +} + enum TailnetDiscoveryClient { static func discover(email: String, socketURL: URL) async throws -> TailnetDiscoveryResponse { var request = Burrow_TailnetDiscoverRequest() @@ -74,6 +87,58 @@ enum TailnetAuthorityProbeClient { } } +enum TailnetLoginClient { + static func start( + accountName: String, + identityName: String, + hostname: String?, + authority: String, + socketURL: URL + ) async throws -> TailnetLoginStatus { + var request = Burrow_TailnetLoginStartRequest() + request.accountName = accountName + request.identityName = identityName + request.hostname = hostname ?? "" + request.authority = authority + let response = try await TailnetClient.unix(socketURL: socketURL).loginStart(request) + return decode(response) + } + + static func status(sessionID: String, socketURL: URL) async throws -> TailnetLoginStatus { + var request = Burrow_TailnetLoginStatusRequest() + request.sessionID = sessionID + let response = try await TailnetClient.unix(socketURL: socketURL).loginStatus(request) + return decode(response) + } + + static func cancel(sessionID: String, socketURL: URL) async throws { + var request = Burrow_TailnetLoginCancelRequest() + request.sessionID = sessionID + _ = try await TailnetClient.unix(socketURL: socketURL).loginCancel(request) + } + + private static func decode(_ response: Burrow_TailnetLoginStatusResponse) -> TailnetLoginStatus { + TailnetLoginStatus( + sessionID: response.sessionID, + backendState: response.backendState, + authURL: URL(string: response.authURL.trimmingCharacters(in: .whitespacesAndNewlines)), + running: response.running, + needsLogin: response.needsLogin, + tailnetName: response.tailnetName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : response.tailnetName, + magicDNSSuffix: response.magicDNSSuffix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : response.magicDNSSuffix, + selfDNSName: response.selfDNSName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : response.selfDNSName, + tailnetIPs: response.tailnetIPs, + health: response.health + ) + } +} + @Observable @MainActor final class NetworkViewModel: Sendable { @@ -118,6 +183,32 @@ final class NetworkViewModel: Sendable { return try await TailnetAuthorityProbeClient.probe(authority: authority, socketURL: socketURL) } + func startTailnetLogin( + accountName: String, + identityName: String, + hostname: String?, + authority: String + ) async throws -> TailnetLoginStatus { + let socketURL = try socketURLResult.get() + return try await TailnetLoginClient.start( + accountName: accountName, + identityName: identityName, + hostname: hostname, + authority: authority, + socketURL: socketURL + ) + } + + func tailnetLoginStatus(sessionID: String) async throws -> TailnetLoginStatus { + let socketURL = try socketURLResult.get() + return try await TailnetLoginClient.status(sessionID: sessionID, socketURL: socketURL) + } + + func cancelTailnetLogin(sessionID: String) async throws { + let socketURL = try socketURLResult.get() + try await TailnetLoginClient.cancel(sessionID: sessionID, socketURL: socketURL) + } + private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 { let socketURL = try socketURLResult.get() let networkID = nextNetworkID @@ -317,6 +408,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { } enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { + case web case none case password case preauthKey @@ -325,6 +417,7 @@ enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { var title: String { switch self { + case .web: "Browser Sign-In" case .none: "None" case .password: "Password" case .preauthKey: "Preauth Key" diff --git a/burrow/src/auth/server/tailscale.rs b/burrow/src/auth/server/tailscale.rs index fbe1980..55516e1 100644 --- a/burrow/src/auth/server/tailscale.rs +++ b/burrow/src/auth/server/tailscale.rs @@ -82,11 +82,22 @@ impl TailscaleBridgeManager { let key = session_key(&request.account_name, &request.identity_name); if let Some(existing) = self.sessions.lock().await.get(&key).cloned() { - let status = self.fetch_status(existing.as_ref()).await?; - return Ok(TailscaleLoginStartResponse { - session_id: existing.session_id.clone(), - status, - }); + match self.fetch_status(existing.as_ref()).await { + Ok(status) => { + return Ok(TailscaleLoginStartResponse { + session_id: existing.session_id.clone(), + status, + }); + } + Err(err) => { + log::warn!( + "tailscale login session {} is stale, restarting: {err}", + existing.session_id + ); + self.sessions.lock().await.remove(&key); + let _ = self.shutdown_session(existing.as_ref()).await; + } + } } let state_dir = state_root().join(session_dir_name(&request)); @@ -155,11 +166,28 @@ impl TailscaleBridgeManager { }; match session { - Some(session) => self.fetch_status(session.as_ref()).await.map(Some), + Some(session) => match self.fetch_status(session.as_ref()).await { + Ok(status) => Ok(Some(status)), + Err(err) => { + self.remove_session_by_id(session_id).await; + Err(err) + } + }, None => Ok(None), } } + pub async fn cancel(&self, session_id: &str) -> Result { + let session = self.remove_session_by_id(session_id).await; + match session { + Some(session) => { + self.shutdown_session(session.as_ref()).await?; + Ok(true) + } + None => Ok(false), + } + } + async fn wait_for_status(&self, session: &ManagedSession) -> Result { let mut last_error = None; let mut last_status = None; @@ -201,6 +229,38 @@ impl TailscaleBridgeManager { .await .context("invalid tailscale helper status response") } + + async fn remove_session_by_id(&self, session_id: &str) -> Option> { + let mut sessions = self.sessions.lock().await; + let key = sessions + .iter() + .find_map(|(key, session)| (session.session_id == session_id).then(|| key.clone()))?; + sessions.remove(&key) + } + + async fn shutdown_session(&self, session: &ManagedSession) -> Result<()> { + let _ = self + .client + .post(format!("{}/shutdown", session.listen_url)) + .send() + .await; + + for _ in 0..10 { + let mut child = session.child.lock().await; + if child.try_wait()?.is_some() { + return Ok(()); + } + drop(child); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + let mut child = session.child.lock().await; + child + .start_kill() + .context("failed to kill tailscale helper")?; + let _ = child.wait().await; + Ok(()) + } } fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Result { @@ -249,7 +309,10 @@ fn state_root() -> PathBuf { .join("Burrow") .join("tailscale"); } - home.join(".local").join("share").join("burrow").join("tailscale") + home.join(".local") + .join("share") + .join("burrow") + .join("tailscale") } fn session_dir_name(request: &TailscaleLoginStartRequest) -> String { diff --git a/burrow/src/daemon/instance.rs b/burrow/src/daemon/instance.rs index e4e6d96..0a23ddc 100644 --- a/burrow/src/daemon/instance.rs +++ b/burrow/src/daemon/instance.rs @@ -13,15 +13,19 @@ use tun::tokio::TunInterface; use super::{ rpc::grpc_defs::{ - networks_server::Networks, tailnet_control_server::TailnetControl, - tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest, NetworkListResponse, - NetworkReorderRequest, State as RPCTunnelState, TailnetDiscoverRequest, - TailnetDiscoverResponse, TailnetProbeRequest, TailnetProbeResponse, - TunnelConfigurationResponse, TunnelStatusResponse, + networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel, + Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest, + State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse, + TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, + TunnelStatusResponse, }, runtime::{ActiveTunnel, ResolvedTunnel}, }; use crate::{ + auth::server::tailscale::{ + TailscaleBridgeManager, TailscaleLoginStartRequest as BridgeLoginStartRequest, + TailscaleLoginStatus, + }, control::discovery, daemon::rpc::ServerConfig, database::{add_network, delete_network, get_connection, list_networks, reorder_network}, @@ -49,6 +53,7 @@ pub struct DaemonRPCServer { wg_state_chan: (watch::Sender, watch::Receiver), network_update_chan: (watch::Sender<()>, watch::Receiver<()>), active_tunnel: Arc>>, + tailnet_login: TailscaleBridgeManager, } impl DaemonRPCServer { @@ -59,6 +64,7 @@ impl DaemonRPCServer { wg_state_chan: watch::channel(RunState::Idle), network_update_chan: watch::channel(()), active_tunnel: Arc::new(RwLock::new(None)), + tailnet_login: TailscaleBridgeManager::default(), }) } @@ -130,6 +136,11 @@ impl DaemonRPCServer { Ok(()) } + + fn tailnet_control_url(authority: &str) -> Option { + let authority = discovery::normalize_authority(authority); + (!discovery::is_managed_tailscale_authority(&authority)).then_some(authority) + } } #[tonic::async_trait] @@ -308,6 +319,60 @@ impl TailnetControl for DaemonRPCServer { reachable: status.reachable, })) } + + async fn login_start( + &self, + request: Request, + ) -> Result, RspStatus> { + let request = request.into_inner(); + let response = self + .tailnet_login + .start_login(BridgeLoginStartRequest { + account_name: request.account_name, + identity_name: request.identity_name, + hostname: (!request.hostname.trim().is_empty()).then_some(request.hostname), + control_url: Self::tailnet_control_url(&request.authority), + }) + .await + .map_err(proc_err)?; + + Ok(Response::new(tailnet_login_rsp( + response.session_id, + response.status, + ))) + } + + async fn login_status( + &self, + request: Request, + ) -> Result, RspStatus> { + let request = request.into_inner(); + let status = self + .tailnet_login + .status(&request.session_id) + .await + .map_err(proc_err)?; + let Some(status) = status else { + return Err(RspStatus::not_found("tailnet login session not found")); + }; + Ok(Response::new(tailnet_login_rsp(request.session_id, status))) + } + + async fn login_cancel( + &self, + request: Request, + ) -> Result, RspStatus> { + let request = request.into_inner(); + let canceled = self + .tailnet_login + .cancel(&request.session_id) + .await + .map_err(proc_err)?; + if !canceled { + return Err(RspStatus::not_found("tailnet login session not found")); + } + Ok(Response::new(Empty {})) + } } fn proc_err(err: impl ToString) -> RspStatus { @@ -327,3 +392,21 @@ fn status_rsp(state: RunState) -> TunnelStatusResponse { start: None, // TODO: Add timestamp } } + +fn tailnet_login_rsp( + session_id: String, + status: TailscaleLoginStatus, +) -> super::rpc::grpc_defs::TailnetLoginStatusResponse { + super::rpc::grpc_defs::TailnetLoginStatusResponse { + session_id, + backend_state: status.backend_state, + auth_url: status.auth_url.unwrap_or_default(), + running: status.running, + needs_login: status.needs_login, + tailnet_name: status.tailnet_name.unwrap_or_default(), + magic_dns_suffix: status.magic_dns_suffix.unwrap_or_default(), + self_dns_name: status.self_dns_name.unwrap_or_default(), + tailnet_ips: status.tailscale_ips, + health: status.health, + } +} diff --git a/proto/burrow.proto b/proto/burrow.proto index 79e8976..a590cb1 100644 --- a/proto/burrow.proto +++ b/proto/burrow.proto @@ -20,6 +20,9 @@ service Networks { service TailnetControl { rpc Discover (TailnetDiscoverRequest) returns (TailnetDiscoverResponse); rpc Probe (TailnetProbeRequest) returns (TailnetProbeResponse); + rpc LoginStart (TailnetLoginStartRequest) returns (TailnetLoginStatusResponse); + rpc LoginStatus (TailnetLoginStatusRequest) returns (TailnetLoginStatusResponse); + rpc LoginCancel (TailnetLoginCancelRequest) returns (Empty); } message NetworkReorderRequest { @@ -84,6 +87,34 @@ message TailnetProbeResponse { bool reachable = 5; } +message TailnetLoginStartRequest { + string account_name = 1; + string identity_name = 2; + string hostname = 3; + string authority = 4; +} + +message TailnetLoginStatusRequest { + string session_id = 1; +} + +message TailnetLoginCancelRequest { + string session_id = 1; +} + +message TailnetLoginStatusResponse { + string session_id = 1; + string backend_state = 2; + string auth_url = 3; + bool running = 4; + bool needs_login = 5; + string tailnet_name = 6; + string magic_dns_suffix = 7; + string self_dns_name = 8; + repeated string tailnet_ips = 9; + repeated string health = 10; +} + enum State { Stopped = 0; Running = 1; From 75bcfaf6559bec939a07480c14dbab07438e0e14 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Fri, 3 Apr 2026 03:03:17 -0700 Subject: [PATCH 054/102] Add Tailnet UI auth test flow --- Apple/AppUITests/BurrowUITests.swift | 232 ++++++++++++++ Apple/Burrow.xcodeproj/project.pbxproj | 95 ++++++ .../xcshareddata/xcschemes/App.xcscheme | 15 +- Apple/Configuration/UITests.xcconfig | 14 + Apple/UI/BurrowView.swift | 10 + Scripts/authentik-sync-burrow-directory.sh | 16 +- Scripts/authentik-sync-tailnet-auth-flow.sh | 294 ++++++++++++++++++ Scripts/run-ios-tailnet-ui-tests.sh | 73 +++++ contributors.nix | 13 + nixos/hosts/burrow-forge/default.nix | 11 + nixos/modules/burrow-authentik.nix | 93 +++++- secrets.nix | 1 + secrets/infra/authentik-ui-test-password.age | 9 + 13 files changed, 872 insertions(+), 4 deletions(-) create mode 100644 Apple/AppUITests/BurrowUITests.swift create mode 100644 Apple/Configuration/UITests.xcconfig create mode 100755 Scripts/authentik-sync-tailnet-auth-flow.sh create mode 100755 Scripts/run-ios-tailnet-ui-tests.sh create mode 100644 secrets/infra/authentik-ui-test-password.age diff --git a/Apple/AppUITests/BurrowUITests.swift b/Apple/AppUITests/BurrowUITests.swift new file mode 100644 index 0000000..f9dbeae --- /dev/null +++ b/Apple/AppUITests/BurrowUITests.swift @@ -0,0 +1,232 @@ +import XCTest + +@MainActor +final class BurrowTailnetLoginUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testTailnetLoginThroughAuthentikWebSession() throws { + let email = try requiredEnvironment("BURROW_UI_TEST_EMAIL") + let username = ProcessInfo.processInfo.environment["BURROW_UI_TEST_USERNAME"] ?? email + let password = try requiredEnvironment("BURROW_UI_TEST_PASSWORD") + + let app = XCUIApplication() + app.launch() + + let tailnetButton = app.buttons["quick-add-tailnet"] + XCTAssertTrue(tailnetButton.waitForExistence(timeout: 15), "Tailnet add button did not appear") + tailnetButton.tap() + + let discoveryField = app.textFields["tailnet-discovery-email"] + XCTAssertTrue(discoveryField.waitForExistence(timeout: 10), "Tailnet discovery email field did not appear") + replaceText(in: discoveryField, with: email) + + let findServerButton = app.buttons["tailnet-find-server"] + XCTAssertTrue(findServerButton.waitForExistence(timeout: 5), "Find Server button did not appear") + findServerButton.tap() + + let discoveryCard = app.otherElements["tailnet-discovery-card"] + XCTAssertTrue(discoveryCard.waitForExistence(timeout: 20), "Tailnet discovery result did not appear") + + let authorityField = app.textFields["tailnet-authority"] + XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear") + XCTAssertTrue( + waitForFieldValue(authorityField, containing: "ts.burrow.net", timeout: 20), + "Tailnet authority was not populated from discovery" + ) + + let probeButton = app.buttons["tailnet-check-connection"] + XCTAssertTrue(probeButton.waitForExistence(timeout: 5), "Check Connection button did not appear") + probeButton.tap() + + let probeCard = app.otherElements["tailnet-authority-probe-card"] + XCTAssertTrue(probeCard.waitForExistence(timeout: 20), "Tailnet connection probe did not complete") + + let signInButton = app.buttons["tailnet-start-sign-in"] + XCTAssertTrue(signInButton.waitForExistence(timeout: 10), "Tailnet sign-in button did not appear") + signInButton.tap() + + acceptAuthenticationPromptIfNeeded(in: app) + + let webSession = webAuthenticationSession() + XCTAssertTrue(webSession.waitForExistence(timeout: 20), "Safari authentication session did not appear") + + signIntoAuthentik(in: webSession, username: username, password: password) + + app.activate() + XCTAssertTrue( + waitForButtonLabel(app.buttons["tailnet-start-sign-in"], equals: "Signed In", timeout: 60), + "Tailnet sign-in never reached the running state" + ) + } + + private func acceptAuthenticationPromptIfNeeded(in app: XCUIApplication) { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + let promptCandidates = [ + springboard.buttons["Continue"], + springboard.buttons["Allow"], + app.buttons["Continue"], + app.buttons["Allow"], + ] + + for button in promptCandidates where button.waitForExistence(timeout: 3) { + button.tap() + return + } + } + + private func webAuthenticationSession() -> XCUIApplication { + let safariViewService = XCUIApplication(bundleIdentifier: "com.apple.SafariViewService") + if safariViewService.waitForExistence(timeout: 5) { + return safariViewService + } + + let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") + _ = safari.waitForExistence(timeout: 5) + return safari + } + + private func signIntoAuthentik(in webSession: XCUIApplication, username: String, password: String) { + let usernameField = firstExistingElement( + in: webSession, + queries: [ + { $0.textFields["Username"] }, + { $0.textFields["Email or Username"] }, + { $0.textFields["Email address"] }, + { $0.textFields["Email"] }, + { $0.webViews.textFields["Username"] }, + { $0.webViews.textFields["Email or Username"] }, + { $0.descendants(matching: .textField).firstMatch }, + ], + timeout: 25 + ) + XCTAssertTrue(usernameField.exists, "Authentik username field did not appear") + replaceText(in: usernameField, with: username) + + let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2) + if immediatePasswordField.exists { + replaceSecureText(in: immediatePasswordField, with: password) + tapFirstExistingButton( + in: webSession, + titles: ["Continue", "Sign In", "Log in", "Login"], + timeout: 5 + ) + return + } + + tapFirstExistingButton( + in: webSession, + titles: ["Continue", "Next", "Sign In", "Log in", "Login"], + timeout: 5 + ) + + let passwordField = firstExistingSecureField(in: webSession, timeout: 20) + XCTAssertTrue(passwordField.exists, "Authentik password field did not appear") + replaceSecureText(in: passwordField, with: password) + tapFirstExistingButton( + in: webSession, + titles: ["Continue", "Sign In", "Log in", "Login"], + timeout: 5 + ) + } + + private func firstExistingSecureField(in app: XCUIApplication, timeout: TimeInterval) -> XCUIElement { + let candidates = [ + app.secureTextFields["Password"], + app.secureTextFields["Password or Token"], + app.webViews.secureTextFields["Password"], + app.webViews.secureTextFields["Password or Token"], + app.descendants(matching: .secureTextField).firstMatch, + ] + + return firstExistingElement(from: candidates, timeout: timeout) + } + + private func tapFirstExistingButton( + in app: XCUIApplication, + titles: [String], + timeout: TimeInterval + ) { + let candidates = titles.flatMap { title in + [ + app.buttons[title], + app.webViews.buttons[title], + ] + } + [app.descendants(matching: .button).firstMatch] + + let button = firstExistingElement(from: candidates, timeout: timeout) + XCTAssertTrue(button.exists, "Expected one of \(titles.joined(separator: ", ")) to appear") + button.tap() + } + + private func requiredEnvironment(_ key: String) throws -> String { + guard let value = ProcessInfo.processInfo.environment[key], + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw XCTSkip("Missing required UI test environment variable \(key)") + } + return value + } + + private func waitForFieldValue( + _ field: XCUIElement, + containing substring: String, + timeout: TimeInterval + ) -> Bool { + let predicate = NSPredicate(format: "value CONTAINS %@", substring) + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: field) + return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed + } + + private func waitForButtonLabel( + _ button: XCUIElement, + equals expected: String, + timeout: TimeInterval + ) -> Bool { + let predicate = NSPredicate(format: "label == %@", expected) + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: button) + return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed + } + + private func firstExistingElement( + in app: XCUIApplication, + queries: [(XCUIApplication) -> XCUIElement], + timeout: TimeInterval + ) -> XCUIElement { + firstExistingElement(from: queries.map { $0(app) }, timeout: timeout) + } + + private func firstExistingElement(from candidates: [XCUIElement], timeout: TimeInterval) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + repeat { + for candidate in candidates where candidate.exists { + return candidate + } + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } while Date() < deadline + + return candidates[0] + } + + private func replaceText(in element: XCUIElement, with value: String) { + element.tap() + clearText(in: element) + element.typeText(value) + } + + private func replaceSecureText(in element: XCUIElement, with value: String) { + element.tap() + clearText(in: element) + element.typeText(value) + } + + private func clearText(in element: XCUIElement) { + guard let currentValue = element.value as? String, !currentValue.isEmpty else { + return + } + + let deleteSequence = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count) + element.typeText(deleteSequence) + } +} diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index 9897f79..83d32e0 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ D00AA8972A4669BC005C8102 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00AA8962A4669BC005C8102 /* AppDelegate.swift */; }; + D11000012F70000100112233 /* BurrowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11000042F70000100112233 /* BurrowUITests.swift */; }; D020F65829E4A697002790F6 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F65729E4A697002790F6 /* PacketTunnelProvider.swift */; }; D020F65D29E4A697002790F6 /* BurrowNetworkExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D03383AD2C8E67E300F7C44E /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = D078F7E22C8DA375008A8CEC /* SwiftProtobuf */; }; @@ -49,6 +50,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + D11000022F70000100112233 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D05B9F7129E39EEC008CB1F9; + remoteInfo = App; + }; D020F65B29E4A697002790F6 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; @@ -130,6 +138,9 @@ /* Begin PBXFileReference section */ D00117422B30348D00D87C25 /* Configuration.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Configuration.xcconfig; sourceTree = ""; }; D00AA8962A4669BC005C8102 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + D11000032F70000100112233 /* BurrowUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BurrowUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + D11000042F70000100112233 /* BurrowUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurrowUITests.swift; sourceTree = ""; }; + D11000052F70000100112233 /* UITests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = UITests.xcconfig; sourceTree = ""; }; D020F63D29E4A1FF002790F6 /* Identity.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Identity.xcconfig; sourceTree = ""; }; D020F64029E4A1FF002790F6 /* Compiler.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Compiler.xcconfig; sourceTree = ""; }; D020F64229E4A1FF002790F6 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -182,6 +193,13 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + D11000062F70000100112233 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; D020F65029E4A697002790F6 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -243,6 +261,7 @@ D0D4E4F72C8D941D007F820A /* Framework.xcconfig */, D020F64029E4A1FF002790F6 /* Compiler.xcconfig */, D0D4E4F62C8D932D007F820A /* Debug.xcconfig */, + D11000052F70000100112233 /* UITests.xcconfig */, D04A3E1D2BAF465F0043EC85 /* Version.xcconfig */, D020F64229E4A1FF002790F6 /* Info.plist */, D0D4E5912C8D9D0A007F820A /* Constants */, @@ -268,6 +287,7 @@ isa = PBXGroup; children = ( D05B9F7429E39EEC008CB1F9 /* App */, + D11000072F70000100112233 /* AppUITests */, D020F65629E4A697002790F6 /* NetworkExtension */, D0D4E49C2C8D921A007F820A /* Core */, D0D4E4AD2C8D921A007F820A /* UI */, @@ -281,6 +301,7 @@ isa = PBXGroup; children = ( D05B9F7229E39EEC008CB1F9 /* Burrow.app */, + D11000032F70000100112233 /* BurrowUITests.xctest */, D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */, D0BCC6032A09535900AD070D /* libburrow.a */, D0D4E5312C8D996F007F820A /* BurrowCore.framework */, @@ -303,6 +324,14 @@ path = App; sourceTree = ""; }; + D11000072F70000100112233 /* AppUITests */ = { + isa = PBXGroup; + children = ( + D11000042F70000100112233 /* BurrowUITests.swift */, + ); + path = AppUITests; + sourceTree = ""; + }; D0B98FD729FDDB57004E7149 /* libburrow */ = { isa = PBXGroup; children = ( @@ -375,6 +404,24 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + D11000082F70000100112233 /* BurrowUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = D110000E2F70000100112233 /* Build configuration list for PBXNativeTarget "BurrowUITests" */; + buildPhases = ( + D110000A2F70000100112233 /* Sources */, + D11000062F70000100112233 /* Frameworks */, + D11000092F70000100112233 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + D110000B2F70000100112233 /* PBXTargetDependency */, + ); + name = BurrowUITests; + productName = BurrowUITests; + productReference = D11000032F70000100112233 /* BurrowUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; D020F65229E4A697002790F6 /* NetworkExtension */ = { isa = PBXNativeTarget; buildConfigurationList = D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */; @@ -490,6 +537,10 @@ LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1520; TargetAttributes = { + D11000082F70000100112233 = { + CreatedOnToolsVersion = 16.0; + TestTargetID = D05B9F7129E39EEC008CB1F9; + }; D020F65229E4A697002790F6 = { CreatedOnToolsVersion = 14.3; }; @@ -522,6 +573,7 @@ projectRoot = ""; targets = ( D05B9F7129E39EEC008CB1F9 /* App */, + D11000082F70000100112233 /* BurrowUITests */, D020F65229E4A697002790F6 /* NetworkExtension */, D0D4E5502C8D9BF2007F820A /* UI */, D0D4E5302C8D996F007F820A /* Core */, @@ -531,6 +583,13 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + D11000092F70000100112233 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; D05B9F7029E39EEC008CB1F9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -594,6 +653,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + D110000A2F70000100112233 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D11000012F70000100112233 /* BurrowUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; D020F64F29E4A697002790F6 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -652,6 +719,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + D110000B2F70000100112233 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D05B9F7129E39EEC008CB1F9 /* App */; + targetProxy = D11000022F70000100112233 /* PBXContainerItemProxy */; + }; D020F65C29E4A697002790F6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = D020F65229E4A697002790F6 /* NetworkExtension */; @@ -694,6 +766,20 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + D110000C2F70000100112233 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D11000052F70000100112233 /* UITests.xcconfig */; + buildSettings = { + }; + name = Debug; + }; + D110000D2F70000100112233 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D11000052F70000100112233 /* UITests.xcconfig */; + buildSettings = { + }; + name = Release; + }; D020F65F29E4A697002790F6 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = D020F66229E4A6E5002790F6 /* NetworkExtension.xcconfig */; @@ -781,6 +867,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + D110000E2F70000100112233 /* Build configuration list for PBXNativeTarget "BurrowUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D110000C2F70000100112233 /* Debug */, + D110000D2F70000100112233 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme index a524e87..f580ea7 100644 --- a/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme +++ b/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -28,7 +28,20 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "YES"> + shouldAutocreateTestPlan = "NO"> + + + + + + some View { diff --git a/Scripts/authentik-sync-burrow-directory.sh b/Scripts/authentik-sync-burrow-directory.sh index 656b738..277c5f4 100644 --- a/Scripts/authentik-sync-burrow-directory.sh +++ b/Scripts/authentik-sync-burrow-directory.sh @@ -116,7 +116,7 @@ lookup_user_pk() { ensure_user() { local user_spec="$1" - local username name email is_admin groups_json effective_groups_json group_name + local username name email is_admin groups_json password_file effective_groups_json group_name local group_pks_json payload user_pk username="$(printf '%s\n' "$user_spec" | jq -r '.username')" @@ -124,6 +124,7 @@ ensure_user() { email="$(printf '%s\n' "$user_spec" | jq -r '.email')" is_admin="$(printf '%s\n' "$user_spec" | jq -r '.isAdmin // false')" groups_json="$(printf '%s\n' "$user_spec" | jq -c '.groups // []')" + password_file="$(printf '%s\n' "$user_spec" | jq -r '.passwordFile // empty')" if [[ -z "$username" || "$username" == "null" || -z "$email" || "$email" == "null" ]]; then echo "error: each Burrow Authentik user requires username and email" >&2 @@ -178,6 +179,19 @@ ensure_user() { echo "error: could not create Authentik user ${username}" >&2 exit 1 fi + + if [[ -n "$password_file" ]]; then + if [[ ! -s "$password_file" ]]; then + echo "error: password file for Authentik user ${username} is missing: ${password_file}" >&2 + exit 1 + fi + + api POST "/api/v3/core/users/${user_pk}/set_password/" "$( + jq -cn \ + --arg password "$(tr -d '\r\n' < "$password_file")" \ + '{password: $password}' + )" >/dev/null + fi } lookup_application_pk() { diff --git a/Scripts/authentik-sync-tailnet-auth-flow.sh b/Scripts/authentik-sync-tailnet-auth-flow.sh new file mode 100755 index 0000000..bfb00ef --- /dev/null +++ b/Scripts/authentik-sync-tailnet-auth-flow.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +provider_slug="${AUTHENTIK_TAILNET_PROVIDER_SLUG:-ts}" +authentication_flow_name="${AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME:-Burrow Tailnet Authentication}" +authentication_flow_slug="${AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG:-burrow-tailnet-authentication}" +identification_stage_name="${AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME:-burrow-tailnet-identification-stage}" +password_stage_name="${AUTHENTIK_TAILNET_PASSWORD_STAGE_NAME:-burrow-tailnet-password-stage}" +user_login_stage_name="${AUTHENTIK_TAILNET_USER_LOGIN_STAGE_NAME:-burrow-tailnet-user-login-stage}" +google_source_slug="${AUTHENTIK_TAILNET_GOOGLE_SOURCE_SLUG:-google}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-tailnet-auth-flow.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_TAILNET_PROVIDER_SLUG + AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME + AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG + AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME + AUTHENTIK_TAILNET_PASSWORD_STAGE_NAME + AUTHENTIK_TAILNET_USER_LOGIN_STAGE_NAME + AUTHENTIK_TAILNET_GOOGLE_SOURCE_SLUG +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ -z "$bootstrap_token" ]]; then + echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 + exit 1 +fi + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + + if [[ -n "$data" ]]; then + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + else + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + fi +} + +wait_for_authentik() { + for _ in $(seq 1 90); do + if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +lookup_stage_by_name() { + local path="$1" + local name="$2" + + api GET "${path}?page_size=200" \ + | jq -c --arg name "$name" '.results[]? | select(.name == $name)' \ + | head -n1 +} + +lookup_flow_pk() { + local slug="$1" + + api GET "/api/v3/flows/instances/?slug=${slug}" \ + | jq -r '.results[]? | select(.slug != null) | .pk // empty' \ + | head -n1 +} + +lookup_source_pk() { + local slug="$1" + + api GET "/api/v3/sources/oauth/?page_size=200&slug=${slug}" \ + | jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \ + | head -n1 +} + +ensure_password_stage() { + local existing payload stage_pk + + existing="$(lookup_stage_by_name "/api/v3/stages/password/" "$password_stage_name")" + payload="$( + jq -cn \ + --arg name "$password_stage_name" \ + '{ + name: $name, + backends: [ + "authentik.core.auth.InbuiltBackend", + "authentik.core.auth.TokenBackend" + ], + allow_show_password: false, + failed_attempts_before_cancel: 5 + }' + )" + + if [[ -n "$existing" ]]; then + stage_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" + api PATCH "/api/v3/stages/password/${stage_pk}/" "$payload" >/dev/null + else + stage_pk="$( + api POST "/api/v3/stages/password/" "$payload" \ + | jq -r '.pk // empty' + )" + fi + + printf '%s\n' "$stage_pk" +} + +ensure_identification_stage() { + local password_stage_pk="$1" + local google_source_pk="$2" + local existing payload stage_pk sources_json + + existing="$(lookup_stage_by_name "/api/v3/stages/identification/" "$identification_stage_name")" + if [[ -n "$google_source_pk" ]]; then + sources_json="$(jq -cn --arg source "$google_source_pk" '[$source]')" + else + sources_json='[]' + fi + + payload="$( + jq -cn \ + --arg name "$identification_stage_name" \ + --arg password_stage "$password_stage_pk" \ + --argjson sources "$sources_json" \ + '{ + name: $name, + user_fields: ["username", "email"], + password_stage: $password_stage, + case_insensitive_matching: true, + show_matched_user: true, + sources: $sources, + show_source_labels: true, + pretend_user_exists: false, + enable_remember_me: false + }' + )" + + if [[ -n "$existing" ]]; then + stage_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" + api PATCH "/api/v3/stages/identification/${stage_pk}/" "$payload" >/dev/null + else + stage_pk="$( + api POST "/api/v3/stages/identification/" "$payload" \ + | jq -r '.pk // empty' + )" + fi + + printf '%s\n' "$stage_pk" +} + +ensure_user_login_stage() { + local existing payload stage_pk + + existing="$(lookup_stage_by_name "/api/v3/stages/user_login/" "$user_login_stage_name")" + payload="$( + jq -cn \ + --arg name "$user_login_stage_name" \ + '{ + name: $name, + session_duration: "hours=12", + terminate_other_sessions: false, + remember_me_offset: "seconds=0", + network_binding: "no_binding", + geoip_binding: "no_binding" + }' + )" + + if [[ -n "$existing" ]]; then + stage_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" + api PATCH "/api/v3/stages/user_login/${stage_pk}/" "$payload" >/dev/null + else + stage_pk="$( + api POST "/api/v3/stages/user_login/" "$payload" \ + | jq -r '.pk // empty' + )" + fi + + printf '%s\n' "$stage_pk" +} + +ensure_authentication_flow() { + local existing_pk payload + + existing_pk="$(lookup_flow_pk "$authentication_flow_slug")" + payload="$( + jq -cn \ + --arg name "$authentication_flow_name" \ + --arg slug "$authentication_flow_slug" \ + '{ + name: $name, + title: $name, + slug: $slug, + designation: "authentication", + policy_engine_mode: "any", + layout: "stacked" + }' + )" + + if [[ -n "$existing_pk" ]]; then + api PATCH "/api/v3/flows/instances/${authentication_flow_slug}/" "$payload" >/dev/null + printf '%s\n' "$existing_pk" + else + api POST "/api/v3/flows/instances/" "$payload" \ + | jq -r '.pk // empty' + fi +} + +ensure_flow_binding() { + local flow_pk="$1" + local stage_pk="$2" + local order="$3" + local existing payload binding_pk + + existing="$( + api GET "/api/v3/flows/bindings/?target=${flow_pk}&stage=${stage_pk}&page_size=200" \ + | jq -c '.results[]?' \ + | head -n1 + )" + + payload="$( + jq -cn \ + --arg target "$flow_pk" \ + --arg stage "$stage_pk" \ + --argjson order "$order" \ + '{ + target: $target, + stage: $stage, + order: $order, + policy_engine_mode: "any" + }' + )" + + if [[ -n "$existing" ]]; then + binding_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" + api PATCH "/api/v3/flows/bindings/${binding_pk}/" "$payload" >/dev/null + else + api POST "/api/v3/flows/bindings/" "$payload" >/dev/null + fi +} + +wait_for_authentik + +provider_pk="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -r --arg provider_slug "$provider_slug" ' + .results[]? + | select(.assigned_application_slug == $provider_slug or .slug == $provider_slug) + | .pk // empty + ' \ + | head -n1 +)" + +if [[ -z "$provider_pk" ]]; then + echo "error: could not resolve Authentik Tailnet OAuth provider ${provider_slug}" >&2 + exit 1 +fi + +google_source_pk="$(lookup_source_pk "$google_source_slug" || true)" +password_stage_pk="$(ensure_password_stage)" +identification_stage_pk="$(ensure_identification_stage "$password_stage_pk" "$google_source_pk")" +user_login_stage_pk="$(ensure_user_login_stage)" +authentication_flow_pk="$(ensure_authentication_flow)" + +ensure_flow_binding "$authentication_flow_pk" "$identification_stage_pk" 10 +ensure_flow_binding "$authentication_flow_pk" "$user_login_stage_pk" 30 + +api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$( + jq -cn --arg flow "$authentication_flow_pk" '{authentication_flow: $flow}' +)" >/dev/null + +echo "Synced Burrow Tailnet authentication flow for provider ${provider_slug}." diff --git a/Scripts/run-ios-tailnet-ui-tests.sh b/Scripts/run-ios-tailnet-ui-tests.sh new file mode 100755 index 0000000..5086bd1 --- /dev/null +++ b/Scripts/run-ios-tailnet-ui-tests.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}" +simulator_name="${BURROW_UI_TEST_SIMULATOR_NAME:-iPhone 17 Pro}" +simulator_os="${BURROW_UI_TEST_SIMULATOR_OS:-26.4}" +derived_data_path="${BURROW_UI_TEST_DERIVED_DATA_PATH:-/tmp/burrow-ui-tests-deriveddata}" +source_packages_path="${BURROW_UI_TEST_SOURCE_PACKAGES_PATH:-/tmp/burrow-ui-tests-sourcepackages}" +fallback_dir="${HOME}/Library/Application Support/${bundle_id}/SimulatorFallback" +socket_path="${fallback_dir}/burrow.sock" +daemon_log="${BURROW_UI_TEST_DAEMON_LOG:-/tmp/burrow-ui-test-daemon.log}" +ui_test_email="${BURROW_UI_TEST_EMAIL:-ui-test@burrow.net}" +ui_test_username="${BURROW_UI_TEST_USERNAME:-ui-test}" +password_secret="${repo_root}/secrets/infra/authentik-ui-test-password.age" +age_identity="${BURROW_UI_TEST_AGE_IDENTITY:-${HOME}/.ssh/id_ed25519}" + +ui_test_password="${BURROW_UI_TEST_PASSWORD:-}" +if [[ -z "$ui_test_password" ]]; then + if [[ -f "$password_secret" && -f "$age_identity" ]]; then + ui_test_password="$(age -d -i "$age_identity" "$password_secret" | tr -d '\r\n')" + else + echo "error: BURROW_UI_TEST_PASSWORD is unset and ${password_secret} could not be decrypted" >&2 + exit 1 + fi +fi + +mkdir -p "$fallback_dir" "$derived_data_path" "$source_packages_path" +rm -f "$socket_path" + +cleanup() { + if [[ -n "${daemon_pid:-}" ]]; then + kill "$daemon_pid" >/dev/null 2>&1 || true + wait "$daemon_pid" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +cargo build -p burrow --bin burrow + +( + cd "$fallback_dir" + BURROW_SOCKET_PATH="burrow.sock" \ + "${repo_root}/target/debug/burrow" daemon >"$daemon_log" 2>&1 +) & +daemon_pid=$! + +for _ in $(seq 1 50); do + [[ -S "$socket_path" ]] && break + sleep 0.2 +done + +if [[ ! -S "$socket_path" ]]; then + echo "error: Burrow daemon did not create ${socket_path}" >&2 + [[ -f "$daemon_log" ]] && cat "$daemon_log" >&2 + exit 1 +fi + +BURROW_UI_TEST_EMAIL="$ui_test_email" \ +BURROW_UI_TEST_USERNAME="$ui_test_username" \ +BURROW_UI_TEST_PASSWORD="$ui_test_password" \ +xcodebuild \ + -quiet \ + -skipPackagePluginValidation \ + -project "${repo_root}/Apple/Burrow.xcodeproj" \ + -scheme App \ + -configuration Debug \ + -destination "platform=iOS Simulator,name=${simulator_name},OS=${simulator_os}" \ + -derivedDataPath "$derived_data_path" \ + -clonedSourcePackagesDirPath "$source_packages_path" \ + -only-testing:BurrowUITests \ + CODE_SIGNING_ALLOWED=NO \ + test diff --git a/contributors.nix b/contributors.nix index f6cc014..22c28b6 100644 --- a/contributors.nix +++ b/contributors.nix @@ -43,5 +43,18 @@ "automation" ]; }; + + ui-test = { + displayName = "Burrow UI Test"; + canonicalEmail = "ui-test@burrow.net"; + isAdmin = false; + forgeAuthorized = false; + bootstrapAuthentik = true; + authentikPasswordSecret = "burrowAuthentikUiTestPassword"; + roles = [ + "testing" + "apple-ui" + ]; + }; }; } diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index fb5b8ae..6c106f4 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -3,6 +3,10 @@ let contributors = import ../../../contributors.nix; identities = contributors.identities; + authentikPasswordSecretPath = identity: + if identity ? authentikPasswordSecret + then config.age.secrets.${identity.authentikPasswordSecret}.path + else null; bootstrapUsers = lib.mapAttrsToList ( username: identity: { @@ -11,6 +15,7 @@ let email = identity.canonicalEmail; sourceEmail = identity.sourceEmail or null; isAdmin = identity.isAdmin or false; + passwordFile = authentikPasswordSecretPath identity; } ) (lib.filterAttrs (_: identity: identity.bootstrapAuthentik or false) identities); @@ -70,6 +75,12 @@ in group = "root"; mode = "0400"; }; + age.secrets.burrowAuthentikUiTestPassword = { + file = ../../../secrets/infra/authentik-ui-test-password.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; networking.extraHosts = '' 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 4e31d43..478d0d9 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -11,6 +11,7 @@ let directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh; forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; + tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh; authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' version: 1 metadata: @@ -175,6 +176,36 @@ in description = "Identification-stage behavior for the Google Authentik source."; }; + headscaleAuthenticationFlowSlug = lib.mkOption { + type = lib.types.str; + default = "burrow-tailnet-authentication"; + description = "Authentik authentication flow slug used for Burrow Tailnet sign-in."; + }; + + headscaleAuthenticationFlowName = lib.mkOption { + type = lib.types.str; + default = "Burrow Tailnet Authentication"; + description = "Authentik authentication flow name used for Burrow Tailnet sign-in."; + }; + + headscaleIdentificationStageName = lib.mkOption { + type = lib.types.str; + default = "burrow-tailnet-identification-stage"; + description = "Authentik identification stage used for Burrow Tailnet sign-in."; + }; + + headscalePasswordStageName = lib.mkOption { + type = lib.types.str; + default = "burrow-tailnet-password-stage"; + description = "Authentik password stage used for Burrow Tailnet sign-in."; + }; + + headscaleUserLoginStageName = lib.mkOption { + type = lib.types.str; + default = "burrow-tailnet-user-login-stage"; + description = "Authentik user-login stage used for Burrow Tailnet sign-in."; + }; + userGroupName = lib.mkOption { type = lib.types.str; default = "burrow-users"; @@ -217,6 +248,11 @@ in default = false; description = "Whether this user should be in the Burrow admin group."; }; + passwordFile = lib.mkOption { + type = nullOr str; + default = null; + description = "Optional host-local file containing a bootstrap password for this user."; + }; }; }); default = [ ]; @@ -468,7 +504,7 @@ EOF restartTriggers = [ directorySyncScript cfg.envFile - ]; + ] ++ lib.concatMap (user: lib.optional (user.passwordFile != null) user.passwordFile) cfg.bootstrapUsers; path = [ pkgs.bash pkgs.coreutils @@ -491,7 +527,7 @@ EOF export AUTHENTIK_BURROW_ADMINS_GROUP=${lib.escapeShellArg cfg.adminGroupName} export AUTHENTIK_FORGEJO_APPLICATION_SLUG=${lib.escapeShellArg cfg.forgejoProviderSlug} export AUTHENTIK_BURROW_DIRECTORY_JSON='${builtins.toJSON (map (user: { - inherit (user) username name email isAdmin; + inherit (user) username name email isAdmin passwordFile; groups = user.groups; }) cfg.bootstrapUsers)}' @@ -499,6 +535,59 @@ EOF ''; }; + systemd.services.burrow-authentik-tailnet-auth-flow = { + description = "Reconcile the Burrow Tailnet authentication flow"; + after = + [ + "burrow-authentik-ready.service" + "network-online.target" + ] + ++ lib.optionals ( + cfg.googleClientIDFile != null && cfg.googleClientSecretFile != null + ) [ "burrow-authentik-google-source.service" ]; + wants = + [ + "burrow-authentik-ready.service" + "network-online.target" + ] + ++ lib.optionals ( + cfg.googleClientIDFile != null && cfg.googleClientSecretFile != null + ) [ "burrow-authentik-google-source.service" ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + tailnetAuthFlowSyncScript + cfg.envFile + ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + set -a + source ${lib.escapeShellArg cfg.envFile} + set +a + + export AUTHENTIK_URL=https://${cfg.domain} + export AUTHENTIK_TAILNET_PROVIDER_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} + export AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME=${lib.escapeShellArg cfg.headscaleAuthenticationFlowName} + export AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG=${lib.escapeShellArg cfg.headscaleAuthenticationFlowSlug} + export AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME=${lib.escapeShellArg cfg.headscaleIdentificationStageName} + export AUTHENTIK_TAILNET_PASSWORD_STAGE_NAME=${lib.escapeShellArg cfg.headscalePasswordStageName} + export AUTHENTIK_TAILNET_USER_LOGIN_STAGE_NAME=${lib.escapeShellArg cfg.headscaleUserLoginStageName} + export AUTHENTIK_TAILNET_GOOGLE_SOURCE_SLUG=${lib.escapeShellArg cfg.googleSourceSlug} + + ${pkgs.bash}/bin/bash ${tailnetAuthFlowSyncScript} + ''; + }; + systemd.services.burrow-authentik-forgejo-oidc = lib.mkIf (cfg.forgejoClientSecretFile != null) { description = "Reconcile the Burrow Authentik Forgejo OIDC application"; after = [ diff --git a/secrets.nix b/secrets.nix index 909b929..cc23605 100644 --- a/secrets.nix +++ b/secrets.nix @@ -12,6 +12,7 @@ in "secrets/infra/authentik.env.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-google-client-id.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients; + "secrets/infra/authentik-ui-test-password.age".publicKeys = burrowForgeRecipients; "secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; } diff --git a/secrets/infra/authentik-ui-test-password.age b/secrets/infra/authentik-ui-test-password.age new file mode 100644 index 0000000..f39c21a --- /dev/null +++ b/secrets/infra/authentik-ui-test-password.age @@ -0,0 +1,9 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q 4+zOIEyQTCHqKdZKV/H4D7e4y+UTrc9rYzvCgGUPVEg +S+tAlc4wvzVUe9r9+mBAnUj5C31bQqo4PK3muBCzs2Y +-> ssh-ed25519 IrZmAg 1KasjHiY1MQVLIzoDdGshhDhaDimOtZ5EyE4GyZngHg +ov711Sp+Q/zQw0NUpB2rnKEF8bFxoVafdVQ/8gSbSZA +-> X25519 3EWdCP5UkWd1g6bDaQm/kNCNlhSONrz8RB7OZgT9nXE +6+HoM9mg6P/CtU39P8SCyutLkmYw27MikoZZ5L9nI54 +--- Rw0o+MvtvHQrrYPNtCPxHGR67K67nyJUQRd4DN3nOCY +fn Date: Fri, 3 Apr 2026 03:08:06 -0700 Subject: [PATCH 055/102] Allow local UI test secret decryption --- secrets.nix | 4 +++- secrets/infra/authentik-ui-test-password.age | 23 ++++++++++++-------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/secrets.nix b/secrets.nix index cc23605..5a3ac8c 100644 --- a/secrets.nix +++ b/secrets.nix @@ -1,4 +1,5 @@ let + conradev = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBueQxNbP2246pxr/m7au4zNVm+ShC96xuOcfEcpIjWZ"; contact = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO42guJ5QvNMw3k6YKWlQnjcTsc+X4XI9F2GBtl8aHOa"; agent = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEN0+tRJy7Y2DW0uGYHb86N2t02WyU5lDNX6FaxBF/G8 agent@burrow.net"; burrowForgeHost = "age1quxf27gnun0xghlnxf3jrmqr3h3a3fzd8qxpallsaztd2u74pdfq9e7w9l"; @@ -7,12 +8,13 @@ let agent burrowForgeHost ]; + uiTestRecipients = burrowForgeRecipients ++ [ conradev ]; in { "secrets/infra/authentik.env.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-google-client-id.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients; - "secrets/infra/authentik-ui-test-password.age".publicKeys = burrowForgeRecipients; + "secrets/infra/authentik-ui-test-password.age".publicKeys = uiTestRecipients; "secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; } diff --git a/secrets/infra/authentik-ui-test-password.age b/secrets/infra/authentik-ui-test-password.age index f39c21a..e84a7be 100644 --- a/secrets/infra/authentik-ui-test-password.age +++ b/secrets/infra/authentik-ui-test-password.age @@ -1,9 +1,14 @@ -age-encryption.org/v1 --> ssh-ed25519 ux4N8Q 4+zOIEyQTCHqKdZKV/H4D7e4y+UTrc9rYzvCgGUPVEg -S+tAlc4wvzVUe9r9+mBAnUj5C31bQqo4PK3muBCzs2Y --> ssh-ed25519 IrZmAg 1KasjHiY1MQVLIzoDdGshhDhaDimOtZ5EyE4GyZngHg -ov711Sp+Q/zQw0NUpB2rnKEF8bFxoVafdVQ/8gSbSZA --> X25519 3EWdCP5UkWd1g6bDaQm/kNCNlhSONrz8RB7OZgT9nXE -6+HoM9mg6P/CtU39P8SCyutLkmYw27MikoZZ5L9nI54 ---- Rw0o+MvtvHQrrYPNtCPxHGR67K67nyJUQRd4DN3nOCY -fn Date: Fri, 3 Apr 2026 17:49:11 -0700 Subject: [PATCH 056/102] Add tailnet connectivity smoke path --- Scripts/run-tailnet-connectivity-smoke.sh | 186 ++++++++++ Tools/tailscale-login-bridge/main.go | 410 +++++++++++++++++++- burrow/src/main.rs | 433 ++++++++++++++++++++++ 3 files changed, 1019 insertions(+), 10 deletions(-) create mode 100755 Scripts/run-tailnet-connectivity-smoke.sh diff --git a/Scripts/run-tailnet-connectivity-smoke.sh b/Scripts/run-tailnet-connectivity-smoke.sh new file mode 100755 index 0000000..f3053d3 --- /dev/null +++ b/Scripts/run-tailnet-connectivity-smoke.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}" +smoke_root="${BURROW_TAILNET_SMOKE_ROOT:-/tmp/burrow-tailnet-connectivity}" +socket_path="${smoke_root}/burrow.sock" +db_path="${smoke_root}/burrow.db" +daemon_log="${BURROW_TAILNET_SMOKE_DAEMON_LOG:-${smoke_root}/daemon.log}" +payload_path="${smoke_root}/tailnet.json" +authority="${BURROW_TAILNET_SMOKE_AUTHORITY:-https://ts.burrow.net}" +account_name="${BURROW_TAILNET_SMOKE_ACCOUNT:-ui-test}" +identity_name="${BURROW_TAILNET_SMOKE_IDENTITY:-apple}" +hostname="${BURROW_TAILNET_SMOKE_HOSTNAME:-burrow-apple}" +message="${BURROW_TAILNET_SMOKE_MESSAGE:-burrow-tailnet-smoke}" +timeout_ms="${BURROW_TAILNET_SMOKE_TIMEOUT_MS:-8000}" +remote_ip="${BURROW_TAILNET_SMOKE_REMOTE_IP:-}" +remote_port="${BURROW_TAILNET_SMOKE_REMOTE_PORT:-18081}" +remote_hostname="${BURROW_TAILNET_SMOKE_REMOTE_HOSTNAME:-burrow-echo}" +remote_authkey="${BURROW_TAILNET_SMOKE_REMOTE_AUTHKEY:-}" +helper_bin="${BURROW_TAILNET_SMOKE_HELPER_BIN:-${smoke_root}/tailscale-login-bridge}" +remote_state_root="${BURROW_TAILNET_SMOKE_REMOTE_STATE_ROOT:-${smoke_root}/remote-state}" +remote_stdout="${smoke_root}/remote-helper.stdout" +remote_stderr="${BURROW_TAILNET_SMOKE_REMOTE_LOG:-${smoke_root}/remote-helper.log}" + +if [[ -n "${TS_AUTHKEY:-}" ]]; then + default_tailnet_state_root="${smoke_root}/local-state" +else + default_tailnet_state_root="/tmp/${bundle_id}/SimulatorTailnetState" +fi +tailnet_state_root="${BURROW_TAILNET_STATE_ROOT:-${default_tailnet_state_root}}" + +need_login=0 +if [[ -z "${TS_AUTHKEY:-}" ]] && { [[ ! -d "$tailnet_state_root" ]] || [[ -z "$(find "$tailnet_state_root" -mindepth 1 -maxdepth 2 -print -quit 2>/dev/null)" ]]; }; then + need_login=1 +fi + +if [[ "$need_login" -eq 1 ]]; then + echo "Tailnet state root is empty; running iOS login bootstrap first..." + "${repo_root}/Scripts/run-ios-tailnet-ui-tests.sh" +fi + +rm -rf "$smoke_root" +mkdir -p "$smoke_root" + +cleanup() { + rm -f "$payload_path" + if [[ -n "${daemon_pid:-}" ]]; then + kill "$daemon_pid" >/dev/null 2>&1 || true + wait "$daemon_pid" >/dev/null 2>&1 || true + fi + if [[ -n "${remote_pid:-}" ]]; then + kill "$remote_pid" >/dev/null 2>&1 || true + wait "$remote_pid" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +wait_for_helper_listen() { + python3 - <<'PY' "$1" +import json +import pathlib +import sys +import time + +path = pathlib.Path(sys.argv[1]) +deadline = time.time() + 20 +while time.time() < deadline: + if path.exists(): + with path.open("r", encoding="utf-8") as handle: + line = handle.readline().strip() + if line: + hello = json.loads(line) + print(hello["listen_addr"]) + raise SystemExit(0) + time.sleep(0.1) +raise SystemExit("timed out waiting for helper startup line") +PY +} + +wait_for_helper_ip() { + python3 - <<'PY' "$1" +import json +import sys +import time +import urllib.request + +url = sys.argv[1] +deadline = time.time() + 30 +while time.time() < deadline: + with urllib.request.urlopen(url, timeout=5) as response: + status = json.load(response) + if status.get("running") and status.get("tailscale_ips"): + print(status["tailscale_ips"][0]) + raise SystemExit(0) + time.sleep(0.25) +raise SystemExit("timed out waiting for helper to become ready") +PY +} + +python3 - <<'PY' "$payload_path" "$authority" "$account_name" "$identity_name" "$hostname" +import json +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +payload = { + "authority": sys.argv[2], + "account": sys.argv[3], + "identity": sys.argv[4], + "hostname": sys.argv[5], +} +path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") +PY + +cargo build -p burrow --bin burrow +( + cd "${repo_root}/Tools/tailscale-login-bridge" + GOWORK=off go build -o "$helper_bin" . +) + +if [[ -z "$remote_ip" ]]; then + if [[ -z "$remote_authkey" ]] && { [[ ! -d "$remote_state_root" ]] || [[ -z "$(find "$remote_state_root" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; }; then + echo "error: set BURROW_TAILNET_SMOKE_REMOTE_IP, BURROW_TAILNET_SMOKE_REMOTE_AUTHKEY, or BURROW_TAILNET_SMOKE_REMOTE_STATE_ROOT to an existing logged-in helper state" >&2 + exit 1 + fi + + if [[ -n "$remote_authkey" ]]; then + rm -rf "$remote_state_root" + mkdir -p "$remote_state_root" + fi + + ( + cd "$repo_root" + if [[ -n "$remote_authkey" ]]; then + export TS_AUTHKEY="$remote_authkey" + fi + "$helper_bin" \ + --listen 127.0.0.1:0 \ + --state-dir "$remote_state_root" \ + --hostname "$remote_hostname" \ + --control-url "$authority" \ + --udp-echo-port "$remote_port" \ + >"$remote_stdout" 2>"$remote_stderr" + ) & + remote_pid=$! + + remote_listen_addr="$(wait_for_helper_listen "$remote_stdout")" + remote_ip="$(wait_for_helper_ip "http://${remote_listen_addr}/status")" +fi + +( + cd "$smoke_root" + RUST_LOG="${BURROW_TAILNET_SMOKE_RUST_LOG:-info,burrow=debug}" \ + BURROW_SOCKET_PATH="$socket_path" \ + BURROW_TAILSCALE_STATE_ROOT="$tailnet_state_root" \ + "${repo_root}/target/debug/burrow" daemon >"$daemon_log" 2>&1 +) & +daemon_pid=$! + +for _ in $(seq 1 50); do + [[ -S "$socket_path" ]] && break + sleep 0.2 +done + +if [[ ! -S "$socket_path" ]]; then + echo "error: Burrow daemon did not create ${socket_path}" >&2 + [[ -f "$daemon_log" ]] && cat "$daemon_log" >&2 + exit 1 +fi + +run_burrow() { + BURROW_SOCKET_PATH="$socket_path" \ + BURROW_TAILSCALE_STATE_ROOT="$tailnet_state_root" \ + "${repo_root}/target/debug/burrow" "$@" +} + +run_burrow network-add 1 1 "$payload_path" +run_burrow start +run_burrow tunnel-config +run_burrow tailnet-udp-echo "${remote_ip}:${remote_port}" --message "$message" --timeout-ms "$timeout_ms" + +echo +echo "Tailnet connectivity smoke passed." +echo "State root: $tailnet_state_root" +echo "Remote: ${remote_ip}:${remote_port}" diff --git a/Tools/tailscale-login-bridge/main.go b/Tools/tailscale-login-bridge/main.go index 82ca9b0..877d0e4 100644 --- a/Tools/tailscale-login-bridge/main.go +++ b/Tools/tailscale-login-bridge/main.go @@ -2,17 +2,26 @@ package main import ( "context" + "encoding/binary" "encoding/json" + "errors" "flag" "fmt" + "io" "log" "net" + "net/netip" "net/http" "os" + "strconv" + "sync" "time" + "github.com/tailscale/wireguard-go/tun" "tailscale.com/client/local" "tailscale.com/ipn" + "tailscale.com/ipn/ipnstate" + "tailscale.com/tailcfg" "tailscale.com/tsnet" ) @@ -26,13 +35,123 @@ type statusResponse struct { SelfDNSName string `json:"self_dns_name,omitempty"` TailscaleIPs []string `json:"tailscale_ips,omitempty"` Health []string `json:"health,omitempty"` + Peers []peerSummary `json:"peers,omitempty"` } +type peerSummary struct { + Name string `json:"name,omitempty"` + DNSName string `json:"dns_name,omitempty"` + TailscaleIPs []string `json:"tailscale_ips,omitempty"` + Online bool `json:"online"` + Active bool `json:"active"` + Relay string `json:"relay,omitempty"` + CurAddr string `json:"cur_addr,omitempty"` + LastSeenUnix int64 `json:"last_seen_unix,omitempty"` +} + +type pingResponse struct { + Result *ipnstate.PingResult `json:"result,omitempty"` +} + +type helperHello struct { + ListenAddr string `json:"listen_addr"` + PacketSocket string `json:"packet_socket,omitempty"` +} + +type helperState struct { + mu sync.RWMutex + authURL string +} + +func (s *helperState) authURLSnapshot() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.authURL +} + +func (s *helperState) setAuthURL(url string) { + s.mu.Lock() + defer s.mu.Unlock() + s.authURL = url +} + +func (s *helperState) clearAuthURL() { + s.setAuthURL("") +} + +// chanTUN is a tun.Device backed by channels so another process can feed and +// consume raw IP packets while tsnet handles the Tailnet control/data plane. +type chanTUN struct { + Inbound chan []byte + Outbound chan []byte + closed chan struct{} + events chan tun.Event +} + +func newChanTUN() *chanTUN { + t := &chanTUN{ + Inbound: make(chan []byte, 1024), + Outbound: make(chan []byte, 1024), + closed: make(chan struct{}), + events: make(chan tun.Event, 1), + } + t.events <- tun.EventUp + return t +} + +func (t *chanTUN) File() *os.File { return nil } + +func (t *chanTUN) Close() error { + select { + case <-t.closed: + default: + close(t.closed) + close(t.Inbound) + } + return nil +} + +func (t *chanTUN) Read(bufs [][]byte, sizes []int, offset int) (int, error) { + select { + case <-t.closed: + return 0, io.EOF + case pkt, ok := <-t.Outbound: + if !ok { + return 0, io.EOF + } + sizes[0] = copy(bufs[0][offset:], pkt) + return 1, nil + } +} + +func (t *chanTUN) Write(bufs [][]byte, offset int) (int, error) { + for _, buf := range bufs { + pkt := buf[offset:] + if len(pkt) == 0 { + continue + } + select { + case <-t.closed: + return 0, errors.New("closed") + case t.Inbound <- append([]byte(nil), pkt...): + default: + } + } + return len(bufs), nil +} + +func (t *chanTUN) MTU() (int, error) { return 1280, nil } +func (t *chanTUN) Name() (string, error) { return "burrow-tailnet", nil } +func (t *chanTUN) Events() <-chan tun.Event { return t.events } +func (t *chanTUN) BatchSize() int { return 1 } + func main() { listen := flag.String("listen", "127.0.0.1:0", "local listen address") stateDir := flag.String("state-dir", "", "persistent state directory") hostname := flag.String("hostname", "burrow-apple", "tailnet hostname") controlURL := flag.String("control-url", "", "optional control URL") + packetSocket := flag.String("packet-socket", "", "optional unix socket path for raw packet bridging") + udpEchoPort := flag.Int("udp-echo-port", 0, "optional tailnet UDP echo port") flag.Parse() if *stateDir == "" { @@ -48,6 +167,24 @@ func main() { Hostname: *hostname, UserLogf: log.Printf, } + + var tunDevice *chanTUN + var packetListener net.Listener + if *packetSocket != "" { + _ = os.Remove(*packetSocket) + ln, err := net.Listen("unix", *packetSocket) + if err != nil { + log.Fatalf("packet listen: %v", err) + } + packetListener = ln + defer func() { + packetListener.Close() + _ = os.Remove(*packetSocket) + }() + + tunDevice = newChanTUN() + server.Tun = tunDevice + } if *controlURL != "" { server.ControlURL = *controlURL } @@ -61,6 +198,7 @@ func main() { if err != nil { log.Fatalf("local client: %v", err) } + state := &helperState{} ln, err := net.Listen("tcp", *listen) if err != nil { @@ -68,12 +206,27 @@ func main() { } defer ln.Close() - fmt.Printf("{\"listen_addr\":%q}\n", ln.Addr().String()) + if packetListener != nil { + go servePacketBridge(packetListener, tunDevice) + } + if *udpEchoPort > 0 { + go serveUDPEcho(context.Background(), server, localClient, *udpEchoPort) + } + + hello := helperHello{ + ListenAddr: ln.Addr().String(), + } + if *packetSocket != "" { + hello.PacketSocket = *packetSocket + } + if err := json.NewEncoder(os.Stdout).Encode(hello); err != nil { + log.Fatalf("write hello: %v", err) + } _ = os.Stdout.Sync() mux := http.NewServeMux() mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { - status, err := snapshot(r.Context(), localClient) + status, err := snapshot(r.Context(), localClient, state) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return @@ -81,6 +234,40 @@ func main() { w.Header().Set("content-type", "application/json") _ = json.NewEncoder(w).Encode(status) }) + mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { + ip := r.URL.Query().Get("ip") + if ip == "" { + http.Error(w, "missing ip", http.StatusBadRequest) + return + } + target, err := netip.ParseAddr(ip) + if err != nil { + http.Error(w, fmt.Sprintf("invalid ip: %v", err), http.StatusBadRequest) + return + } + + pingType := tailcfg.PingTSMP + switch r.URL.Query().Get("type") { + case "", "tsmp", "TSMP": + pingType = tailcfg.PingTSMP + case "icmp", "ICMP": + pingType = tailcfg.PingICMP + case "peerapi": + pingType = tailcfg.PingPeerAPI + default: + http.Error(w, "unsupported ping type", http.StatusBadRequest) + return + } + + result, err := localClient.Ping(r.Context(), target, pingType) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(&pingResponse{Result: result}) + }) mux.HandleFunc("/shutdown", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) go func() { @@ -96,16 +283,110 @@ func main() { log.Fatal(httpServer.Serve(ln)) } -func snapshot(ctx context.Context, localClient *local.Client) (*statusResponse, error) { - status, err := localClient.StatusWithoutPeers(ctx) +func servePacketBridge(listener net.Listener, device *chanTUN) { + for { + conn, err := listener.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) { + return + } + log.Printf("packet accept: %v", err) + continue + } + log.Printf("packet bridge connected") + if err := bridgePacketConn(conn, device); err != nil && !errors.Is(err, io.EOF) { + log.Printf("packet bridge error: %v", err) + } + _ = conn.Close() + log.Printf("packet bridge disconnected") + } +} + +func bridgePacketConn(conn net.Conn, device *chanTUN) error { + errCh := make(chan error, 2) + + go func() { + for { + pkt, err := readFrame(conn) + if err != nil { + errCh <- err + return + } + select { + case <-device.closed: + errCh <- io.EOF + return + case device.Outbound <- pkt: + } + } + }() + + go func() { + for { + select { + case <-device.closed: + errCh <- io.EOF + return + case pkt, ok := <-device.Inbound: + if !ok { + errCh <- io.EOF + return + } + if err := writeFrame(conn, pkt); err != nil { + errCh <- err + return + } + } + } + }() + + return <-errCh +} + +func readFrame(r io.Reader) ([]byte, error) { + var size [4]byte + if _, err := io.ReadFull(r, size[:]); err != nil { + return nil, err + } + length := binary.BigEndian.Uint32(size[:]) + if length == 0 { + return []byte{}, nil + } + packet := make([]byte, length) + if _, err := io.ReadFull(r, packet); err != nil { + return nil, err + } + return packet, nil +} + +func writeFrame(w io.Writer, packet []byte) error { + var size [4]byte + binary.BigEndian.PutUint32(size[:], uint32(len(packet))) + if _, err := w.Write(size[:]); err != nil { + return err + } + if len(packet) == 0 { + return nil + } + _, err := w.Write(packet) + return err +} + +func snapshot(ctx context.Context, localClient *local.Client, state *helperState) (*statusResponse, error) { + status, err := localClient.Status(ctx) if err != nil { return nil, err } - if (status.BackendState == ipn.NeedsLogin.String() || status.BackendState == ipn.NoState.String()) && status.AuthURL == "" { - if err := localClient.StartLoginInteractive(ctx); err != nil { - return nil, err - } - status, err = localClient.StatusWithoutPeers(ctx) + + authURL := status.AuthURL + if authURL == "" { + authURL = state.authURLSnapshot() + } + if status.BackendState == ipn.Running.String() { + state.clearAuthURL() + authURL = "" + } else if (status.BackendState == ipn.NeedsLogin.String() || status.BackendState == ipn.NoState.String()) && authURL == "" { + authURL, err = awaitAuthURL(ctx, localClient, state) if err != nil { return nil, err } @@ -113,7 +394,7 @@ func snapshot(ctx context.Context, localClient *local.Client) (*statusResponse, response := &statusResponse{ BackendState: status.BackendState, - AuthURL: status.AuthURL, + AuthURL: authURL, Running: status.BackendState == ipn.Running.String(), NeedsLogin: status.BackendState == ipn.NeedsLogin.String(), Health: append([]string(nil), status.Health...), @@ -129,5 +410,114 @@ func snapshot(ctx context.Context, localClient *local.Client) (*statusResponse, for _, ip := range status.TailscaleIPs { response.TailscaleIPs = append(response.TailscaleIPs, ip.String()) } + for _, key := range status.Peers() { + peer := status.Peer[key] + if peer == nil { + continue + } + summary := peerSummary{ + Name: peer.HostName, + DNSName: peer.DNSName, + Online: peer.Online, + Active: peer.Active, + Relay: peer.Relay, + CurAddr: peer.CurAddr, + LastSeenUnix: peer.LastSeen.Unix(), + } + for _, ip := range peer.TailscaleIPs { + summary.TailscaleIPs = append(summary.TailscaleIPs, ip.String()) + } + response.Peers = append(response.Peers, summary) + } return response, nil } + +func serveUDPEcho(ctx context.Context, server *tsnet.Server, localClient *local.Client, port int) { + ip, err := awaitTailscaleIP(ctx, localClient) + if err != nil { + log.Printf("udp echo setup failed: %v", err) + return + } + + listenAddr := net.JoinHostPort(ip.String(), strconv.Itoa(port)) + pc, err := server.ListenPacket("udp", listenAddr) + if err != nil { + log.Printf("udp echo listen failed on %s: %v", listenAddr, err) + return + } + defer pc.Close() + + log.Printf("udp echo listening on %s", pc.LocalAddr()) + buf := make([]byte, 64<<10) + for { + n, addr, err := pc.ReadFrom(buf) + if err != nil { + if errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) { + return + } + log.Printf("udp echo read failed: %v", err) + return + } + if _, err := pc.WriteTo(buf[:n], addr); err != nil { + log.Printf("udp echo write failed: %v", err) + return + } + } +} + +func awaitTailscaleIP(ctx context.Context, localClient *local.Client) (netip.Addr, error) { + for range 60 { + status, err := localClient.StatusWithoutPeers(ctx) + if err == nil { + for _, ip := range status.TailscaleIPs { + if ip.Is4() { + return ip, nil + } + } + for _, ip := range status.TailscaleIPs { + if ip.Is6() { + return ip, nil + } + } + } + select { + case <-ctx.Done(): + return netip.Addr{}, ctx.Err() + case <-time.After(250 * time.Millisecond): + } + } + return netip.Addr{}, errors.New("timed out waiting for tailscale IP") +} + +func awaitAuthURL(ctx context.Context, localClient *local.Client, state *helperState) (string, error) { + watchCtx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + + watcher, err := localClient.WatchIPNBus(watchCtx, ipn.NotifyInitialState) + if err != nil { + return "", err + } + defer watcher.Close() + + if err := localClient.StartLoginInteractive(ctx); err != nil { + return "", err + } + + for { + notify, err := watcher.Next() + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return state.authURLSnapshot(), nil + } + return "", err + } + if notify.BrowseToURL != nil && *notify.BrowseToURL != "" { + state.setAuthURL(*notify.BrowseToURL) + return *notify.BrowseToURL, nil + } + if notify.State != nil && *notify.State == ipn.Running { + state.clearAuthURL() + return "", nil + } + } +} diff --git a/burrow/src/main.rs b/burrow/src/main.rs index c91f36f..4ab7700 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -72,6 +72,14 @@ enum Commands { NetworkReorder(NetworkReorderArgs), /// Delete Network NetworkDelete(NetworkDeleteArgs), + /// Discover a Tailnet authority through the daemon + TailnetDiscover(TailnetDiscoverArgs), + /// Probe a Tailnet authority through the daemon + TailnetProbe(TailnetProbeArgs), + /// Send an ICMP echo probe through the active Tailnet tunnel over daemon packet streaming + TailnetPing(TailnetPingArgs), + /// Send a UDP echo probe through the active Tailnet tunnel over daemon packet streaming + TailnetUdpEcho(TailnetUdpEchoArgs), #[cfg(target_os = "linux")] /// Run a command in an unshared Linux namespace using a Burrow backend Exec(ExecArgs), @@ -110,6 +118,36 @@ struct NetworkDeleteArgs { id: i32, } +#[derive(Args)] +struct TailnetDiscoverArgs { + email: String, +} + +#[derive(Args)] +struct TailnetProbeArgs { + authority: String, +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +#[derive(Args)] +struct TailnetPingArgs { + remote: String, + #[arg(long, default_value = "burrow-tailnet-smoke")] + payload: String, + #[arg(long, default_value_t = 5000)] + timeout_ms: u64, +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +#[derive(Args)] +struct TailnetUdpEchoArgs { + remote: String, + #[arg(long, default_value = "burrow-tailnet-smoke")] + message: String, + #[arg(long, default_value_t = 5000)] + timeout_ms: u64, +} + #[cfg(target_os = "linux")] #[derive(Args)] struct TorExecArgs { @@ -240,6 +278,393 @@ async fn try_network_delete(id: i32) -> Result<()> { Ok(()) } +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_tailnet_discover(email: &str) -> Result<()> { + let mut client = BurrowClient::from_uds().await?; + let response = client + .tailnet_client + .discover(crate::daemon::rpc::grpc_defs::TailnetDiscoverRequest { + email: email.to_owned(), + }) + .await? + .into_inner(); + println!("Tailnet Discover Response: {:?}", response); + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_tailnet_probe(authority: &str) -> Result<()> { + let mut client = BurrowClient::from_uds().await?; + let response = client + .tailnet_client + .probe(crate::daemon::rpc::grpc_defs::TailnetProbeRequest { + authority: authority.to_owned(), + }) + .await? + .into_inner(); + println!("Tailnet Probe Response: {:?}", response); + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_tailnet_ping(remote: &str, payload: &str, timeout_ms: u64) -> Result<()> { + use std::net::IpAddr; + + use anyhow::Context; + use rand::Rng; + use tokio::{ + sync::mpsc, + time::{timeout, Duration}, + }; + use tokio_stream::wrappers::ReceiverStream; + + use crate::daemon::rpc::grpc_defs::{Empty, TunnelPacket}; + + let remote_ip: IpAddr = remote + .parse() + .with_context(|| format!("invalid remote IP address {remote}"))?; + let message = payload.as_bytes().to_vec(); + + let mut client = BurrowClient::from_uds().await?; + client.tunnel_client.tunnel_start(Empty {}).await?; + + let mut config_stream = client + .tunnel_client + .tunnel_configuration(Empty {}) + .await? + .into_inner(); + let config = config_stream + .message() + .await? + .context("tunnel configuration stream ended before yielding a config")?; + let local_ip = select_tailnet_local_ip(&config.addresses, remote_ip)?; + + let identifier = rand::thread_rng().gen::(); + let sequence = 1_u16; + let packet = build_icmp_echo_request(local_ip, remote_ip, identifier, sequence, &message)?; + + let (outbound_tx, outbound_rx) = mpsc::channel::(128); + let mut tunnel_packets = client + .tunnel_client + .tunnel_packets(ReceiverStream::new(outbound_rx)) + .await? + .into_inner(); + + outbound_tx + .send(TunnelPacket { payload: packet }) + .await + .context("failed to send ICMP echo probe into daemon packet stream")?; + log::debug!( + "tailnet ping probe queued from {local_ip} to {remote_ip} identifier={identifier} sequence={sequence}" + ); + drop(outbound_tx); + + let reply = timeout(Duration::from_millis(timeout_ms), async { + loop { + let packet = tunnel_packets + .message() + .await + .context("failed to read packet from daemon packet stream")? + .context("daemon packet stream ended before returning a reply")?; + log::debug!( + "tailnet ping received {} bytes from daemon packet stream", + packet.payload.len() + ); + if let Some(reply) = parse_icmp_echo_reply( + &packet.payload, + local_ip, + remote_ip, + identifier, + sequence, + )? { + break Ok::<_, anyhow::Error>(reply); + } + } + }) + .await + .with_context(|| format!("timed out waiting for ICMP echo reply from {remote_ip}"))??; + + println!("Tailnet Ping Source: {}", reply.source); + println!("Tailnet Ping Destination: {}", reply.destination); + println!( + "Tailnet Ping Payload: {}", + String::from_utf8_lossy(&reply.payload) + ); + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> Result<()> { + use std::net::SocketAddr; + + use anyhow::{bail, Context}; + use futures::{SinkExt, StreamExt}; + use netstack_smoltcp::StackBuilder; + use tokio::{ + sync::mpsc, + time::{timeout, Duration}, + }; + use tokio_stream::wrappers::ReceiverStream; + + use crate::daemon::rpc::grpc_defs::{Empty, TunnelPacket}; + + let remote_addr: SocketAddr = remote + .parse() + .with_context(|| format!("invalid remote socket address {remote}"))?; + + let mut client = BurrowClient::from_uds().await?; + client.tunnel_client.tunnel_start(Empty {}).await?; + + let mut config_stream = client + .tunnel_client + .tunnel_configuration(Empty {}) + .await? + .into_inner(); + let config = config_stream + .message() + .await? + .context("tunnel configuration stream ended before yielding a config")?; + let local_addr = select_tailnet_local_socket(&config.addresses, remote_addr.ip())?; + + let (stack, runner, udp_socket, _) = StackBuilder::default() + .enable_udp(true) + .enable_tcp(true) + .build() + .context("failed to build userspace UDP stack")?; + let runner = runner.context("userspace UDP stack runner unavailable")?; + let udp_socket = udp_socket.context("userspace UDP stack socket unavailable")?; + let (mut stack_sink, mut stack_stream) = stack.split(); + let (mut udp_reader, mut udp_writer) = udp_socket.split(); + + let (outbound_tx, outbound_rx) = mpsc::channel::(128); + let mut tunnel_packets = client + .tunnel_client + .tunnel_packets(ReceiverStream::new(outbound_rx)) + .await? + .into_inner(); + + let ingress_task = tokio::spawn(async move { + loop { + match tunnel_packets.message().await? { + Some(packet) => { + log::debug!( + "tailnet udp echo received {} bytes from daemon packet stream", + packet.payload.len() + ); + stack_sink + .send(packet.payload) + .await + .context("failed to feed inbound tailnet packet into userspace stack")?; + } + None => break, + } + } + Result::<()>::Ok(()) + }); + + let egress_task = tokio::spawn(async move { + while let Some(packet) = stack_stream.next().await { + let payload = + packet.context("failed to read outbound packet from userspace stack")?; + log::debug!( + "tailnet udp echo sending {} bytes into daemon packet stream", + payload.len() + ); + outbound_tx + .send(TunnelPacket { payload }) + .await + .context("failed to forward outbound tailnet packet to daemon")?; + } + Result::<()>::Ok(()) + }); + + let runner_task = tokio::spawn(async move { runner.await.map_err(anyhow::Error::from) }); + + udp_writer + .send((message.as_bytes().to_vec(), local_addr, remote_addr)) + .await + .context("failed to send UDP echo probe into userspace stack")?; + log::debug!( + "tailnet udp echo probe queued from {local_addr} to {remote_addr}" + ); + + let response = timeout(Duration::from_millis(timeout_ms), udp_reader.next()) + .await + .with_context(|| format!("timed out waiting for UDP echo from {remote_addr}"))? + .context("userspace UDP stack ended before returning a reply")?; + let (payload, reply_source, reply_destination) = response; + let response_text = String::from_utf8_lossy(&payload); + + ingress_task.abort(); + egress_task.abort(); + runner_task.abort(); + + if reply_source != remote_addr { + bail!("received UDP reply from unexpected source {reply_source}"); + } + if reply_destination != local_addr { + bail!("received UDP reply for unexpected local socket {reply_destination}"); + } + if payload != message.as_bytes() { + bail!("UDP echo payload mismatch"); + } + + println!("Tailnet UDP Echo Source: {reply_source}"); + println!("Tailnet UDP Echo Destination: {reply_destination}"); + println!("Tailnet UDP Echo Payload: {response_text}"); + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn select_tailnet_local_ip(addresses: &[String], remote_ip: std::net::IpAddr) -> Result { + use anyhow::Context; + + let family_is_v4 = remote_ip.is_ipv4(); + addresses + .iter() + .filter_map(|cidr| cidr.split('/').next()) + .filter_map(|ip| ip.parse::().ok()) + .find(|ip| ip.is_ipv4() == family_is_v4) + .with_context(|| { + format!( + "no local {} tailnet address found in daemon config {:?}", + if family_is_v4 { "IPv4" } else { "IPv6" }, + addresses + ) + }) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn select_tailnet_local_socket( + addresses: &[String], + remote_ip: std::net::IpAddr, +) -> Result { + use rand::Rng; + + let local_ip = select_tailnet_local_ip(addresses, remote_ip)?; + let port = rand::thread_rng().gen_range(40000..50000); + Ok(std::net::SocketAddr::new(local_ip, port)) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +struct IcmpEchoReply { + source: std::net::IpAddr, + destination: std::net::IpAddr, + payload: Vec, +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn build_icmp_echo_request( + source: std::net::IpAddr, + destination: std::net::IpAddr, + identifier: u16, + sequence: u16, + payload: &[u8], +) -> Result> { + use anyhow::bail; + + let (source, destination) = match (source, destination) { + (std::net::IpAddr::V4(source), std::net::IpAddr::V4(destination)) => (source, destination), + _ => bail!("tailnet ping currently supports IPv4 only"), + }; + + let mut icmp = Vec::with_capacity(8 + payload.len()); + icmp.push(8); + icmp.push(0); + icmp.extend_from_slice(&[0, 0]); + icmp.extend_from_slice(&identifier.to_be_bytes()); + icmp.extend_from_slice(&sequence.to_be_bytes()); + icmp.extend_from_slice(payload); + let icmp_checksum = internet_checksum(&icmp); + icmp[2..4].copy_from_slice(&icmp_checksum.to_be_bytes()); + + let total_len = 20 + icmp.len(); + let mut packet = Vec::with_capacity(total_len); + packet.push(0x45); + packet.push(0); + packet.extend_from_slice(&(total_len as u16).to_be_bytes()); + packet.extend_from_slice(&0u16.to_be_bytes()); + packet.extend_from_slice(&0u16.to_be_bytes()); + packet.push(64); + packet.push(1); + packet.extend_from_slice(&[0, 0]); + packet.extend_from_slice(&source.octets()); + packet.extend_from_slice(&destination.octets()); + let header_checksum = internet_checksum(&packet); + packet[10..12].copy_from_slice(&header_checksum.to_be_bytes()); + packet.extend_from_slice(&icmp); + Ok(packet) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn parse_icmp_echo_reply( + packet: &[u8], + local_ip: std::net::IpAddr, + remote_ip: std::net::IpAddr, + identifier: u16, + sequence: u16, +) -> Result> { + use anyhow::bail; + + let (local_ip, remote_ip) = match (local_ip, remote_ip) { + (std::net::IpAddr::V4(local_ip), std::net::IpAddr::V4(remote_ip)) => (local_ip, remote_ip), + _ => bail!("tailnet ping currently supports IPv4 only"), + }; + + if packet.len() < 20 { + return Ok(None); + } + let version = packet[0] >> 4; + if version != 4 { + return Ok(None); + } + let ihl = (packet[0] & 0x0f) as usize * 4; + if packet.len() < ihl + 8 { + return Ok(None); + } + if packet[9] != 1 { + return Ok(None); + } + + let source = std::net::Ipv4Addr::new(packet[12], packet[13], packet[14], packet[15]); + let destination = std::net::Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]); + if source != remote_ip || destination != local_ip { + return Ok(None); + } + + let icmp = &packet[ihl..]; + if icmp[0] != 0 || icmp[1] != 0 { + return Ok(None); + } + let reply_identifier = u16::from_be_bytes([icmp[4], icmp[5]]); + let reply_sequence = u16::from_be_bytes([icmp[6], icmp[7]]); + if reply_identifier != identifier || reply_sequence != sequence { + return Ok(None); + } + + Ok(Some(IcmpEchoReply { + source: std::net::IpAddr::V4(source), + destination: std::net::IpAddr::V4(destination), + payload: icmp[8..].to_vec(), + })) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn internet_checksum(bytes: &[u8]) -> u16 { + let mut sum = 0u32; + let mut chunks = bytes.chunks_exact(2); + for chunk in &mut chunks { + sum += u16::from_be_bytes([chunk[0], chunk[1]]) as u32; + } + if let Some(&last) = chunks.remainder().first() { + sum += (last as u32) << 8; + } + while (sum >> 16) != 0 { + sum = (sum & 0xffff) + (sum >> 16); + } + !(sum as u16) +} + #[cfg(target_os = "linux")] async fn try_tor_exec(payload_path: &str, command: Vec) -> Result<()> { let exit_code = usernet::run_exec(usernet::ExecInvocation { @@ -348,6 +773,14 @@ async fn main() -> Result<()> { Commands::NetworkList => try_network_list().await?, Commands::NetworkReorder(args) => try_network_reorder(args.id, args.index).await?, Commands::NetworkDelete(args) => try_network_delete(args.id).await?, + Commands::TailnetDiscover(args) => try_tailnet_discover(&args.email).await?, + Commands::TailnetProbe(args) => try_tailnet_probe(&args.authority).await?, + Commands::TailnetPing(args) => { + try_tailnet_ping(&args.remote, &args.payload, args.timeout_ms).await? + } + Commands::TailnetUdpEcho(args) => { + try_tailnet_udp_echo(&args.remote, &args.message, args.timeout_ms).await? + } #[cfg(target_os = "linux")] Commands::Exec(args) => { try_exec( From 9e3e8fa7834bc09a2152feba7ae45f7e38784810 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 4 Apr 2026 22:20:55 -0700 Subject: [PATCH 057/102] Use upstream nsc-autoscaler on burrow forge --- flake.lock | 26 ++- flake.nix | 9 +- nixos/README.md | 4 +- nixos/hosts/burrow-forge/default.nix | 2 +- nixos/modules/burrow-forge.nix | 2 +- nixos/modules/burrow-forgejo-nsc.nix | 234 --------------------------- 6 files changed, 36 insertions(+), 241 deletions(-) delete mode 100644 nixos/modules/burrow-forgejo-nsc.nix diff --git a/flake.lock b/flake.lock index 1bafc37..0067dab 100644 --- a/flake.lock +++ b/flake.lock @@ -123,13 +123,37 @@ "url": "https://codeload.github.com/NixOS/nixpkgs/tar.gz/nixos-unstable" } }, + "nsc-autoscaler": { + "inputs": { + "flake-utils": [ + "flake-utils" + ], + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1775221037, + "narHash": "sha256-tv6Y3cqn76PEyZpSMMItVW96KKIboovBWTOv5Lt7PXg=", + "ref": "refs/heads/main", + "rev": "2c485752fde28ec3be2f228b571d1906f4bcf917", + "revCount": 10, + "type": "git", + "url": "https://compatible.systems/conrad/nsc-autoscaler.git" + }, + "original": { + "type": "git", + "url": "https://compatible.systems/conrad/nsc-autoscaler.git" + } + }, "root": { "inputs": { "agenix": "agenix", "disko": "disko", "flake-utils": "flake-utils", "hcloud-upload-image-src": "hcloud-upload-image-src", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "nsc-autoscaler": "nsc-autoscaler" } }, "systems": { diff --git a/flake.nix b/flake.nix index 5814c19..1e91dcc 100644 --- a/flake.nix +++ b/flake.nix @@ -12,13 +12,18 @@ url = "tarball+https://codeload.github.com/nix-community/disko/tar.gz/master"; inputs.nixpkgs.follows = "nixpkgs"; }; + nsc-autoscaler = { + url = "git+https://compatible.systems/conrad/nsc-autoscaler.git"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.flake-utils.follows = "flake-utils"; + }; hcloud-upload-image-src = { url = "tarball+https://codeload.github.com/apricote/hcloud-upload-image/tar.gz/v1.3.0"; flake = false; }; }; - outputs = { self, nixpkgs, flake-utils, agenix, disko, hcloud-upload-image-src }: + outputs = { self, nixpkgs, flake-utils, agenix, disko, nsc-autoscaler, hcloud-upload-image-src }: let supportedSystems = [ "x86_64-linux" @@ -175,7 +180,7 @@ // { nixosModules.burrow-forge = import ./nixos/modules/burrow-forge.nix; nixosModules.burrow-forge-runner = import ./nixos/modules/burrow-forge-runner.nix; - nixosModules.burrow-forgejo-nsc = import ./nixos/modules/burrow-forgejo-nsc.nix; + nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default; nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix; nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.nix; diff --git a/nixos/README.md b/nixos/README.md index 07b421d..c79d8ce 100644 --- a/nixos/README.md +++ b/nixos/README.md @@ -9,7 +9,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B - `hosts/burrow-forge/default.nix`: host entrypoint - `modules/burrow-forge.nix`: Forgejo, Caddy, PostgreSQL, and admin bootstrap module - `modules/burrow-forge-runner.nix`: Forgejo Actions runner and agent identity bootstrap -- `modules/burrow-forgejo-nsc.nix`: Namespace-backed ephemeral Forgejo runner services +- upstream `compatible.systems/conrad/nsc-autoscaler`: Namespace-backed ephemeral Forgejo runner module consumed via the Burrow flake input - `modules/burrow-authentik.nix`: minimal Authentik IdP for Burrow control planes - `modules/burrow-headscale.nix`: Headscale control plane rooted in Authentik OIDC - `../secrets.nix`: agenix recipient map for tracked Burrow forge secrets @@ -32,7 +32,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B 3. Run `Scripts/bootstrap-forge-intake.sh` to place the Forgejo bootstrap password file and automation SSH key under `/var/lib/burrow/intake/`. 4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account. 5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent `. -6. Run `Scripts/provision-forgejo-nsc.sh` locally, then `Scripts/sync-forgejo-nsc-config.sh` to place the Namespace dispatcher/autoscaler runtime inputs under `/var/lib/burrow/intake/`. +6. Run `Scripts/provision-forgejo-nsc.sh` locally, then `Scripts/sync-forgejo-nsc-config.sh` to place the raw Namespace dispatcher/autoscaler runtime inputs under `/var/lib/burrow/intake/` for the upstream `services.forgejo-nsc` module. 7. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`. 8. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. 9. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace. diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 6c106f4..67c87ec 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -104,7 +104,7 @@ in sshPrivateKeyFile = "/var/lib/burrow/intake/agent_at_burrow_net_ed25519"; }; - services.burrow.forgejoNsc = { + services.forgejo-nsc = { enable = true; nscTokenFile = "/var/lib/burrow/intake/forgejo_nsc_token.txt"; dispatcher = { diff --git a/nixos/modules/burrow-forge.nix b/nixos/modules/burrow-forge.nix index 0d0f5c8..d74fc65 100644 --- a/nixos/modules/burrow-forge.nix +++ b/nixos/modules/burrow-forge.nix @@ -271,7 +271,7 @@ in ''; } // lib.optionalAttrs ( - config.services.burrow.forgejoNsc.enable && config.services.burrow.forgejoNsc.autoscaler.enable + config.services.forgejo-nsc.enable && config.services.forgejo-nsc.autoscaler.enable ) { "${cfg.nscAutoscalerDomain}".extraConfig = '' encode gzip zstd diff --git a/nixos/modules/burrow-forgejo-nsc.nix b/nixos/modules/burrow-forgejo-nsc.nix deleted file mode 100644 index ba116f7..0000000 --- a/nixos/modules/burrow-forgejo-nsc.nix +++ /dev/null @@ -1,234 +0,0 @@ -{ config, lib, pkgs, self, ... }: - -let - inherit (lib) - mkEnableOption - mkIf - mkOption - types - mkAfter - mkDefault - optional - optionalAttrs - optionalString - ; - - cfg = config.services.burrow.forgejoNsc; - dispatcherRuntimeConfig = "${cfg.stateDir}/dispatcher.yaml"; - autoscalerRuntimeConfig = "${cfg.stateDir}/autoscaler.yaml"; - - pendingCheck = configPath: pkgs.writeShellScript "forgejo-nsc-check-pending" '' - set -euo pipefail - if ${pkgs.gnugrep}/bin/grep -q 'PENDING-' '${configPath}'; then - echo "forgejo-nsc config still contains placeholder values (PENDING-); update ${configPath} before starting." >&2 - exit 1 - fi - ''; - - nscTokenPath = "${cfg.stateDir}/nsc.token"; - tokenSync = optionalString (cfg.nscTokenFile != null) '' - install -m 600 ${lib.escapeShellArg cfg.nscTokenFile} ${lib.escapeShellArg nscTokenPath} - chown ${cfg.user}:${cfg.group} ${nscTokenPath} - chmod 600 ${nscTokenPath} - ''; - dispatcherConfigSync = optionalString (cfg.dispatcher.configFile != null) '' - install -m 400 ${lib.escapeShellArg cfg.dispatcher.configFile} ${lib.escapeShellArg dispatcherRuntimeConfig} - chown ${cfg.user}:${cfg.group} ${lib.escapeShellArg dispatcherRuntimeConfig} - chmod 400 ${lib.escapeShellArg dispatcherRuntimeConfig} - ''; - autoscalerConfigSync = optionalString (cfg.autoscaler.configFile != null) '' - install -m 400 ${lib.escapeShellArg cfg.autoscaler.configFile} ${lib.escapeShellArg autoscalerRuntimeConfig} - chown ${cfg.user}:${cfg.group} ${lib.escapeShellArg autoscalerRuntimeConfig} - chmod 400 ${lib.escapeShellArg autoscalerRuntimeConfig} - ''; - - dispatcherEnv = - cfg.extraEnv - // optionalAttrs (cfg.nscTokenFile != null) { NSC_TOKEN_FILE = nscTokenPath; } - // optionalAttrs (cfg.nscTokenSpecFile != null) { NSC_TOKEN_SPEC_FILE = cfg.nscTokenSpecFile; } - // optionalAttrs (cfg.nscEndpoint != null) { NSC_ENDPOINT = cfg.nscEndpoint; }; -in { - options.services.burrow.forgejoNsc = { - enable = mkEnableOption "Forgejo Namespace Cloud runner dispatcher"; - - user = mkOption { - type = types.str; - default = "forgejo-nsc"; - description = "System user that runs the forgejo-nsc services."; - }; - - group = mkOption { - type = types.str; - default = "forgejo-nsc"; - description = "System group for the forgejo-nsc services."; - }; - - stateDir = mkOption { - type = types.str; - default = "/var/lib/forgejo-nsc"; - description = "State directory for the dispatcher/autoscaler."; - }; - - nscTokenFile = mkOption { - type = types.nullOr types.str; - default = null; - description = "Optional NSC token file (exported as NSC_TOKEN_FILE)."; - }; - - nscTokenSpecFile = mkOption { - type = types.nullOr types.str; - default = null; - description = "Optional NSC token spec file (exported as NSC_TOKEN_SPEC_FILE)."; - }; - - nscEndpoint = mkOption { - type = types.nullOr types.str; - default = null; - description = "Optional NSC endpoint override (exported as NSC_ENDPOINT)."; - }; - - extraEnv = mkOption { - type = types.attrsOf types.str; - default = { }; - description = "Extra environment variables injected into the services."; - }; - - nscPackage = mkOption { - type = types.nullOr types.package; - default = self.packages.${pkgs.stdenv.hostPlatform.system}.nsc or null; - description = "Optional nsc CLI package added to the service PATH."; - }; - - dispatcher = { - enable = mkOption { - type = types.bool; - default = true; - description = "Enable the forgejo-nsc dispatcher service."; - }; - - package = mkOption { - type = types.package; - default = self.packages.${pkgs.stdenv.hostPlatform.system}.forgejo-nsc-dispatcher; - description = "Package providing the forgejo-nsc dispatcher binary."; - }; - - configFile = mkOption { - type = types.nullOr types.str; - default = null; - description = "Host-local YAML config file for the dispatcher."; - }; - - allowPending = mkOption { - type = types.bool; - default = false; - description = "Allow placeholder values (PENDING-) in the dispatcher config."; - }; - }; - - autoscaler = { - enable = mkOption { - type = types.bool; - default = false; - description = "Enable the forgejo-nsc autoscaler service."; - }; - - package = mkOption { - type = types.package; - default = self.packages.${pkgs.stdenv.hostPlatform.system}.forgejo-nsc-autoscaler; - description = "Package providing the forgejo-nsc autoscaler binary."; - }; - - configFile = mkOption { - type = types.nullOr types.str; - default = null; - description = "Host-local YAML config file for the autoscaler."; - }; - - allowPending = mkOption { - type = types.bool; - default = false; - description = "Allow placeholder values (PENDING-) in the autoscaler config."; - }; - }; - }; - - config = mkIf cfg.enable { - assertions = [ - { - assertion = (!cfg.dispatcher.enable) || cfg.dispatcher.configFile != null; - message = "services.burrow.forgejoNsc.dispatcher.configFile must be set when the dispatcher is enabled."; - } - { - assertion = (!cfg.autoscaler.enable) || cfg.autoscaler.configFile != null; - message = "services.burrow.forgejoNsc.autoscaler.configFile must be set when the autoscaler is enabled."; - } - ]; - - users.groups.${cfg.group} = { }; - users.users.${cfg.user} = { - uid = mkDefault 2011; - isSystemUser = true; - group = cfg.group; - description = "Forgejo Namespace Cloud runner services"; - home = cfg.stateDir; - createHome = true; - shell = pkgs.bashInteractive; - }; - - systemd.tmpfiles.rules = mkAfter [ - "d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} - -" - ]; - - systemd.services.forgejo-nsc-dispatcher = mkIf cfg.dispatcher.enable { - description = "Forgejo Namespace Cloud dispatcher"; - wantedBy = [ "multi-user.target" ]; - after = [ "network-online.target" ]; - wants = [ "network-online.target" ]; - unitConfig.ConditionPathExists = - optional (cfg.dispatcher.configFile != null) cfg.dispatcher.configFile - ++ optional (cfg.nscTokenFile != null) cfg.nscTokenFile; - serviceConfig = { - Type = "simple"; - User = cfg.user; - Group = cfg.group; - WorkingDirectory = cfg.stateDir; - ExecStart = "${cfg.dispatcher.package}/bin/forgejo-nsc-dispatcher --config ${dispatcherRuntimeConfig}"; - Restart = "on-failure"; - RestartSec = 5; - }; - path = lib.optional (cfg.nscPackage != null) cfg.nscPackage; - environment = dispatcherEnv; - preStart = lib.concatStringsSep "\n" (lib.filter (s: s != "") [ - (optionalString (!cfg.dispatcher.allowPending) (pendingCheck cfg.dispatcher.configFile)) - dispatcherConfigSync - tokenSync - ]); - }; - - systemd.services.forgejo-nsc-autoscaler = mkIf cfg.autoscaler.enable { - description = "Forgejo Namespace Cloud autoscaler"; - wantedBy = [ "multi-user.target" ]; - after = [ "network-online.target" "forgejo-nsc-dispatcher.service" ]; - wants = [ "network-online.target" ]; - unitConfig.ConditionPathExists = - optional (cfg.autoscaler.configFile != null) cfg.autoscaler.configFile - ++ optional (cfg.nscTokenFile != null) cfg.nscTokenFile; - serviceConfig = { - Type = "simple"; - User = cfg.user; - Group = cfg.group; - WorkingDirectory = cfg.stateDir; - ExecStart = "${cfg.autoscaler.package}/bin/forgejo-nsc-autoscaler --config ${autoscalerRuntimeConfig}"; - Restart = "on-failure"; - RestartSec = 5; - }; - path = lib.optional (cfg.nscPackage != null) cfg.nscPackage; - environment = dispatcherEnv; - preStart = lib.concatStringsSep "\n" (lib.filter (s: s != "") [ - (optionalString (!cfg.autoscaler.allowPending) (pendingCheck cfg.autoscaler.configFile)) - autoscalerConfigSync - tokenSync - ]); - }; - }; -} From b15b6624cbeaba430a48a9e4c09ef963bbe45bd3 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 4 Apr 2026 22:21:03 -0700 Subject: [PATCH 058/102] Add Forgejo namespace release workflow --- .forgejo/workflows/release.yml | 60 ++++++++++ Scripts/ci/build-release-artifacts.sh | 20 ++++ Scripts/ci/ensure-nix.sh | 157 ++++++++++++++++++++++++++ Scripts/ci/publish-forgejo-release.sh | 65 +++++++++++ 4 files changed, 302 insertions(+) create mode 100644 .forgejo/workflows/release.yml create mode 100755 Scripts/ci/build-release-artifacts.sh create mode 100755 Scripts/ci/ensure-nix.sh create mode 100755 Scripts/ci/publish-forgejo-release.sh diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..3d1e92a --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,60 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Release Build + runs-on: namespace-profile-linux-medium + steps: + - name: Checkout + uses: https://code.forgejo.org/actions/checkout@v4 + with: + token: ${{ github.token }} + fetch-depth: 0 + + - name: Bootstrap Nix + shell: bash + run: | + set -euo pipefail + chmod +x Scripts/ci/ensure-nix.sh + Scripts/ci/ensure-nix.sh + + - name: Build release artifacts + shell: bash + env: + RELEASE_REF: ${{ github.ref_name }} + run: | + set -euo pipefail + ref="${RELEASE_REF:-manual-${GITHUB_SHA::7}}" + export RELEASE_REF="${ref}" + chmod +x Scripts/ci/build-release-artifacts.sh + nix develop .#ci -c Scripts/ci/build-release-artifacts.sh + + - name: Upload release artifacts + uses: https://code.forgejo.org/actions/upload-artifact@v4 + with: + name: burrow-release-${{ github.ref_name }} + path: dist/* + if-no-files-found: error + + - name: Publish Forgejo release + if: startsWith(github.ref, 'refs/tags/') + shell: bash + env: + RELEASE_TAG: ${{ github.ref_name }} + API_URL: ${{ github.api_url }} + REPOSITORY: ${{ github.repository }} + TOKEN: ${{ github.token }} + run: | + set -euo pipefail + chmod +x Scripts/ci/publish-forgejo-release.sh + nix develop .#ci -c Scripts/ci/publish-forgejo-release.sh diff --git a/Scripts/ci/build-release-artifacts.sh b/Scripts/ci/build-release-artifacts.sh new file mode 100755 index 0000000..20b4c06 --- /dev/null +++ b/Scripts/ci/build-release-artifacts.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "${repo_root}" + +release_ref="${RELEASE_REF:-manual-${GITHUB_SHA:-unknown}}" +target="x86_64-unknown-linux-gnu" +out_dir="${repo_root}/dist" +staging="${out_dir}/burrow-${release_ref}-${target}" + +mkdir -p "${staging}" + +cargo build --locked --release -p burrow --bin burrow +install -m 0755 target/release/burrow "${staging}/burrow" +cp README.md "${staging}/README.md" + +tarball="${out_dir}/burrow-${release_ref}-${target}.tar.gz" +tar -C "${out_dir}" -czf "${tarball}" "$(basename "${staging}")" +shasum -a 256 "${tarball}" > "${tarball}.sha256" diff --git a/Scripts/ci/ensure-nix.sh b/Scripts/ci/ensure-nix.sh new file mode 100755 index 0000000..14be895 --- /dev/null +++ b/Scripts/ci/ensure-nix.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +set -euo pipefail + +source_nix_profile() { + local candidate + for candidate in \ + "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh" \ + "${HOME}/.nix-profile/etc/profile.d/nix.sh" + do + if [[ -f "${candidate}" ]]; then + # shellcheck disable=SC1090 + . "${candidate}" + return 0 + fi + done + return 1 +} + +linux_cp_supports_preserve() { + cp --help 2>&1 | grep -q -- '--preserve' +} + +ensure_root_owned_home() { + if [[ "$(id -u)" -ne 0 ]]; then + return 0 + fi + + if [[ ! -d "${HOME}" ]] || [[ ! -O "${HOME}" ]]; then + export HOME="/root" + fi + + mkdir -p "${HOME}" +} + +ensure_linux_nixbld_accounts() { + if [[ "$(id -u)" -ne 0 ]]; then + return 0 + fi + + if command -v getent >/dev/null 2>&1 && getent group nixbld >/dev/null 2>&1; then + return 0 + fi + + if command -v addgroup >/dev/null 2>&1 && ! command -v groupadd >/dev/null 2>&1; then + addgroup -S nixbld >/dev/null 2>&1 || true + for i in $(seq 1 10); do + adduser -S -D -H -h /var/empty -s /sbin/nologin -G nixbld "nixbld${i}" >/dev/null 2>&1 || true + done + return 0 + fi + + if command -v groupadd >/dev/null 2>&1; then + groupadd -r nixbld >/dev/null 2>&1 || true + for i in $(seq 1 10); do + useradd \ + --system \ + --no-create-home \ + --home-dir /var/empty \ + --shell /usr/sbin/nologin \ + --gid nixbld \ + "nixbld${i}" >/dev/null 2>&1 || true + done + return 0 + fi + + echo "linux nix bootstrap requires nixbld group creation support" >&2 + exit 1 +} + +ensure_linux_nix_bootstrap_prereqs() { + if linux_cp_supports_preserve; then + ensure_root_owned_home + ensure_linux_nixbld_accounts + return 0 + fi + + if command -v apk >/dev/null 2>&1; then + apk add --no-cache coreutils xz >/dev/null + elif command -v apt-get >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update -y >/dev/null + apt-get install -y coreutils xz-utils >/dev/null + elif command -v dnf >/dev/null 2>&1; then + dnf install -y coreutils xz >/dev/null + elif command -v yum >/dev/null 2>&1; then + yum install -y coreutils xz >/dev/null + else + echo "linux nix bootstrap requires GNU cp but no supported package manager was found" >&2 + exit 1 + fi + + linux_cp_supports_preserve || { + echo "linux nix bootstrap still lacks GNU cp after installing prerequisites" >&2 + exit 1 + } + + ensure_root_owned_home + ensure_linux_nixbld_accounts +} + +if ! command -v nix >/dev/null 2>&1; then + if ! command -v curl >/dev/null 2>&1; then + echo "curl is required to install nix" >&2 + exit 1 + fi + + case "$(uname -s)" in + Linux) + ensure_linux_nix_bootstrap_prereqs + curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon + ;; + Darwin) + installer="$(mktemp -t burrow-nix.XXXXXX)" + trap 'rm -f "${installer}"' EXIT + curl -fsSL -o "${installer}" https://install.determinate.systems/nix + chmod +x "${installer}" + if command -v sudo >/dev/null 2>&1; then + if sudo -n true 2>/dev/null; then + sudo -n sh "${installer}" install --no-confirm + else + sudo sh "${installer}" install --no-confirm + fi + else + sh "${installer}" install --no-confirm + fi + ;; + *) + echo "unsupported platform for nix bootstrap: $(uname -s)" >&2 + exit 1 + ;; + esac +fi + +source_nix_profile || true +export PATH="${HOME}/.nix-profile/bin:/nix/var/nix/profiles/default/bin:/nix/var/nix/profiles/default/sbin:${PATH}" + +config_root="${XDG_CONFIG_HOME:-$HOME/.config}" +config_file="${config_root}/nix/nix.conf" +if [[ -e "${config_file}" && ! -w "${config_file}" ]]; then + config_root="$(mktemp -d -t burrow-nix-config.XXXXXX)" + export XDG_CONFIG_HOME="${config_root}" + config_file="${XDG_CONFIG_HOME}/nix/nix.conf" +fi + +mkdir -p "$(dirname -- "${config_file}")" +cat > "${config_file}" <<'EOF' +experimental-features = nix-command flakes +sandbox = true +fallback = true +substituters = https://cache.nixos.org +trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= +EOF + +command -v nix >/dev/null 2>&1 || { + echo "nix is still unavailable after bootstrap" >&2 + exit 1 +} diff --git a/Scripts/ci/publish-forgejo-release.sh b/Scripts/ci/publish-forgejo-release.sh new file mode 100755 index 0000000..338f71b --- /dev/null +++ b/Scripts/ci/publish-forgejo-release.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${API_URL:?API_URL is required}" +: "${REPOSITORY:?REPOSITORY is required}" +: "${RELEASE_TAG:?RELEASE_TAG is required}" +: "${TOKEN:?TOKEN is required}" + +release_api="${API_URL}/repos/${REPOSITORY}/releases" +tag_api="${release_api}/tags/${RELEASE_TAG}" +release_json="$(mktemp)" +create_json="$(mktemp)" +trap 'rm -f "${release_json}" "${create_json}"' EXIT + +status="$( + curl -sS -o "${release_json}" -w '%{http_code}' \ + -H "Authorization: token ${TOKEN}" \ + "${tag_api}" +)" + +if [[ "${status}" == "404" ]]; then + jq -n \ + --arg tag "${RELEASE_TAG}" \ + --arg name "Burrow ${RELEASE_TAG}" \ + '{ + tag_name: $tag, + target_commitish: $tag, + name: $name, + body: "Automated prerelease built on Forgejo Namespace runners.", + draft: false, + prerelease: true + }' > "${create_json}" + + curl -fsS \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d @"${create_json}" \ + "${release_api}" > "${release_json}" +elif [[ "${status}" != "200" ]]; then + echo "failed to query Forgejo release for ${RELEASE_TAG} (HTTP ${status})" >&2 + cat "${release_json}" >&2 + exit 1 +fi + +release_id="$(jq -r '.id' "${release_json}")" +if [[ -z "${release_id}" || "${release_id}" == "null" ]]; then + echo "Forgejo release payload is missing an id" >&2 + cat "${release_json}" >&2 + exit 1 +fi + +for file in dist/*; do + name="$(basename "${file}")" + asset_id="$(jq -r --arg name "${name}" '.assets[]? | select(.name == $name) | .id' "${release_json}" | head -n1)" + if [[ -n "${asset_id}" ]]; then + curl -fsS -X DELETE \ + -H "Authorization: token ${TOKEN}" \ + "${release_api}/${release_id}/assets/${asset_id}" >/dev/null + fi + + curl -fsS \ + -H "Authorization: token ${TOKEN}" \ + -F "attachment=@${file}" \ + "${release_api}/${release_id}/assets?name=${name}" >/dev/null +done From c8aa036ade560b76c128700b1e0922186a9b8626 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 4 Apr 2026 23:53:33 -0700 Subject: [PATCH 059/102] Add Tailscale Authentik OIDC app --- Scripts/authentik-sync-tailscale-oidc.sh | 251 ++++++++++++++++++ nixos/hosts/burrow-forge/default.nix | 7 + nixos/modules/burrow-authentik.nix | 73 +++++ nixos/modules/burrow-forge.nix | 4 +- secrets.nix | 1 + .../infra/tailscale-oidc-client-secret.age | 10 + 6 files changed, 344 insertions(+), 2 deletions(-) create mode 100755 Scripts/authentik-sync-tailscale-oidc.sh create mode 100644 secrets/infra/tailscale-oidc-client-secret.age diff --git a/Scripts/authentik-sync-tailscale-oidc.sh b/Scripts/authentik-sync-tailscale-oidc.sh new file mode 100755 index 0000000..54564ad --- /dev/null +++ b/Scripts/authentik-sync-tailscale-oidc.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +application_slug="${AUTHENTIK_TAILSCALE_APPLICATION_SLUG:-tailscale}" +application_name="${AUTHENTIK_TAILSCALE_APPLICATION_NAME:-Tailscale}" +provider_name="${AUTHENTIK_TAILSCALE_PROVIDER_NAME:-Tailscale}" +template_slug="${AUTHENTIK_TAILSCALE_TEMPLATE_SLUG:-ts}" +client_id="${AUTHENTIK_TAILSCALE_CLIENT_ID:-tailscale.burrow.net}" +client_secret="${AUTHENTIK_TAILSCALE_CLIENT_SECRET:-}" +launch_url="${AUTHENTIK_TAILSCALE_LAUNCH_URL:-https://login.tailscale.com/start/oidc}" +redirect_uris_json="${AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON:-[ + \"https://login.tailscale.com/a/oauth_response\" +]}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-tailscale-oidc.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + AUTHENTIK_TAILSCALE_CLIENT_SECRET + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_TAILSCALE_APPLICATION_SLUG + AUTHENTIK_TAILSCALE_APPLICATION_NAME + AUTHENTIK_TAILSCALE_PROVIDER_NAME + AUTHENTIK_TAILSCALE_TEMPLATE_SLUG + AUTHENTIK_TAILSCALE_CLIENT_ID + AUTHENTIK_TAILSCALE_LAUNCH_URL + AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ -z "$bootstrap_token" ]]; then + echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 + exit 1 +fi + +if [[ -z "$client_secret" || "$client_secret" == PENDING* ]]; then + echo "Tailscale OIDC client secret is not configured; skipping Authentik Tailscale sync." >&2 + exit 0 +fi + +if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then + echo "error: AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON must be a non-empty JSON array" >&2 + exit 1 +fi + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + + if [[ -n "$data" ]]; then + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + else + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + fi +} + +api_with_status() { + local method="$1" + local path="$2" + local data="${3:-}" + local response_file status + + response_file="$(mktemp)" + trap 'rm -f "$response_file"' RETURN + + if [[ -n "$data" ]]; then + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + )" + else + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + )" + fi + + printf '%s\n' "$status" + cat "$response_file" +} + +wait_for_authentik() { + for _ in $(seq 1 90); do + if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +wait_for_authentik + +template_provider="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -c --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \ + | head -n1 +)" + +if [[ -z "$template_provider" ]]; then + echo "error: could not resolve the Authentik OAuth provider template ${template_slug}" >&2 + exit 1 +fi + +authorization_flow="$(printf '%s\n' "$template_provider" | jq -r '.authorization_flow')" +invalidation_flow="$(printf '%s\n' "$template_provider" | jq -r '.invalidation_flow')" +property_mappings="$(printf '%s\n' "$template_provider" | jq -c '.property_mappings')" +signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')" + +provider_payload="$( + jq -n \ + --arg name "$provider_name" \ + --arg authorization_flow "$authorization_flow" \ + --arg invalidation_flow "$invalidation_flow" \ + --arg client_id "$client_id" \ + --arg client_secret "$client_secret" \ + --arg signing_key "$signing_key" \ + --argjson property_mappings "$property_mappings" \ + --argjson redirect_uris "$redirect_uris_json" \ + '{ + name: $name, + authorization_flow: $authorization_flow, + invalidation_flow: $invalidation_flow, + client_type: "confidential", + client_id: $client_id, + client_secret: $client_secret, + include_claims_in_id_token: true, + redirect_uris: ($redirect_uris | map({matching_mode: "strict", url: .})), + property_mappings: $property_mappings, + signing_key: $signing_key, + issuer_mode: "per_provider", + sub_mode: "hashed_user_id" + }' +)" + +existing_provider="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -c \ + --arg application_slug "$application_slug" \ + --arg provider_name "$provider_name" \ + '.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \ + | head -n1 +)" + +if [[ -n "$existing_provider" ]]; then + provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')" + api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$provider_payload" >/dev/null +else + provider_pk="$( + api POST "/api/v3/providers/oauth2/" "$provider_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "${provider_pk:-}" ]]; then + echo "error: Tailscale OIDC provider did not return a primary key" >&2 + exit 1 +fi + +application_payload="$( + jq -n \ + --arg name "$application_name" \ + --arg slug "$application_slug" \ + --arg provider "$provider_pk" \ + --arg launch_url "$launch_url" \ + '{ + name: $name, + slug: $slug, + provider: ($provider | tonumber), + meta_launch_url: $launch_url, + open_in_new_tab: true, + policy_engine_mode: "any" + }' +)" + +existing_application="$( + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \ + | head -n1 +)" + +if [[ -n "$existing_application" ]]; then + application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" +else + create_application_result="$( + api_with_status POST "/api/v3/core/applications/" "$application_payload" + )" + create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')" + create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')" + + if [[ "$create_application_status" =~ ^20[01]$ ]]; then + application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')" + elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e ' + (.slug // [] | index("Application with this slug already exists.")) != null + or (.provider // [] | index("Application with this provider already exists.")) != null + ' >/dev/null; then + application_pk="existing-duplicate" + else + printf '%s\n' "$create_application_body" >&2 + echo "error: could not reconcile Authentik application ${application_slug}" >&2 + exit 1 + fi +fi + +if [[ -z "${application_pk:-}" ]]; then + echo "error: Tailscale OIDC application did not return a primary key" >&2 + exit 1 +fi + +for _ in $(seq 1 30); do + if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then + echo "Synced Authentik Tailscale OIDC application ${application_slug} (${application_name})." + exit 0 + fi + sleep 2 +done + +echo "warning: Tailscale OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2 +echo "Synced Authentik Tailscale OIDC application ${application_slug} (${application_name})." diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 67c87ec..75b76d4 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -63,6 +63,12 @@ in group = "forgejo"; mode = "0440"; }; + age.secrets.burrowTailscaleOidcClientSecret = { + file = ../../../secrets/infra/tailscale-oidc-client-secret.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; age.secrets.burrowAuthentikGoogleClientId = { file = ../../../secrets/infra/authentik-google-client-id.age; owner = "root"; @@ -121,6 +127,7 @@ in envFile = config.age.secrets.burrowAuthentikEnv.path; forgejoClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; + tailscaleClientSecretFile = config.age.secrets.burrowTailscaleOidcClientSecret.path; googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; googleLoginMode = "redirect"; diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 478d0d9..6861f17 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -10,6 +10,7 @@ let dataVolume = "burrow-authentik-data:/data"; directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh; forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; + tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh; authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' @@ -131,6 +132,24 @@ in description = "Authentik application slug for Forgejo."; }; + tailscaleProviderSlug = lib.mkOption { + type = lib.types.str; + default = "tailscale"; + description = "Authentik application slug for Tailscale custom OIDC sign-in."; + }; + + tailscaleClientId = lib.mkOption { + type = lib.types.str; + default = "tailscale.burrow.net"; + description = "Client ID Authentik should present to Tailscale."; + }; + + tailscaleClientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Host-local file containing the Authentik Tailscale OIDC client secret."; + }; + forgejoClientId = lib.mkOption { type = lib.types.str; default = "git.burrow.net"; @@ -313,6 +332,13 @@ in fi ''} + ${lib.optionalString (cfg.tailscaleClientSecretFile != null) '' + if [ ! -s ${lib.escapeShellArg cfg.tailscaleClientSecretFile} ]; then + echo "Tailscale client secret missing: ${cfg.tailscaleClientSecretFile}" >&2 + exit 1 + fi + ''} + install -d -m 0750 -o root -g root ${runtimeDir} ${blueprintDir} install -m 0644 -o root -g root ${authentikBlueprint} ${blueprintFile} @@ -634,6 +660,53 @@ EOF ''; }; + systemd.services.burrow-authentik-tailscale-oidc = lib.mkIf (cfg.tailscaleClientSecretFile != null) { + description = "Reconcile the Burrow Authentik Tailscale OIDC application"; + after = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + tailscaleOidcSyncScript + cfg.envFile + cfg.tailscaleClientSecretFile + ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + set -a + source ${lib.escapeShellArg cfg.envFile} + set +a + + export AUTHENTIK_URL=https://${cfg.domain} + export AUTHENTIK_TAILSCALE_APPLICATION_SLUG=${lib.escapeShellArg cfg.tailscaleProviderSlug} + export AUTHENTIK_TAILSCALE_APPLICATION_NAME=Tailscale + export AUTHENTIK_TAILSCALE_PROVIDER_NAME=Tailscale + export AUTHENTIK_TAILSCALE_TEMPLATE_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} + export AUTHENTIK_TAILSCALE_CLIENT_ID=${lib.escapeShellArg cfg.tailscaleClientId} + export AUTHENTIK_TAILSCALE_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.tailscaleClientSecretFile})" + export AUTHENTIK_TAILSCALE_LAUNCH_URL=https://login.tailscale.com/start/oidc + export AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON='["https://login.tailscale.com/a/oauth_response"]' + + ${pkgs.bash}/bin/bash ${tailscaleOidcSyncScript} + ''; + }; + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd reverse_proxy 127.0.0.1:${toString cfg.port} diff --git a/nixos/modules/burrow-forge.nix b/nixos/modules/burrow-forge.nix index d74fc65..d733135 100644 --- a/nixos/modules/burrow-forge.nix +++ b/nixos/modules/burrow-forge.nix @@ -258,13 +258,13 @@ in "${cfg.siteDomain}".extraConfig = '' encode gzip zstd @oidcConfig path /.well-known/openid-configuration - redir @oidcConfig https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.forgejoProviderSlug}/.well-known/openid-configuration 308 + redir @oidcConfig https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.tailscaleProviderSlug}/.well-known/openid-configuration 308 @tailnetConfig path /.well-known/burrow-tailnet header @tailnetConfig Content-Type application/json respond @tailnetConfig "{\"domain\":\"${cfg.siteDomain}\",\"provider\":\"headscale\",\"authority\":\"https://${config.services.burrow.headscale.domain}\",\"oidc_issuer\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.headscaleProviderSlug}/\"}" 200 @webfinger path /.well-known/webfinger header @webfinger Content-Type application/jrd+json - respond @webfinger "{\"subject\":\"{query.resource}\",\"links\":[{\"rel\":\"http://openid.net/specs/connect/1.0/issuer\",\"href\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.forgejoProviderSlug}/\"},{\"rel\":\"https://burrow.net/rel/tailnet-control-server\",\"href\":\"https://${config.services.burrow.headscale.domain}\"}]}" 200 + respond @webfinger "{\"subject\":\"{query.resource}\",\"links\":[{\"rel\":\"http://openid.net/specs/connect/1.0/issuer\",\"href\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.tailscaleProviderSlug}/\"},{\"rel\":\"https://burrow.net/rel/tailnet-control-server\",\"href\":\"https://${config.services.burrow.headscale.domain}\"}]}" 200 @root path / redir @root ${homeRepoUrl} 308 respond 404 diff --git a/secrets.nix b/secrets.nix index 5a3ac8c..c0b9b53 100644 --- a/secrets.nix +++ b/secrets.nix @@ -17,4 +17,5 @@ in "secrets/infra/authentik-ui-test-password.age".publicKeys = uiTestRecipients; "secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; + "secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; } diff --git a/secrets/infra/tailscale-oidc-client-secret.age b/secrets/infra/tailscale-oidc-client-secret.age new file mode 100644 index 0000000..e88c2d1 --- /dev/null +++ b/secrets/infra/tailscale-oidc-client-secret.age @@ -0,0 +1,10 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q KfvLMiH7JHE6v74Pp//SqzBP8WU1MNy1/EcqsONTTQQ +Y6SFXWe/5Pru6+3vU6e67bRZDWDkukdfgEX7uQjB4Uw +-> ssh-ed25519 IrZmAg AFn7BP4FktUYH9QvNJPVDdNcEpJjYqmOrisvX9XGV08 +Zho+KNtk1vUQZ55j1xUHdswAj0T0Soji/HC6p1tsVcA +-> X25519 sv50iZjBijWKfp6I+LfRlEJ2sqnj5/2m0hRWz5NqLTk +Hdfvo+87zemSCFWDSlzkpmvHLuvc0tjxEt0ociTPrCg +--- BkQd4O2m/i98rlBcNhczU6Wj0htoiNLQDn0W6yKn1/c + a "WL\#zDRq6.竂}#8²koyq>L\`wƔ>f/Ѵ^,# +hD<>]C \ No newline at end of file From 8de798469bac11fec1906b54b388f9c1e836e795 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 5 Apr 2026 01:34:32 -0700 Subject: [PATCH 060/102] Bind tailnet auth flow to tailscale --- Scripts/authentik-sync-tailnet-auth-flow.sh | 39 ++++++++++++++------- nixos/modules/burrow-authentik.nix | 1 + 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/Scripts/authentik-sync-tailnet-auth-flow.sh b/Scripts/authentik-sync-tailnet-auth-flow.sh index bfb00ef..bae760b 100755 --- a/Scripts/authentik-sync-tailnet-auth-flow.sh +++ b/Scripts/authentik-sync-tailnet-auth-flow.sh @@ -4,6 +4,7 @@ set -euo pipefail authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" provider_slug="${AUTHENTIK_TAILNET_PROVIDER_SLUG:-ts}" +provider_slugs_json="${AUTHENTIK_TAILNET_PROVIDER_SLUGS_JSON:-}" authentication_flow_name="${AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME:-Burrow Tailnet Authentication}" authentication_flow_slug="${AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG:-burrow-tailnet-authentication}" identification_stage_name="${AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME:-burrow-tailnet-identification-stage}" @@ -21,6 +22,7 @@ Required environment: Optional environment: AUTHENTIK_URL AUTHENTIK_TAILNET_PROVIDER_SLUG + AUTHENTIK_TAILNET_PROVIDER_SLUGS_JSON AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME @@ -40,6 +42,15 @@ if [[ -z "$bootstrap_token" ]]; then exit 1 fi +if [[ -n "$provider_slugs_json" ]]; then + if ! printf '%s' "$provider_slugs_json" | jq -e 'type == "array" and length > 0 and all(.[]; type == "string" and length > 0)' >/dev/null; then + echo "error: AUTHENTIK_TAILNET_PROVIDER_SLUGS_JSON must be a non-empty JSON array of strings" >&2 + exit 1 + fi +else + provider_slugs_json="$(jq -cn --arg slug "$provider_slug" '[$slug]')" +fi + api() { local method="$1" local path="$2" @@ -263,18 +274,20 @@ ensure_flow_binding() { wait_for_authentik -provider_pk="$( +mapfile -t provider_pks < <( api GET "/api/v3/providers/oauth2/?page_size=200" \ - | jq -r --arg provider_slug "$provider_slug" ' + | jq -r --argjson provider_slugs "$provider_slugs_json" ' .results[]? - | select(.assigned_application_slug == $provider_slug or .slug == $provider_slug) + | select( + (.assigned_application_slug != null and ($provider_slugs | index(.assigned_application_slug) != null)) + or (.slug != null and ($provider_slugs | index(.slug) != null)) + ) | .pk // empty - ' \ - | head -n1 -)" + ' +) -if [[ -z "$provider_pk" ]]; then - echo "error: could not resolve Authentik Tailnet OAuth provider ${provider_slug}" >&2 +if [[ "${#provider_pks[@]}" -eq 0 ]]; then + echo "error: could not resolve any Authentik Tailnet OAuth providers from ${provider_slugs_json}" >&2 exit 1 fi @@ -287,8 +300,10 @@ authentication_flow_pk="$(ensure_authentication_flow)" ensure_flow_binding "$authentication_flow_pk" "$identification_stage_pk" 10 ensure_flow_binding "$authentication_flow_pk" "$user_login_stage_pk" 30 -api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$( - jq -cn --arg flow "$authentication_flow_pk" '{authentication_flow: $flow}' -)" >/dev/null +for provider_pk in "${provider_pks[@]}"; do + api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$( + jq -cn --arg flow "$authentication_flow_pk" '{authentication_flow: $flow}' + )" >/dev/null +done -echo "Synced Burrow Tailnet authentication flow for provider ${provider_slug}." +echo "Synced Burrow Tailnet authentication flow for providers ${provider_slugs_json}." diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 6861f17..1616b36 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -603,6 +603,7 @@ EOF export AUTHENTIK_URL=https://${cfg.domain} export AUTHENTIK_TAILNET_PROVIDER_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} + export AUTHENTIK_TAILNET_PROVIDER_SLUGS_JSON='["${cfg.headscaleProviderSlug}","${cfg.tailscaleProviderSlug}"]' export AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME=${lib.escapeShellArg cfg.headscaleAuthenticationFlowName} export AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG=${lib.escapeShellArg cfg.headscaleAuthenticationFlowSlug} export AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME=${lib.escapeShellArg cfg.headscaleIdentificationStageName} From 3ebb0a8e61b3420097483bf5a9f033c53e1cd5cf Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 5 Apr 2026 01:36:52 -0700 Subject: [PATCH 061/102] Fix tailnet auth flow provider lookup --- Scripts/authentik-sync-tailnet-auth-flow.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Scripts/authentik-sync-tailnet-auth-flow.sh b/Scripts/authentik-sync-tailnet-auth-flow.sh index bae760b..1c715cc 100755 --- a/Scripts/authentik-sync-tailnet-auth-flow.sh +++ b/Scripts/authentik-sync-tailnet-auth-flow.sh @@ -279,8 +279,8 @@ mapfile -t provider_pks < <( | jq -r --argjson provider_slugs "$provider_slugs_json" ' .results[]? | select( - (.assigned_application_slug != null and ($provider_slugs | index(.assigned_application_slug) != null)) - or (.slug != null and ($provider_slugs | index(.slug) != null)) + ((.assigned_application_slug // empty) as $assigned | ($provider_slugs | index($assigned)) != null) + or ((.slug // empty) as $slug | ($provider_slugs | index($slug)) != null) ) | .pk // empty ' From 64103abbea58979a360c5be7976be313a8c0d1e4 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 5 Apr 2026 02:10:49 -0700 Subject: [PATCH 062/102] Refocus Tailnet flow on Tailscale --- Apple/App/AppDelegate.swift | 2 +- Apple/AppUITests/BurrowUITests.swift | 317 +++++++++--- Apple/Configuration/Constants/Constants.swift | 10 +- Apple/Core/Client.swift | 50 ++ Apple/Core/Client/Generated/burrow.pb.swift | 32 ++ .../PacketTunnelProvider.swift | 250 +++++++++- Apple/UI/BurrowView.swift | 393 ++++++++++----- Apple/UI/Networks/Network.swift | 6 +- Scripts/run-ios-tailnet-ui-tests.sh | 116 ++++- burrow/src/auth/server/tailscale.rs | 338 +++++++++---- burrow/src/control/discovery.rs | 13 + burrow/src/daemon/instance.rs | 164 ++++++- burrow/src/daemon/rpc/response.rs | 20 + burrow/src/daemon/runtime.rs | 464 +++++++++++++++++- burrow/src/tracing.rs | 14 +- proto/burrow.proto | 9 + 16 files changed, 1856 insertions(+), 342 deletions(-) diff --git a/Apple/App/AppDelegate.swift b/Apple/App/AppDelegate.swift index 12fe52c..c3cb4cb 100644 --- a/Apple/App/AppDelegate.swift +++ b/Apple/App/AppDelegate.swift @@ -55,7 +55,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let statusBar = NSStatusBar.system let statusItem = statusBar.statusItem(withLength: NSStatusItem.squareLength) if let button = statusItem.button { - button.image = NSImage(systemSymbolName: "network.badge.shield.half.filled", accessibilityDescription: nil) + button.image = NSImage(systemSymbolName: "pipe.and.drop.fill", accessibilityDescription: nil) } return statusItem }() diff --git a/Apple/AppUITests/BurrowUITests.swift b/Apple/AppUITests/BurrowUITests.swift index f9dbeae..b7d8111 100644 --- a/Apple/AppUITests/BurrowUITests.swift +++ b/Apple/AppUITests/BurrowUITests.swift @@ -1,15 +1,31 @@ import XCTest +import UIKit @MainActor final class BurrowTailnetLoginUITests: XCTestCase { + private enum TailnetLoginMode: String, Decodable { + case tailscale + case discovered + } + + private struct TestConfig: Decodable { + let email: String + let username: String + let password: String + let mode: TailnetLoginMode? + } + override func setUpWithError() throws { continueAfterFailure = false } func testTailnetLoginThroughAuthentikWebSession() throws { - let email = try requiredEnvironment("BURROW_UI_TEST_EMAIL") - let username = ProcessInfo.processInfo.environment["BURROW_UI_TEST_USERNAME"] ?? email - let password = try requiredEnvironment("BURROW_UI_TEST_PASSWORD") + let config = try loadTestConfig() + let email = config.email + let username = config.username + let password = config.password + let mode = config.mode ?? .tailscale + let browserIdentity = mode == .tailscale ? email : username let app = XCUIApplication() app.launch() @@ -18,51 +34,90 @@ final class BurrowTailnetLoginUITests: XCTestCase { XCTAssertTrue(tailnetButton.waitForExistence(timeout: 15), "Tailnet add button did not appear") tailnetButton.tap() + configureTailnetIfNeeded(in: app, mode: mode) + let discoveryField = app.textFields["tailnet-discovery-email"] XCTAssertTrue(discoveryField.waitForExistence(timeout: 10), "Tailnet discovery email field did not appear") replaceText(in: discoveryField, with: email) - let findServerButton = app.buttons["tailnet-find-server"] - XCTAssertTrue(findServerButton.waitForExistence(timeout: 5), "Find Server button did not appear") - findServerButton.tap() - - let discoveryCard = app.otherElements["tailnet-discovery-card"] - XCTAssertTrue(discoveryCard.waitForExistence(timeout: 20), "Tailnet discovery result did not appear") - - let authorityField = app.textFields["tailnet-authority"] - XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear") - XCTAssertTrue( - waitForFieldValue(authorityField, containing: "ts.burrow.net", timeout: 20), - "Tailnet authority was not populated from discovery" - ) - - let probeButton = app.buttons["tailnet-check-connection"] - XCTAssertTrue(probeButton.waitForExistence(timeout: 5), "Check Connection button did not appear") - probeButton.tap() - - let probeCard = app.otherElements["tailnet-authority-probe-card"] - XCTAssertTrue(probeCard.waitForExistence(timeout: 20), "Tailnet connection probe did not complete") + let serverCard = app.descendants(matching: .any) + .matching(identifier: "tailnet-server-card") + .firstMatch + XCTAssertTrue(serverCard.waitForExistence(timeout: 5), "Tailnet server card did not appear") let signInButton = app.buttons["tailnet-start-sign-in"] XCTAssertTrue(signInButton.waitForExistence(timeout: 10), "Tailnet sign-in button did not appear") signInButton.tap() - acceptAuthenticationPromptIfNeeded(in: app) + acceptAuthenticationPromptIfNeeded(in: app, timeout: 20) let webSession = webAuthenticationSession() XCTAssertTrue(webSession.waitForExistence(timeout: 20), "Safari authentication session did not appear") - signIntoAuthentik(in: webSession, username: username, password: password) + signIntoAuthentik(in: webSession, username: browserIdentity, password: password) app.activate() XCTAssertTrue( - waitForButtonLabel(app.buttons["tailnet-start-sign-in"], equals: "Signed In", timeout: 60), + waitForTailnetSignedIn(in: app, timeout: 60), "Tailnet sign-in never reached the running state" ) } - private func acceptAuthenticationPromptIfNeeded(in app: XCUIApplication) { + private func configureTailnetIfNeeded(in app: XCUIApplication, mode: TailnetLoginMode) { + guard mode == .discovered else { return } + + openTailnetMenu(in: app) + tapMenuButton(named: "Edit Custom Server", in: app) + + openTailnetMenu(in: app) + tapMenuButton(named: "Show Advanced Settings", in: app) + + let authorityField = app.textFields["tailnet-authority"] + XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear") + replaceText(in: authorityField, with: "") + } + + private func openTailnetMenu(in app: XCUIApplication) { + let moreButton = app.buttons["More"] + XCTAssertTrue(moreButton.waitForExistence(timeout: 5), "Tailnet menu button did not appear") + moreButton.tap() + } + + private func tapMenuButton(named title: String, in app: XCUIApplication) { + let menuButton = firstExistingElement( + from: [ + app.buttons[title], + app.descendants(matching: .button)[title], + ], + timeout: 5 + ) + XCTAssertTrue(menuButton.exists, "Menu action \(title) did not appear") + menuButton.tap() + } + + private func acceptAuthenticationPromptIfNeeded( + in app: XCUIApplication, + timeout: TimeInterval + ) { let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + let deadline = Date().addingTimeInterval(timeout) + + repeat { + let promptCandidates = [ + springboard.buttons["Continue"], + springboard.buttons["Allow"], + app.buttons["Continue"], + app.buttons["Allow"], + ] + + for button in promptCandidates where button.exists && button.isHittable { + button.tap() + return + } + + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + } while Date() < deadline + let promptCandidates = [ springboard.buttons["Continue"], springboard.buttons["Allow"], @@ -70,7 +125,7 @@ final class BurrowTailnetLoginUITests: XCTestCase { app.buttons["Allow"], ] - for button in promptCandidates where button.waitForExistence(timeout: 3) { + for button in promptCandidates where button.exists { button.tap() return } @@ -88,6 +143,19 @@ final class BurrowTailnetLoginUITests: XCTestCase { } private func signIntoAuthentik(in webSession: XCUIApplication, username: String, password: String) { + followTailnetRedirectIfNeeded(in: webSession) + + if !webSession.exists { + return + } + + let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2) + if immediatePasswordField.exists { + replaceSecureText(in: immediatePasswordField, within: webSession, with: password) + submitAuthenticationForm(in: webSession, focusedField: immediatePasswordField) + return + } + let usernameField = firstExistingElement( in: webSession, queries: [ @@ -99,21 +167,12 @@ final class BurrowTailnetLoginUITests: XCTestCase { { $0.webViews.textFields["Email or Username"] }, { $0.descendants(matching: .textField).firstMatch }, ], - timeout: 25 + timeout: 12 ) - XCTAssertTrue(usernameField.exists, "Authentik username field did not appear") - replaceText(in: usernameField, with: username) - - let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2) - if immediatePasswordField.exists { - replaceSecureText(in: immediatePasswordField, with: password) - tapFirstExistingButton( - in: webSession, - titles: ["Continue", "Sign In", "Log in", "Login"], - timeout: 5 - ) + if !usernameField.exists { return } + replaceText(in: usernameField, with: username) tapFirstExistingButton( in: webSession, @@ -123,21 +182,31 @@ final class BurrowTailnetLoginUITests: XCTestCase { let passwordField = firstExistingSecureField(in: webSession, timeout: 20) XCTAssertTrue(passwordField.exists, "Authentik password field did not appear") - replaceSecureText(in: passwordField, with: password) - tapFirstExistingButton( - in: webSession, - titles: ["Continue", "Sign In", "Log in", "Login"], - timeout: 5 - ) + replaceSecureText(in: passwordField, within: webSession, with: password) + submitAuthenticationForm(in: webSession, focusedField: passwordField) + } + + private func followTailnetRedirectIfNeeded(in webSession: XCUIApplication) { + let redirectCandidates = [ + webSession.links["Found"], + webSession.webViews.links["Found"], + webSession.buttons["Found"], + webSession.webViews.buttons["Found"], + ] + + let redirectLink = firstExistingElement(from: redirectCandidates, timeout: 8) + if redirectLink.exists { + redirectLink.tap() + } } private func firstExistingSecureField(in app: XCUIApplication, timeout: TimeInterval) -> XCUIElement { let candidates = [ + app.descendants(matching: .secureTextField).firstMatch, app.secureTextFields["Password"], app.secureTextFields["Password or Token"], app.webViews.secureTextFields["Password"], app.webViews.secureTextFields["Password or Token"], - app.descendants(matching: .secureTextField).firstMatch, ] return firstExistingElement(from: candidates, timeout: timeout) @@ -160,11 +229,92 @@ final class BurrowTailnetLoginUITests: XCTestCase { button.tap() } - private func requiredEnvironment(_ key: String) throws -> String { - guard let value = ProcessInfo.processInfo.environment[key], - !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + private func submitAuthenticationForm(in app: XCUIApplication, focusedField: XCUIElement) { + focus(focusedField) + focusedField.typeText("\n") + if waitForAny( + [ + { !focusedField.exists }, + { !app.staticTexts["Burrow Tailnet Authentication"].exists }, + ], + timeout: 1.5 + ) { + return + } + + let keyboard = app.keyboards.firstMatch + if keyboard.waitForExistence(timeout: 2) { + let keyboardCandidates = [ + "Return", + "return", + "Go", + "go", + "Continue", + "continue", + "Done", + "done", + "Join", + "join", + "Sign In", + "Log In", + "Login", + ] + for title in keyboardCandidates { + let key = keyboard.buttons[title] + if key.exists && key.isHittable { + key.tap() + return + } + } + + if let lastKey = keyboard.buttons.allElementsBoundByIndex.last, + lastKey.exists, + lastKey.isHittable + { + lastKey.tap() + return + } + } + + tapFirstExistingButton( + in: app, + titles: ["Continue", "Sign In", "Log in", "Login"], + timeout: 5 + ) + } + + private func loadTestConfig() throws -> TestConfig { + let environment = ProcessInfo.processInfo.environment + if let email = nonEmptyEnvironment("BURROW_UI_TEST_EMAIL"), + let password = nonEmptyEnvironment("BURROW_UI_TEST_PASSWORD") + { + return TestConfig( + email: email, + username: nonEmptyEnvironment("BURROW_UI_TEST_USERNAME") ?? email, + password: password, + mode: nonEmptyEnvironment("BURROW_UI_TEST_TAILNET_MODE") + .flatMap(TailnetLoginMode.init(rawValue:)) + ) + } + + let configPath = environment["BURROW_UI_TEST_CONFIG_PATH"] ?? "/tmp/burrow-ui-test-config.json" + let configURL = URL(fileURLWithPath: configPath) + guard FileManager.default.fileExists(atPath: configURL.path) else { + throw XCTSkip( + "Missing UI test configuration. Expected env vars or config file at \(configURL.path)" + ) + } + + let data = try Data(contentsOf: configURL) + return try JSONDecoder().decode(TestConfig.self, from: data) + } + + private func nonEmptyEnvironment(_ key: String) -> String? { + guard let value = ProcessInfo.processInfo.environment[key]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { - throw XCTSkip("Missing required UI test environment variable \(key)") + return nil } return value } @@ -189,6 +339,32 @@ final class BurrowTailnetLoginUITests: XCTestCase { return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed } + private func waitForTailnetSignedIn(in app: XCUIApplication, timeout: TimeInterval) -> Bool { + let button = app.buttons["tailnet-start-sign-in"] + let deadline = Date().addingTimeInterval(timeout) + + repeat { + acceptAuthenticationPromptIfNeeded(in: app, timeout: 1) + if button.exists, button.label == "Signed In" { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + } while Date() < deadline + + return button.exists && button.label == "Signed In" + } + + private func waitForAny(_ conditions: [() -> Bool], timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + repeat { + if conditions.contains(where: { $0() }) { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } while Date() < deadline + return conditions.contains(where: { $0() }) + } + private func firstExistingElement( in app: XCUIApplication, queries: [(XCUIApplication) -> XCUIElement], @@ -210,14 +386,27 @@ final class BurrowTailnetLoginUITests: XCTestCase { } private func replaceText(in element: XCUIElement, with value: String) { - element.tap() + focus(element) clearText(in: element) element.typeText(value) } - private func replaceSecureText(in element: XCUIElement, with value: String) { - element.tap() - clearText(in: element) + private func replaceSecureText(in element: XCUIElement, within app: XCUIApplication, with value: String) { + UIPasteboard.general.string = value + focus(element) + for revealMenu in [ + { element.doubleTap() }, + { element.press(forDuration: 1.2) }, + ] { + revealMenu() + let pasteButton = firstExistingElement(from: pasteCandidates(in: app), timeout: 3) + if pasteButton.exists { + pasteButton.tap() + return + } + } + + focus(element) element.typeText(value) } @@ -229,4 +418,22 @@ final class BurrowTailnetLoginUITests: XCTestCase { let deleteSequence = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count) element.typeText(deleteSequence) } + + private func focus(_ element: XCUIElement) { + element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + } + + private func pasteCandidates(in app: XCUIApplication) -> [XCUIElement] { + let pasteLabels = ["Paste", "Incolla", "Paste from Clipboard"] + return pasteLabels.flatMap { label in + [ + app.menuItems[label], + app.buttons[label], + app.webViews.buttons[label], + app.descendants(matching: .button).matching(NSPredicate(format: "label == %@", label)).firstMatch, + app.descendants(matching: .menuItem).matching(NSPredicate(format: "label == %@", label)).firstMatch, + ] + } + } } diff --git a/Apple/Configuration/Constants/Constants.swift b/Apple/Configuration/Constants/Constants.swift index 8844564..95d8c78 100644 --- a/Apple/Configuration/Constants/Constants.swift +++ b/Apple/Configuration/Constants/Constants.swift @@ -36,13 +36,9 @@ public enum Constants { private static func fallbackContainerURL() -> Result { #if targetEnvironment(simulator) Result { - let baseURL = try FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - let url = baseURL + // The simulator app's Application Support path lives inside its sandbox container, + // so the host daemon cannot reach it. Use a shared host temp location instead. + let url = URL(filePath: "/tmp", directoryHint: .isDirectory) .appending(component: bundleIdentifier, directoryHint: .isDirectory) .appending(component: "SimulatorFallback", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) diff --git a/Apple/Core/Client.swift b/Apple/Core/Client.swift index e44ebcd..7d4cfc7 100644 --- a/Apple/Core/Client.swift +++ b/Apple/Core/Client.swift @@ -108,6 +108,13 @@ public struct Burrow_TailnetLoginStatusResponse: Sendable { public init() {} } +public struct Burrow_TunnelPacket: Sendable { + public var payload = Data() + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + extension Burrow_TailnetDiscoverRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "burrow.TailnetDiscoverRequest" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ @@ -387,6 +394,29 @@ extension Burrow_TailnetLoginStatusResponse: SwiftProtobuf.Message, SwiftProtobu } } +extension Burrow_TunnelPacket: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.TunnelPacket" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "payload") + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularBytesField(value: &self.payload) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.payload.isEmpty { + try visitor.visitSingularBytesField(value: self.payload, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } +} + public struct TailnetClient: Client, GRPCClient { public let channel: GRPCChannel public var defaultCallOptions: CallOptions @@ -456,3 +486,23 @@ public struct TailnetClient: Client, GRPCClient { ) } } + +public struct TunnelPacketClient: Client, GRPCClient { + public let channel: GRPCChannel + public var defaultCallOptions: CallOptions + + public init(channel: any GRPCChannel) { + self.channel = channel + self.defaultCallOptions = .init() + } + + public func makeTunnelPacketsCall( + callOptions: CallOptions? = nil + ) -> GRPCAsyncBidirectionalStreamingCall { + self.makeAsyncBidirectionalStreamingCall( + path: "/burrow.Tunnel/TunnelPackets", + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: [] + ) + } +} diff --git a/Apple/Core/Client/Generated/burrow.pb.swift b/Apple/Core/Client/Generated/burrow.pb.swift index bba0f16..fccd769 100644 --- a/Apple/Core/Client/Generated/burrow.pb.swift +++ b/Apple/Core/Client/Generated/burrow.pb.swift @@ -215,6 +215,14 @@ public struct Burrow_TunnelConfigurationResponse: Sendable { public var mtu: Int32 = 0 + public var routes: [String] = [] + + public var dnsServers: [String] = [] + + public var searchDomains: [String] = [] + + public var includeDefaultRoute: Bool = false + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -532,6 +540,10 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "addresses"), 2: .same(proto: "mtu"), + 3: .same(proto: "routes"), + 4: .standard(proto: "dns_servers"), + 5: .standard(proto: "search_domains"), + 6: .standard(proto: "include_default_route"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -542,6 +554,10 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob switch fieldNumber { case 1: try { try decoder.decodeRepeatedStringField(value: &self.addresses) }() case 2: try { try decoder.decodeSingularInt32Field(value: &self.mtu) }() + case 3: try { try decoder.decodeRepeatedStringField(value: &self.routes) }() + case 4: try { try decoder.decodeRepeatedStringField(value: &self.dnsServers) }() + case 5: try { try decoder.decodeRepeatedStringField(value: &self.searchDomains) }() + case 6: try { try decoder.decodeSingularBoolField(value: &self.includeDefaultRoute) }() default: break } } @@ -554,12 +570,28 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob if self.mtu != 0 { try visitor.visitSingularInt32Field(value: self.mtu, fieldNumber: 2) } + if !self.routes.isEmpty { + try visitor.visitRepeatedStringField(value: self.routes, fieldNumber: 3) + } + if !self.dnsServers.isEmpty { + try visitor.visitRepeatedStringField(value: self.dnsServers, fieldNumber: 4) + } + if !self.searchDomains.isEmpty { + try visitor.visitRepeatedStringField(value: self.searchDomains, fieldNumber: 5) + } + if self.includeDefaultRoute { + try visitor.visitSingularBoolField(value: self.includeDefaultRoute, fieldNumber: 6) + } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Burrow_TunnelConfigurationResponse, rhs: Burrow_TunnelConfigurationResponse) -> Bool { if lhs.addresses != rhs.addresses {return false} if lhs.mtu != rhs.mtu {return false} + if lhs.routes != rhs.routes {return false} + if lhs.dnsServers != rhs.dnsServers {return false} + if lhs.searchDomains != rhs.searchDomains {return false} + if lhs.includeDefaultRoute != rhs.includeDefaultRoute {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Apple/NetworkExtension/PacketTunnelProvider.swift b/Apple/NetworkExtension/PacketTunnelProvider.swift index 4f29543..3f3d8b4 100644 --- a/Apple/NetworkExtension/PacketTunnelProvider.swift +++ b/Apple/NetworkExtension/PacketTunnelProvider.swift @@ -1,6 +1,7 @@ import AsyncAlgorithms import BurrowConfiguration import BurrowCore +import GRPC import libburrow import NetworkExtension import os @@ -19,6 +20,9 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { } private let logger = Logger.logger(for: PacketTunnelProvider.self) + private var packetCall: GRPCAsyncBidirectionalStreamingCall? + private var inboundPacketTask: Task? + private var outboundPacketTask: Task? private var client: TunnelClient { get throws { try _client.get() } @@ -45,16 +49,18 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { let completion = SendableCallbackBox(completionHandler) Task { do { + _ = try await client.tunnelStart(.init()) let configuration = try await Array(client.tunnelConfiguration(.init()).prefix(1)).first guard let settings = configuration?.settings else { throw Error.missingTunnelConfiguration } try await setTunnelNetworkSettings(settings) - _ = try await client.tunnelStart(.init()) + try startPacketBridge() logger.log("Started tunnel with network settings: \(settings)") completion.callback(nil) } catch { logger.error("Failed to start tunnel: \(error)") + stopPacketBridge() completion.callback(error) } } @@ -66,6 +72,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { ) { let completion = SendableCallbackBox(completionHandler) Task { + stopPacketBridge() do { _ = try await client.tunnelStop(.init()) logger.log("Stopped client") @@ -77,20 +84,243 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { } } +extension PacketTunnelProvider { + private func startPacketBridge() throws { + stopPacketBridge() + + let packetClient = TunnelPacketClient.unix(socketURL: try Constants.socketURL) + let call = packetClient.makeTunnelPacketsCall() + self.packetCall = call + + inboundPacketTask = Task { [weak self] in + guard let self else { return } + do { + for try await packet in call.responseStream { + let payload = packet.payload + self.packetFlow.writePackets( + [payload], + withProtocols: [Self.protocolNumber(for: payload)] + ) + } + } catch { + guard !Task.isCancelled else { return } + self.logger.error("Tunnel packet receive loop failed: \(error)") + } + } + + outboundPacketTask = Task { [weak self] in + guard let self else { return } + defer { call.requestStream.finish() } + do { + while !Task.isCancelled { + let packets = await self.readPacketsBatch() + for (payload, _) in packets { + var packet = Burrow_TunnelPacket() + packet.payload = payload + try await call.requestStream.send(packet) + } + } + } catch { + guard !Task.isCancelled else { return } + self.logger.error("Tunnel packet send loop failed: \(error)") + } + } + } + + private func stopPacketBridge() { + inboundPacketTask?.cancel() + inboundPacketTask = nil + outboundPacketTask?.cancel() + outboundPacketTask = nil + packetCall?.cancel() + packetCall = nil + } + + private func readPacketsBatch() async -> [(Data, NSNumber)] { + await withCheckedContinuation { continuation in + packetFlow.readPackets { packets, protocols in + continuation.resume(returning: Array(zip(packets, protocols))) + } + } + } + + private static func protocolNumber(for payload: Data) -> NSNumber { + guard let version = payload.first.map({ $0 >> 4 }) else { + return NSNumber(value: AF_INET) + } + switch version { + case 6: + return NSNumber(value: AF_INET6) + default: + return NSNumber(value: AF_INET) + } + } +} + extension Burrow_TunnelConfigurationResponse { fileprivate var settings: NEPacketTunnelNetworkSettings { - let ipv6Addresses = addresses.filter { IPv6Address($0) != nil } + let parsedAddresses = addresses.compactMap(ParsedTunnelAddress.init(rawValue:)) + let ipv4Addresses = parsedAddresses.compactMap(\.ipv4Address) + let ipv6Addresses = parsedAddresses.compactMap(\.ipv6Address) + let parsedRoutes = routes.compactMap(ParsedTunnelRoute.init(rawValue:)) + var ipv4Routes = parsedRoutes.compactMap(\.ipv4Route) + var ipv6Routes = parsedRoutes.compactMap(\.ipv6Route) + if includeDefaultRoute { + ipv4Routes.append(.default()) + ipv6Routes.append(.default()) + } let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "1.1.1.1") settings.mtu = NSNumber(value: mtu) - settings.ipv4Settings = NEIPv4Settings( - addresses: addresses.filter { IPv4Address($0) != nil }, - subnetMasks: ["255.255.255.0"] - ) - settings.ipv6Settings = NEIPv6Settings( - addresses: ipv6Addresses, - networkPrefixLengths: ipv6Addresses.map { _ in 64 } - ) + if !ipv4Addresses.isEmpty { + let ipv4Settings = NEIPv4Settings( + addresses: ipv4Addresses.map(\.address), + subnetMasks: ipv4Addresses.map(\.subnetMask) + ) + if !ipv4Routes.isEmpty { + ipv4Settings.includedRoutes = ipv4Routes + } + settings.ipv4Settings = ipv4Settings + } + if !ipv6Addresses.isEmpty { + let ipv6Settings = NEIPv6Settings( + addresses: ipv6Addresses.map(\.address), + networkPrefixLengths: ipv6Addresses.map(\.prefixLength) + ) + if !ipv6Routes.isEmpty { + ipv6Settings.includedRoutes = ipv6Routes + } + settings.ipv6Settings = ipv6Settings + } + if !dnsServers.isEmpty { + let dnsSettings = NEDNSSettings(servers: dnsServers) + if !searchDomains.isEmpty { + dnsSettings.matchDomains = searchDomains + } + settings.dnsSettings = dnsSettings + } return settings } } + +private struct ParsedTunnelAddress { + struct IPv4AddressSetting { + let address: String + let subnetMask: String + } + + struct IPv6AddressSetting { + let address: String + let prefixLength: NSNumber + } + + let ipv4Address: IPv4AddressSetting? + let ipv6Address: IPv6AddressSetting? + + init?(rawValue: String) { + let components = rawValue.split(separator: "/", maxSplits: 1).map(String.init) + let address = components.first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !address.isEmpty else { + return nil + } + + let prefix = components.count == 2 ? Int(components[1]) : nil + if IPv4Address(address) != nil { + let prefixLength = prefix ?? 32 + guard (0 ... 32).contains(prefixLength) else { + return nil + } + ipv4Address = IPv4AddressSetting( + address: address, + subnetMask: Self.ipv4SubnetMask(prefixLength: prefixLength) + ) + ipv6Address = nil + return + } + + if IPv6Address(address) != nil { + let prefixLength = prefix ?? 128 + guard (0 ... 128).contains(prefixLength) else { + return nil + } + ipv4Address = nil + ipv6Address = IPv6AddressSetting( + address: address, + prefixLength: NSNumber(value: prefixLength) + ) + return + } + + return nil + } + + private static func ipv4SubnetMask(prefixLength: Int) -> String { + guard prefixLength > 0 else { + return "0.0.0.0" + } + let mask = UInt32.max << (32 - prefixLength) + let octets = [ + (mask >> 24) & 0xff, + (mask >> 16) & 0xff, + (mask >> 8) & 0xff, + mask & 0xff, + ] + return octets.map(String.init).joined(separator: ".") + } +} + +private struct ParsedTunnelRoute { + let ipv4Route: NEIPv4Route? + let ipv6Route: NEIPv6Route? + + init?(rawValue: String) { + let components = rawValue.split(separator: "/", maxSplits: 1).map(String.init) + let address = components.first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !address.isEmpty else { + return nil + } + + let prefix = components.count == 2 ? Int(components[1]) : nil + if IPv4Address(address) != nil { + let prefixLength = prefix ?? 32 + guard (0 ... 32).contains(prefixLength) else { + return nil + } + ipv4Route = NEIPv4Route( + destinationAddress: address, + subnetMask: Self.ipv4SubnetMask(prefixLength: prefixLength) + ) + ipv6Route = nil + return + } + + if IPv6Address(address) != nil { + let prefixLength = prefix ?? 128 + guard (0 ... 128).contains(prefixLength) else { + return nil + } + ipv4Route = nil + ipv6Route = NEIPv6Route( + destinationAddress: address, + networkPrefixLength: NSNumber(value: prefixLength) + ) + return + } + + return nil + } + + private static func ipv4SubnetMask(prefixLength: Int) -> String { + var mask = UInt32.max << (32 - prefixLength) + if prefixLength == 0 { + mask = 0 + } + let octets = [ + String((mask >> 24) & 0xff), + String((mask >> 16) & 0xff), + String((mask >> 8) & 0xff), + String(mask & 0xff), + ] + return octets.joined(separator: ".") + } +} diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index 2128ec3..e15d3f7 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -83,7 +83,7 @@ public struct BurrowView: View { ContentUnavailableView( "No Accounts Yet", systemImage: "person.crop.circle.badge.plus", - description: Text("Save a Tor account or sign in to a Tailnet provider to keep network identities ready on this device.") + description: Text("Save a Tor account or sign in to Tailnet to keep network identities ready on this device.") ) .frame(maxWidth: .infinity, minHeight: 180) } else { @@ -135,7 +135,7 @@ public struct BurrowView: View { private func runAutomationIfNeeded() { guard !didRunAutomation, let automation = BurrowAutomationConfig.current, - automation.action == .tailnetLogin || automation.action == .headscaleProbe + automation.action == .tailnetLogin || automation.action == .tailnetProbe else { return } @@ -340,8 +340,12 @@ private struct ConfigurationSheetView: View { @State private var isStartingTailnetLogin = false @State private var tailnetPresentedAuthURL: URL? @State private var preserveTailnetLoginSession = false + @State private var usesCustomTailnetAuthority = false + @State private var showsAdvancedTailnetSettings = false @State private var browserAuthenticator = TailnetBrowserAuthenticator() @State private var tailnetLoginPollTask: Task? + @State private var tailnetDiscoveryTask: Task? + @State private var tailnetProbeTask: Task? @State private var didRunAutomation = false init( @@ -364,14 +368,9 @@ private struct ConfigurationSheetView: View { .listRowInsets(.init(top: 4, leading: 0, bottom: 4, trailing: 0)) .listRowBackground(Color.clear) - Section("Identity") { - TextField("Title", text: $draft.title) - TextField("Account", text: $draft.accountName) - TextField("Identity", text: $draft.identityName) - if sheet == .tailnet { - TextField("Hostname", text: $draft.hostname) - .burrowLoginField() - .autocorrectionDisabled() + if showsIdentitySection { + Section("Identity") { + identityFields } } @@ -458,9 +457,15 @@ private struct ConfigurationSheetView: View { } .onChange(of: draft.authority) { _, _ in resetAuthorityProbe() + if sheet == .tailnet, usesCustomTailnetAuthority { + scheduleTailnetAuthorityProbe() + } } .onChange(of: draft.discoveryEmail) { _, _ in resetTailnetDiscoveryFeedback() + if sheet == .tailnet, !usesCustomTailnetAuthority { + scheduleTailnetDiscovery() + } } .onChange(of: draft.authMode) { _, newMode in guard newMode != .web else { return } @@ -470,6 +475,8 @@ private struct ConfigurationSheetView: View { } .onDisappear { tailnetLoginPollTask?.cancel() + tailnetDiscoveryTask?.cancel() + tailnetProbeTask?.cancel() browserAuthenticator.cancel() if !preserveTailnetLoginSession { Task { @MainActor in @@ -479,6 +486,18 @@ private struct ConfigurationSheetView: View { } } + @ViewBuilder + private var identityFields: some View { + TextField("Title", text: $draft.title) + TextField("Account", text: $draft.accountName) + TextField("Identity", text: $draft.identityName) + if sheet == .tailnet { + TextField("Hostname", text: $draft.hostname) + .burrowLoginField() + .autocorrectionDisabled() + } + } + @ViewBuilder private var tailnetSections: some View { Section("Connection") { @@ -487,67 +506,39 @@ private struct ConfigurationSheetView: View { .burrowLoginField() .autocorrectionDisabled() .accessibilityIdentifier("tailnet-discovery-email") - - Button { - discoverTailnetAuthority() - } label: { - Label { - Text(isDiscoveringTailnet ? "Finding Server" : "Find Server") - } icon: { - Image(systemName: isDiscoveringTailnet ? "hourglass" : "at.circle") + .submitLabel(.continue) + .onSubmit { + if !usesCustomTailnetAuthority { + scheduleTailnetDiscovery(immediate: true) } } - .buttonStyle(.borderless) - .disabled(isDiscoveringTailnet || normalizedOptional(draft.discoveryEmail) == nil) - .accessibilityIdentifier("tailnet-find-server") - if let discoveryStatus { - tailnetDiscoveryCard(status: discoveryStatus, failure: nil) - } else if let discoveryError { - tailnetDiscoveryCard(status: nil, failure: discoveryError) - } + tailnetServerCard - TextField("Authority URL", text: $draft.authority) - .burrowLoginField() - .autocorrectionDisabled() - .accessibilityIdentifier("tailnet-authority") - - Text("Use the managed Tailnet authority or enter a custom Tailnet control server.") - .font(.footnote) - .foregroundStyle(.secondary) - - Button { - probeTailnetAuthority() - } label: { - Label { - Text(isProbingAuthority ? "Checking Connection" : "Check Connection") - } icon: { - Image(systemName: isProbingAuthority ? "hourglass" : "bolt.horizontal.circle") + if showsAdvancedTailnetSettings { + if usesCustomTailnetAuthority { + TextField("Server URL", text: $draft.authority) + .burrowLoginField() + .autocorrectionDisabled() + .accessibilityIdentifier("tailnet-authority") + } else { + TextField("Tailnet", text: $draft.tailnet) + .burrowLoginField() + .autocorrectionDisabled() + .accessibilityIdentifier("tailnet-name") } } - .buttonStyle(.borderless) - .disabled(isProbingAuthority || normalizedOptional(draft.authority) == nil) - .accessibilityIdentifier("tailnet-check-connection") - - if let authorityProbeStatus { - tailnetAuthorityProbeCard(status: authorityProbeStatus, failure: nil) - } else if let authorityProbeError { - tailnetAuthorityProbeCard(status: nil, failure: authorityProbeError) - } - - TextField("Tailnet", text: $draft.tailnet) - .burrowLoginField() - .autocorrectionDisabled() - .accessibilityIdentifier("tailnet-name") } Section("Authentication") { - Picker("Authentication", selection: $draft.authMode) { - ForEach(availableTailnetAuthModes) { mode in - Text(mode.title).tag(mode) + if showsAdvancedTailnetSettings { + Picker("Authentication", selection: $draft.authMode) { + ForEach(availableTailnetAuthModes) { mode in + Text(mode.title).tag(mode) + } } + .pickerStyle(.menu) } - .pickerStyle(.menu) if draft.authMode == .web { Button { @@ -560,7 +551,7 @@ private struct ConfigurationSheetView: View { } } .buttonStyle(.borderless) - .disabled(isStartingTailnetLogin || normalizedOptional(draft.authority) == nil) + .disabled(isStartingTailnetLogin || tailnetLoginActionDisabled) .accessibilityIdentifier("tailnet-start-sign-in") if let tailnetLoginStatus { @@ -616,32 +607,14 @@ private struct ConfigurationSheetView: View { } if sheet == .tailnet { - if let authorityProbeStatus { - Text(authorityProbeStatus.summary) + labeledValue("Server", tailnetServerDisplayLabel) + if let connectionSummary = tailnetConnectionSummary { + Text(connectionSummary) .font(.footnote.weight(.medium)) - .foregroundStyle(.primary) - if let detail = authorityProbeStatus.detail { - Text(detail) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(3) - } - } else if let authorityProbeError { - Text("Connection failed") - .font(.footnote.weight(.medium)) - .foregroundStyle(.red) - Text(authorityProbeError) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(3) + .foregroundStyle(tailnetConnectionSummaryColor) } - } - - if sheet == .tailnet { - HStack(spacing: 8) { - summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom") - summaryBadge(draft.authMode.title) - if tailnetLoginStatus?.running == true { + if tailnetLoginStatus?.running == true { + HStack(spacing: 8) { summaryBadge("Signed In") } } @@ -654,6 +627,44 @@ private struct ConfigurationSheetView: View { ) } + private var tailnetServerCard: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(usesCustomTailnetAuthority ? "Custom Server" : "Server") + .font(.subheadline.weight(.medium)) + Text(tailnetServerDisplayLabel) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + + Spacer() + + if isDiscoveringTailnet || isProbingAuthority { + ProgressView() + .controlSize(.small) + } else if let summary = tailnetConnectionSummary { + Text(summary) + .font(.caption.weight(.medium)) + .foregroundStyle(tailnetConnectionSummaryColor) + } + } + + if let detail = tailnetServerDetail { + Text(detail) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.thinMaterial) + ) + .accessibilityIdentifier("tailnet-server-card") + } + private func tailnetAuthorityProbeCard( status: TailnetAuthorityProbeStatus?, failure: String? @@ -827,11 +838,15 @@ private struct ConfigurationSheetView: View { } case .tailnet: - Button("Use Tailscale Managed Server") { - applyTailnetDefaults(for: .tailscale) + Button(usesCustomTailnetAuthority ? "Use Automatic Server" : "Edit Custom Server") { + toggleTailnetAuthorityMode() } - if availableTailnetAuthModes.count > 1 { + Button(showsAdvancedTailnetSettings ? "Hide Advanced Settings" : "Show Advanced Settings") { + showsAdvancedTailnetSettings.toggle() + } + + if showsAdvancedTailnetSettings, availableTailnetAuthModes.count > 1 { Menu("Authentication") { ForEach(availableTailnetAuthModes) { mode in Button(mode.title) { @@ -844,9 +859,10 @@ private struct ConfigurationSheetView: View { } } - Button("Clear Discovery Result") { - resetTailnetDiscoveryFeedback() + Button("Refresh Server Lookup") { + scheduleTailnetDiscovery(immediate: true) } + .disabled(usesCustomTailnetAuthority || normalizedOptional(draft.discoveryEmail) == nil) } } @@ -885,12 +901,21 @@ private struct ConfigurationSheetView: View { private var showsBottomActionButton: Bool { #if os(iOS) - true + return true #else - false + return false #endif } + private var showsIdentitySection: Bool { + switch sheet { + case .wireGuard, .tor: + return true + case .tailnet: + return showsAdvancedTailnetSettings + } + } + private var wireGuardEditorHeight: CGFloat { #if os(iOS) 180 @@ -910,6 +935,18 @@ private struct ConfigurationSheetView: View { } } + private var tailnetLoginActionDisabled: Bool { + switch sheet { + case .tailnet: + if usesCustomTailnetAuthority { + return normalizedOptional(draft.authority) == nil + } + return false + case .wireGuard, .tor: + return true + } + } + private var submissionDisabled: Bool { switch sheet { case .wireGuard: @@ -933,6 +970,50 @@ private struct ConfigurationSheetView: View { } } + private var tailnetServerDisplayLabel: String { + if usesCustomTailnetAuthority { + return normalizedOptional(draft.authority) + ?? "Enter a custom Tailnet server" + } + return TailnetProvider.tailscale.defaultAuthority ?? "Tailscale managed" + } + + private var tailnetServerDetail: String? { + if usesCustomTailnetAuthority { + if let discovery = discoveryStatus { + return "Discovered from \(discovery.domain)." + } + if let discoveryError { + return discoveryError + } + return "Use a custom Tailnet authority when your domain does not advertise one." + } + return "Continue with Tailscale, or open advanced settings to use a custom server." + } + + private var tailnetConnectionSummary: String? { + if isDiscoveringTailnet { + return "Finding server" + } + if isProbingAuthority { + return "Checking" + } + if let authorityProbeStatus { + return authorityProbeStatus.summary + } + if authorityProbeError != nil { + return "Unavailable" + } + return nil + } + + private var tailnetConnectionSummaryColor: Color { + if authorityProbeError != nil { + return .red + } + return .secondary + } + private func submit() { isSubmitting = true errorMessage = nil @@ -1021,7 +1102,7 @@ private struct ConfigurationSheetView: View { guard !didRunAutomation, sheet == .tailnet, let automation = BurrowAutomationConfig.current, - automation.action == .tailnetLogin || automation.action == .headscaleProbe + automation.action == .tailnetLogin || automation.action == .tailnetProbe else { return } @@ -1037,7 +1118,9 @@ private struct ConfigurationSheetView: View { case .tailnetLogin: applyTailnetDefaults(for: .tailscale) startTailnetLogin() - case .headscaleProbe: + case .tailnetProbe: + usesCustomTailnetAuthority = true + showsAdvancedTailnetSettings = true draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority probeTailnetAuthority() } @@ -1060,10 +1143,13 @@ private struct ConfigurationSheetView: View { ) var noteParts: [String] = [ - isManagedTailnetAuthority ? "Managed Tailnet" : "Custom Tailnet", - "Auth: \(draft.authMode.title)", + "Server: \(hostnameFallback(from: payload.authority ?? "", fallback: "tailnet"))", ] + if showsAdvancedTailnetSettings || draft.authMode != .web { + noteParts.append("Auth: \(draft.authMode.title)") + } + if draft.authMode == .web, tailnetLoginStatus?.running == true { noteParts.append("Browser sign-in complete") } @@ -1119,6 +1205,7 @@ private struct ConfigurationSheetView: View { private func applyTailnetDefaults(for provider: TailnetProvider) { resetTailnetDiscoveryFeedback() + usesCustomTailnetAuthority = provider != .tailscale draft.authority = provider.defaultAuthority ?? "" if !availableTailnetAuthModes.contains(draft.authMode) { draft.authMode = .web @@ -1126,12 +1213,6 @@ private struct ConfigurationSheetView: View { } private func startTailnetLogin() { - guard let authority = normalizedOptional(draft.authority) else { - tailnetLoginStatus = nil - tailnetLoginError = "Enter a server URL first." - return - } - isStartingTailnetLogin = true tailnetLoginError = nil preserveTailnetLoginSession = false @@ -1139,6 +1220,7 @@ private struct ConfigurationSheetView: View { Task { @MainActor in defer { isStartingTailnetLogin = false } do { + let authority = try await resolveTailnetAuthorityForLogin() let status = try await networkViewModel.startTailnetLogin( accountName: normalized(draft.accountName, fallback: "default"), identityName: normalized(draft.identityName, fallback: "apple"), @@ -1176,12 +1258,14 @@ private struct ConfigurationSheetView: View { } private func resetAuthorityProbe() { + tailnetProbeTask?.cancel() authorityProbeStatus = nil authorityProbeError = nil tailnetLoginError = nil } private func resetTailnetDiscoveryFeedback() { + tailnetDiscoveryTask?.cancel() discoveryStatus = nil discoveryError = nil } @@ -1210,6 +1294,83 @@ private struct ConfigurationSheetView: View { } } + private func scheduleTailnetDiscovery(immediate: Bool = false) { + guard sheet == .tailnet else { return } + tailnetDiscoveryTask?.cancel() + + guard !usesCustomTailnetAuthority else { + discoveryStatus = nil + discoveryError = nil + return + } + + guard normalizedOptional(draft.discoveryEmail) != nil else { + discoveryStatus = nil + discoveryError = nil + draft.authority = TailnetProvider.tailscale.defaultAuthority ?? "" + return + } + + tailnetDiscoveryTask = Task { @MainActor in + if !immediate { + try? await Task.sleep(for: .milliseconds(450)) + } + guard !Task.isCancelled else { return } + discoverTailnetAuthority() + } + } + + private func scheduleTailnetAuthorityProbe() { + guard sheet == .tailnet else { return } + tailnetProbeTask?.cancel() + guard normalizedOptional(draft.authority) != nil else { return } + + tailnetProbeTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + probeTailnetAuthority() + } + } + + private func toggleTailnetAuthorityMode() { + let discoveredAuthority = discoveryStatus?.authority + usesCustomTailnetAuthority.toggle() + resetTailnetDiscoveryFeedback() + resetAuthorityProbe() + if usesCustomTailnetAuthority { + draft.authority = discoveredAuthority ?? draft.authority + } else { + draft.authority = TailnetProvider.tailscale.defaultAuthority ?? "" + scheduleTailnetDiscovery(immediate: normalizedOptional(draft.discoveryEmail) != nil) + } + } + + private func resolveTailnetAuthorityForLogin() async throws -> String { + if !usesCustomTailnetAuthority { + let authority = TailnetProvider.tailscale.defaultAuthority ?? "" + draft.authority = authority + scheduleTailnetAuthorityProbe() + return authority + } + + if let authority = normalizedOptional(draft.authority) { + return authority + } + + if let email = normalizedOptional(draft.discoveryEmail) { + let discovery = try await networkViewModel.discoverTailnet(email: email) + discoveryStatus = discovery + discoveryError = nil + draft.authority = discovery.authority + scheduleTailnetAuthorityProbe() + return discovery.authority + } + + throw NSError(domain: "BurrowTailnet", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Enter an email address or a custom server URL first." + ]) + } + private func beginTailnetLoginPolling(sessionID: String) { tailnetLoginPollTask?.cancel() tailnetLoginPollTask = Task { @MainActor in @@ -1336,13 +1497,16 @@ private struct ConfigurationSheetView: View { if tailnetLoginSessionID != nil { return "Resume Sign-In" } - return "Start Sign-In" + return "Continue with Tailscale" } private var tailnetAuthenticationFootnote: String { switch draft.authMode { case .web: - return "Burrow asks the daemon to start a Tailnet browser sign-in session, then closes it locally once the daemon reports the device is running." + if usesCustomTailnetAuthority { + return "Burrow signs in through the daemon using your custom Tailnet server." + } + return "Burrow signs in through the daemon using Tailscale's managed browser flow." case .none: return "Save the authority only. Useful when the control plane handles authentication elsewhere." case .password, .preauthKey: @@ -1357,10 +1521,6 @@ private struct ConfigurationSheetView: View { ) } - private var isManagedTailnetAuthority: Bool { - TailnetProvider.isManagedTailscaleAuthority(normalizedOptional(draft.authority)) - } - @ViewBuilder private func labeledValue(_ label: String, _ value: String) -> some View { VStack(alignment: .leading, spacing: 2) { @@ -1383,12 +1543,7 @@ private struct AccountRowView: View { VStack(alignment: .leading, spacing: 4) { Text(account.title) .font(.headline) - HStack(spacing: 8) { - Text(account.kind.title) - if let provider = account.provider { - Text(provider.title) - } - } + Text(account.kind.title) .font(.subheadline) .foregroundStyle(account.kind.accentColor) } @@ -1470,6 +1625,12 @@ private extension View { @MainActor private final class TailnetBrowserAuthenticator: NSObject { private var session: ASWebAuthenticationSession? + private static var prefersEphemeralSessionForCurrentProcess: Bool { + let rawValue = ProcessInfo.processInfo.environment["BURROW_UI_TEST_EPHEMERAL_AUTH"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return rawValue == "1" || rawValue == "true" || rawValue == "yes" + } func start(url: URL, onDismiss: @escaping @Sendable () -> Void) { cancel() @@ -1477,7 +1638,7 @@ private final class TailnetBrowserAuthenticator: NSObject { onDismiss() } session.presentationContextProvider = self - session.prefersEphemeralWebBrowserSession = false + session.prefersEphemeralWebBrowserSession = Self.prefersEphemeralSessionForCurrentProcess self.session = session _ = session.start() } @@ -1516,7 +1677,7 @@ private final class TailnetBrowserAuthenticator { private struct BurrowAutomationConfig { enum Action: String { case tailnetLogin = "tailnet-login" - case headscaleProbe = "headscale-probe" + case tailnetProbe = "tailnet-probe" } let action: Action diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift index 32f0b8c..35bd0e1 100644 --- a/Apple/UI/Networks/Network.swift +++ b/Apple/UI/Networks/Network.swift @@ -303,7 +303,7 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable { var title: String { switch self { case .tailscale: "Tailscale" - case .headscale: "Headscale" + case .headscale: "Custom Tailnet" case .burrow: "Burrow" } } @@ -375,7 +375,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { switch self { case .wireGuard: "Import a tunnel and optional account metadata." case .tor: "Store Arti account and identity preferences." - case .tailnet: "Save Tailnet authority, identity, and login material." + case .tailnet: "Save Tailnet authority, identity defaults, and login material." } } @@ -402,7 +402,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { case .tor: "Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet." case .tailnet: - "Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can be stored in the daemon." + "Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can already be stored in the daemon." } } } diff --git a/Scripts/run-ios-tailnet-ui-tests.sh b/Scripts/run-ios-tailnet-ui-tests.sh index 5086bd1..5170a1e 100755 --- a/Scripts/run-ios-tailnet-ui-tests.sh +++ b/Scripts/run-ios-tailnet-ui-tests.sh @@ -5,13 +5,18 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}" simulator_name="${BURROW_UI_TEST_SIMULATOR_NAME:-iPhone 17 Pro}" simulator_os="${BURROW_UI_TEST_SIMULATOR_OS:-26.4}" +simulator_id="${BURROW_UI_TEST_SIMULATOR_ID:-}" derived_data_path="${BURROW_UI_TEST_DERIVED_DATA_PATH:-/tmp/burrow-ui-tests-deriveddata}" source_packages_path="${BURROW_UI_TEST_SOURCE_PACKAGES_PATH:-/tmp/burrow-ui-tests-sourcepackages}" -fallback_dir="${HOME}/Library/Application Support/${bundle_id}/SimulatorFallback" +fallback_dir="/tmp/${bundle_id}/SimulatorFallback" socket_path="${fallback_dir}/burrow.sock" +tailnet_state_root="/tmp/${bundle_id}/SimulatorTailnetState" daemon_log="${BURROW_UI_TEST_DAEMON_LOG:-/tmp/burrow-ui-test-daemon.log}" +ui_test_config_path="${BURROW_UI_TEST_CONFIG_PATH:-/tmp/burrow-ui-test-config.json}" +ui_test_runner_bundle_id="${bundle_id}.uitests.xctrunner" ui_test_email="${BURROW_UI_TEST_EMAIL:-ui-test@burrow.net}" ui_test_username="${BURROW_UI_TEST_USERNAME:-ui-test}" +ui_test_tailnet_mode="${BURROW_UI_TEST_TAILNET_MODE:-tailscale}" password_secret="${repo_root}/secrets/infra/authentik-ui-test-password.age" age_identity="${BURROW_UI_TEST_AGE_IDENTITY:-${HOME}/.ssh/id_ed25519}" @@ -25,10 +30,60 @@ if [[ -z "$ui_test_password" ]]; then fi fi -mkdir -p "$fallback_dir" "$derived_data_path" "$source_packages_path" +rm -rf "$fallback_dir" "$tailnet_state_root" +mkdir -p "$fallback_dir" "$tailnet_state_root" "$derived_data_path" "$source_packages_path" rm -f "$socket_path" +resolve_simulator_id() { + xcrun simctl list devices available -j | python3 -c ' +import json +import os +import sys + +target_name = sys.argv[1] +target_os = sys.argv[2] +target_runtime = "com.apple.CoreSimulator.SimRuntime.iOS-" + target_os.replace(".", "-") +devices = json.load(sys.stdin).get("devices", {}) +healthy = [] +for runtime, entries in devices.items(): + if runtime != target_runtime: + continue + for entry in entries: + if not entry.get("isAvailable", False): + continue + if not os.path.isdir(entry.get("dataPath", "")): + continue + healthy.append(entry) +for entry in healthy: + if entry.get("name") == target_name: + print(entry["udid"]) + raise SystemExit(0) +for entry in healthy: + if target_name in entry.get("name", ""): + print(entry["udid"]) + raise SystemExit(0) +raise SystemExit(1) +' "$simulator_name" "$simulator_os" +} + +if [[ -z "$simulator_id" ]]; then + simulator_id="$(resolve_simulator_id || true)" +fi + +if [[ -n "$simulator_id" ]]; then + xcrun simctl boot "$simulator_id" >/dev/null 2>&1 || true + xcrun simctl bootstatus "$simulator_id" -b + xcrun simctl terminate "$simulator_id" "$bundle_id" >/dev/null 2>&1 || true + xcrun simctl terminate "$simulator_id" "$ui_test_runner_bundle_id" >/dev/null 2>&1 || true + xcrun simctl uninstall "$simulator_id" "$bundle_id" >/dev/null 2>&1 || true + xcrun simctl uninstall "$simulator_id" "$ui_test_runner_bundle_id" >/dev/null 2>&1 || true + destination="id=${simulator_id}" +else + destination="platform=iOS Simulator,name=${simulator_name},OS=${simulator_os}" +fi + cleanup() { + rm -f "$ui_test_config_path" if [[ -n "${daemon_pid:-}" ]]; then kill "$daemon_pid" >/dev/null 2>&1 || true wait "$daemon_pid" >/dev/null 2>&1 || true @@ -36,11 +91,33 @@ cleanup() { } trap cleanup EXIT +umask 077 +python3 - <<'PY' "$ui_test_config_path" "$ui_test_email" "$ui_test_username" "$ui_test_password" "$ui_test_tailnet_mode" +import json +import pathlib +import sys + +config_path = pathlib.Path(sys.argv[1]) +config_path.write_text( + json.dumps( + { + "email": sys.argv[2], + "username": sys.argv[3], + "password": sys.argv[4], + "mode": sys.argv[5], + } + ), + encoding="utf-8", +) +PY + cargo build -p burrow --bin burrow ( cd "$fallback_dir" + RUST_LOG="${BURROW_UI_TEST_RUST_LOG:-info,burrow=debug}" \ BURROW_SOCKET_PATH="burrow.sock" \ + BURROW_TAILSCALE_STATE_ROOT="$tailnet_state_root" \ "${repo_root}/target/debug/burrow" daemon >"$daemon_log" 2>&1 ) & daemon_pid=$! @@ -56,18 +133,31 @@ if [[ ! -S "$socket_path" ]]; then exit 1 fi +common_xcodebuild_args=( + -quiet + -skipPackagePluginValidation + -project "${repo_root}/Apple/Burrow.xcodeproj" + -scheme App + -configuration Debug + -destination "$destination" + -derivedDataPath "$derived_data_path" + -clonedSourcePackagesDirPath "$source_packages_path" + -only-testing:BurrowUITests + -parallel-testing-enabled NO + -maximum-concurrent-test-simulator-destinations 1 + -maximum-parallel-testing-workers 1 + CODE_SIGNING_ALLOWED=NO +) + +xcodebuild \ + "${common_xcodebuild_args[@]}" \ + build-for-testing + BURROW_UI_TEST_EMAIL="$ui_test_email" \ BURROW_UI_TEST_USERNAME="$ui_test_username" \ BURROW_UI_TEST_PASSWORD="$ui_test_password" \ +BURROW_UI_TEST_CONFIG_PATH="$ui_test_config_path" \ +BURROW_UI_TEST_EPHEMERAL_AUTH=1 \ xcodebuild \ - -quiet \ - -skipPackagePluginValidation \ - -project "${repo_root}/Apple/Burrow.xcodeproj" \ - -scheme App \ - -configuration Debug \ - -destination "platform=iOS Simulator,name=${simulator_name},OS=${simulator_os}" \ - -derivedDataPath "$derived_data_path" \ - -clonedSourcePackagesDirPath "$source_packages_path" \ - -only-testing:BurrowUITests \ - CODE_SIGNING_ALLOWED=NO \ - test + "${common_xcodebuild_args[@]}" \ + test-without-building diff --git a/burrow/src/auth/server/tailscale.rs b/burrow/src/auth/server/tailscale.rs index 55516e1..d08c807 100644 --- a/burrow/src/auth/server/tailscale.rs +++ b/burrow/src/auth/server/tailscale.rs @@ -26,6 +26,8 @@ pub struct TailscaleLoginStartRequest { pub hostname: Option, #[serde(default)] pub control_url: Option, + #[serde(default)] + pub packet_socket: Option, } #[derive(Clone, Debug, Serialize, Deserialize, Default)] @@ -55,23 +57,35 @@ pub struct TailscaleLoginStartResponse { pub status: TailscaleLoginStatus, } +pub struct TailscaleLoginSession { + pub session_id: String, + pub helper: Arc, + pub status: TailscaleLoginStatus, +} + #[derive(Clone, Default)] pub struct TailscaleBridgeManager { client: Client, sessions: Arc>>>, } -struct ManagedSession { +pub struct TailscaleHelperProcess { session_id: String, listen_url: String, + packet_socket: Option, + control_url: Option, state_dir: PathBuf, child: Arc>, _stderr_task: JoinHandle<()>, } +type ManagedSession = TailscaleHelperProcess; + #[derive(Debug, Deserialize)] struct HelperHello { listen_addr: String, + #[serde(default)] + packet_socket: Option, } impl TailscaleBridgeManager { @@ -79,76 +93,71 @@ impl TailscaleBridgeManager { &self, request: TailscaleLoginStartRequest, ) -> Result { - let key = session_key(&request.account_name, &request.identity_name); + let session = self.ensure_session(request).await?; + Ok(TailscaleLoginStartResponse { + session_id: session.session_id, + status: session.status, + }) + } + + pub async fn ensure_session( + &self, + request: TailscaleLoginStartRequest, + ) -> Result { + let key = session_key_for_request(&request); + let requested_packet_socket = request + .packet_socket + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let requested_control_url = request + .control_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); if let Some(existing) = self.sessions.lock().await.get(&key).cloned() { - match self.fetch_status(existing.as_ref()).await { - Ok(status) => { - return Ok(TailscaleLoginStartResponse { - session_id: existing.session_id.clone(), - status, - }); - } - Err(err) => { - log::warn!( - "tailscale login session {} is stale, restarting: {err}", - existing.session_id - ); - self.sessions.lock().await.remove(&key); - let _ = self.shutdown_session(existing.as_ref()).await; + let needs_restart_for_socket = match (requested_packet_socket, existing.packet_socket()) + { + (Some(requested), Some(current)) => current != Path::new(requested), + (Some(_), None) => true, + _ => false, + }; + let needs_restart_for_control_url = + requested_control_url != existing.control_url().map(|value| value.trim()); + + if !needs_restart_for_socket && !needs_restart_for_control_url { + match self.fetch_status(existing.as_ref()).await { + Ok(status) => { + return Ok(TailscaleLoginSession { + session_id: existing.session_id.clone(), + helper: existing, + status, + }); + } + Err(err) => { + log::warn!( + "tailscale login session {} is stale, restarting: {err}", + existing.session_id + ); + } } + } else { + log::info!( + "tailscale login session {} no longer matches requested transport, restarting", + existing.session_id + ); } + + self.sessions.lock().await.remove(&key); + let _ = self.shutdown_session(existing.as_ref()).await; } - let state_dir = state_root().join(session_dir_name(&request)); - tokio::fs::create_dir_all(&state_dir) - .await - .with_context(|| format!("failed to create {}", state_dir.display()))?; - - let mut child = helper_command(&request, &state_dir)? - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .context("failed to spawn tailscale login helper")?; - - let stdout = child - .stdout - .take() - .context("tailscale helper stdout unavailable")?; - let stderr = child - .stderr - .take() - .context("tailscale helper stderr unavailable")?; - - let hello_line = tokio::time::timeout(Duration::from_secs(20), async move { - let mut lines = BufReader::new(stdout).lines(); - lines.next_line().await - }) - .await - .context("timed out waiting for tailscale helper startup")?? - .context("tailscale helper exited before reporting listen address")?; - - let hello: HelperHello = - serde_json::from_str(&hello_line).context("invalid tailscale helper startup line")?; - - let stderr_task = tokio::spawn(async move { - let mut lines = BufReader::new(stderr).lines(); - while let Ok(Some(line)) = lines.next_line().await { - log::info!("tailscale-login-bridge: {line}"); - } - }); - - let session = Arc::new(ManagedSession { - session_id: random_session_id(), - listen_url: format!("http://{}", hello.listen_addr), - state_dir, - child: Arc::new(Mutex::new(child)), - _stderr_task: stderr_task, - }); - + let session = Arc::new(spawn_tailscale_helper(&request).await?); let status = self.wait_for_status(session.as_ref()).await?; - let response = TailscaleLoginStartResponse { + let response = TailscaleLoginSession { session_id: session.session_id.clone(), + helper: session.clone(), status, }; @@ -192,7 +201,7 @@ impl TailscaleBridgeManager { let mut last_error = None; let mut last_status = None; for _ in 0..40 { - match self.fetch_status(session).await { + match session.status_with_client(&self.client).await { Ok(status) if status.running || status.auth_url.is_some() => return Ok(status), Ok(status) => last_status = Some(status), Err(err) => last_error = Some(err), @@ -206,28 +215,7 @@ impl TailscaleBridgeManager { } async fn fetch_status(&self, session: &ManagedSession) -> Result { - let mut child = session.child.lock().await; - if let Some(status) = child.try_wait()? { - return Err(anyhow!( - "tailscale helper exited with status {status} for {}", - session.state_dir.display() - )); - } - drop(child); - - let response = self - .client - .get(format!("{}/status", session.listen_url)) - .send() - .await - .context("failed to query tailscale helper status")? - .error_for_status() - .context("tailscale helper status request failed")?; - - response - .json::() - .await - .context("invalid tailscale helper status response") + session.status_with_client(&self.client).await } async fn remove_session_by_id(&self, session_id: &str) -> Option> { @@ -239,14 +227,74 @@ impl TailscaleBridgeManager { } async fn shutdown_session(&self, session: &ManagedSession) -> Result<()> { - let _ = self - .client - .post(format!("{}/shutdown", session.listen_url)) + session.shutdown_with_client(&self.client).await + } +} + +impl TailscaleHelperProcess { + pub fn session_id(&self) -> &str { + &self.session_id + } + + pub fn packet_socket(&self) -> Option<&Path> { + self.packet_socket.as_deref() + } + + pub fn control_url(&self) -> Option<&str> { + self.control_url.as_deref() + } + + pub fn state_dir(&self) -> &Path { + &self.state_dir + } + + pub async fn status(&self) -> Result { + self.status_with_client(&Client::new()).await + } + + pub async fn shutdown(&self) -> Result<()> { + self.shutdown_with_client(&Client::new()).await + } + + async fn status_with_client(&self, client: &Client) -> Result { + let mut child = self.child.lock().await; + if let Some(status) = child.try_wait()? { + return Err(anyhow!( + "tailscale helper exited with status {status} for {}", + self.state_dir.display() + )); + } + drop(child); + + let response = client + .get(format!("{}/status", self.listen_url)) .send() - .await; + .await + .context("failed to query tailscale helper status")? + .error_for_status() + .context("tailscale helper status request failed")?; + + let status = response + .json::() + .await + .context("invalid tailscale helper status response")?; + + log::info!( + "tailscale helper status session={} backend_state={} running={} needs_login={} auth_url={:?}", + self.session_id, + status.backend_state, + status.running, + status.needs_login, + status.auth_url + ); + Ok(status) + } + + async fn shutdown_with_client(&self, client: &Client) -> Result<()> { + let _ = client.post(format!("{}/shutdown", self.listen_url)).send().await; for _ in 0..10 { - let mut child = session.child.lock().await; + let mut child = self.child.lock().await; if child.try_wait()?.is_some() { return Ok(()); } @@ -254,7 +302,7 @@ impl TailscaleBridgeManager { tokio::time::sleep(Duration::from_millis(100)).await; } - let mut child = session.child.lock().await; + let mut child = self.child.lock().await; child .start_kill() .context("failed to kill tailscale helper")?; @@ -263,6 +311,58 @@ impl TailscaleBridgeManager { } } +pub async fn spawn_tailscale_helper( + request: &TailscaleLoginStartRequest, +) -> Result { + let state_dir = state_root().join(session_dir_name(request)); + tokio::fs::create_dir_all(&state_dir) + .await + .with_context(|| format!("failed to create {}", state_dir.display()))?; + + let mut child = helper_command(request, &state_dir)? + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("failed to spawn tailscale login helper")?; + + let stdout = child + .stdout + .take() + .context("tailscale helper stdout unavailable")?; + let stderr = child + .stderr + .take() + .context("tailscale helper stderr unavailable")?; + + let hello_line = tokio::time::timeout(Duration::from_secs(20), async move { + let mut lines = BufReader::new(stdout).lines(); + lines.next_line().await + }) + .await + .context("timed out waiting for tailscale helper startup")?? + .context("tailscale helper exited before reporting listen address")?; + + let hello: HelperHello = + serde_json::from_str(&hello_line).context("invalid tailscale helper startup line")?; + + let stderr_task = tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + log::info!("tailscale-login-bridge: {line}"); + } + }); + + Ok(TailscaleHelperProcess { + session_id: random_session_id(), + listen_url: format!("http://{}", hello.listen_addr), + packet_socket: hello.packet_socket.map(PathBuf::from), + control_url: request.control_url.clone(), + state_dir, + child: Arc::new(Mutex::new(child)), + _stderr_task: stderr_task, + }) +} + fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Result { let mut command = if let Ok(path) = env::var("BURROW_TAILSCALE_HELPER") { Command::new(path) @@ -291,10 +391,21 @@ fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Res } } + if let Some(packet_socket) = request.packet_socket.as_deref() { + let trimmed = packet_socket.trim(); + if !trimmed.is_empty() { + command.arg("--packet-socket").arg(trimmed); + } + } + Ok(command) } -fn state_root() -> PathBuf { +pub(crate) fn packet_socket_path(request: &TailscaleLoginStartRequest) -> PathBuf { + state_root().join(session_dir_name(request)).join("packet.sock") +} + +pub(crate) fn state_root() -> PathBuf { if let Ok(path) = env::var("BURROW_TAILSCALE_STATE_ROOT") { return PathBuf::from(path); } @@ -315,19 +426,34 @@ fn state_root() -> PathBuf { .join("tailscale") } -fn session_dir_name(request: &TailscaleLoginStartRequest) -> String { +pub(crate) fn session_dir_name(request: &TailscaleLoginStartRequest) -> String { format!( - "{}-{}", + "{}-{}-{}", slug(&request.account_name), - slug(&request.identity_name) + slug(&request.identity_name), + slug(control_scope(request)) ) } -fn session_key(account_name: &str, identity_name: &str) -> String { - format!("{account_name}:{identity_name}") +fn session_key_for_request(request: &TailscaleLoginStartRequest) -> String { + format!( + "{}:{}:{}", + request.account_name, + request.identity_name, + control_scope(request) + ) } -fn default_hostname(request: &TailscaleLoginStartRequest) -> String { +fn control_scope(request: &TailscaleLoginStartRequest) -> &str { + request + .control_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("tailscale-managed") +} + +pub(crate) fn default_hostname(request: &TailscaleLoginStartRequest) -> String { request .hostname .as_deref() @@ -370,14 +496,24 @@ mod tests { } #[test] - fn state_dir_is_stable_by_account_and_identity() { + fn state_dir_is_scoped_by_account_identity_and_control_plane() { let request = TailscaleLoginStartRequest { account_name: "default".to_owned(), identity_name: "apple".to_owned(), hostname: None, control_url: None, + packet_socket: None, }; - assert_eq!(session_dir_name(&request), "default-apple"); + assert_eq!(session_dir_name(&request), "default-apple-tailscale-managed"); assert_eq!(default_hostname(&request), "burrow-apple"); + + let custom_request = TailscaleLoginStartRequest { + control_url: Some("https://ts.burrow.net".to_owned()), + ..request + }; + assert_eq!( + session_dir_name(&custom_request), + "default-apple-httpstsburrownet" + ); } } diff --git a/burrow/src/control/discovery.rs b/burrow/src/control/discovery.rs index 5fc7add..d044a62 100644 --- a/burrow/src/control/discovery.rs +++ b/burrow/src/control/discovery.rs @@ -1,6 +1,7 @@ use anyhow::{anyhow, Context, Result}; use reqwest::{Client, StatusCode, Url}; use serde::{Deserialize, Serialize}; +use tracing::{debug, info}; use super::TailnetProvider; @@ -43,6 +44,7 @@ struct WebFingerLink { pub async fn discover_tailnet(email: &str) -> Result { let domain = email_domain(email)?; + info!(%email, %domain, "tailnet discovery requested"); let base_url = Url::parse(&format!("https://{domain}")) .with_context(|| format!("invalid discovery domain {domain}"))?; let client = Client::builder() @@ -116,12 +118,21 @@ pub async fn discover_tailnet_at( base_url: &Url, ) -> Result { let domain = email_domain(email)?; + debug!(%email, %domain, base_url = %base_url, "starting tailnet domain discovery"); if let Some(discovery) = discover_well_known(client, base_url).await? { + info!( + %email, + %domain, + authority = %discovery.authority, + provider = ?discovery.provider, + "resolved tailnet discovery from well-known document" + ); return Ok(TailnetDiscovery { domain, ..discovery }); } if let Some(authority) = discover_webfinger(client, email, base_url).await? { + info!(%email, %domain, %authority, "resolved tailnet discovery from webfinger"); return Ok(TailnetDiscovery { domain, provider: inferred_provider(Some(&authority), None), @@ -162,6 +173,7 @@ async fn discover_well_known(client: &Client, base_url: &Url) -> Result Res url.query_pairs_mut() .append_pair("resource", &format!("acct:{email}")) .append_pair("rel", TAILNET_DISCOVERY_REL); + debug!(%email, url = %url, "requesting tailnet webfinger document"); let response = client .get(url) diff --git a/burrow/src/daemon/instance.rs b/burrow/src/daemon/instance.rs index 0a23ddc..9b2e138 100644 --- a/burrow/src/daemon/instance.rs +++ b/burrow/src/daemon/instance.rs @@ -8,7 +8,7 @@ use rusqlite::Connection; use tokio::sync::{mpsc, watch, RwLock}; use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status as RspStatus}; -use tracing::warn; +use tracing::{debug, info, warn}; use tun::tokio::TunInterface; use super::{ @@ -16,15 +16,15 @@ use super::{ networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest, State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse, - TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, + TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, TunnelPacket, TunnelStatusResponse, }, - runtime::{ActiveTunnel, ResolvedTunnel}, + runtime::{tailnet_helper_request, ActiveTunnel, ResolvedTunnel}, }; use crate::{ auth::server::tailscale::{ - TailscaleBridgeManager, TailscaleLoginStartRequest as BridgeLoginStartRequest, - TailscaleLoginStatus, + packet_socket_path, TailscaleBridgeManager, + TailscaleLoginStartRequest as BridgeLoginStartRequest, TailscaleLoginStatus, }, control::discovery, daemon::rpc::ServerConfig, @@ -87,11 +87,20 @@ impl DaemonRPCServer { } async fn current_tunnel_configuration(&self) -> Result { - let config = self - .resolve_tunnel() - .await? - .server_config() - .map_err(proc_err)?; + let config = { + let active = self.active_tunnel.read().await; + active + .as_ref() + .map(|tunnel| tunnel.server_config().clone()) + }; + let config = match config { + Some(config) => config, + None => self + .resolve_tunnel() + .await? + .server_config() + .map_err(proc_err)?, + }; Ok(configuration_rsp(config)) } @@ -111,8 +120,18 @@ impl DaemonRPCServer { async fn replace_active_tunnel(&self, desired: ResolvedTunnel) -> Result<(), RspStatus> { let _ = self.stop_active_tunnel().await?; + let tailnet_helper = match &desired { + ResolvedTunnel::Tailnet { identity, config } => Some( + self.tailnet_login + .ensure_session(tailnet_helper_request(identity, config)) + .await + .map_err(proc_err)? + .helper, + ), + _ => None, + }; let active = desired - .start(self.tun_interface.clone()) + .start(self.tun_interface.clone(), tailnet_helper) .await .map_err(proc_err)?; self.active_tunnel.write().await.replace(active); @@ -137,6 +156,23 @@ impl DaemonRPCServer { Ok(()) } + fn tailnet_bridge_request( + account_name: String, + identity_name: String, + hostname: String, + authority: String, + ) -> BridgeLoginStartRequest { + let mut request = BridgeLoginStartRequest { + account_name, + identity_name, + hostname: (!hostname.trim().is_empty()).then_some(hostname), + control_url: Self::tailnet_control_url(&authority), + packet_socket: None, + }; + request.packet_socket = Some(packet_socket_path(&request).display().to_string()); + request + } + fn tailnet_control_url(authority: &str) -> Option { let authority = discovery::normalize_authority(authority); (!discovery::is_managed_tailscale_authority(&authority)).then_some(authority) @@ -146,6 +182,7 @@ impl DaemonRPCServer { #[tonic::async_trait] impl Tunnel for DaemonRPCServer { type TunnelConfigurationStream = ReceiverStream>; + type TunnelPacketsStream = ReceiverStream>; type TunnelStatusStream = ReceiverStream>; async fn tunnel_configuration( @@ -171,6 +208,62 @@ impl Tunnel for DaemonRPCServer { Ok(Response::new(ReceiverStream::new(rx))) } + async fn tunnel_packets( + &self, + request: Request>, + ) -> Result, RspStatus> { + let (packet_tx, mut packet_rx) = { + let guard = self.active_tunnel.read().await; + let Some(active) = guard.as_ref() else { + return Err(RspStatus::failed_precondition("no active tunnel")); + }; + active.packet_stream().ok_or_else(|| { + RspStatus::failed_precondition( + "active tunnel does not support packet streaming", + ) + })? + }; + + let (tx, rx) = mpsc::channel(128); + tokio::spawn(async move { + loop { + match packet_rx.recv().await { + Ok(payload) => { + if tx.send(Ok(TunnelPacket { payload })).await.is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }); + + let mut inbound = request.into_inner(); + tokio::spawn(async move { + loop { + match inbound.message().await { + Ok(Some(packet)) => { + debug!( + "daemon tunnel packet stream received {} bytes from client", + packet.payload.len() + ); + if packet_tx.send(packet.payload).await.is_err() { + break; + } + } + Ok(None) => break, + Err(error) => { + warn!("tailnet packet stream receive error: {error}"); + break; + } + } + } + }); + + Ok(Response::new(ReceiverStream::new(rx))) + } + async fn tunnel_start(&self, _request: Request) -> Result, RspStatus> { let desired = self.resolve_tunnel().await?; let already_running = { @@ -287,9 +380,16 @@ impl TailnetControl for DaemonRPCServer { request: Request, ) -> Result, RspStatus> { let request = request.into_inner(); + info!(email = %request.email, "daemon tailnet discover RPC received"); let discovery = discovery::discover_tailnet(&request.email) .await .map_err(proc_err)?; + info!( + email = %request.email, + authority = %discovery.authority, + provider = ?discovery.provider, + "daemon tailnet discover RPC resolved" + ); Ok(Response::new(TailnetDiscoverResponse { domain: discovery.domain, @@ -325,17 +425,32 @@ impl TailnetControl for DaemonRPCServer { request: Request, ) -> Result, RspStatus> { let request = request.into_inner(); + info!( + account = %request.account_name, + identity = %request.identity_name, + authority = %request.authority, + "daemon tailnet login start RPC received" + ); let response = self .tailnet_login - .start_login(BridgeLoginStartRequest { - account_name: request.account_name, - identity_name: request.identity_name, - hostname: (!request.hostname.trim().is_empty()).then_some(request.hostname), - control_url: Self::tailnet_control_url(&request.authority), - }) + .start_login(Self::tailnet_bridge_request( + request.account_name, + request.identity_name, + request.hostname, + request.authority, + )) .await .map_err(proc_err)?; + info!( + session_id = %response.session_id, + backend_state = %response.status.backend_state, + running = response.status.running, + needs_login = response.status.needs_login, + auth_url = ?response.status.auth_url, + "daemon tailnet login start RPC resolved" + ); + Ok(Response::new(tailnet_login_rsp( response.session_id, response.status, @@ -347,6 +462,7 @@ impl TailnetControl for DaemonRPCServer { request: Request, ) -> Result, RspStatus> { let request = request.into_inner(); + info!(session_id = %request.session_id, "daemon tailnet login status RPC received"); let status = self .tailnet_login .status(&request.session_id) @@ -355,6 +471,14 @@ impl TailnetControl for DaemonRPCServer { let Some(status) = status else { return Err(RspStatus::not_found("tailnet login session not found")); }; + info!( + session_id = %request.session_id, + backend_state = %status.backend_state, + running = status.running, + needs_login = status.needs_login, + auth_url = ?status.auth_url, + "daemon tailnet login status RPC resolved" + ); Ok(Response::new(tailnet_login_rsp(request.session_id, status))) } @@ -381,8 +505,12 @@ fn proc_err(err: impl ToString) -> RspStatus { fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse { TunnelConfigurationResponse { - mtu: config.mtu.unwrap_or(1000), addresses: config.address, + mtu: config.mtu.unwrap_or(1000), + routes: config.routes, + dns_servers: config.dns_servers, + search_domains: config.search_domains, + include_default_route: config.include_default_route, } } diff --git a/burrow/src/daemon/rpc/response.rs b/burrow/src/daemon/rpc/response.rs index 8948ca4..6d03581 100644 --- a/burrow/src/daemon/rpc/response.rs +++ b/burrow/src/daemon/rpc/response.rs @@ -68,6 +68,14 @@ impl TryFrom<&TunInterface> for ServerInfo { #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct ServerConfig { pub address: Vec, + #[serde(default)] + pub routes: Vec, + #[serde(default)] + pub dns_servers: Vec, + #[serde(default)] + pub search_domains: Vec, + #[serde(default)] + pub include_default_route: bool, pub name: Option, pub mtu: Option, } @@ -78,6 +86,14 @@ impl TryFrom<&Config> for ServerConfig { fn try_from(config: &Config) -> anyhow::Result { Ok(ServerConfig { address: config.interface.address.clone(), + routes: config + .peers + .iter() + .flat_map(|peer| peer.allowed_ips.iter().cloned()) + .collect(), + dns_servers: config.interface.dns.clone(), + search_domains: Vec::new(), + include_default_route: false, name: None, mtu: config.interface.mtu.map(|mtu| mtu as i32), }) @@ -88,6 +104,10 @@ impl Default for ServerConfig { fn default() -> Self { Self { address: vec!["10.13.13.2".to_string()], // Dummy remote address + routes: Vec::new(), + dns_servers: Vec::new(), + search_domains: Vec::new(), + include_default_route: false, name: None, mtu: None, } diff --git a/burrow/src/daemon/runtime.rs b/burrow/src/daemon/runtime.rs index 84dfd2b..31821a2 100644 --- a/burrow/src/daemon/runtime.rs +++ b/burrow/src/daemon/runtime.rs @@ -1,7 +1,13 @@ -use std::sync::Arc; +use std::{path::PathBuf, sync::Arc}; -use anyhow::{Context, Result}; -use tokio::{sync::RwLock, task::JoinHandle}; +use anyhow::{bail, Context, Result}; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + net::UnixStream, + sync::{broadcast, mpsc, RwLock}, + task::JoinHandle, + time::{sleep, Duration}, +}; use tun::{tokio::TunInterface, TunOptions}; use super::rpc::{ @@ -9,7 +15,11 @@ use super::rpc::{ ServerConfig, }; use crate::{ - control::TailnetConfig, + auth::server::tailscale::{ + default_hostname, packet_socket_path, spawn_tailscale_helper, TailscaleHelperProcess, + TailscaleLoginStartRequest, TailscaleLoginStatus, + }, + control::{discovery, TailnetConfig}, wireguard::{Config, Interface as WireGuardInterface}, }; @@ -78,11 +88,19 @@ impl ResolvedTunnel { match self { Self::Passthrough { .. } => Ok(ServerConfig { address: Vec::new(), + routes: Vec::new(), + dns_servers: Vec::new(), + search_domains: Vec::new(), + include_default_route: false, name: None, mtu: Some(1500), }), Self::Tailnet { .. } => Ok(ServerConfig { address: Vec::new(), + routes: tailnet_routes(), + dns_servers: tailnet_dns_servers(), + search_domains: Vec::new(), + include_default_route: false, name: None, mtu: Some(1280), }), @@ -93,21 +111,71 @@ impl ResolvedTunnel { pub async fn start( self, tun_interface: Arc>>, + tailnet_helper: Option>, ) -> Result { match self { - Self::Passthrough { identity } => Ok(ActiveTunnel::Passthrough { identity }), - Self::Tailnet { config, .. } => Err(anyhow::anyhow!( - "tailnet runtime is not wired in this checkout yet ({:?})", - config.provider - )), + Self::Passthrough { identity } => Ok(ActiveTunnel::Passthrough { + identity, + server_config: ServerConfig { + address: Vec::new(), + routes: Vec::new(), + dns_servers: Vec::new(), + search_domains: Vec::new(), + include_default_route: false, + name: None, + mtu: Some(1500), + }, + }), + Self::Tailnet { identity, config } => { + let (helper, shutdown_helper_on_stop) = match tailnet_helper { + Some(helper) => (helper, false), + None => { + let helper_request = tailnet_helper_request(&identity, &config); + let helper = Arc::new(spawn_tailscale_helper(&helper_request).await?); + (helper, true) + } + }; + let status = wait_for_tailnet_ready(helper.as_ref()).await?; + let server_config = tailnet_server_config(&status); + let packet_socket = helper + .packet_socket() + .map(PathBuf::from) + .ok_or_else(|| anyhow::anyhow!("tailnet helper did not report a packet socket"))?; + let packet_bridge = connect_tailnet_packet_bridge(packet_socket).await?; + #[cfg(target_vendor = "apple")] + let tun_task = None; + #[cfg(not(target_vendor = "apple"))] + let tun_task = { + let tun = TunOptions::new().open()?; + tun_interface.write().await.replace(tun); + Some(tokio::spawn(run_tailnet_tun_bridge( + tun_interface.clone(), + packet_bridge.outbound_sender(), + packet_bridge.subscribe(), + ))) + }; + + Ok(ActiveTunnel::Tailnet { + identity, + server_config, + helper, + shutdown_helper_on_stop, + packet_bridge, + tun_task, + }) + } Self::WireGuard { identity, config } => { + let server_config = ServerConfig::try_from(&config)?; let tun = TunOptions::new().open()?; tun_interface.write().await.replace(tun); match start_wireguard_runtime(config, tun_interface.clone()).await { - Ok((interface, task)) => { - Ok(ActiveTunnel::WireGuard { identity, interface, task }) - } + Ok((interface, task)) => Ok(ActiveTunnel::WireGuard { + identity, + server_config, + interface, + task, + }), Err(err) => { tun_interface.write().await.take(); Err(err) @@ -121,9 +189,19 @@ impl ResolvedTunnel { pub enum ActiveTunnel { Passthrough { identity: RuntimeIdentity, + server_config: ServerConfig, + }, + Tailnet { + identity: RuntimeIdentity, + server_config: ServerConfig, + helper: Arc, + shutdown_helper_on_stop: bool, + packet_bridge: TailnetPacketBridge, + tun_task: Option>>, }, WireGuard { identity: RuntimeIdentity, + server_config: ServerConfig, interface: Arc>, task: JoinHandle>, }, @@ -132,15 +210,69 @@ pub enum ActiveTunnel { impl ActiveTunnel { pub fn identity(&self) -> &RuntimeIdentity { match self { - Self::Passthrough { identity } + Self::Passthrough { identity, .. } + | Self::Tailnet { identity, .. } | Self::WireGuard { identity, .. } => identity, } } + pub fn server_config(&self) -> &ServerConfig { + match self { + Self::Passthrough { server_config, .. } + | Self::Tailnet { server_config, .. } + | Self::WireGuard { server_config, .. } => server_config, + } + } + + pub fn packet_stream( + &self, + ) -> Option<(mpsc::Sender>, broadcast::Receiver>)> { + match self { + Self::Tailnet { packet_bridge, .. } => Some(( + packet_bridge.outbound_sender(), + packet_bridge.subscribe(), + )), + _ => None, + } + } + pub async fn shutdown(self, tun_interface: &Arc>>) -> Result<()> { match self { Self::Passthrough { .. } => Ok(()), - Self::WireGuard { interface, task, .. } => { + Self::Tailnet { + helper, + shutdown_helper_on_stop, + packet_bridge, + tun_task, + .. + } => { + if let Some(tun_task) = tun_task { + tun_task.abort(); + match tun_task.await { + Ok(Ok(())) => {} + Ok(Err(err)) => return Err(err), + Err(err) if err.is_cancelled() => {} + Err(err) => return Err(err.into()), + } + } + packet_bridge.task.abort(); + match packet_bridge.task.await { + Ok(Ok(())) => {} + Ok(Err(err)) => return Err(err), + Err(err) if err.is_cancelled() => {} + Err(err) => return Err(err.into()), + } + tun_interface.write().await.take(); + if shutdown_helper_on_stop { + helper.shutdown().await?; + } + Ok(()) + } + Self::WireGuard { + interface, + task, + .. + } => { interface.read().await.remove_tun().await; let task_result = task.await; tun_interface.write().await.take(); @@ -151,6 +283,22 @@ impl ActiveTunnel { } } +pub struct TailnetPacketBridge { + outbound: mpsc::Sender>, + inbound: broadcast::Sender>, + task: JoinHandle>, +} + +impl TailnetPacketBridge { + fn outbound_sender(&self) -> mpsc::Sender> { + self.outbound.clone() + } + + fn subscribe(&self) -> broadcast::Receiver> { + self.inbound.subscribe() + } +} + async fn start_wireguard_runtime( config: Config, tun_interface: Arc>>, @@ -166,6 +314,279 @@ async fn start_wireguard_runtime( Ok((interface, task)) } +pub(crate) fn tailnet_helper_request( + identity: &RuntimeIdentity, + config: &TailnetConfig, +) -> TailscaleLoginStartRequest { + let account_name = config + .account + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("default") + .to_owned(); + let identity_name = config + .identity + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| match identity { + RuntimeIdentity::Network { id, .. } => format!("network-{id}"), + RuntimeIdentity::Passthrough => "apple".to_owned(), + }); + let control_url = config.authority.as_deref().and_then(|authority| { + let authority = discovery::normalize_authority(authority); + (!discovery::is_managed_tailscale_authority(&authority)).then_some(authority) + }); + + let mut request = TailscaleLoginStartRequest { + account_name, + identity_name, + hostname: config.hostname.clone(), + control_url, + packet_socket: None, + }; + request.packet_socket = Some(packet_socket_path(&request).display().to_string()); + if request + .hostname + .as_deref() + .map(|value| value.trim().is_empty()) + .unwrap_or(true) + { + request.hostname = Some(default_hostname(&request)); + } + request +} + +async fn wait_for_tailnet_ready(helper: &TailscaleHelperProcess) -> Result { + let mut last_status = None; + for _ in 0..120 { + let status = helper.status().await?; + if status.running && !status.tailscale_ips.is_empty() { + return Ok(status); + } + if status.needs_login || status.auth_url.is_some() { + bail!("tailnet runtime requires a completed login before the tunnel can start"); + } + last_status = Some(status); + sleep(Duration::from_millis(250)).await; + } + + if let Some(status) = last_status { + bail!( + "tailnet helper never became ready (backend_state={})", + status.backend_state + ); + } + bail!("tailnet helper never produced a status update") +} + +fn tailnet_server_config(status: &TailscaleLoginStatus) -> ServerConfig { + let mut search_domains = Vec::new(); + if let Some(suffix) = status.magic_dns_suffix.as_deref() { + let suffix = suffix.trim().trim_end_matches('.'); + if !suffix.is_empty() { + search_domains.push(suffix.to_owned()); + } + } + + ServerConfig { + address: status + .tailscale_ips + .iter() + .map(|ip| tailnet_cidr(ip)) + .collect(), + routes: tailnet_routes(), + dns_servers: tailnet_dns_servers(), + search_domains, + include_default_route: false, + name: status.self_dns_name.clone(), + mtu: Some(1280), + } +} + +fn tailnet_routes() -> Vec { + vec!["100.64.0.0/10".to_owned(), "fd7a:115c:a1e0::/48".to_owned()] +} + +fn tailnet_dns_servers() -> Vec { + vec!["100.100.100.100".to_owned()] +} + +fn tailnet_cidr(ip: &str) -> String { + if ip.contains('/') { + return ip.to_owned(); + } + if ip.contains(':') { + format!("{ip}/128") + } else { + format!("{ip}/32") + } +} + +async fn connect_tailnet_packet_bridge(packet_socket: PathBuf) -> Result { + let mut last_error = None; + let mut stream = None; + for _ in 0..50 { + match UnixStream::connect(&packet_socket).await { + Ok(connected) => { + stream = Some(connected); + break; + } + Err(err) => { + last_error = Some(err); + sleep(Duration::from_millis(100)).await; + } + } + } + let stream = if let Some(stream) = stream { + stream + } else { + return Err(last_error + .context("failed to connect to tailnet helper packet socket")? + .into()); + }; + + let (outbound_tx, outbound_rx) = mpsc::channel(128); + let (inbound_tx, _) = broadcast::channel(128); + let task = tokio::spawn(run_tailnet_socket_bridge( + stream, + outbound_rx, + inbound_tx.clone(), + )); + + Ok(TailnetPacketBridge { + outbound: outbound_tx, + inbound: inbound_tx, + task, + }) +} + +async fn run_tailnet_socket_bridge( + stream: UnixStream, + mut outbound_rx: mpsc::Receiver>, + inbound_tx: broadcast::Sender>, +) -> Result<()> { + let (mut reader, mut writer) = stream.into_split(); + + let inbound = tokio::spawn(async move { + loop { + let packet = read_packet_frame(&mut reader).await?; + tracing::debug!( + "tailnet packet bridge received {} bytes from helper socket", + packet.len() + ); + let _ = inbound_tx.send(packet); + } + #[allow(unreachable_code)] + Result::<()>::Ok(()) + }); + + let outbound = tokio::spawn(async move { + while let Some(packet) = outbound_rx.recv().await { + tracing::debug!( + "tailnet packet bridge writing {} bytes to helper socket", + packet.len() + ); + write_packet_frame(&mut writer, &packet).await?; + } + Result::<()>::Ok(()) + }); + + let (inbound_result, outbound_result) = tokio::try_join!(inbound, outbound)?; + inbound_result?; + outbound_result?; + Ok(()) +} + +#[cfg(not(target_vendor = "apple"))] +async fn run_tailnet_tun_bridge( + tun_interface: Arc>>, + outbound_tx: mpsc::Sender>, + mut inbound_rx: broadcast::Receiver>, +) -> Result<()> { + let inbound_tun = tun_interface.clone(); + let inbound = tokio::spawn(async move { + loop { + let packet = match inbound_rx.recv().await { + Ok(packet) => packet, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + }; + let guard = inbound_tun.read().await; + let Some(tun) = guard.as_ref() else { + bail!("tailnet tun interface unavailable"); + }; + tun.send(&packet) + .await + .context("failed to write tailnet packet to tun")?; + } + Result::<()>::Ok(()) + }); + + let outbound_tun = tun_interface.clone(); + let outbound = tokio::spawn(async move { + let mut buf = vec![0u8; 65_535]; + loop { + let len = { + let guard = outbound_tun.read().await; + let Some(tun) = guard.as_ref() else { + bail!("tailnet tun interface unavailable"); + }; + tun.recv(&mut buf) + .await + .context("failed to read packet from tailnet tun")? + }; + outbound_tx + .send(buf[..len].to_vec()) + .await + .context("failed to forward packet to tailnet helper")?; + } + #[allow(unreachable_code)] + Result::<()>::Ok(()) + }); + + let (inbound_result, outbound_result) = tokio::try_join!(inbound, outbound)?; + inbound_result?; + outbound_result?; + Ok(()) +} + +async fn read_packet_frame(reader: &mut R) -> Result> +where + R: AsyncRead + Unpin, +{ + let mut len_buf = [0u8; 4]; + reader + .read_exact(&mut len_buf) + .await + .context("failed to read tailnet packet frame length")?; + let len = u32::from_be_bytes(len_buf) as usize; + let mut packet = vec![0u8; len]; + reader + .read_exact(&mut packet) + .await + .context("failed to read tailnet packet frame payload")?; + Ok(packet) +} + +async fn write_packet_frame(writer: &mut W, packet: &[u8]) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + writer + .write_all(&(packet.len() as u32).to_be_bytes()) + .await + .context("failed to write tailnet packet frame length")?; + writer + .write_all(packet) + .await + .context("failed to write tailnet packet frame payload")?; + writer + .flush() + .await + .context("failed to flush tailnet packet frame") +} + #[cfg(test)] mod tests { use super::*; @@ -179,4 +600,19 @@ mod tests { Vec::::new() ); } + + #[test] + fn tailnet_server_config_uses_host_prefixes() { + let status = TailscaleLoginStatus { + running: true, + tailscale_ips: vec!["100.101.102.103".to_owned(), "fd7a:115c:a1e0::123".to_owned()], + ..Default::default() + }; + let config = tailnet_server_config(&status); + assert_eq!( + config.address, + vec!["100.101.102.103/32", "fd7a:115c:a1e0::123/128"] + ); + assert_eq!(config.mtu, Some(1280)); + } } diff --git a/burrow/src/tracing.rs b/burrow/src/tracing.rs index 21e16ae..8a245ef 100644 --- a/burrow/src/tracing.rs +++ b/burrow/src/tracing.rs @@ -47,10 +47,16 @@ pub fn initialize() { #[cfg(target_os = "macos")] let subscriber = { - let system_log = Some(tracing_oslog::OsLogger::new( - "com.hackclub.burrow", - "tracing", - )); + // `tracing_oslog` is crashing under Tokio/h2 span churn in the host daemon on + // current macOS. Keep logging on stderr by default and allow opt-in OSLog + // only when explicitly requested for local debugging. + let enable_oslog = matches!( + std::env::var("BURROW_ENABLE_OSLOG").as_deref(), + Ok("1" | "true" | "TRUE" | "yes" | "YES") + ); + let system_log = enable_oslog.then(|| { + tracing_oslog::OsLogger::new("com.hackclub.burrow", "tracing") + }); let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr); Registry::default().with(stderr).with(system_log) }; diff --git a/proto/burrow.proto b/proto/burrow.proto index a590cb1..ed1f89e 100644 --- a/proto/burrow.proto +++ b/proto/burrow.proto @@ -5,6 +5,7 @@ import "google/protobuf/timestamp.proto"; service Tunnel { rpc TunnelConfiguration (Empty) returns (stream TunnelConfigurationResponse); + rpc TunnelPackets (stream TunnelPacket) returns (stream TunnelPacket); rpc TunnelStart (Empty) returns (Empty); rpc TunnelStop (Empty) returns (Empty); rpc TunnelStatus (Empty) returns (stream TunnelStatusResponse); @@ -128,4 +129,12 @@ message TunnelStatusResponse { message TunnelConfigurationResponse { repeated string addresses = 1; int32 mtu = 2; + repeated string routes = 3; + repeated string dns_servers = 4; + repeated string search_domains = 5; + bool include_default_route = 6; +} + +message TunnelPacket { + bytes payload = 1; } From e40a947223e8dce37ca20665262d1d239d010301 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 5 Apr 2026 20:52:52 -0700 Subject: [PATCH 063/102] Add forge-owned Namespace auth portal --- .../authentik-sync-namespace-portal-oidc.sh | 246 +++++ Scripts/check-forge-host.sh | 4 + ...c__response__response_serialization-4.snap | 2 +- burrow/src/main.rs | 38 +- burrow/src/namespace_portal.rs | 880 ++++++++++++++++++ flake.nix | 32 + nixos/README.md | 11 +- nixos/hosts/burrow-forge/default.nix | 12 +- nixos/modules/burrow-authentik.nix | 75 ++ nixos/modules/burrow-namespace-portal.nix | 126 +++ 10 files changed, 1403 insertions(+), 23 deletions(-) create mode 100644 Scripts/authentik-sync-namespace-portal-oidc.sh create mode 100644 burrow/src/namespace_portal.rs create mode 100644 nixos/modules/burrow-namespace-portal.nix diff --git a/Scripts/authentik-sync-namespace-portal-oidc.sh b/Scripts/authentik-sync-namespace-portal-oidc.sh new file mode 100644 index 0000000..a62b0cf --- /dev/null +++ b/Scripts/authentik-sync-namespace-portal-oidc.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +application_slug="${AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_SLUG:-namespace}" +application_name="${AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_NAME:-Namespace Portal}" +provider_name="${AUTHENTIK_NAMESPACE_PORTAL_PROVIDER_NAME:-Namespace Portal}" +template_slug="${AUTHENTIK_NAMESPACE_PORTAL_TEMPLATE_SLUG:-ts}" +client_id="${AUTHENTIK_NAMESPACE_PORTAL_CLIENT_ID:-nsc.burrow.net}" +client_secret="${AUTHENTIK_NAMESPACE_PORTAL_CLIENT_SECRET:-}" +launch_url="${AUTHENTIK_NAMESPACE_PORTAL_LAUNCH_URL:-https://nsc.burrow.net/}" +redirect_uris_json="${AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON:-[ + \"https://nsc.burrow.net/oauth/callback\" +]}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-namespace-portal-oidc.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_SLUG + AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_NAME + AUTHENTIK_NAMESPACE_PORTAL_PROVIDER_NAME + AUTHENTIK_NAMESPACE_PORTAL_TEMPLATE_SLUG + AUTHENTIK_NAMESPACE_PORTAL_CLIENT_ID + AUTHENTIK_NAMESPACE_PORTAL_CLIENT_SECRET + AUTHENTIK_NAMESPACE_PORTAL_LAUNCH_URL + AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ -z "$bootstrap_token" ]]; then + echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 + exit 1 +fi + +if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then + echo "error: AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON must be a non-empty JSON array" >&2 + exit 1 +fi + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + + if [[ -n "$data" ]]; then + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + else + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + fi +} + +api_with_status() { + local method="$1" + local path="$2" + local data="${3:-}" + local response_file status + + response_file="$(mktemp)" + trap 'rm -f "$response_file"' RETURN + + if [[ -n "$data" ]]; then + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + )" + else + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + )" + fi + + printf '%s\n' "$status" + cat "$response_file" +} + +wait_for_authentik() { + for _ in $(seq 1 90); do + if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +wait_for_authentik + +template_provider="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -c --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \ + | head -n1 +)" + +if [[ -z "$template_provider" ]]; then + echo "error: could not resolve the Authentik OAuth provider template ${template_slug}" >&2 + exit 1 +fi + +authorization_flow="$(printf '%s\n' "$template_provider" | jq -r '.authorization_flow')" +invalidation_flow="$(printf '%s\n' "$template_provider" | jq -r '.invalidation_flow')" +property_mappings="$(printf '%s\n' "$template_provider" | jq -c '.property_mappings')" +signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')" + +provider_payload="$( + jq -n \ + --arg name "$provider_name" \ + --arg authorization_flow "$authorization_flow" \ + --arg invalidation_flow "$invalidation_flow" \ + --arg client_id "$client_id" \ + --arg client_secret "$client_secret" \ + --arg signing_key "$signing_key" \ + --argjson property_mappings "$property_mappings" \ + --argjson redirect_uris "$redirect_uris_json" \ + '{ + name: $name, + authorization_flow: $authorization_flow, + invalidation_flow: $invalidation_flow, + client_type: (if $client_secret == "" then "public" else "confidential" end), + client_id: $client_id, + include_claims_in_id_token: true, + redirect_uris: ($redirect_uris | map({matching_mode: "strict", url: .})), + property_mappings: $property_mappings, + signing_key: $signing_key, + issuer_mode: "per_provider", + sub_mode: "hashed_user_id" + } + + (if $client_secret == "" then {} else {client_secret: $client_secret} end)' +)" + +existing_provider="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -c \ + --arg application_slug "$application_slug" \ + --arg provider_name "$provider_name" \ + '.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \ + | head -n1 +)" + +if [[ -n "$existing_provider" ]]; then + provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')" + api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$provider_payload" >/dev/null +else + provider_pk="$( + api POST "/api/v3/providers/oauth2/" "$provider_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "${provider_pk:-}" ]]; then + echo "error: Namespace portal OIDC provider did not return a primary key" >&2 + exit 1 +fi + +application_payload="$( + jq -n \ + --arg name "$application_name" \ + --arg slug "$application_slug" \ + --arg provider "$provider_pk" \ + --arg launch_url "$launch_url" \ + '{ + name: $name, + slug: $slug, + provider: ($provider | tonumber), + meta_launch_url: $launch_url, + open_in_new_tab: false, + policy_engine_mode: "any" + }' +)" + +existing_application="$( + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \ + | head -n1 +)" + +if [[ -n "$existing_application" ]]; then + application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" +else + create_application_result="$( + api_with_status POST "/api/v3/core/applications/" "$application_payload" + )" + create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')" + create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')" + + if [[ "$create_application_status" =~ ^20[01]$ ]]; then + application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')" + elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e ' + (.slug // [] | index("Application with this slug already exists.")) != null + or (.provider // [] | index("Application with this provider already exists.")) != null + ' >/dev/null; then + application_pk="existing-duplicate" + else + printf '%s\n' "$create_application_body" >&2 + echo "error: could not reconcile Authentik application ${application_slug}" >&2 + exit 1 + fi +fi + +if [[ -z "${application_pk:-}" ]]; then + echo "error: Namespace portal OIDC application did not return a primary key" >&2 + exit 1 +fi + +for _ in $(seq 1 30); do + if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then + echo "Synced Authentik Namespace portal OIDC application ${application_slug} (${application_name})." + exit 0 + fi + sleep 2 +done + +echo "warning: Namespace portal OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2 +echo "Synced Authentik Namespace portal OIDC application ${application_slug} (${application_name})." diff --git a/Scripts/check-forge-host.sh b/Scripts/check-forge-host.sh index f4d646d..d824f6d 100755 --- a/Scripts/check-forge-host.sh +++ b/Scripts/check-forge-host.sh @@ -84,6 +84,7 @@ base_services=( nsc_services=( forgejo-nsc-dispatcher.service forgejo-nsc-autoscaler.service + burrow-namespace-portal.service ) tailnet_services=( @@ -173,5 +174,8 @@ if command -v curl >/dev/null 2>&1; then curl -fsS -o /dev/null -H 'Host: auth.burrow.net' -w 'authentik_ready %{http_code}\n' http://127.0.0.1/-/health/ready/ curl -sS -o /dev/null -H 'Host: ts.burrow.net' -w 'headscale_root %{http_code}\n' http://127.0.0.1/ || true fi + if [[ "${EXPECT_NSC}" == "1" ]]; then + curl -fsS -o /dev/null -H 'Host: nsc.burrow.net' -w 'namespace_portal %{http_code}\n' http://127.0.0.1/ + fi fi EOF diff --git a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap index c40db25..68b4195 100644 --- a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap +++ b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap @@ -2,4 +2,4 @@ source: burrow/src/daemon/rpc/response.rs expression: "serde_json::to_string(&DaemonResponse::new(Ok::(DaemonResponseData::ServerConfig(ServerConfig::default()))))?" --- -{"result":{"Ok":{"type":"ServerConfig","address":["10.13.13.2"],"name":null,"mtu":null}},"id":0} +{"result":{"Ok":{"type":"ServerConfig","address":["10.13.13.2"],"routes":[],"dns_servers":[],"search_domains":[],"include_default_route":false,"name":null,"mtu":null}},"id":0} diff --git a/burrow/src/main.rs b/burrow/src/main.rs index 4ab7700..01591e7 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -5,6 +5,8 @@ use clap::{Args, Parser, Subcommand}; mod control; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod daemon; +#[cfg(target_os = "linux")] +mod namespace_portal; pub(crate) mod tracing; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod wireguard; @@ -60,6 +62,12 @@ enum Commands { ReloadConfig(ReloadConfigArgs), /// Authentication server AuthServer, + #[cfg(target_os = "linux")] + /// Admin portal for forge-owned Namespace authentication and NSC token minting + NamespacePortal, + #[cfg(target_os = "linux")] + /// Refresh the forge-owned Namespace dev token once + NamespaceRefreshToken, /// Server Status ServerStatus, /// Tunnel Config @@ -283,9 +291,7 @@ async fn try_tailnet_discover(email: &str) -> Result<()> { let mut client = BurrowClient::from_uds().await?; let response = client .tailnet_client - .discover(crate::daemon::rpc::grpc_defs::TailnetDiscoverRequest { - email: email.to_owned(), - }) + .discover(crate::daemon::rpc::grpc_defs::TailnetDiscoverRequest { email: email.to_owned() }) .await? .into_inner(); println!("Tailnet Discover Response: {:?}", response); @@ -370,13 +376,9 @@ async fn try_tailnet_ping(remote: &str, payload: &str, timeout_ms: u64) -> Resul "tailnet ping received {} bytes from daemon packet stream", packet.payload.len() ); - if let Some(reply) = parse_icmp_echo_reply( - &packet.payload, - local_ip, - remote_ip, - identifier, - sequence, - )? { + if let Some(reply) = + parse_icmp_echo_reply(&packet.payload, local_ip, remote_ip, identifier, sequence)? + { break Ok::<_, anyhow::Error>(reply); } } @@ -464,8 +466,7 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R let egress_task = tokio::spawn(async move { while let Some(packet) = stack_stream.next().await { - let payload = - packet.context("failed to read outbound packet from userspace stack")?; + let payload = packet.context("failed to read outbound packet from userspace stack")?; log::debug!( "tailnet udp echo sending {} bytes into daemon packet stream", payload.len() @@ -484,9 +485,7 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R .send((message.as_bytes().to_vec(), local_addr, remote_addr)) .await .context("failed to send UDP echo probe into userspace stack")?; - log::debug!( - "tailnet udp echo probe queued from {local_addr} to {remote_addr}" - ); + log::debug!("tailnet udp echo probe queued from {local_addr} to {remote_addr}"); let response = timeout(Duration::from_millis(timeout_ms), udp_reader.next()) .await @@ -516,7 +515,10 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R } #[cfg(any(target_os = "linux", target_vendor = "apple"))] -fn select_tailnet_local_ip(addresses: &[String], remote_ip: std::net::IpAddr) -> Result { +fn select_tailnet_local_ip( + addresses: &[String], + remote_ip: std::net::IpAddr, +) -> Result { use anyhow::Context; let family_is_v4 = remote_ip.is_ipv4(); @@ -765,6 +767,10 @@ async fn main() -> Result<()> { Commands::ServerConfig => try_serverconfig().await?, Commands::ReloadConfig(args) => try_reloadconfig(args.interface_id.clone()).await?, Commands::AuthServer => crate::auth::server::serve().await?, + #[cfg(target_os = "linux")] + Commands::NamespacePortal => crate::namespace_portal::serve().await?, + #[cfg(target_os = "linux")] + Commands::NamespaceRefreshToken => crate::namespace_portal::refresh_token_once().await?, Commands::ServerStatus => try_serverstatus().await?, Commands::TunnelConfig => try_tun_config().await?, Commands::NetworkAdd(args) => { diff --git a/burrow/src/namespace_portal.rs b/burrow/src/namespace_portal.rs new file mode 100644 index 0000000..eb20775 --- /dev/null +++ b/burrow/src/namespace_portal.rs @@ -0,0 +1,880 @@ +#![cfg(target_os = "linux")] + +use std::{ + collections::HashMap, + env, fs, + path::{Path, PathBuf}, + process::Stdio, + sync::Arc, + time::{Duration, Instant}, +}; + +use anyhow::{anyhow, bail, Context, Result}; +use axum::{ + extract::{Query, State}, + http::{ + header::{COOKIE, LOCATION, SET_COOKIE}, + HeaderMap, HeaderValue, StatusCode, + }, + response::{Html, IntoResponse, Redirect, Response}, + routing::{get, post}, + Router, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use rand::RngCore; +use reqwest::Url; +use ring::digest::{digest, SHA256}; +use serde::Deserialize; +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + process::Command, + sync::Mutex, +}; + +const SESSION_COOKIE: &str = "burrow_namespace_portal_session"; +const OIDC_TIMEOUT: Duration = Duration::from_secs(600); +const AUTH_CHECK_DURATION: &str = "10m"; + +#[derive(Clone, Debug)] +pub struct NamespacePortalConfig { + pub listen: String, + pub public_base_url: String, + pub oidc_discovery_url: String, + pub oidc_client_id: String, + pub oidc_client_secret: Option, + pub allowed_group: String, + pub nsc_bin: String, + pub nsc_state_dir: PathBuf, + pub token_output_path: PathBuf, +} + +impl Default for NamespacePortalConfig { + fn default() -> Self { + Self { + listen: "127.0.0.1:9080".to_owned(), + public_base_url: "https://nsc.burrow.net".to_owned(), + oidc_discovery_url: + "https://auth.burrow.net/application/o/namespace/.well-known/openid-configuration" + .to_owned(), + oidc_client_id: "nsc.burrow.net".to_owned(), + oidc_client_secret: None, + allowed_group: "burrow-admins".to_owned(), + nsc_bin: "nsc".to_owned(), + nsc_state_dir: PathBuf::from("/var/lib/burrow/namespace-portal/nsc"), + token_output_path: PathBuf::from("/var/lib/burrow/intake/forgejo_nsc_token.txt"), + } + } +} + +impl NamespacePortalConfig { + pub fn from_env() -> Self { + let mut config = Self::default(); + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_LISTEN") { + config.listen = value; + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_BASE_URL") { + config.public_base_url = value; + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_OIDC_DISCOVERY_URL") { + config.oidc_discovery_url = value; + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_ID") { + config.oidc_client_id = value; + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_SECRET") { + let value = value.trim().to_owned(); + if !value.is_empty() { + config.oidc_client_secret = Some(value); + } + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_ALLOWED_GROUP") { + config.allowed_group = value; + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_NSC_BIN") { + config.nsc_bin = value; + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_NSC_STATE_DIR") { + config.nsc_state_dir = PathBuf::from(value); + } + if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_TOKEN_OUTPUT_PATH") { + config.token_output_path = PathBuf::from(value); + } + config + } + + fn callback_url(&self) -> Result { + let mut url = Url::parse(&self.public_base_url) + .with_context(|| format!("invalid public base url {}", self.public_base_url))?; + url.set_path("/oauth/callback"); + url.set_query(None); + Ok(url.to_string()) + } + + fn ensure_paths(&self) -> Result<()> { + fs::create_dir_all(&self.nsc_state_dir).with_context(|| { + format!( + "failed to create namespace portal state dir {}", + self.nsc_state_dir.display() + ) + })?; + if let Some(parent) = self.token_output_path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("failed to create token output dir {}", parent.display()) + })?; + } + Ok(()) + } +} + +#[derive(Clone)] +struct AppState { + config: NamespacePortalConfig, + client: reqwest::Client, + oidc: OidcDiscovery, + pending_logins: Arc>>, + sessions: Arc>>, + namespace: NamespaceSessionManager, +} + +#[derive(Clone, Debug, Deserialize)] +struct OidcDiscovery { + authorization_endpoint: String, + token_endpoint: String, + userinfo_endpoint: String, +} + +#[derive(Clone, Debug)] +struct PendingOidcLogin { + verifier: String, + expires_at: Instant, +} + +#[derive(Clone, Debug)] +struct PortalSession { + email: String, + display_name: String, + groups: Vec, + issued_at: Instant, +} + +#[derive(Debug, Deserialize)] +struct OidcCallbackQuery { + code: Option, + state: Option, + error: Option, + error_description: Option, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, +} + +#[derive(Debug, Deserialize)] +struct UserInfo { + #[serde(default)] + email: String, + #[serde(default)] + name: String, + #[serde(default)] + preferred_username: String, + #[serde(default)] + groups: Vec, +} + +#[derive(Clone)] +struct NamespaceSessionManager { + config: NamespacePortalConfig, + state: Arc>, +} + +#[derive(Clone, Debug, Default)] +struct NamespacePortalState { + active_login: Option, + last_error: Option, +} + +#[derive(Clone, Debug)] +struct ActiveNamespaceLogin { + login_url: String, +} + +#[derive(Clone, Debug)] +struct NamespaceStatus { + linked: bool, + login_url: Option, + last_error: Option, + token_present: bool, +} + +pub async fn serve() -> Result<()> { + serve_with_config(NamespacePortalConfig::from_env()).await +} + +pub async fn refresh_token_once() -> Result<()> { + let config = NamespacePortalConfig::from_env(); + config.ensure_paths()?; + NamespaceSessionManager::new(config).refresh_token().await +} + +pub async fn serve_with_config(config: NamespacePortalConfig) -> Result<()> { + config.ensure_paths()?; + let oidc = fetch_oidc_discovery(&config.oidc_discovery_url).await?; + let listen = config.listen.clone(); + let app = Router::new() + .route("/", get(index)) + .route("/healthz", get(healthz)) + .route("/login", get(oidc_login)) + .route("/logout", post(logout)) + .route("/oauth/callback", get(oidc_callback)) + .route("/namespace/link/start", post(namespace_link_start)) + .route("/namespace/token/refresh", post(namespace_token_refresh)) + .with_state(AppState { + config: config.clone(), + client: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?, + oidc, + pending_logins: Arc::new(Mutex::new(HashMap::new())), + sessions: Arc::new(Mutex::new(HashMap::new())), + namespace: NamespaceSessionManager::new(config), + }); + + let listener = tokio::net::TcpListener::bind(&listen).await?; + log::info!("Starting Namespace portal on {}", listen); + axum::serve(listener, app).await?; + Ok(()) +} + +async fn fetch_oidc_discovery(discovery_url: &str) -> Result { + reqwest::Client::new() + .get(discovery_url) + .send() + .await + .with_context(|| format!("failed to fetch oidc discovery {}", discovery_url))? + .error_for_status() + .with_context(|| format!("oidc discovery returned non-success {}", discovery_url))? + .json() + .await + .context("failed to decode oidc discovery document") +} + +async fn healthz() -> impl IntoResponse { + StatusCode::OK +} + +async fn index(State(state): State, headers: HeaderMap) -> Response { + match current_session(&state, &headers).await { + Ok(Some(session)) => { + let namespace_status = match state.namespace.status().await { + Ok(status) => status, + Err(err) => NamespaceStatus { + linked: false, + login_url: None, + last_error: Some(err.to_string()), + token_present: false, + }, + }; + Html(render_dashboard(&state.config, &session, &namespace_status)).into_response() + } + Ok(None) => Html(render_login_page()).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Html(render_error_page(&format!("session lookup failed: {err}"))), + ) + .into_response(), + } +} + +async fn oidc_login(State(state): State) -> Result { + prune_pending(&state).await; + let state_token = random_url_token(32); + let verifier = random_url_token(48); + let challenge = pkce_challenge(&verifier); + let callback_url = state.config.callback_url().map_err(internal_error)?; + + state.pending_logins.lock().await.insert( + state_token.clone(), + PendingOidcLogin { + verifier, + expires_at: Instant::now() + OIDC_TIMEOUT, + }, + ); + + let mut url = Url::parse(&state.oidc.authorization_endpoint).map_err(internal_error)?; + url.query_pairs_mut() + .append_pair("client_id", &state.config.oidc_client_id) + .append_pair("response_type", "code") + .append_pair("scope", "openid profile email groups") + .append_pair("redirect_uri", &callback_url) + .append_pair("state", &state_token) + .append_pair("code_challenge", &challenge) + .append_pair("code_challenge_method", "S256"); + Ok(Redirect::to(url.as_str())) +} + +async fn oidc_callback( + State(state): State, + Query(query): Query, +) -> Result { + if let Some(error) = query.error { + let description = query.error_description.unwrap_or_default(); + return Err(( + StatusCode::BAD_GATEWAY, + format!("oidc login failed: {error} {description}") + .trim() + .to_owned(), + )); + } + + let code = query + .code + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing oidc code".to_owned()))?; + let state_token = query + .state + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing oidc state".to_owned()))?; + + let verifier = { + let mut pending = state.pending_logins.lock().await; + let Some(login) = pending.remove(&state_token) else { + return Err((StatusCode::BAD_REQUEST, "unknown oidc state".to_owned())); + }; + if login.expires_at <= Instant::now() { + return Err((StatusCode::BAD_REQUEST, "expired oidc state".to_owned())); + } + login.verifier + }; + + let callback_url = state.config.callback_url().map_err(internal_error)?; + + let mut params = vec![ + ("grant_type", "authorization_code".to_owned()), + ("code", code), + ("client_id", state.config.oidc_client_id.clone()), + ("redirect_uri", callback_url), + ("code_verifier", verifier), + ]; + if let Some(secret) = &state.config.oidc_client_secret { + params.push(("client_secret", secret.clone())); + } + + let token = state + .client + .post(&state.oidc.token_endpoint) + .form(¶ms) + .send() + .await + .context("failed to exchange oidc code") + .map_err(internal_error)? + .error_for_status() + .context("oidc token endpoint returned non-success") + .map_err(internal_error)? + .json::() + .await + .context("failed to decode oidc token response") + .map_err(internal_error)?; + + let userinfo = state + .client + .get(&state.oidc.userinfo_endpoint) + .bearer_auth(&token.access_token) + .send() + .await + .context("failed to fetch oidc userinfo") + .map_err(internal_error)? + .error_for_status() + .context("oidc userinfo returned non-success") + .map_err(internal_error)? + .json::() + .await + .context("failed to decode oidc userinfo") + .map_err(internal_error)?; + + if !userinfo + .groups + .iter() + .any(|group| group == &state.config.allowed_group) + { + return Err(( + StatusCode::FORBIDDEN, + format!( + "authenticated user is not in required group {}", + state.config.allowed_group + ), + )); + } + + let session_id = random_url_token(32); + state.sessions.lock().await.insert( + session_id.clone(), + PortalSession { + email: userinfo.email.clone(), + display_name: display_name(&userinfo), + groups: userinfo.groups, + issued_at: Instant::now(), + }, + ); + + let mut response = Redirect::to("/").into_response(); + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_str(&session_cookie_value(&session_id)).map_err(internal_error)?, + ); + Ok(response) +} + +async fn logout( + State(state): State, + headers: HeaderMap, +) -> Result { + if let Some(session_id) = session_cookie(&headers) { + state.sessions.lock().await.remove(&session_id); + } + let mut response = Redirect::to("/").into_response(); + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_static( + "burrow_namespace_portal_session=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", + ), + ); + Ok(response) +} + +async fn namespace_link_start( + State(state): State, + headers: HeaderMap, +) -> Result { + require_session(&state, &headers).await?; + state + .namespace + .start_login() + .await + .map_err(internal_error)?; + Ok(Redirect::to("/")) +} + +async fn namespace_token_refresh( + State(state): State, + headers: HeaderMap, +) -> Result { + require_session(&state, &headers).await?; + state + .namespace + .refresh_token() + .await + .map_err(internal_error)?; + Ok(Redirect::to("/")) +} + +fn render_login_page() -> String { + r#" + + + + + Burrow Namespace Portal + + + +
+

Burrow Namespace Portal

+

Authenticate with burrow.net to manage the dedicated Namespace session that backs Forgejo NSC automation.

+ Sign in with burrow.net +
+ +"# + .to_owned() +} + +fn render_dashboard( + config: &NamespacePortalConfig, + session: &PortalSession, + status: &NamespaceStatus, +) -> String { + let refresh = if status.login_url.is_some() { + r#""# + } else { + "" + }; + let login_action = if let Some(url) = &status.login_url { + format!( + "

Namespace Login In Progress

Open the live Namespace URL below with the dedicated Burrow account. This page will refresh automatically until the server-side session is ready.

Open Namespace Login

", + escape_html(url) + ) + } else if status.linked { + "

Namespace Linked

The forge-owned NSC session is authenticated and ready to mint runner tokens.

".to_owned() + } else { + "

Namespace Not Linked

Start a server-side Namespace login. The portal will produce a Namespace URL, and completing that browser flow will authenticate the forge-owned NSC state directory.

".to_owned() + }; + let error = status + .last_error + .as_ref() + .map(|error| format!("

{}

", escape_html(error))) + .unwrap_or_default(); + let token_state = if status.token_present { + "present" + } else { + "missing" + }; + format!( + r#" + + + + + Burrow Namespace Portal + {refresh} + + + +
+
+
+

Burrow Namespace Portal

+

Signed in as {email}. This page controls the forge-owned NSC session and token material for Forgejo Namespace runners.

+
+
+
+ +
+
+
burrow.net identity
{identity}
+
required group
{group}
+
NSC token file
{token_path}
+
current token
{token_state}
+
+
+ + {login_action} + {error} + +
+

Actions

+
+
+
+
+
+
+ +"#, + refresh = refresh, + email = escape_html(&session.email), + identity = escape_html(&session.display_name), + group = escape_html(&config.allowed_group), + token_path = escape_html(&config.token_output_path.display().to_string()), + token_state = token_state, + login_action = login_action, + error = error, + ) +} + +fn render_error_page(message: &str) -> String { + format!( + r#"

Namespace Portal Error

{}

"#, + escape_html(message) + ) +} + +fn display_name(userinfo: &UserInfo) -> String { + if !userinfo.name.trim().is_empty() { + return userinfo.name.trim().to_owned(); + } + if !userinfo.preferred_username.trim().is_empty() { + return userinfo.preferred_username.trim().to_owned(); + } + userinfo.email.clone() +} + +async fn current_session(state: &AppState, headers: &HeaderMap) -> Result> { + let Some(session_id) = session_cookie(headers) else { + return Ok(None); + }; + Ok(state.sessions.lock().await.get(&session_id).cloned()) +} + +async fn require_session( + state: &AppState, + headers: &HeaderMap, +) -> Result { + current_session(state, headers) + .await + .map_err(internal_error)? + .ok_or_else(|| (StatusCode::UNAUTHORIZED, "sign-in required".to_owned())) +} + +async fn prune_pending(state: &AppState) { + state + .pending_logins + .lock() + .await + .retain(|_, login| login.expires_at > Instant::now()); +} + +fn session_cookie(headers: &HeaderMap) -> Option { + let cookie_header = headers.get(COOKIE)?.to_str().ok()?; + for pair in cookie_header.split(';') { + let mut parts = pair.trim().splitn(2, '='); + let name = parts.next()?.trim(); + let value = parts.next()?.trim(); + if name == SESSION_COOKIE && !value.is_empty() { + return Some(value.to_owned()); + } + } + None +} + +fn session_cookie_value(session_id: &str) -> String { + format!("{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; Secure; SameSite=Lax") +} + +fn random_url_token(bytes: usize) -> String { + let mut buf = vec![0u8; bytes]; + rand::thread_rng().fill_bytes(&mut buf); + URL_SAFE_NO_PAD.encode(buf) +} + +fn pkce_challenge(verifier: &str) -> String { + let digest = digest(&SHA256, verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(digest.as_ref()) +} + +fn escape_html(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn internal_error(err: impl std::fmt::Display) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) +} + +impl NamespaceSessionManager { + fn new(config: NamespacePortalConfig) -> Self { + Self { + config, + state: Arc::new(Mutex::new(NamespacePortalState::default())), + } + } + + async fn status(&self) -> Result { + let linked = self.check_login().await.is_ok(); + let state = self.state.lock().await.clone(); + let token_present = tokio::fs::metadata(&self.config.token_output_path) + .await + .is_ok(); + Ok(NamespaceStatus { + linked, + login_url: state.active_login.map(|login| login.login_url), + last_error: state.last_error, + token_present, + }) + } + + async fn start_login(&self) -> Result { + if self.check_login().await.is_ok() { + self.refresh_token().await?; + return Ok("already linked".to_owned()); + } + + { + let state = self.state.lock().await; + if let Some(active) = &state.active_login { + return Ok(active.login_url.clone()); + } + } + + self.config.ensure_paths()?; + let mut command = self.base_command(); + command + .args(["auth", "login", "--browser=false"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut child = command.spawn().context("failed to spawn nsc auth login")?; + let stdout = child + .stdout + .take() + .context("nsc auth login stdout was not piped")?; + let mut lines = BufReader::new(stdout).lines(); + let mut login_url = None; + while let Some(line) = lines.next_line().await? { + if let Some(candidate) = extract_namespace_login_url(&line) { + login_url = Some(candidate); + break; + } + } + + let login_url = login_url + .ok_or_else(|| anyhow!("nsc auth login did not emit a Namespace login URL"))?; + { + let mut state = self.state.lock().await; + state.active_login = Some(ActiveNamespaceLogin { login_url: login_url.clone() }); + state.last_error = None; + } + + let manager = self.clone(); + tokio::spawn(async move { + let outcome = child.wait().await; + let mut state = manager.state.lock().await; + state.active_login = None; + match outcome { + Ok(status) if status.success() => { + drop(state); + if let Err(err) = manager.refresh_token().await { + manager.state.lock().await.last_error = Some(format!( + "Namespace login finished, but token refresh failed: {err}" + )); + } + } + Ok(status) => { + state.last_error = Some(format!( + "Namespace login command exited with status {}", + status + )); + } + Err(err) => { + state.last_error = Some(format!("Namespace login command failed: {err}")); + } + } + }); + + Ok(login_url) + } + + async fn refresh_token(&self) -> Result<()> { + self.config.ensure_paths()?; + self.check_login().await?; + let mut command = self.base_command(); + command.args([ + "auth", + "generate-dev-token", + "--output_to", + self.config + .token_output_path + .to_str() + .ok_or_else(|| anyhow!("token output path is not valid UTF-8"))?, + ]); + let output = command + .output() + .await + .context("failed to run nsc token refresh")?; + if !output.status.success() { + bail!( + "nsc auth generate-dev-token failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + #[cfg(target_family = "unix")] + { + use std::os::unix::fs::PermissionsExt; + + let perms = fs::Permissions::from_mode(0o440); + fs::set_permissions(&self.config.token_output_path, perms).with_context(|| { + format!( + "failed to set permissions on {}", + self.config.token_output_path.display() + ) + })?; + } + self.state.lock().await.last_error = None; + Ok(()) + } + + async fn check_login(&self) -> Result<()> { + let mut command = self.base_command(); + command.args(["auth", "check-login", "--duration", AUTH_CHECK_DURATION]); + let output = command + .output() + .await + .context("failed to run nsc auth check-login")?; + if output.status.success() { + return Ok(()); + } + bail!("{}", String::from_utf8_lossy(&output.stderr).trim()); + } + + fn base_command(&self) -> Command { + let mut command = Command::new(&self.config.nsc_bin); + let home = self.config.nsc_state_dir.join("home"); + let data = self.config.nsc_state_dir.join("data"); + let cache = self.config.nsc_state_dir.join("cache"); + let config = self.config.nsc_state_dir.join("config"); + let _ = fs::create_dir_all(&home); + let _ = fs::create_dir_all(&data); + let _ = fs::create_dir_all(&cache); + let _ = fs::create_dir_all(&config); + command + .env("HOME", &home) + .env("XDG_DATA_HOME", &data) + .env("XDG_CACHE_HOME", &cache) + .env("XDG_CONFIG_HOME", &config); + command + } +} + +fn extract_namespace_login_url(line: &str) -> Option { + line.split_whitespace() + .find(|token| token.starts_with("https://")) + .map(ToOwned::to_owned) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_namespace_login_url_from_output() { + let url = extract_namespace_login_url( + " https://cloud.namespace.so/login/workspace?id=p0cl4ik19c4c473u14tvc3vq2o", + ); + assert_eq!( + url.as_deref(), + Some("https://cloud.namespace.so/login/workspace?id=p0cl4ik19c4c473u14tvc3vq2o") + ); + } + + #[test] + fn pkce_challenge_is_stable() { + assert_eq!( + pkce_challenge("hello"), + "LPJNul-wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ" + ); + } + + #[test] + fn parses_session_cookie() { + let mut headers = HeaderMap::new(); + headers.insert( + COOKIE, + HeaderValue::from_static( + "something=else; burrow_namespace_portal_session=session123; another=value", + ), + ); + assert_eq!(session_cookie(&headers).as_deref(), Some("session123")); + } +} diff --git a/flake.nix b/flake.nix index 1e91dcc..0bba0b1 100644 --- a/flake.nix +++ b/flake.nix @@ -94,6 +94,7 @@ pkgs.stdenvNoCC.mkDerivation { pname = "nsc"; inherit version src; + meta.mainProgram = "nsc"; dontConfigure = true; dontBuild = true; unpackPhase = '' @@ -144,6 +145,35 @@ subPackages = [ "./cmd/forgejo-nsc-autoscaler" ]; vendorHash = "sha256-Kpr+5Q7Dy4JiLuJVZbFeJAzLR7PLPYxhtJqfxMEytcs="; }; + burrowSrc = lib.cleanSourceWith { + src = ./.; + filter = path: type: + let + p = toString path; + name = builtins.baseNameOf path; + hasDir = dir: lib.hasInfix "/${dir}/" p || lib.hasSuffix "/${dir}" p; + in + !(hasDir ".git" || hasDir "target" || hasDir "node_modules" || name == "result"); + }; + burrowPkg = pkgs.rustPlatform.buildRustPackage { + pname = "burrow"; + version = "0.1.0"; + src = burrowSrc; + cargoLock = { + lockFile = ./Cargo.lock; + outputHashes = { + "tracing-oslog-0.1.2" = "sha256-DjJDiPCTn43zJmmOfuRnyti8iQf9qoXICMKIx4bAG3I="; + }; + }; + cargoBuildFlags = [ + "-p" + "burrow" + "--bin" + "burrow" + ]; + nativeBuildInputs = [ pkgs.protobuf ]; + meta.mainProgram = "burrow"; + }; in { devShells.default = pkgs.mkShell { @@ -171,6 +201,7 @@ packages = { agenix = agenix.packages.${system}.agenix; + burrow = burrowPkg; hcloud-upload-image = hcloudUploadImagePkg; forgejo-nsc-dispatcher = forgejoNscDispatcher; forgejo-nsc-autoscaler = forgejoNscAutoscaler; @@ -183,6 +214,7 @@ nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default; nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix; nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.nix; + nixosModules.burrow-namespace-portal = import ./nixos/modules/burrow-namespace-portal.nix; nixosConfigurations.burrow-forge = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; diff --git a/nixos/README.md b/nixos/README.md index c79d8ce..13fe76d 100644 --- a/nixos/README.md +++ b/nixos/README.md @@ -12,6 +12,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B - upstream `compatible.systems/conrad/nsc-autoscaler`: Namespace-backed ephemeral Forgejo runner module consumed via the Burrow flake input - `modules/burrow-authentik.nix`: minimal Authentik IdP for Burrow control planes - `modules/burrow-headscale.nix`: Headscale control plane rooted in Authentik OIDC +- `modules/burrow-namespace-portal.nix`: small admin portal for forge-owned Namespace authentication and NSC token refresh - `../secrets.nix`: agenix recipient map for tracked Burrow forge secrets - `hetzner-cloud-config.yaml`: desired Hetzner host shape - `keys/contact_at_burrow_net.pub`: initial operator SSH public key @@ -24,6 +25,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B - `../Scripts/forge-deploy.sh`: remote `nixos-rebuild` entrypoint for the forge host - `../Scripts/provision-forgejo-nsc.sh`: render Burrow Namespace dispatcher/autoscaler runtime inputs and ensure the default Forgejo scope exists - `../Scripts/sync-forgejo-nsc-config.sh`: copy intake-backed dispatcher/autoscaler inputs to the host +- `../Scripts/authentik-sync-namespace-portal-oidc.sh`: reconcile the Authentik OIDC app used by `nsc.burrow.net` ## Intended Flow @@ -33,10 +35,11 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B 4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account. 5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent `. 6. Run `Scripts/provision-forgejo-nsc.sh` locally, then `Scripts/sync-forgejo-nsc-config.sh` to place the raw Namespace dispatcher/autoscaler runtime inputs under `/var/lib/burrow/intake/` for the upstream `services.forgejo-nsc` module. -7. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`. -8. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. -9. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace. -10. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`. +7. Visit `https://nsc.burrow.net/` as a Burrow admin to link the forge-owned Namespace session and rotate `/var/lib/burrow/intake/forgejo_nsc_token.txt` without relying on a personal local `nsc` login. +8. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`. +9. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, `nsc.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. +10. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace. +11. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`. ## Current Constraints diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 75b76d4..aecdbfa 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -33,6 +33,7 @@ in self.nixosModules.burrow-forgejo-nsc self.nixosModules.burrow-authentik self.nixosModules.burrow-headscale + self.nixosModules.burrow-namespace-portal ]; system.stateVersion = "24.11"; @@ -89,8 +90,8 @@ in }; networking.extraHosts = '' - 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net - ::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net + 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net nsc.burrow.net + ::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net nsc.burrow.net ''; services.burrow.forge = { @@ -140,4 +141,11 @@ in enable = true; oidcClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; }; + + services.burrow.namespacePortal = { + enable = true; + domain = "nsc.burrow.net"; + baseUrl = "https://nsc.burrow.net"; + adminGroup = contributors.groups.admins; + }; } diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 1616b36..e2ee18d 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -10,6 +10,7 @@ let dataVolume = "burrow-authentik-data:/data"; directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh; forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; + namespacePortalOidcSyncScript = ../../Scripts/authentik-sync-namespace-portal-oidc.sh; tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh; @@ -138,6 +139,30 @@ in description = "Authentik application slug for Tailscale custom OIDC sign-in."; }; + namespacePortalDomain = lib.mkOption { + type = lib.types.str; + default = "nsc.burrow.net"; + description = "Public domain for the Burrow Namespace portal."; + }; + + namespacePortalProviderSlug = lib.mkOption { + type = lib.types.str; + default = "namespace"; + description = "Authentik application slug for the Namespace portal."; + }; + + namespacePortalClientId = lib.mkOption { + type = lib.types.str; + default = "nsc.burrow.net"; + description = "Client ID Authentik should present to the Namespace portal."; + }; + + namespacePortalClientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Optional host-local file containing the Authentik Namespace portal OIDC client secret."; + }; + tailscaleClientId = lib.mkOption { type = lib.types.str; default = "tailscale.burrow.net"; @@ -708,6 +733,56 @@ EOF ''; }; + systemd.services.burrow-authentik-namespace-portal-oidc = { + description = "Reconcile the Burrow Authentik Namespace portal OIDC application"; + after = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = + [ + namespacePortalOidcSyncScript + cfg.envFile + ] + ++ lib.optionals (cfg.namespacePortalClientSecretFile != null) [ cfg.namespacePortalClientSecretFile ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + set -a + source ${lib.escapeShellArg cfg.envFile} + set +a + + export AUTHENTIK_URL=https://${cfg.domain} + export AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_SLUG=${lib.escapeShellArg cfg.namespacePortalProviderSlug} + export AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_NAME="Namespace Portal" + export AUTHENTIK_NAMESPACE_PORTAL_PROVIDER_NAME="Namespace Portal" + export AUTHENTIK_NAMESPACE_PORTAL_TEMPLATE_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} + export AUTHENTIK_NAMESPACE_PORTAL_CLIENT_ID=${lib.escapeShellArg cfg.namespacePortalClientId} + ${lib.optionalString (cfg.namespacePortalClientSecretFile != null) '' + export AUTHENTIK_NAMESPACE_PORTAL_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.namespacePortalClientSecretFile})" + ''} + export AUTHENTIK_NAMESPACE_PORTAL_LAUNCH_URL=https://${cfg.namespacePortalDomain}/ + export AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON='["https://${cfg.namespacePortalDomain}/oauth/callback"]' + + ${pkgs.bash}/bin/bash ${namespacePortalOidcSyncScript} + ''; + }; + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd reverse_proxy 127.0.0.1:${toString cfg.port} diff --git a/nixos/modules/burrow-namespace-portal.nix b/nixos/modules/burrow-namespace-portal.nix new file mode 100644 index 0000000..2eb7b24 --- /dev/null +++ b/nixos/modules/burrow-namespace-portal.nix @@ -0,0 +1,126 @@ +{ config, lib, pkgs, self, ... }: + +let + cfg = config.services.burrow.namespacePortal; + burrowExe = lib.getExe self.packages.${pkgs.system}.burrow; + nscExe = lib.getExe self.packages.${pkgs.system}.nsc; +in +{ + options.services.burrow.namespacePortal = { + enable = lib.mkEnableOption "the Burrow Namespace authentication portal"; + + domain = lib.mkOption { + type = lib.types.str; + default = "nsc.burrow.net"; + description = "Public domain for the Namespace portal."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 9080; + description = "Local listen port for the Namespace portal."; + }; + + baseUrl = lib.mkOption { + type = lib.types.str; + default = "https://nsc.burrow.net"; + description = "Public base URL for redirects."; + }; + + oidcProviderSlug = lib.mkOption { + type = lib.types.str; + default = "namespace"; + description = "Authentik provider slug used for the portal."; + }; + + oidcClientId = lib.mkOption { + type = lib.types.str; + default = "nsc.burrow.net"; + description = "OIDC client ID used by the portal."; + }; + + oidcClientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Optional host-local OIDC client secret for the portal."; + }; + + adminGroup = lib.mkOption { + type = lib.types.str; + default = "burrow-admins"; + description = "Authentik group required to access the portal."; + }; + + stateDir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/burrow/namespace-portal"; + description = "Persistent state directory for the portal-owned NSC session."; + }; + + tokenOutputPath = lib.mkOption { + type = lib.types.str; + default = "/var/lib/burrow/intake/forgejo_nsc_token.txt"; + description = "Path where refreshed NSC tokens should be written."; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = config.services.forgejo-nsc.enable; + message = "services.burrow.namespacePortal requires services.forgejo-nsc.enable"; + } + ]; + + systemd.tmpfiles.rules = [ + "d ${cfg.stateDir} 0750 forgejo-nsc forgejo-nsc -" + "d ${cfg.stateDir}/nsc 0750 forgejo-nsc forgejo-nsc -" + ]; + + systemd.services.burrow-namespace-portal = { + description = "Burrow Namespace authentication portal"; + after = [ + "network-online.target" + "burrow-authentik-ready.service" + ]; + wants = [ + "network-online.target" + "burrow-authentik-ready.service" + ]; + wantedBy = [ "multi-user.target" ]; + path = [ + self.packages.${pkgs.system}.burrow + self.packages.${pkgs.system}.nsc + pkgs.coreutils + ]; + serviceConfig = { + Type = "simple"; + User = "forgejo-nsc"; + Group = "forgejo-nsc"; + WorkingDirectory = cfg.stateDir; + Restart = "on-failure"; + RestartSec = "2s"; + }; + script = '' + set -euo pipefail + export BURROW_NAMESPACE_PORTAL_LISTEN=127.0.0.1:${toString cfg.port} + export BURROW_NAMESPACE_PORTAL_BASE_URL=${lib.escapeShellArg cfg.baseUrl} + export BURROW_NAMESPACE_PORTAL_OIDC_DISCOVERY_URL=${lib.escapeShellArg "https://${config.services.burrow.authentik.domain}/application/o/${cfg.oidcProviderSlug}/.well-known/openid-configuration"} + export BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_ID=${lib.escapeShellArg cfg.oidcClientId} + export BURROW_NAMESPACE_PORTAL_ALLOWED_GROUP=${lib.escapeShellArg cfg.adminGroup} + export BURROW_NAMESPACE_PORTAL_NSC_BIN=${lib.escapeShellArg nscExe} + export BURROW_NAMESPACE_PORTAL_NSC_STATE_DIR=${lib.escapeShellArg "${cfg.stateDir}/nsc"} + export BURROW_NAMESPACE_PORTAL_TOKEN_OUTPUT_PATH=${lib.escapeShellArg cfg.tokenOutputPath} + ${lib.optionalString (cfg.oidcClientSecretFile != null) '' + export BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.oidcClientSecretFile})" + ''} + exec ${burrowExe} namespace-portal + ''; + }; + + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' + encode gzip zstd + reverse_proxy 127.0.0.1:${toString cfg.port} + ''; + }; +} From 70607e874ce710bb05823f9206735c6fe6ea259a Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 5 Apr 2026 23:08:23 -0700 Subject: [PATCH 064/102] Move forgejo-nsc credentials into agenix --- .../authentik-sync-namespace-portal-oidc.sh | 246 ----- Scripts/check-forge-host.sh | 12 +- Scripts/seal-forgejo-nsc-secrets.sh | 112 +++ Scripts/sync-forgejo-nsc-config.sh | 133 +-- burrow/src/main.rs | 12 - burrow/src/namespace_portal.rs | 880 ------------------ flake.nix | 2 - nixos/README.md | 15 +- nixos/hosts/burrow-forge/default.nix | 36 +- nixos/modules/burrow-authentik.nix | 75 -- nixos/modules/burrow-namespace-portal.nix | 126 --- secrets.nix | 3 + .../infra/forgejo-nsc-autoscaler-config.age | Bin 0 -> 1264 bytes .../infra/forgejo-nsc-dispatcher-config.age | Bin 0 -> 1127 bytes secrets/infra/forgejo-nsc-token.age | 15 + 15 files changed, 172 insertions(+), 1495 deletions(-) delete mode 100644 Scripts/authentik-sync-namespace-portal-oidc.sh create mode 100755 Scripts/seal-forgejo-nsc-secrets.sh delete mode 100644 burrow/src/namespace_portal.rs delete mode 100644 nixos/modules/burrow-namespace-portal.nix create mode 100644 secrets/infra/forgejo-nsc-autoscaler-config.age create mode 100644 secrets/infra/forgejo-nsc-dispatcher-config.age create mode 100644 secrets/infra/forgejo-nsc-token.age diff --git a/Scripts/authentik-sync-namespace-portal-oidc.sh b/Scripts/authentik-sync-namespace-portal-oidc.sh deleted file mode 100644 index a62b0cf..0000000 --- a/Scripts/authentik-sync-namespace-portal-oidc.sh +++ /dev/null @@ -1,246 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" -bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" -application_slug="${AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_SLUG:-namespace}" -application_name="${AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_NAME:-Namespace Portal}" -provider_name="${AUTHENTIK_NAMESPACE_PORTAL_PROVIDER_NAME:-Namespace Portal}" -template_slug="${AUTHENTIK_NAMESPACE_PORTAL_TEMPLATE_SLUG:-ts}" -client_id="${AUTHENTIK_NAMESPACE_PORTAL_CLIENT_ID:-nsc.burrow.net}" -client_secret="${AUTHENTIK_NAMESPACE_PORTAL_CLIENT_SECRET:-}" -launch_url="${AUTHENTIK_NAMESPACE_PORTAL_LAUNCH_URL:-https://nsc.burrow.net/}" -redirect_uris_json="${AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON:-[ - \"https://nsc.burrow.net/oauth/callback\" -]}" - -usage() { - cat <<'EOF' -Usage: Scripts/authentik-sync-namespace-portal-oidc.sh - -Required environment: - AUTHENTIK_BOOTSTRAP_TOKEN - -Optional environment: - AUTHENTIK_URL - AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_SLUG - AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_NAME - AUTHENTIK_NAMESPACE_PORTAL_PROVIDER_NAME - AUTHENTIK_NAMESPACE_PORTAL_TEMPLATE_SLUG - AUTHENTIK_NAMESPACE_PORTAL_CLIENT_ID - AUTHENTIK_NAMESPACE_PORTAL_CLIENT_SECRET - AUTHENTIK_NAMESPACE_PORTAL_LAUNCH_URL - AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON -EOF -} - -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage - exit 0 -fi - -if [[ -z "$bootstrap_token" ]]; then - echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 - exit 1 -fi - -if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then - echo "error: AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON must be a non-empty JSON array" >&2 - exit 1 -fi - -api() { - local method="$1" - local path="$2" - local data="${3:-}" - - if [[ -n "$data" ]]; then - curl -fsS \ - -X "$method" \ - -H "Authorization: Bearer ${bootstrap_token}" \ - -H "Content-Type: application/json" \ - -d "$data" \ - "${authentik_url}${path}" - else - curl -fsS \ - -X "$method" \ - -H "Authorization: Bearer ${bootstrap_token}" \ - "${authentik_url}${path}" - fi -} - -api_with_status() { - local method="$1" - local path="$2" - local data="${3:-}" - local response_file status - - response_file="$(mktemp)" - trap 'rm -f "$response_file"' RETURN - - if [[ -n "$data" ]]; then - status="$( - curl -sS \ - -o "$response_file" \ - -w '%{http_code}' \ - -X "$method" \ - -H "Authorization: Bearer ${bootstrap_token}" \ - -H "Content-Type: application/json" \ - -d "$data" \ - "${authentik_url}${path}" - )" - else - status="$( - curl -sS \ - -o "$response_file" \ - -w '%{http_code}' \ - -X "$method" \ - -H "Authorization: Bearer ${bootstrap_token}" \ - "${authentik_url}${path}" - )" - fi - - printf '%s\n' "$status" - cat "$response_file" -} - -wait_for_authentik() { - for _ in $(seq 1 90); do - if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then - return 0 - fi - sleep 2 - done - - echo "error: Authentik did not become ready at ${authentik_url}" >&2 - exit 1 -} - -wait_for_authentik - -template_provider="$( - api GET "/api/v3/providers/oauth2/?page_size=200" \ - | jq -c --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \ - | head -n1 -)" - -if [[ -z "$template_provider" ]]; then - echo "error: could not resolve the Authentik OAuth provider template ${template_slug}" >&2 - exit 1 -fi - -authorization_flow="$(printf '%s\n' "$template_provider" | jq -r '.authorization_flow')" -invalidation_flow="$(printf '%s\n' "$template_provider" | jq -r '.invalidation_flow')" -property_mappings="$(printf '%s\n' "$template_provider" | jq -c '.property_mappings')" -signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')" - -provider_payload="$( - jq -n \ - --arg name "$provider_name" \ - --arg authorization_flow "$authorization_flow" \ - --arg invalidation_flow "$invalidation_flow" \ - --arg client_id "$client_id" \ - --arg client_secret "$client_secret" \ - --arg signing_key "$signing_key" \ - --argjson property_mappings "$property_mappings" \ - --argjson redirect_uris "$redirect_uris_json" \ - '{ - name: $name, - authorization_flow: $authorization_flow, - invalidation_flow: $invalidation_flow, - client_type: (if $client_secret == "" then "public" else "confidential" end), - client_id: $client_id, - include_claims_in_id_token: true, - redirect_uris: ($redirect_uris | map({matching_mode: "strict", url: .})), - property_mappings: $property_mappings, - signing_key: $signing_key, - issuer_mode: "per_provider", - sub_mode: "hashed_user_id" - } - + (if $client_secret == "" then {} else {client_secret: $client_secret} end)' -)" - -existing_provider="$( - api GET "/api/v3/providers/oauth2/?page_size=200" \ - | jq -c \ - --arg application_slug "$application_slug" \ - --arg provider_name "$provider_name" \ - '.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \ - | head -n1 -)" - -if [[ -n "$existing_provider" ]]; then - provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')" - api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$provider_payload" >/dev/null -else - provider_pk="$( - api POST "/api/v3/providers/oauth2/" "$provider_payload" \ - | jq -r '.pk // empty' - )" -fi - -if [[ -z "${provider_pk:-}" ]]; then - echo "error: Namespace portal OIDC provider did not return a primary key" >&2 - exit 1 -fi - -application_payload="$( - jq -n \ - --arg name "$application_name" \ - --arg slug "$application_slug" \ - --arg provider "$provider_pk" \ - --arg launch_url "$launch_url" \ - '{ - name: $name, - slug: $slug, - provider: ($provider | tonumber), - meta_launch_url: $launch_url, - open_in_new_tab: false, - policy_engine_mode: "any" - }' -)" - -existing_application="$( - api GET "/api/v3/core/applications/?page_size=200" \ - | jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \ - | head -n1 -)" - -if [[ -n "$existing_application" ]]; then - application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" -else - create_application_result="$( - api_with_status POST "/api/v3/core/applications/" "$application_payload" - )" - create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')" - create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')" - - if [[ "$create_application_status" =~ ^20[01]$ ]]; then - application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')" - elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e ' - (.slug // [] | index("Application with this slug already exists.")) != null - or (.provider // [] | index("Application with this provider already exists.")) != null - ' >/dev/null; then - application_pk="existing-duplicate" - else - printf '%s\n' "$create_application_body" >&2 - echo "error: could not reconcile Authentik application ${application_slug}" >&2 - exit 1 - fi -fi - -if [[ -z "${application_pk:-}" ]]; then - echo "error: Namespace portal OIDC application did not return a primary key" >&2 - exit 1 -fi - -for _ in $(seq 1 30); do - if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then - echo "Synced Authentik Namespace portal OIDC application ${application_slug} (${application_name})." - exit 0 - fi - sleep 2 -done - -echo "warning: Namespace portal OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2 -echo "Synced Authentik Namespace portal OIDC application ${application_slug} (${application_name})." diff --git a/Scripts/check-forge-host.sh b/Scripts/check-forge-host.sh index d824f6d..0f79bf4 100755 --- a/Scripts/check-forge-host.sh +++ b/Scripts/check-forge-host.sh @@ -84,7 +84,6 @@ base_services=( nsc_services=( forgejo-nsc-dispatcher.service forgejo-nsc-autoscaler.service - burrow-namespace-portal.service ) tailnet_services=( @@ -165,6 +164,14 @@ if [[ "${EXPECT_TAILNET}" == "1" ]]; then test -s /run/agenix/burrowHeadscaleOidcClientSecret fi +if [[ "${EXPECT_NSC}" == "1" ]]; then + echo "== agenix-nsc ==" + ls -l /run/agenix || true + test -s /run/agenix/burrowForgejoNscToken + test -s /run/agenix/burrowForgejoNscDispatcherConfig + test -s /run/agenix/burrowForgejoNscAutoscalerConfig +fi + if command -v curl >/dev/null 2>&1; then echo "== http-local ==" curl -fsS -o /dev/null -w 'forgejo_login %{http_code}\n' http://127.0.0.1:3000/user/login @@ -174,8 +181,5 @@ if command -v curl >/dev/null 2>&1; then curl -fsS -o /dev/null -H 'Host: auth.burrow.net' -w 'authentik_ready %{http_code}\n' http://127.0.0.1/-/health/ready/ curl -sS -o /dev/null -H 'Host: ts.burrow.net' -w 'headscale_root %{http_code}\n' http://127.0.0.1/ || true fi - if [[ "${EXPECT_NSC}" == "1" ]]; then - curl -fsS -o /dev/null -H 'Host: nsc.burrow.net' -w 'namespace_portal %{http_code}\n' http://127.0.0.1/ - fi fi EOF diff --git a/Scripts/seal-forgejo-nsc-secrets.sh b/Scripts/seal-forgejo-nsc-secrets.sh new file mode 100755 index 0000000..a6b3918 --- /dev/null +++ b/Scripts/seal-forgejo-nsc-secrets.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +usage() { + cat <<'EOF' +Usage: Scripts/seal-forgejo-nsc-secrets.sh [options] + +Encrypt Burrow forgejo-nsc runtime inputs from intake/ into the agenix secrets +consumed by burrow-forge. + +Options: + --provision Re-render the local intake files before sealing. + --host SSH target forwarded to provision-forgejo-nsc.sh. + --ssh-key SSH private key forwarded to provision-forgejo-nsc.sh. + --nsc-bin Override the nsc binary for provisioning. + -h, --help Show this help text. +EOF +} + +PROVISION=0 +HOST="${BURROW_FORGE_HOST:-root@git.burrow.net}" +SSH_KEY="${BURROW_FORGE_SSH_KEY:-${REPO_ROOT}/intake/agent_at_burrow_net_ed25519}" +NSC_BIN="${NSC_BIN:-}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --provision) + PROVISION=1 + shift + ;; + --host) + HOST="${2:?missing value for --host}" + shift 2 + ;; + --ssh-key) + SSH_KEY="${2:?missing value for --ssh-key}" + shift 2 + ;; + --nsc-bin) + NSC_BIN="${2:?missing value for --nsc-bin}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown option: $1" >&2 + usage >&2 + exit 64 + ;; + esac +done + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + exit 1 + fi +} + +require_cmd age +require_cmd nix +require_cmd python3 + +if [[ "${PROVISION}" -eq 1 ]]; then + provision_args=(--host "${HOST}" --ssh-key "${SSH_KEY}") + if [[ -n "${NSC_BIN}" ]]; then + provision_args+=(--nsc-bin "${NSC_BIN}") + fi + "${SCRIPT_DIR}/provision-forgejo-nsc.sh" "${provision_args[@]}" +fi + +tmpdir="$(mktemp -d)" +cleanup() { + rm -rf "${tmpdir}" +} +trap cleanup EXIT + +seal_secret() { + local target="$1" + local source_path="$2" + recipients_file="${tmpdir}/$(basename "${target}").recipients" + if [[ ! -s "${source_path}" ]]; then + echo "required runtime input missing or empty: ${source_path}" >&2 + exit 1 + fi + nix eval --impure --json --expr "let s = import ${REPO_ROOT}/secrets.nix; in s.\"${target}\".publicKeys" \ + | python3 -c 'import json, sys; [print(item) for item in json.load(sys.stdin)]' \ + > "${recipients_file}" + + age -R "${recipients_file}" -o "${REPO_ROOT}/${target}" "${source_path}" +} + +seal_secret "secrets/infra/forgejo-nsc-token.age" "${REPO_ROOT}/intake/forgejo_nsc_token.txt" +seal_secret "secrets/infra/forgejo-nsc-dispatcher-config.age" "${REPO_ROOT}/intake/forgejo_nsc_dispatcher.yaml" +seal_secret "secrets/infra/forgejo-nsc-autoscaler-config.age" "${REPO_ROOT}/intake/forgejo_nsc_autoscaler.yaml" + +chmod 600 \ + "${REPO_ROOT}/secrets/infra/forgejo-nsc-token.age" \ + "${REPO_ROOT}/secrets/infra/forgejo-nsc-dispatcher-config.age" \ + "${REPO_ROOT}/secrets/infra/forgejo-nsc-autoscaler-config.age" + +echo "Sealed forgejo-nsc runtime inputs into:" +printf ' %s\n' \ + "${REPO_ROOT}/secrets/infra/forgejo-nsc-token.age" \ + "${REPO_ROOT}/secrets/infra/forgejo-nsc-dispatcher-config.age" \ + "${REPO_ROOT}/secrets/infra/forgejo-nsc-autoscaler-config.age" +echo "Deploy burrow-forge to apply the new CI credentials." diff --git a/Scripts/sync-forgejo-nsc-config.sh b/Scripts/sync-forgejo-nsc-config.sh index 77581f8..2ce7114 100755 --- a/Scripts/sync-forgejo-nsc-config.sh +++ b/Scripts/sync-forgejo-nsc-config.sh @@ -1,132 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -usage() { - cat <<'EOF' -Usage: Scripts/sync-forgejo-nsc-config.sh [options] - -Copy Burrow forgejo-nsc runtime inputs from intake/ onto the forge host and -restart the dispatcher/autoscaler units. - -Options: - --host SSH target (default: root@git.burrow.net) - --ssh-key SSH private key (default: intake/agent_at_burrow_net_ed25519) - --rotate-pat Re-render the intake files before syncing. - --no-restart Copy files only. - -h, --help Show this help text. -EOF -} - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" - -HOST="${BURROW_FORGE_HOST:-root@git.burrow.net}" -SSH_KEY="${BURROW_FORGE_SSH_KEY:-${REPO_ROOT}/intake/agent_at_burrow_net_ed25519}" -KNOWN_HOSTS_FILE="${BURROW_FORGE_KNOWN_HOSTS_FILE:-${HOME}/.cache/burrow/forge-known_hosts}" -ROTATE_PAT=0 -NO_RESTART=0 - -while [[ $# -gt 0 ]]; do - case "$1" in - --host) - HOST="${2:?missing value for --host}" - shift 2 - ;; - --ssh-key) - SSH_KEY="${2:?missing value for --ssh-key}" - shift 2 - ;; - --rotate-pat) - ROTATE_PAT=1 - shift - ;; - --no-restart) - NO_RESTART=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "unknown option: $1" >&2 - usage >&2 - exit 64 - ;; - esac -done - -mkdir -p "$(dirname "${KNOWN_HOSTS_FILE}")" - -burrow_require_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "missing required command: $1" >&2 - exit 1 - fi -} - -burrow_require_cmd ssh -burrow_require_cmd scp - -if [[ ! -f "${SSH_KEY}" ]]; then - echo "forge SSH key not found: ${SSH_KEY}" >&2 - exit 1 -fi - -if [[ "${ROTATE_PAT}" -eq 1 ]]; then - "${SCRIPT_DIR}/provision-forgejo-nsc.sh" --host "${HOST}" --ssh-key "${SSH_KEY}" -fi - -token_file="${REPO_ROOT}/intake/forgejo_nsc_token.txt" -dispatcher_file="${REPO_ROOT}/intake/forgejo_nsc_dispatcher.yaml" -autoscaler_file="${REPO_ROOT}/intake/forgejo_nsc_autoscaler.yaml" - -for path in "${token_file}" "${dispatcher_file}" "${autoscaler_file}"; do - if [[ ! -s "${path}" ]]; then - echo "required runtime input missing or empty: ${path}" >&2 - exit 1 - fi -done - -ssh_opts=( - -i "${SSH_KEY}" - -o IdentitiesOnly=yes - -o UserKnownHostsFile="${KNOWN_HOSTS_FILE}" - -o StrictHostKeyChecking=accept-new -) - -remote_tmp="$(ssh "${ssh_opts[@]}" "${HOST}" "mktemp -d")" -cleanup() { - if [[ -n "${remote_tmp:-}" ]]; then - ssh "${ssh_opts[@]}" "${HOST}" "rm -rf '${remote_tmp}'" >/dev/null 2>&1 || true - fi -} -trap cleanup EXIT - -scp "${ssh_opts[@]}" \ - "${token_file}" \ - "${dispatcher_file}" \ - "${autoscaler_file}" \ - "${HOST}:${remote_tmp}/" - -ssh "${ssh_opts[@]}" "${HOST}" " - set -euo pipefail - install -d -m 0755 /var/lib/burrow/intake - install -m 0400 -o forgejo-nsc -g forgejo-nsc '${remote_tmp}/$(basename "${token_file}")' /var/lib/burrow/intake/forgejo_nsc_token.txt - install -m 0400 -o forgejo-nsc -g forgejo-nsc '${remote_tmp}/$(basename "${dispatcher_file}")' /var/lib/burrow/intake/forgejo_nsc_dispatcher.yaml - install -m 0400 -o forgejo-nsc -g forgejo-nsc '${remote_tmp}/$(basename "${autoscaler_file}")' /var/lib/burrow/intake/forgejo_nsc_autoscaler.yaml -" - -if [[ "${NO_RESTART}" -eq 0 ]]; then - ssh "${ssh_opts[@]}" "${HOST}" " - set -euo pipefail - systemctl restart forgejo-nsc-dispatcher.service forgejo-nsc-autoscaler.service - systemctl is-active forgejo-nsc-dispatcher.service forgejo-nsc-autoscaler.service - ls -l \ - /var/lib/burrow/intake/forgejo_nsc_token.txt \ - /var/lib/burrow/intake/forgejo_nsc_dispatcher.yaml \ - /var/lib/burrow/intake/forgejo_nsc_autoscaler.yaml - " -fi - -echo "forgejo-nsc runtime sync complete (host=${HOST}, restarted=$((1 - NO_RESTART)))." +echo "Scripts/sync-forgejo-nsc-config.sh is obsolete." >&2 +echo "Burrow forgejo-nsc now consumes agenix-backed secrets instead of host-local intake files." >&2 +echo "Use Scripts/seal-forgejo-nsc-secrets.sh and deploy burrow-forge." >&2 +exit 1 diff --git a/burrow/src/main.rs b/burrow/src/main.rs index 01591e7..cfa2085 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -5,8 +5,6 @@ use clap::{Args, Parser, Subcommand}; mod control; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod daemon; -#[cfg(target_os = "linux")] -mod namespace_portal; pub(crate) mod tracing; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod wireguard; @@ -62,12 +60,6 @@ enum Commands { ReloadConfig(ReloadConfigArgs), /// Authentication server AuthServer, - #[cfg(target_os = "linux")] - /// Admin portal for forge-owned Namespace authentication and NSC token minting - NamespacePortal, - #[cfg(target_os = "linux")] - /// Refresh the forge-owned Namespace dev token once - NamespaceRefreshToken, /// Server Status ServerStatus, /// Tunnel Config @@ -767,10 +759,6 @@ async fn main() -> Result<()> { Commands::ServerConfig => try_serverconfig().await?, Commands::ReloadConfig(args) => try_reloadconfig(args.interface_id.clone()).await?, Commands::AuthServer => crate::auth::server::serve().await?, - #[cfg(target_os = "linux")] - Commands::NamespacePortal => crate::namespace_portal::serve().await?, - #[cfg(target_os = "linux")] - Commands::NamespaceRefreshToken => crate::namespace_portal::refresh_token_once().await?, Commands::ServerStatus => try_serverstatus().await?, Commands::TunnelConfig => try_tun_config().await?, Commands::NetworkAdd(args) => { diff --git a/burrow/src/namespace_portal.rs b/burrow/src/namespace_portal.rs deleted file mode 100644 index eb20775..0000000 --- a/burrow/src/namespace_portal.rs +++ /dev/null @@ -1,880 +0,0 @@ -#![cfg(target_os = "linux")] - -use std::{ - collections::HashMap, - env, fs, - path::{Path, PathBuf}, - process::Stdio, - sync::Arc, - time::{Duration, Instant}, -}; - -use anyhow::{anyhow, bail, Context, Result}; -use axum::{ - extract::{Query, State}, - http::{ - header::{COOKIE, LOCATION, SET_COOKIE}, - HeaderMap, HeaderValue, StatusCode, - }, - response::{Html, IntoResponse, Redirect, Response}, - routing::{get, post}, - Router, -}; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use rand::RngCore; -use reqwest::Url; -use ring::digest::{digest, SHA256}; -use serde::Deserialize; -use tokio::{ - io::{AsyncBufReadExt, BufReader}, - process::Command, - sync::Mutex, -}; - -const SESSION_COOKIE: &str = "burrow_namespace_portal_session"; -const OIDC_TIMEOUT: Duration = Duration::from_secs(600); -const AUTH_CHECK_DURATION: &str = "10m"; - -#[derive(Clone, Debug)] -pub struct NamespacePortalConfig { - pub listen: String, - pub public_base_url: String, - pub oidc_discovery_url: String, - pub oidc_client_id: String, - pub oidc_client_secret: Option, - pub allowed_group: String, - pub nsc_bin: String, - pub nsc_state_dir: PathBuf, - pub token_output_path: PathBuf, -} - -impl Default for NamespacePortalConfig { - fn default() -> Self { - Self { - listen: "127.0.0.1:9080".to_owned(), - public_base_url: "https://nsc.burrow.net".to_owned(), - oidc_discovery_url: - "https://auth.burrow.net/application/o/namespace/.well-known/openid-configuration" - .to_owned(), - oidc_client_id: "nsc.burrow.net".to_owned(), - oidc_client_secret: None, - allowed_group: "burrow-admins".to_owned(), - nsc_bin: "nsc".to_owned(), - nsc_state_dir: PathBuf::from("/var/lib/burrow/namespace-portal/nsc"), - token_output_path: PathBuf::from("/var/lib/burrow/intake/forgejo_nsc_token.txt"), - } - } -} - -impl NamespacePortalConfig { - pub fn from_env() -> Self { - let mut config = Self::default(); - if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_LISTEN") { - config.listen = value; - } - if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_BASE_URL") { - config.public_base_url = value; - } - if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_OIDC_DISCOVERY_URL") { - config.oidc_discovery_url = value; - } - if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_ID") { - config.oidc_client_id = value; - } - if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_SECRET") { - let value = value.trim().to_owned(); - if !value.is_empty() { - config.oidc_client_secret = Some(value); - } - } - if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_ALLOWED_GROUP") { - config.allowed_group = value; - } - if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_NSC_BIN") { - config.nsc_bin = value; - } - if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_NSC_STATE_DIR") { - config.nsc_state_dir = PathBuf::from(value); - } - if let Ok(value) = env::var("BURROW_NAMESPACE_PORTAL_TOKEN_OUTPUT_PATH") { - config.token_output_path = PathBuf::from(value); - } - config - } - - fn callback_url(&self) -> Result { - let mut url = Url::parse(&self.public_base_url) - .with_context(|| format!("invalid public base url {}", self.public_base_url))?; - url.set_path("/oauth/callback"); - url.set_query(None); - Ok(url.to_string()) - } - - fn ensure_paths(&self) -> Result<()> { - fs::create_dir_all(&self.nsc_state_dir).with_context(|| { - format!( - "failed to create namespace portal state dir {}", - self.nsc_state_dir.display() - ) - })?; - if let Some(parent) = self.token_output_path.parent() { - fs::create_dir_all(parent).with_context(|| { - format!("failed to create token output dir {}", parent.display()) - })?; - } - Ok(()) - } -} - -#[derive(Clone)] -struct AppState { - config: NamespacePortalConfig, - client: reqwest::Client, - oidc: OidcDiscovery, - pending_logins: Arc>>, - sessions: Arc>>, - namespace: NamespaceSessionManager, -} - -#[derive(Clone, Debug, Deserialize)] -struct OidcDiscovery { - authorization_endpoint: String, - token_endpoint: String, - userinfo_endpoint: String, -} - -#[derive(Clone, Debug)] -struct PendingOidcLogin { - verifier: String, - expires_at: Instant, -} - -#[derive(Clone, Debug)] -struct PortalSession { - email: String, - display_name: String, - groups: Vec, - issued_at: Instant, -} - -#[derive(Debug, Deserialize)] -struct OidcCallbackQuery { - code: Option, - state: Option, - error: Option, - error_description: Option, -} - -#[derive(Debug, Deserialize)] -struct TokenResponse { - access_token: String, -} - -#[derive(Debug, Deserialize)] -struct UserInfo { - #[serde(default)] - email: String, - #[serde(default)] - name: String, - #[serde(default)] - preferred_username: String, - #[serde(default)] - groups: Vec, -} - -#[derive(Clone)] -struct NamespaceSessionManager { - config: NamespacePortalConfig, - state: Arc>, -} - -#[derive(Clone, Debug, Default)] -struct NamespacePortalState { - active_login: Option, - last_error: Option, -} - -#[derive(Clone, Debug)] -struct ActiveNamespaceLogin { - login_url: String, -} - -#[derive(Clone, Debug)] -struct NamespaceStatus { - linked: bool, - login_url: Option, - last_error: Option, - token_present: bool, -} - -pub async fn serve() -> Result<()> { - serve_with_config(NamespacePortalConfig::from_env()).await -} - -pub async fn refresh_token_once() -> Result<()> { - let config = NamespacePortalConfig::from_env(); - config.ensure_paths()?; - NamespaceSessionManager::new(config).refresh_token().await -} - -pub async fn serve_with_config(config: NamespacePortalConfig) -> Result<()> { - config.ensure_paths()?; - let oidc = fetch_oidc_discovery(&config.oidc_discovery_url).await?; - let listen = config.listen.clone(); - let app = Router::new() - .route("/", get(index)) - .route("/healthz", get(healthz)) - .route("/login", get(oidc_login)) - .route("/logout", post(logout)) - .route("/oauth/callback", get(oidc_callback)) - .route("/namespace/link/start", post(namespace_link_start)) - .route("/namespace/token/refresh", post(namespace_token_refresh)) - .with_state(AppState { - config: config.clone(), - client: reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build()?, - oidc, - pending_logins: Arc::new(Mutex::new(HashMap::new())), - sessions: Arc::new(Mutex::new(HashMap::new())), - namespace: NamespaceSessionManager::new(config), - }); - - let listener = tokio::net::TcpListener::bind(&listen).await?; - log::info!("Starting Namespace portal on {}", listen); - axum::serve(listener, app).await?; - Ok(()) -} - -async fn fetch_oidc_discovery(discovery_url: &str) -> Result { - reqwest::Client::new() - .get(discovery_url) - .send() - .await - .with_context(|| format!("failed to fetch oidc discovery {}", discovery_url))? - .error_for_status() - .with_context(|| format!("oidc discovery returned non-success {}", discovery_url))? - .json() - .await - .context("failed to decode oidc discovery document") -} - -async fn healthz() -> impl IntoResponse { - StatusCode::OK -} - -async fn index(State(state): State, headers: HeaderMap) -> Response { - match current_session(&state, &headers).await { - Ok(Some(session)) => { - let namespace_status = match state.namespace.status().await { - Ok(status) => status, - Err(err) => NamespaceStatus { - linked: false, - login_url: None, - last_error: Some(err.to_string()), - token_present: false, - }, - }; - Html(render_dashboard(&state.config, &session, &namespace_status)).into_response() - } - Ok(None) => Html(render_login_page()).into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Html(render_error_page(&format!("session lookup failed: {err}"))), - ) - .into_response(), - } -} - -async fn oidc_login(State(state): State) -> Result { - prune_pending(&state).await; - let state_token = random_url_token(32); - let verifier = random_url_token(48); - let challenge = pkce_challenge(&verifier); - let callback_url = state.config.callback_url().map_err(internal_error)?; - - state.pending_logins.lock().await.insert( - state_token.clone(), - PendingOidcLogin { - verifier, - expires_at: Instant::now() + OIDC_TIMEOUT, - }, - ); - - let mut url = Url::parse(&state.oidc.authorization_endpoint).map_err(internal_error)?; - url.query_pairs_mut() - .append_pair("client_id", &state.config.oidc_client_id) - .append_pair("response_type", "code") - .append_pair("scope", "openid profile email groups") - .append_pair("redirect_uri", &callback_url) - .append_pair("state", &state_token) - .append_pair("code_challenge", &challenge) - .append_pair("code_challenge_method", "S256"); - Ok(Redirect::to(url.as_str())) -} - -async fn oidc_callback( - State(state): State, - Query(query): Query, -) -> Result { - if let Some(error) = query.error { - let description = query.error_description.unwrap_or_default(); - return Err(( - StatusCode::BAD_GATEWAY, - format!("oidc login failed: {error} {description}") - .trim() - .to_owned(), - )); - } - - let code = query - .code - .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing oidc code".to_owned()))?; - let state_token = query - .state - .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing oidc state".to_owned()))?; - - let verifier = { - let mut pending = state.pending_logins.lock().await; - let Some(login) = pending.remove(&state_token) else { - return Err((StatusCode::BAD_REQUEST, "unknown oidc state".to_owned())); - }; - if login.expires_at <= Instant::now() { - return Err((StatusCode::BAD_REQUEST, "expired oidc state".to_owned())); - } - login.verifier - }; - - let callback_url = state.config.callback_url().map_err(internal_error)?; - - let mut params = vec![ - ("grant_type", "authorization_code".to_owned()), - ("code", code), - ("client_id", state.config.oidc_client_id.clone()), - ("redirect_uri", callback_url), - ("code_verifier", verifier), - ]; - if let Some(secret) = &state.config.oidc_client_secret { - params.push(("client_secret", secret.clone())); - } - - let token = state - .client - .post(&state.oidc.token_endpoint) - .form(¶ms) - .send() - .await - .context("failed to exchange oidc code") - .map_err(internal_error)? - .error_for_status() - .context("oidc token endpoint returned non-success") - .map_err(internal_error)? - .json::() - .await - .context("failed to decode oidc token response") - .map_err(internal_error)?; - - let userinfo = state - .client - .get(&state.oidc.userinfo_endpoint) - .bearer_auth(&token.access_token) - .send() - .await - .context("failed to fetch oidc userinfo") - .map_err(internal_error)? - .error_for_status() - .context("oidc userinfo returned non-success") - .map_err(internal_error)? - .json::() - .await - .context("failed to decode oidc userinfo") - .map_err(internal_error)?; - - if !userinfo - .groups - .iter() - .any(|group| group == &state.config.allowed_group) - { - return Err(( - StatusCode::FORBIDDEN, - format!( - "authenticated user is not in required group {}", - state.config.allowed_group - ), - )); - } - - let session_id = random_url_token(32); - state.sessions.lock().await.insert( - session_id.clone(), - PortalSession { - email: userinfo.email.clone(), - display_name: display_name(&userinfo), - groups: userinfo.groups, - issued_at: Instant::now(), - }, - ); - - let mut response = Redirect::to("/").into_response(); - response.headers_mut().insert( - SET_COOKIE, - HeaderValue::from_str(&session_cookie_value(&session_id)).map_err(internal_error)?, - ); - Ok(response) -} - -async fn logout( - State(state): State, - headers: HeaderMap, -) -> Result { - if let Some(session_id) = session_cookie(&headers) { - state.sessions.lock().await.remove(&session_id); - } - let mut response = Redirect::to("/").into_response(); - response.headers_mut().insert( - SET_COOKIE, - HeaderValue::from_static( - "burrow_namespace_portal_session=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", - ), - ); - Ok(response) -} - -async fn namespace_link_start( - State(state): State, - headers: HeaderMap, -) -> Result { - require_session(&state, &headers).await?; - state - .namespace - .start_login() - .await - .map_err(internal_error)?; - Ok(Redirect::to("/")) -} - -async fn namespace_token_refresh( - State(state): State, - headers: HeaderMap, -) -> Result { - require_session(&state, &headers).await?; - state - .namespace - .refresh_token() - .await - .map_err(internal_error)?; - Ok(Redirect::to("/")) -} - -fn render_login_page() -> String { - r#" - - - - - Burrow Namespace Portal - - - -
-

Burrow Namespace Portal

-

Authenticate with burrow.net to manage the dedicated Namespace session that backs Forgejo NSC automation.

- Sign in with burrow.net -
- -"# - .to_owned() -} - -fn render_dashboard( - config: &NamespacePortalConfig, - session: &PortalSession, - status: &NamespaceStatus, -) -> String { - let refresh = if status.login_url.is_some() { - r#""# - } else { - "" - }; - let login_action = if let Some(url) = &status.login_url { - format!( - "

Namespace Login In Progress

Open the live Namespace URL below with the dedicated Burrow account. This page will refresh automatically until the server-side session is ready.

Open Namespace Login

", - escape_html(url) - ) - } else if status.linked { - "

Namespace Linked

The forge-owned NSC session is authenticated and ready to mint runner tokens.

".to_owned() - } else { - "

Namespace Not Linked

Start a server-side Namespace login. The portal will produce a Namespace URL, and completing that browser flow will authenticate the forge-owned NSC state directory.

".to_owned() - }; - let error = status - .last_error - .as_ref() - .map(|error| format!("

{}

", escape_html(error))) - .unwrap_or_default(); - let token_state = if status.token_present { - "present" - } else { - "missing" - }; - format!( - r#" - - - - - Burrow Namespace Portal - {refresh} - - - -
-
-
-

Burrow Namespace Portal

-

Signed in as {email}. This page controls the forge-owned NSC session and token material for Forgejo Namespace runners.

-
-
-
- -
-
-
burrow.net identity
{identity}
-
required group
{group}
-
NSC token file
{token_path}
-
current token
{token_state}
-
-
- - {login_action} - {error} - -
-

Actions

-
-
-
-
-
-
- -"#, - refresh = refresh, - email = escape_html(&session.email), - identity = escape_html(&session.display_name), - group = escape_html(&config.allowed_group), - token_path = escape_html(&config.token_output_path.display().to_string()), - token_state = token_state, - login_action = login_action, - error = error, - ) -} - -fn render_error_page(message: &str) -> String { - format!( - r#"

Namespace Portal Error

{}

"#, - escape_html(message) - ) -} - -fn display_name(userinfo: &UserInfo) -> String { - if !userinfo.name.trim().is_empty() { - return userinfo.name.trim().to_owned(); - } - if !userinfo.preferred_username.trim().is_empty() { - return userinfo.preferred_username.trim().to_owned(); - } - userinfo.email.clone() -} - -async fn current_session(state: &AppState, headers: &HeaderMap) -> Result> { - let Some(session_id) = session_cookie(headers) else { - return Ok(None); - }; - Ok(state.sessions.lock().await.get(&session_id).cloned()) -} - -async fn require_session( - state: &AppState, - headers: &HeaderMap, -) -> Result { - current_session(state, headers) - .await - .map_err(internal_error)? - .ok_or_else(|| (StatusCode::UNAUTHORIZED, "sign-in required".to_owned())) -} - -async fn prune_pending(state: &AppState) { - state - .pending_logins - .lock() - .await - .retain(|_, login| login.expires_at > Instant::now()); -} - -fn session_cookie(headers: &HeaderMap) -> Option { - let cookie_header = headers.get(COOKIE)?.to_str().ok()?; - for pair in cookie_header.split(';') { - let mut parts = pair.trim().splitn(2, '='); - let name = parts.next()?.trim(); - let value = parts.next()?.trim(); - if name == SESSION_COOKIE && !value.is_empty() { - return Some(value.to_owned()); - } - } - None -} - -fn session_cookie_value(session_id: &str) -> String { - format!("{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; Secure; SameSite=Lax") -} - -fn random_url_token(bytes: usize) -> String { - let mut buf = vec![0u8; bytes]; - rand::thread_rng().fill_bytes(&mut buf); - URL_SAFE_NO_PAD.encode(buf) -} - -fn pkce_challenge(verifier: &str) -> String { - let digest = digest(&SHA256, verifier.as_bytes()); - URL_SAFE_NO_PAD.encode(digest.as_ref()) -} - -fn escape_html(input: &str) -> String { - input - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) -} - -fn internal_error(err: impl std::fmt::Display) -> (StatusCode, String) { - (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) -} - -impl NamespaceSessionManager { - fn new(config: NamespacePortalConfig) -> Self { - Self { - config, - state: Arc::new(Mutex::new(NamespacePortalState::default())), - } - } - - async fn status(&self) -> Result { - let linked = self.check_login().await.is_ok(); - let state = self.state.lock().await.clone(); - let token_present = tokio::fs::metadata(&self.config.token_output_path) - .await - .is_ok(); - Ok(NamespaceStatus { - linked, - login_url: state.active_login.map(|login| login.login_url), - last_error: state.last_error, - token_present, - }) - } - - async fn start_login(&self) -> Result { - if self.check_login().await.is_ok() { - self.refresh_token().await?; - return Ok("already linked".to_owned()); - } - - { - let state = self.state.lock().await; - if let Some(active) = &state.active_login { - return Ok(active.login_url.clone()); - } - } - - self.config.ensure_paths()?; - let mut command = self.base_command(); - command - .args(["auth", "login", "--browser=false"]) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - let mut child = command.spawn().context("failed to spawn nsc auth login")?; - let stdout = child - .stdout - .take() - .context("nsc auth login stdout was not piped")?; - let mut lines = BufReader::new(stdout).lines(); - let mut login_url = None; - while let Some(line) = lines.next_line().await? { - if let Some(candidate) = extract_namespace_login_url(&line) { - login_url = Some(candidate); - break; - } - } - - let login_url = login_url - .ok_or_else(|| anyhow!("nsc auth login did not emit a Namespace login URL"))?; - { - let mut state = self.state.lock().await; - state.active_login = Some(ActiveNamespaceLogin { login_url: login_url.clone() }); - state.last_error = None; - } - - let manager = self.clone(); - tokio::spawn(async move { - let outcome = child.wait().await; - let mut state = manager.state.lock().await; - state.active_login = None; - match outcome { - Ok(status) if status.success() => { - drop(state); - if let Err(err) = manager.refresh_token().await { - manager.state.lock().await.last_error = Some(format!( - "Namespace login finished, but token refresh failed: {err}" - )); - } - } - Ok(status) => { - state.last_error = Some(format!( - "Namespace login command exited with status {}", - status - )); - } - Err(err) => { - state.last_error = Some(format!("Namespace login command failed: {err}")); - } - } - }); - - Ok(login_url) - } - - async fn refresh_token(&self) -> Result<()> { - self.config.ensure_paths()?; - self.check_login().await?; - let mut command = self.base_command(); - command.args([ - "auth", - "generate-dev-token", - "--output_to", - self.config - .token_output_path - .to_str() - .ok_or_else(|| anyhow!("token output path is not valid UTF-8"))?, - ]); - let output = command - .output() - .await - .context("failed to run nsc token refresh")?; - if !output.status.success() { - bail!( - "nsc auth generate-dev-token failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - #[cfg(target_family = "unix")] - { - use std::os::unix::fs::PermissionsExt; - - let perms = fs::Permissions::from_mode(0o440); - fs::set_permissions(&self.config.token_output_path, perms).with_context(|| { - format!( - "failed to set permissions on {}", - self.config.token_output_path.display() - ) - })?; - } - self.state.lock().await.last_error = None; - Ok(()) - } - - async fn check_login(&self) -> Result<()> { - let mut command = self.base_command(); - command.args(["auth", "check-login", "--duration", AUTH_CHECK_DURATION]); - let output = command - .output() - .await - .context("failed to run nsc auth check-login")?; - if output.status.success() { - return Ok(()); - } - bail!("{}", String::from_utf8_lossy(&output.stderr).trim()); - } - - fn base_command(&self) -> Command { - let mut command = Command::new(&self.config.nsc_bin); - let home = self.config.nsc_state_dir.join("home"); - let data = self.config.nsc_state_dir.join("data"); - let cache = self.config.nsc_state_dir.join("cache"); - let config = self.config.nsc_state_dir.join("config"); - let _ = fs::create_dir_all(&home); - let _ = fs::create_dir_all(&data); - let _ = fs::create_dir_all(&cache); - let _ = fs::create_dir_all(&config); - command - .env("HOME", &home) - .env("XDG_DATA_HOME", &data) - .env("XDG_CACHE_HOME", &cache) - .env("XDG_CONFIG_HOME", &config); - command - } -} - -fn extract_namespace_login_url(line: &str) -> Option { - line.split_whitespace() - .find(|token| token.starts_with("https://")) - .map(ToOwned::to_owned) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn extracts_namespace_login_url_from_output() { - let url = extract_namespace_login_url( - " https://cloud.namespace.so/login/workspace?id=p0cl4ik19c4c473u14tvc3vq2o", - ); - assert_eq!( - url.as_deref(), - Some("https://cloud.namespace.so/login/workspace?id=p0cl4ik19c4c473u14tvc3vq2o") - ); - } - - #[test] - fn pkce_challenge_is_stable() { - assert_eq!( - pkce_challenge("hello"), - "LPJNul-wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ" - ); - } - - #[test] - fn parses_session_cookie() { - let mut headers = HeaderMap::new(); - headers.insert( - COOKIE, - HeaderValue::from_static( - "something=else; burrow_namespace_portal_session=session123; another=value", - ), - ); - assert_eq!(session_cookie(&headers).as_deref(), Some("session123")); - } -} diff --git a/flake.nix b/flake.nix index 0bba0b1..1974f17 100644 --- a/flake.nix +++ b/flake.nix @@ -214,8 +214,6 @@ nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default; nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix; nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.nix; - nixosModules.burrow-namespace-portal = import ./nixos/modules/burrow-namespace-portal.nix; - nixosConfigurations.burrow-forge = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; specialArgs = { diff --git a/nixos/README.md b/nixos/README.md index 13fe76d..23907f3 100644 --- a/nixos/README.md +++ b/nixos/README.md @@ -12,7 +12,6 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B - upstream `compatible.systems/conrad/nsc-autoscaler`: Namespace-backed ephemeral Forgejo runner module consumed via the Burrow flake input - `modules/burrow-authentik.nix`: minimal Authentik IdP for Burrow control planes - `modules/burrow-headscale.nix`: Headscale control plane rooted in Authentik OIDC -- `modules/burrow-namespace-portal.nix`: small admin portal for forge-owned Namespace authentication and NSC token refresh - `../secrets.nix`: agenix recipient map for tracked Burrow forge secrets - `hetzner-cloud-config.yaml`: desired Hetzner host shape - `keys/contact_at_burrow_net.pub`: initial operator SSH public key @@ -24,8 +23,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B - `../Scripts/cloudflare-upsert-a-record.sh`: upsert DNS-only Cloudflare `A` records for Burrow host cutovers - `../Scripts/forge-deploy.sh`: remote `nixos-rebuild` entrypoint for the forge host - `../Scripts/provision-forgejo-nsc.sh`: render Burrow Namespace dispatcher/autoscaler runtime inputs and ensure the default Forgejo scope exists -- `../Scripts/sync-forgejo-nsc-config.sh`: copy intake-backed dispatcher/autoscaler inputs to the host -- `../Scripts/authentik-sync-namespace-portal-oidc.sh`: reconcile the Authentik OIDC app used by `nsc.burrow.net` +- `../Scripts/seal-forgejo-nsc-secrets.sh`: encrypt forgejo-nsc runtime inputs into the agenix secrets consumed by `burrow-forge` ## Intended Flow @@ -34,16 +32,17 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B 3. Run `Scripts/bootstrap-forge-intake.sh` to place the Forgejo bootstrap password file and automation SSH key under `/var/lib/burrow/intake/`. 4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account. 5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent `. -6. Run `Scripts/provision-forgejo-nsc.sh` locally, then `Scripts/sync-forgejo-nsc-config.sh` to place the raw Namespace dispatcher/autoscaler runtime inputs under `/var/lib/burrow/intake/` for the upstream `services.forgejo-nsc` module. -7. Visit `https://nsc.burrow.net/` as a Burrow admin to link the forge-owned Namespace session and rotate `/var/lib/burrow/intake/forgejo_nsc_token.txt` without relying on a personal local `nsc` login. -8. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`. -9. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, `nsc.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. +6. Run `Scripts/provision-forgejo-nsc.sh` locally to refresh `intake/forgejo_nsc_token.txt`, `intake/forgejo_nsc_dispatcher.yaml`, and `intake/forgejo_nsc_autoscaler.yaml`. +7. Run `Scripts/seal-forgejo-nsc-secrets.sh` to encrypt those runtime inputs into the agenix secrets used by `burrow-forge`. +8. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, `secrets/infra/headscale-oidc-client-secret.age`, `secrets/infra/forgejo-nsc-token.age`, `secrets/infra/forgejo-nsc-dispatcher-config.age`, and `secrets/infra/forgejo-nsc-autoscaler-config.age`, and let agenix materialize them under `/run/agenix/`. +9. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. 10. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace. 11. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`. ## Current Constraints -- `burrow-forge` is live on NixOS in `hel1` at `89.167.47.21`, and `Scripts/check-forge-host.sh --expect-nsc` passes locally against that host. +- `burrow-forge` is live on NixOS in `hel1` at `89.167.47.21`. +- `services.forgejo-nsc` now expects agenix-backed runtime inputs at `/run/agenix/burrowForgejoNscToken`, `/run/agenix/burrowForgejoNscDispatcherConfig`, and `/run/agenix/burrowForgejoNscAutoscalerConfig`. - Authentik and Headscale secrets now live in tracked agenix blobs under `secrets/infra/` and decrypt to `/run/agenix/` on the forge host. - Public Burrow forge cutover completed on March 15, 2026: - `burrow.net`, `git.burrow.net`, and `nsc-autoscaler.burrow.net` now publish public `A` records to `89.167.47.21` diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index aecdbfa..7f6af22 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -33,7 +33,6 @@ in self.nixosModules.burrow-forgejo-nsc self.nixosModules.burrow-authentik self.nixosModules.burrow-headscale - self.nixosModules.burrow-namespace-portal ]; system.stateVersion = "24.11"; @@ -88,10 +87,28 @@ in group = "root"; mode = "0400"; }; + age.secrets.burrowForgejoNscToken = { + file = ../../../secrets/infra/forgejo-nsc-token.age; + owner = "forgejo-nsc"; + group = "forgejo-nsc"; + mode = "0400"; + }; + age.secrets.burrowForgejoNscDispatcherConfig = { + file = ../../../secrets/infra/forgejo-nsc-dispatcher-config.age; + owner = "forgejo-nsc"; + group = "forgejo-nsc"; + mode = "0400"; + }; + age.secrets.burrowForgejoNscAutoscalerConfig = { + file = ../../../secrets/infra/forgejo-nsc-autoscaler-config.age; + owner = "forgejo-nsc"; + group = "forgejo-nsc"; + mode = "0400"; + }; networking.extraHosts = '' - 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net nsc.burrow.net - ::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net nsc.burrow.net + 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net + ::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net ''; services.burrow.forge = { @@ -113,13 +130,13 @@ in services.forgejo-nsc = { enable = true; - nscTokenFile = "/var/lib/burrow/intake/forgejo_nsc_token.txt"; + nscTokenFile = config.age.secrets.burrowForgejoNscToken.path; dispatcher = { - configFile = "/var/lib/burrow/intake/forgejo_nsc_dispatcher.yaml"; + configFile = config.age.secrets.burrowForgejoNscDispatcherConfig.path; }; autoscaler = { enable = true; - configFile = "/var/lib/burrow/intake/forgejo_nsc_autoscaler.yaml"; + configFile = config.age.secrets.burrowForgejoNscAutoscalerConfig.path; }; }; @@ -141,11 +158,4 @@ in enable = true; oidcClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; }; - - services.burrow.namespacePortal = { - enable = true; - domain = "nsc.burrow.net"; - baseUrl = "https://nsc.burrow.net"; - adminGroup = contributors.groups.admins; - }; } diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index e2ee18d..1616b36 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -10,7 +10,6 @@ let dataVolume = "burrow-authentik-data:/data"; directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh; forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; - namespacePortalOidcSyncScript = ../../Scripts/authentik-sync-namespace-portal-oidc.sh; tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh; @@ -139,30 +138,6 @@ in description = "Authentik application slug for Tailscale custom OIDC sign-in."; }; - namespacePortalDomain = lib.mkOption { - type = lib.types.str; - default = "nsc.burrow.net"; - description = "Public domain for the Burrow Namespace portal."; - }; - - namespacePortalProviderSlug = lib.mkOption { - type = lib.types.str; - default = "namespace"; - description = "Authentik application slug for the Namespace portal."; - }; - - namespacePortalClientId = lib.mkOption { - type = lib.types.str; - default = "nsc.burrow.net"; - description = "Client ID Authentik should present to the Namespace portal."; - }; - - namespacePortalClientSecretFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Optional host-local file containing the Authentik Namespace portal OIDC client secret."; - }; - tailscaleClientId = lib.mkOption { type = lib.types.str; default = "tailscale.burrow.net"; @@ -733,56 +708,6 @@ EOF ''; }; - systemd.services.burrow-authentik-namespace-portal-oidc = { - description = "Reconcile the Burrow Authentik Namespace portal OIDC application"; - after = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wants = [ - "burrow-authentik-ready.service" - "network-online.target" - ]; - wantedBy = [ "multi-user.target" ]; - restartTriggers = - [ - namespacePortalOidcSyncScript - cfg.envFile - ] - ++ lib.optionals (cfg.namespacePortalClientSecretFile != null) [ cfg.namespacePortalClientSecretFile ]; - path = [ - pkgs.bash - pkgs.coreutils - pkgs.curl - pkgs.jq - ]; - serviceConfig = { - Type = "oneshot"; - User = "root"; - Group = "root"; - }; - script = '' - set -euo pipefail - set -a - source ${lib.escapeShellArg cfg.envFile} - set +a - - export AUTHENTIK_URL=https://${cfg.domain} - export AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_SLUG=${lib.escapeShellArg cfg.namespacePortalProviderSlug} - export AUTHENTIK_NAMESPACE_PORTAL_APPLICATION_NAME="Namespace Portal" - export AUTHENTIK_NAMESPACE_PORTAL_PROVIDER_NAME="Namespace Portal" - export AUTHENTIK_NAMESPACE_PORTAL_TEMPLATE_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} - export AUTHENTIK_NAMESPACE_PORTAL_CLIENT_ID=${lib.escapeShellArg cfg.namespacePortalClientId} - ${lib.optionalString (cfg.namespacePortalClientSecretFile != null) '' - export AUTHENTIK_NAMESPACE_PORTAL_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.namespacePortalClientSecretFile})" - ''} - export AUTHENTIK_NAMESPACE_PORTAL_LAUNCH_URL=https://${cfg.namespacePortalDomain}/ - export AUTHENTIK_NAMESPACE_PORTAL_REDIRECT_URIS_JSON='["https://${cfg.namespacePortalDomain}/oauth/callback"]' - - ${pkgs.bash}/bin/bash ${namespacePortalOidcSyncScript} - ''; - }; - services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd reverse_proxy 127.0.0.1:${toString cfg.port} diff --git a/nixos/modules/burrow-namespace-portal.nix b/nixos/modules/burrow-namespace-portal.nix deleted file mode 100644 index 2eb7b24..0000000 --- a/nixos/modules/burrow-namespace-portal.nix +++ /dev/null @@ -1,126 +0,0 @@ -{ config, lib, pkgs, self, ... }: - -let - cfg = config.services.burrow.namespacePortal; - burrowExe = lib.getExe self.packages.${pkgs.system}.burrow; - nscExe = lib.getExe self.packages.${pkgs.system}.nsc; -in -{ - options.services.burrow.namespacePortal = { - enable = lib.mkEnableOption "the Burrow Namespace authentication portal"; - - domain = lib.mkOption { - type = lib.types.str; - default = "nsc.burrow.net"; - description = "Public domain for the Namespace portal."; - }; - - port = lib.mkOption { - type = lib.types.port; - default = 9080; - description = "Local listen port for the Namespace portal."; - }; - - baseUrl = lib.mkOption { - type = lib.types.str; - default = "https://nsc.burrow.net"; - description = "Public base URL for redirects."; - }; - - oidcProviderSlug = lib.mkOption { - type = lib.types.str; - default = "namespace"; - description = "Authentik provider slug used for the portal."; - }; - - oidcClientId = lib.mkOption { - type = lib.types.str; - default = "nsc.burrow.net"; - description = "OIDC client ID used by the portal."; - }; - - oidcClientSecretFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Optional host-local OIDC client secret for the portal."; - }; - - adminGroup = lib.mkOption { - type = lib.types.str; - default = "burrow-admins"; - description = "Authentik group required to access the portal."; - }; - - stateDir = lib.mkOption { - type = lib.types.str; - default = "/var/lib/burrow/namespace-portal"; - description = "Persistent state directory for the portal-owned NSC session."; - }; - - tokenOutputPath = lib.mkOption { - type = lib.types.str; - default = "/var/lib/burrow/intake/forgejo_nsc_token.txt"; - description = "Path where refreshed NSC tokens should be written."; - }; - }; - - config = lib.mkIf cfg.enable { - assertions = [ - { - assertion = config.services.forgejo-nsc.enable; - message = "services.burrow.namespacePortal requires services.forgejo-nsc.enable"; - } - ]; - - systemd.tmpfiles.rules = [ - "d ${cfg.stateDir} 0750 forgejo-nsc forgejo-nsc -" - "d ${cfg.stateDir}/nsc 0750 forgejo-nsc forgejo-nsc -" - ]; - - systemd.services.burrow-namespace-portal = { - description = "Burrow Namespace authentication portal"; - after = [ - "network-online.target" - "burrow-authentik-ready.service" - ]; - wants = [ - "network-online.target" - "burrow-authentik-ready.service" - ]; - wantedBy = [ "multi-user.target" ]; - path = [ - self.packages.${pkgs.system}.burrow - self.packages.${pkgs.system}.nsc - pkgs.coreutils - ]; - serviceConfig = { - Type = "simple"; - User = "forgejo-nsc"; - Group = "forgejo-nsc"; - WorkingDirectory = cfg.stateDir; - Restart = "on-failure"; - RestartSec = "2s"; - }; - script = '' - set -euo pipefail - export BURROW_NAMESPACE_PORTAL_LISTEN=127.0.0.1:${toString cfg.port} - export BURROW_NAMESPACE_PORTAL_BASE_URL=${lib.escapeShellArg cfg.baseUrl} - export BURROW_NAMESPACE_PORTAL_OIDC_DISCOVERY_URL=${lib.escapeShellArg "https://${config.services.burrow.authentik.domain}/application/o/${cfg.oidcProviderSlug}/.well-known/openid-configuration"} - export BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_ID=${lib.escapeShellArg cfg.oidcClientId} - export BURROW_NAMESPACE_PORTAL_ALLOWED_GROUP=${lib.escapeShellArg cfg.adminGroup} - export BURROW_NAMESPACE_PORTAL_NSC_BIN=${lib.escapeShellArg nscExe} - export BURROW_NAMESPACE_PORTAL_NSC_STATE_DIR=${lib.escapeShellArg "${cfg.stateDir}/nsc"} - export BURROW_NAMESPACE_PORTAL_TOKEN_OUTPUT_PATH=${lib.escapeShellArg cfg.tokenOutputPath} - ${lib.optionalString (cfg.oidcClientSecretFile != null) '' - export BURROW_NAMESPACE_PORTAL_OIDC_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.oidcClientSecretFile})" - ''} - exec ${burrowExe} namespace-portal - ''; - }; - - services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' - encode gzip zstd - reverse_proxy 127.0.0.1:${toString cfg.port} - ''; - }; -} diff --git a/secrets.nix b/secrets.nix index c0b9b53..a8fb923 100644 --- a/secrets.nix +++ b/secrets.nix @@ -16,6 +16,9 @@ in "secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-ui-test-password.age".publicKeys = uiTestRecipients; "secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients; + "secrets/infra/forgejo-nsc-autoscaler-config.age".publicKeys = burrowForgeRecipients; + "secrets/infra/forgejo-nsc-dispatcher-config.age".publicKeys = burrowForgeRecipients; + "secrets/infra/forgejo-nsc-token.age".publicKeys = burrowForgeRecipients; "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; } diff --git a/secrets/infra/forgejo-nsc-autoscaler-config.age b/secrets/infra/forgejo-nsc-autoscaler-config.age new file mode 100644 index 0000000000000000000000000000000000000000..28e3d4ae7ab2661a31c8175b638ac203ef7414f5 GIT binary patch literal 1264 zcmYdHPt{G$OD?J`D9Oyv)5|YP*Do{V(zR14F3!+RO))YxHMCSHtuXPk2vn%ZPch6g z^K>k8clR#$4b1T@sj4h43Qut=*Uzpjb`K0Psz{8;@+b}t&gKfL3^d5|%`fms&kQZd z_6sym&eS&b^{Xt(F9{6Hc1kWZ4>n8kC@M0`k3_f4vnVRpFFU78w>%WtJfsNd}RoVYx;= zl|jKt9vNQQ5n0}4`B5c-?!H__AtvdbK`Gwah6aX48Sa(YA*EGGfu82RWjR$Dm5$ol zX^91?S*aFD#jaesy1EKxnLg#om8G6RrvBxwQB`j4LE44BSsoQ8hUxw;!J%$Bx#f-~ ziQazUKKWexCro>@`sL+AF2P%-?VnSA_v+14+vPW86nk&p{X#nQuy^3>RiEE5`Coo} z?rF!3{j&?#^giCOc%DtTWR=@fL$7P=bvA8Z=eTNB{fexA>Y_T^d6?fYiV{doL(PWGBpH+sU}soI|UzCk`oGOo3gt0?<(1%Ho#t=sCSPxQ_6MaZ@Jb&dx{NTgw8oPPsTkz`DNwrLn`}=9mOuGWzVs56Zvh=?;X0j zljR_Paf|yZc7OZ{PowBi~aXT&kjX(VB&C z8f$LOdhX|Y{BXb}`Jm22{+SzR_VJgCP5%5-zHVb#+1_`WcceK~wD!a)J<7?dmuCLcQgw*qZB}LT^+8I2-b%V$upd?Otw`bdkML zXd_l~_1Nu=i+p1@Jzn}QfbV|Nxy5Virt3_5Hg|2hrTp@Ubu*o{y$;;7xxg2%x6Up` zHdgZI+Su7ef2?m9@m_qsASO({D7e2uc0;6X*uSgG#9mK0FQ6^-H)~<)5_!e2y6-D~ zu3G#;@XH$K!B_TB3$myc?H(4!)Us*A8LLvj+w%%v^Yk0xE|MH1d zH#aZW&X|2^=c%gd=;DBtZ_VnZ?^?x3x2c)En0WGE=FYyK%ySRPFs$y@`K;%g)Wn)t zrrdfhSbIS?Gjp-FOu|3r_eVX`=CfRTT(x!G0{J!F3P1ka+5R#*{cF?tPl~76rv6Q8 zJs=XK>Adik;(pF2qNU3wRo%THy}Mn*s=DxTt7Ed48`Ql_>`@MoUi!f$ z`i7<^6=^w1Zs}aQy1EKw#vYz&g}$XFUU?}_6(td=S(#qBW}acD#r~1nP6j?nxyk<8 zMxo|zMJ8N~ZF~a0p;1kLJGi)Bwru4*`t8_5*vAfi zCw!wiR$S%$wD*-znVtCD=5Gu6R=V%FeKp)#_;10!xh_ zF21PLR`jNG!R0mY)8BDRFVnmCbGGA!t6Tp+V4rvL)pZ?Xv)1cVcc?A6meHqmV2!NT zis}!J>qDpM7TlZ|BBtu*ll4zJ>f-?^-l&=TEf33XW2r2>81!yhx9*k}9Y+2?Q~V|$ zS4#YKTXdECT#ngAx1Suh5Z9lwO+xg6_r$zAy<9O&LN_J{728*yy*!_xFuifkRK;gq zrVliY`(C9zc9~nUE@xliVi9vygW~M(-}>(=tW8jy`$W0HG5dq}hLaqA{Ij2ao+>%< zw;J1Il?nZ)H|&)v{gSic)-9`d9(F=@qL&_b+3&N@di+|AeIFNph}<79qt$QjybU`3 zVWX$W{0$5Lh0QLv$yxVmZ|@;96W<*hv_*TiSBvE~9u?9TPMGxj`{(mAZC0r&0Z~8E!>s<85g!8X{YZb^eF8Fe~F3M+0wJFQQML#E{Y)iPbW+i9EuR|~5 zm*?JH@#8_HW8Bs6Ri}%4m$mu)<1ah)DF2qPvB{39@X+W$F|WCV|{ zztO(JNu}(%tnZj-=`eoD62w<>8J%jM`(4w^On%KSc6^+!(owNiW} zwY6S`yeKz2R`=$;;=~+V_q*;N#rtNwn%xoiUCH67wdFBG9k0L_-7eGq&wceig!5o( zC$Hh#T{D)}-R{~ha?R9_slugDRr77p^R?$@FxYDOX_w|dl6O*N+2CA%AvN#wozOMH zQ)OGCdomjTf9Y`3eR}oWUA@&yk3?LlQTXcLvX?o!{ciN19WQ>UF?y-JQ ssh-ed25519 ux4N8Q yCjzc3QW91l62Y+U2YZqLpTkiZyTJAxQQCiZ+DxHiWI +mG/+2fppo3RITeohTM/Dm1M6fsErtxhOgIeI2FqvoUs +-> ssh-ed25519 IrZmAg +Y59O8SVATZfe8Vu2gis1KNWcL34Ct7M3G34XNURczw +GGkVYcmoUtJRx4zftjLFID2wLtNtCgGVnYuMN8XF74s +-> X25519 xqDMDV9XRhSPlFy2IJPBfpUGuNA9gpX73kg8Pnj48VI +TPZZNrRUK+FzruetDFuJcTzed03d7gkxOv8QAZshBn8 +--- PRD84efdrqDmPeRA8zi0D2V8RmT0tFVbDIVD6U/4KVo +2Wk*cS++j9{4j;`wd3,"gligЇ e`''# "'(=LS3hFjgYIF|0$Fp^` +QknUx78b!>n?9^!=ͮ [a ` ϫ_#?T@]Eβ[,g퟇cjx}.̞f45֕DLH4_HdwXwXkRx7DM,0 7*TU{~ä8yC "/oXCe8-ulYt ;ҖDZdm wFyiIώɅ8F}l"Isu{L!+UBei_Z~D>B)L> Date: Mon, 6 Apr 2026 01:08:24 -0700 Subject: [PATCH 065/102] Install nsc on burrow forge host --- nixos/hosts/burrow-forge/default.nix | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 7f6af22..64f45bc 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -1,4 +1,4 @@ -{ config, lib, self, ... }: +{ config, lib, pkgs, self, ... }: let contributors = import ../../../contributors.nix; @@ -44,6 +44,10 @@ in "flakes" ]; + environment.systemPackages = lib.optionals config.services.forgejo-nsc.enable [ + self.packages.${pkgs.stdenv.hostPlatform.system}.nsc + ]; + age.identityPaths = [ "/var/lib/agenix/agenix.key" ]; age.secrets.burrowAuthentikEnv = { file = ../../../secrets/infra/authentik.env.age; From 5e58aafb07e08cac67d01b9a61d9646170a26e2b Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Mon, 6 Apr 2026 01:12:47 -0700 Subject: [PATCH 066/102] Align Forgejo runner labels with workflows --- nixos/hosts/burrow-forge/default.nix | 6 ++++++ nixos/modules/burrow-forge-runner.nix | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 64f45bc..bf6330f 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -130,6 +130,12 @@ in services.burrow.forgeRunner = { enable = true; sshPrivateKeyFile = "/var/lib/burrow/intake/agent_at_burrow_net_ed25519"; + labels = [ + "self-hosted" + "linux" + "x86_64" + "burrow-forge" + ]; }; services.forgejo-nsc = { diff --git a/nixos/modules/burrow-forge-runner.nix b/nixos/modules/burrow-forge-runner.nix index 1e183d2..d4ade40 100644 --- a/nixos/modules/burrow-forge-runner.nix +++ b/nixos/modules/burrow-forge-runner.nix @@ -5,8 +5,10 @@ let runnerPkg = pkgs.forgejo-runner; stateDir = cfg.stateDir; runnerFile = "${stateDir}/.runner"; + registrationFingerprintFile = "${stateDir}/.runner-registration-fingerprint"; configFile = "${stateDir}/runner.yaml"; labelsCsv = lib.concatStringsSep "," (map (label: "${label}:host") cfg.labels); + registrationFingerprint = builtins.hashString "sha256" "${cfg.instanceUrl}\n${cfg.name}\n${labelsCsv}"; sshPrivateKeyFile = cfg.sshPrivateKeyFile or ""; in { @@ -141,6 +143,17 @@ EOF chown ${cfg.user}:${cfg.group} ${configFile} chmod 0640 ${configFile} + expected_fingerprint=${lib.escapeShellArg registrationFingerprint} + if [ -s ${runnerFile} ]; then + current_fingerprint="" + if [ -s ${registrationFingerprintFile} ]; then + current_fingerprint="$(tr -d '\r\n' < ${registrationFingerprintFile})" + fi + if [ "${"$"}current_fingerprint" != "${"$"}expected_fingerprint" ]; then + rm -f ${runnerFile} ${registrationFingerprintFile} + fi + fi + install -d -m 0700 -o ${cfg.user} -g ${cfg.group} ${stateDir}/.ssh ${pkgs.util-linux}/bin/runuser -u ${cfg.user} -- \ ${pkgs.git}/bin/git config --global user.name ${lib.escapeShellArg cfg.gitUserName} @@ -177,6 +190,10 @@ EOF --name ${lib.escapeShellArg cfg.name} \ --labels ${lib.escapeShellArg labelsCsv} \ --config ${configFile} + + printf '%s\n' "${"$"}expected_fingerprint" > ${registrationFingerprintFile} + chown ${cfg.user}:${cfg.group} ${registrationFingerprintFile} + chmod 0640 ${registrationFingerprintFile} fi ''; }; From fbe864391448fb3fafaa4ef7bc45e2ee96469307 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Mon, 6 Apr 2026 01:15:46 -0700 Subject: [PATCH 067/102] Restart Forgejo runner when registration changes --- nixos/modules/burrow-forge-runner.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/nixos/modules/burrow-forge-runner.nix b/nixos/modules/burrow-forge-runner.nix index d4ade40..034fb38 100644 --- a/nixos/modules/burrow-forge-runner.nix +++ b/nixos/modules/burrow-forge-runner.nix @@ -208,6 +208,7 @@ EOF User = cfg.user; Group = cfg.group; WorkingDirectory = stateDir; + Environment = [ "BURROW_RUNNER_REGISTRATION_FINGERPRINT=${registrationFingerprint}" ]; Restart = "on-failure"; RestartSec = 2; ExecStart = pkgs.writeShellScript "burrow-forgejo-runner" '' From aa577c561606e5e391624179150c9f4168030419 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Mon, 6 Apr 2026 04:22:34 -0700 Subject: [PATCH 068/102] Inline Forgejo workflow checkout --- .forgejo/workflows/build-rust.yml | 19 +++++++++++++++---- .forgejo/workflows/build-site.yml | 19 +++++++++++++++---- .forgejo/workflows/lint-governance.yml | 19 +++++++++++++++---- 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/.forgejo/workflows/build-rust.yml b/.forgejo/workflows/build-rust.yml index 2df1ad3..9ed49e1 100644 --- a/.forgejo/workflows/build-rust.yml +++ b/.forgejo/workflows/build-rust.yml @@ -19,10 +19,21 @@ jobs: runs-on: [self-hosted, linux, x86_64, burrow-forge] steps: - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 + shell: bash + run: | + set -euo pipefail + repo_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + if [ ! -d .git ]; then + git init . + fi + if git remote get-url origin >/dev/null 2>&1; then + git remote set-url origin "${repo_url}" + else + git remote add origin "${repo_url}" + fi + git fetch --force --tags origin "${GITHUB_SHA}" + git checkout --force --detach FETCH_HEAD + git clean -ffdqx - name: Test shell: bash diff --git a/.forgejo/workflows/build-site.yml b/.forgejo/workflows/build-site.yml index 6f7c5e2..239b3b2 100644 --- a/.forgejo/workflows/build-site.yml +++ b/.forgejo/workflows/build-site.yml @@ -19,10 +19,21 @@ jobs: runs-on: [self-hosted, linux, x86_64, burrow-forge] steps: - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 + shell: bash + run: | + set -euo pipefail + repo_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + if [ ! -d .git ]; then + git init . + fi + if git remote get-url origin >/dev/null 2>&1; then + git remote set-url origin "${repo_url}" + else + git remote add origin "${repo_url}" + fi + git fetch --force --tags origin "${GITHUB_SHA}" + git checkout --force --detach FETCH_HEAD + git clean -ffdqx - name: Build shell: bash diff --git a/.forgejo/workflows/lint-governance.yml b/.forgejo/workflows/lint-governance.yml index 490702e..2db94cc 100644 --- a/.forgejo/workflows/lint-governance.yml +++ b/.forgejo/workflows/lint-governance.yml @@ -15,10 +15,21 @@ jobs: runs-on: [self-hosted, linux, x86_64, burrow-forge] steps: - name: Checkout - uses: https://code.forgejo.org/actions/checkout@v4 - with: - token: ${{ github.token }} - fetch-depth: 0 + shell: bash + run: | + set -euo pipefail + repo_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + if [ ! -d .git ]; then + git init . + fi + if git remote get-url origin >/dev/null 2>&1; then + git remote set-url origin "${repo_url}" + else + git remote add origin "${repo_url}" + fi + git fetch --force --tags origin "${GITHUB_SHA}" + git checkout --force --detach FETCH_HEAD + git clean -ffdqx - name: Validate BEP metadata shell: bash From bc85e256f2299908468d7007306fd5f62d7e1eeb Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Thu, 9 Apr 2026 20:59:31 -0700 Subject: [PATCH 069/102] Stabilize Forgejo site build --- .forgejo/workflows/build-site.yml | 2 +- site/layout/layout.tsx | 25 +- site/package-lock.json | 3907 +++++++++++++++++++++++++++++ site/pages/index.tsx | 56 +- 4 files changed, 3950 insertions(+), 40 deletions(-) create mode 100644 site/package-lock.json diff --git a/.forgejo/workflows/build-site.yml b/.forgejo/workflows/build-site.yml index 239b3b2..67be5bb 100644 --- a/.forgejo/workflows/build-site.yml +++ b/.forgejo/workflows/build-site.yml @@ -39,4 +39,4 @@ jobs: shell: bash run: | set -euo pipefail - nix develop .#ci -c bash -lc 'cd site && npm install && npm run build' + nix develop .#ci -c bash -lc 'cd site && npm ci --no-audit --no-fund && npm run build' diff --git a/site/layout/layout.tsx b/site/layout/layout.tsx index 28ff24d..057aa68 100644 --- a/site/layout/layout.tsx +++ b/site/layout/layout.tsx @@ -1,20 +1,5 @@ -import { Space_Mono, Poppins } from "next/font/google"; import localFont from "next/font/local"; -const space_mono = Space_Mono({ - weight: ["400", "700"], - subsets: ["latin"], - display: "swap", - variable: "--font-space-mono", -}); - -const poppins = Poppins({ - weight: ["400", "500", "600", "700", "800", "900"], - subsets: ["latin"], - display: "swap", - variable: "--font-poppins", -}); - const phantomSans = localFont({ src: [ { @@ -36,10 +21,18 @@ const phantomSans = localFont({ variable: "--font-phantom-sans", }); +const fallbackFontVariables = { + "--font-space-mono": + '"SFMono-Regular", "SF Mono", ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", monospace', + "--font-poppins": + 'var(--font-phantom-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', +} as React.CSSProperties; + export default function Layout({ children }: { children: React.ReactNode }) { return (
{children}
diff --git a/site/package-lock.json b/site/package-lock.json new file mode 100644 index 0000000..e1357f9 --- /dev/null +++ b/site/package-lock.json @@ -0,0 +1,3907 @@ +{ + "name": "burrow", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "burrow", + "version": "0.1.0", + "dependencies": { + "@fortawesome/fontawesome-free": "^6.4.2", + "@fortawesome/fontawesome-svg-core": "^6.4.2", + "@fortawesome/free-brands-svg-icons": "^6.4.2", + "@fortawesome/free-solid-svg-icons": "^6.4.2", + "@fortawesome/react-fontawesome": "^0.2.0", + "@headlessui/react": "^1.7.17", + "@headlessui/tailwindcss": "^0.2.0", + "@types/node": "20.5.8", + "@types/react": "18.2.21", + "@types/react-dom": "18.2.7", + "autoprefixer": "10.4.15", + "eslint": "8.48.0", + "eslint-config-next": "13.4.19", + "next": "13.4.19", + "postcss": "8.4.29", + "react": "18.2.0", + "react-dom": "18.2.0", + "tailwindcss": "3.3.3", + "typescript": "5.2.2" + }, + "devDependencies": { + "prettier": "^3.0.3", + "prettier-plugin-tailwindcss": "^0.5.4" + } + }, + "node_modules/@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/runtime": { + "version": "7.22.11", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.8.0", + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.2", + "license": "MIT", + "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" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.48.0", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.5.1", + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-free": { + "version": "6.5.1", + "hasInstallScript": true, + "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", + "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core/node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", + "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-brands-svg-icons": { + "version": "6.5.1", + "hasInstallScript": true, + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.5.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.5.1", + "hasInstallScript": true, + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.5.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-fontawesome": { + "version": "0.2.0", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6", + "react": ">=16.3" + } + }, + "node_modules/@headlessui/react": { + "version": "1.7.18", + "license": "MIT", + "dependencies": { + "@tanstack/react-virtual": "^3.0.0-beta.60", + "client-only": "^0.0.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "react-dom": "^16 || ^17 || ^18" + } + }, + "node_modules/@headlessui/tailwindcss": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "tailwindcss": "^3.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.11", + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^1.2.1", + "debug": "^4.1.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "1.2.1", + "license": "BSD-3-Clause" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.19", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "13.4.19", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "13.4.19", + "license": "MIT", + "dependencies": { + "glob": "7.1.7" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "13.4.19", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.4.19.tgz", + "integrity": "sha512-jyzO6wwYhx6F+7gD8ddZfuqO4TtpJdw3wyOduR4fxTUCm3aLw7YmHGYNjS0xRSYGAkLpBkH1E0RcelyId6lNsw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.4.19.tgz", + "integrity": "sha512-vdlnIlaAEh6H+G6HrKZB9c2zJKnpPVKnA6LBwjwT2BTjxI7e0Hx30+FoWCgi50e+YO49p6oPOtesP9mXDRiiUg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.4.19.tgz", + "integrity": "sha512-aU0HkH2XPgxqrbNRBFb3si9Ahu/CpaR5RPmN2s9GiM9qJCiBBlZtRTiEca+DC+xRPyCThTtWYgxjWHgU7ZkyvA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.4.19.tgz", + "integrity": "sha512-htwOEagMa/CXNykFFeAHHvMJeqZfNQEoQvHfsA4wgg5QqGNqD5soeCer4oGlCol6NGUxknrQO6VEustcv+Md+g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.4.19.tgz", + "integrity": "sha512-4Gj4vvtbK1JH8ApWTT214b3GwUh9EKKQjY41hH/t+u55Knxi/0wesMzwQRhppK6Ddalhu0TEttbiJ+wRcoEj5Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.4.19.tgz", + "integrity": "sha512-bUfDevQK4NsIAHXs3/JNgnvEY+LRyneDN788W2NYiRIIzmILjba7LaQTfihuFawZDhRtkYCv3JDC3B4TwnmRJw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.4.19.tgz", + "integrity": "sha512-Y5kikILFAr81LYIFaw6j/NrOtmiM4Sf3GtOc0pn50ez2GCkr+oejYuKGcwAwq3jiTKuzF6OF4iT2INPoxRycEA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.4.19.tgz", + "integrity": "sha512-YzA78jBDXMYiINdPdJJwGgPNT3YqBNNGhsthsDoWHL9p24tEJn9ViQf/ZqTbwSpX/RrkPupLfuuTH2sf73JBAw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.3.3", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.1", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.2.0", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.2.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.5.8", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.5", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.2.21", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.2.7", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.3", + "license": "MIT" + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.5.0", + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "6.5.0", + "@typescript-eslint/types": "6.5.0", + "@typescript-eslint/typescript-estree": "6.5.0", + "@typescript-eslint/visitor-keys": "6.5.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.5.0", + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.5.0", + "@typescript-eslint/visitor-keys": "6.5.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.5.0", + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.5.0", + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "6.5.0", + "@typescript-eslint/visitor-keys": "6.5.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.5.4", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.5.0", + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.5.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.10.0", + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "license": "MIT", + "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" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-array-buffer": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.6", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4", + "get-intrinsic": "^1.1.3", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.3", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.1", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.1", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.1.3" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.7", + "license": "ISC" + }, + "node_modules/asynciterator.prototype": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.15", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.10", + "caniuse-lite": "^1.0.30001520", + "fraction.js": "^4.2.0", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.7.2", + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "3.2.1", + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.21.10", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "caniuse-lite": "^1.0.30001517", + "electron-to-chromium": "^1.4.477", + "node-releases": "^2.0.13", + "update-browserslist-db": "^1.0.11" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001525", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.2", + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "license": "BSD-2-Clause" + }, + "node_modules/debug": { + "version": "4.3.4", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "license": "MIT" + }, + "node_modules/define-properties": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "license": "Apache-2.0" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.4.508", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.15.0", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.22.1", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.1", + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "es-set-tostringtag": "^2.0.1", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.5", + "get-intrinsic": "^1.2.1", + "get-symbol-description": "^1.0.0", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has": "^1.0.3", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", + "is-callable": "^1.2.7", + "is-negative-zero": "^2.0.2", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.10", + "is-weakref": "^1.0.2", + "object-inspect": "^1.12.3", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.0", + "safe-array-concat": "^1.0.0", + "safe-regex-test": "^1.0.0", + "string.prototype.trim": "^1.2.7", + "string.prototype.trimend": "^1.0.6", + "string.prototype.trimstart": "^1.0.6", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.0.14", + "license": "MIT", + "dependencies": { + "asynciterator.prototype": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-set-tostringtag": "^2.0.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.2.1", + "globalthis": "^1.0.3", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.5", + "iterator.prototype": "^1.1.0", + "safe-array-concat": "^1.0.0" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.1.3", + "has": "^1.0.3", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "has": "^1.0.3" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.48.0", + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.2", + "@eslint/js": "8.48.0", + "@humanwhocodes/config-array": "^0.11.10", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "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" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-next": { + "version": "13.4.19", + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "13.4.19", + "@rushstack/eslint-patch": "^1.1.3", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-jsx-a11y": "^6.5.1", + "eslint-plugin-react": "^7.31.7", + "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.6.0", + "license": "ISC", + "dependencies": { + "debug": "^4.3.4", + "enhanced-resolve": "^5.12.0", + "eslint-module-utils": "^2.7.4", + "fast-glob": "^3.3.1", + "get-tsconfig": "^4.5.0", + "is-core-module": "^2.11.0", + "is-glob": "^4.0.3" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts/projects/eslint-import-resolver-ts" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.8.0", + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.28.1", + "license": "MIT", + "peer": true, + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.findlastindex": "^1.2.2", + "array.prototype.flat": "^1.3.1", + "array.prototype.flatmap": "^1.3.1", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.7", + "eslint-module-utils": "^2.8.0", + "has": "^1.0.3", + "is-core-module": "^2.13.0", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.6", + "object.groupby": "^1.0.0", + "object.values": "^1.1.6", + "semver": "^6.3.1", + "tsconfig-paths": "^3.14.2" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.7.1", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.7", + "aria-query": "^5.1.3", + "array-includes": "^3.1.6", + "array.prototype.flatmap": "^1.3.1", + "ast-types-flow": "^0.0.7", + "axe-core": "^4.6.2", + "axobject-query": "^3.1.1", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "has": "^1.0.3", + "jsx-ast-utils": "^3.3.3", + "language-tags": "=1.0.5", + "minimatch": "^3.1.2", + "object.entries": "^1.1.6", + "object.fromentries": "^2.0.6", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.33.2", + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flatmap": "^1.3.1", + "array.prototype.tosorted": "^1.1.1", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.0.12", + "estraverse": "^5.3.0", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.6", + "object.fromentries": "^2.0.6", + "object.hasown": "^1.1.2", + "object.values": "^1.1.6", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.4", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.8" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.4", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.9.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "license": "MIT", + "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" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.15.0", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "flatted": "^3.2.7", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.2.7", + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.3", + "license": "MIT", + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/fraction.js": { + "version": "4.3.6", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.7.0", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "7.1.7", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "license": "BSD-2-Clause" + }, + "node_modules/globals": { + "version": "13.21.0", + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.3", + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "license": "MIT" + }, + "node_modules/has": { + "version": "1.0.3", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ignore": { + "version": "5.2.4", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.0.5", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.0", + "has": "^1.0.3", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "is-typed-array": "^1.1.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.13.0", + "license": "MIT", + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.0.7", + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.1.4", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.12", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.11" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.1", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.0", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "reflect.getprototypeof": "^1.0.3" + } + }, + "node_modules/jiti": { + "version": "1.19.3", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.3", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.22", + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.5", + "license": "MIT", + "dependencies": { + "language-subtag-registry": "~0.3.2" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "license": "MIT", + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.6", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "license": "MIT" + }, + "node_modules/next": { + "version": "13.4.19", + "license": "MIT", + "dependencies": { + "@next/env": "13.4.19", + "@swc/helpers": "0.5.1", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001406", + "postcss": "8.4.14", + "styled-jsx": "5.1.1", + "watchpack": "2.4.0", + "zod": "3.21.4" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=16.8.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "13.4.19", + "@next/swc-darwin-x64": "13.4.19", + "@next/swc-linux-arm64-gnu": "13.4.19", + "@next/swc-linux-arm64-musl": "13.4.19", + "@next/swc-linux-x64-gnu": "13.4.19", + "@next/swc-linux-x64-musl": "13.4.19", + "@next/swc-win32-arm64-msvc": "13.4.19", + "@next/swc-win32-ia32-msvc": "13.4.19", + "@next/swc-win32-x64-msvc": "13.4.19" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.14", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.4", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.13", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.12.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.4", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.7", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.7", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1" + } + }, + "node_modules/object.hasown": { + "version": "1.1.3", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.1.7", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.3", + "license": "MIT", + "dependencies": { + "@aashutoshrathi/word-wrap": "^1.2.3", + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.4.29", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^2.1.1" + }, + "engines": { + "node": ">= 14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.11" + }, + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.13", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.2.5", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.5.13", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig-melody": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig-melody": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.2.0", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "globalthis": "^1.0.3", + "which-builtin-type": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.0", + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.0", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.4", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "is-regex": "^1.1.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.23.0", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.9", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.5", + "regexp.prototype.flags": "^1.5.0", + "side-channel": "^1.0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.7", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.6", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.6", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.34.0", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "7.1.6", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "7.1.6", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.3.3", + "license": "MIT", + "peer": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.2.12", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.18.2", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.0.2", + "license": "MIT", + "engines": { + "node": ">=16.13.0" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "license": "Apache-2.0" + }, + "node_modules/tsconfig-paths": { + "version": "3.14.2", + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.6.2", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.2.2", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.0.11", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/watchpack": { + "version": "2.4.0", + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.1.3", + "license": "MIT", + "dependencies": { + "function.prototype.name": "^1.1.5", + "has-tostringtag": "^1.0.0", + "is-async-function": "^2.0.0", + "is-date-object": "^1.0.5", + "is-finalizationregistry": "^1.0.2", + "is-generator-function": "^1.0.10", + "is-regex": "^1.1.4", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.1", + "is-set": "^2.0.1", + "is-weakmap": "^2.0.1", + "is-weakset": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.11", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "4.0.0", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.3.2", + "license": "ISC", + "engines": { + "node": ">= 14" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.21.4", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/site/pages/index.tsx b/site/pages/index.tsx index 73fbc33..20d7f1b 100644 --- a/site/pages/index.tsx +++ b/site/pages/index.tsx @@ -1,13 +1,36 @@ -import { faGithub } from "@fortawesome/free-brands-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import Head from "next/head"; -import { - faChevronDown, - faChevronUp, - faUpRightFromSquare, -} from "@fortawesome/free-solid-svg-icons"; import { Menu, Transition } from "@headlessui/react"; import { useState, useRef, useEffect } from "react"; + +function ChevronIcon({ open }: { open: boolean }) { + return ( + + ); +} + +function ExternalLinkIcon() { + return ( + + ); +} + +function GithubIcon() { + return ( + + ); +} + export default function Page() { const [chevron, setChevron] = useState(false); const menuButtonRef = useRef(null); @@ -71,17 +94,7 @@ export default function Page() { className="w-50 h-12 rounded-2xl bg-hackClubRed px-3 font-SpaceMono hover:scale-105 md:h-12 md:w-auto md:rounded-3xl md:text-xl 2xl:h-16 2xl:text-2xl " > Install for Linux - {chevron ? ( - - ) : ( - - )} +
From c58d06dfc1079d567d643cfca852dc451f93c936 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 18 Apr 2026 02:18:22 -0700 Subject: [PATCH 070/102] Move Burrow Google account aliases into agenix --- contributors.nix | 25 ++++++++++++++++-- nixos/hosts/burrow-forge/default.nix | 8 +++++- nixos/modules/burrow-authentik.nix | 24 ++++++++++++----- secrets.nix | 1 + .../authentik-google-account-map.json.age | Bin 0 -> 968 bytes 5 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 secrets/infra/authentik-google-account-map.json.age diff --git a/contributors.nix b/contributors.nix index 22c28b6..36bc1c9 100644 --- a/contributors.nix +++ b/contributors.nix @@ -8,7 +8,6 @@ contact = { displayName = "Burrow"; canonicalEmail = "contact@burrow.net"; - sourceEmail = "net.burrow@gmail.com"; isAdmin = true; forgeAuthorized = true; bootstrapAuthentik = true; @@ -22,7 +21,6 @@ conrad = { displayName = "Conrad Kramer"; canonicalEmail = "conrad@burrow.net"; - sourceEmail = "ckrames1234@gmail.com"; isAdmin = true; forgeAuthorized = false; bootstrapAuthentik = true; @@ -32,6 +30,29 @@ ]; }; + jett = { + displayName = "Jett"; + canonicalEmail = "jett@burrow.net"; + isAdmin = false; + forgeAuthorized = false; + bootstrapAuthentik = true; + roles = [ + "member" + ]; + }; + + davnotdev = { + displayName = "David"; + canonicalEmail = "davnotdev@burrow.net"; + isAdmin = true; + forgeAuthorized = false; + bootstrapAuthentik = true; + roles = [ + "member" + "operator" + ]; + }; + agent = { displayName = "Burrow Agent"; canonicalEmail = "agent@burrow.net"; diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index bf6330f..497d40e 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -13,7 +13,6 @@ let inherit username; name = identity.displayName; email = identity.canonicalEmail; - sourceEmail = identity.sourceEmail or null; isAdmin = identity.isAdmin or false; passwordFile = authentikPasswordSecretPath identity; } @@ -85,6 +84,12 @@ in group = "root"; mode = "0400"; }; + age.secrets.burrowAuthentikGoogleAccountMap = { + file = ../../../secrets/infra/authentik-google-account-map.json.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; age.secrets.burrowAuthentikUiTestPassword = { file = ../../../secrets/infra/authentik-ui-test-password.age; owner = "root"; @@ -158,6 +163,7 @@ in tailscaleClientSecretFile = config.age.secrets.burrowTailscaleOidcClientSecret.path; googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; + googleAccountMapFile = config.age.secrets.burrowAuthentikGoogleAccountMap.path; googleLoginMode = "redirect"; userGroupName = contributors.groups.users; adminGroupName = contributors.groups.admins; diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 1616b36..2fa83da 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -180,6 +180,12 @@ in description = "Host-local file containing the Google OAuth client secret for the Authentik source."; }; + googleAccountMapFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Optional host-local JSON file mapping external Google accounts onto Burrow Authentik users."; + }; + googleSourceSlug = lib.mkOption { type = lib.types.str; default = "google"; @@ -477,7 +483,7 @@ EOF cfg.envFile cfg.googleClientIDFile cfg.googleClientSecretFile - ]; + ] ++ lib.optional (cfg.googleAccountMapFile != null) cfg.googleAccountMapFile; path = [ pkgs.bash pkgs.coreutils @@ -501,12 +507,16 @@ EOF export AUTHENTIK_GOOGLE_USER_MATCHING_MODE=email_link export AUTHENTIK_GOOGLE_CLIENT_ID="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.googleClientIDFile})" export AUTHENTIK_GOOGLE_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.googleClientSecretFile})" - export AUTHENTIK_GOOGLE_ACCOUNT_MAP_JSON='${builtins.toJSON (map (user: { - source_email = user.sourceEmail; - username = user.username; - email = user.email; - name = user.name; - }) (lib.filter (user: user.sourceEmail != null) cfg.bootstrapUsers))}' + if [ -n ${lib.escapeShellArg (cfg.googleAccountMapFile or "")} ]; then + export AUTHENTIK_GOOGLE_ACCOUNT_MAP_JSON="$(tr -d '\n' < ${lib.escapeShellArg (cfg.googleAccountMapFile or "/dev/null")})" + else + export AUTHENTIK_GOOGLE_ACCOUNT_MAP_JSON='${builtins.toJSON (map (user: { + source_email = user.sourceEmail; + username = user.username; + email = user.email; + name = user.name; + }) (lib.filter (user: user.sourceEmail != null) cfg.bootstrapUsers))}' + fi ${pkgs.bash}/bin/bash ${googleSourceSyncScript} ''; diff --git a/secrets.nix b/secrets.nix index a8fb923..e3fd9a2 100644 --- a/secrets.nix +++ b/secrets.nix @@ -14,6 +14,7 @@ in "secrets/infra/authentik.env.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-google-client-id.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients; + "secrets/infra/authentik-google-account-map.json.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-ui-test-password.age".publicKeys = uiTestRecipients; "secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/forgejo-nsc-autoscaler-config.age".publicKeys = burrowForgeRecipients; diff --git a/secrets/infra/authentik-google-account-map.json.age b/secrets/infra/authentik-google-account-map.json.age new file mode 100644 index 0000000000000000000000000000000000000000..b3cb6f84c8d7f174f404cabbccb26a8525167538 GIT binary patch literal 968 zcmYdHPt{G$OD?J`D9Oyv)5|YP*Do{V(zR14F3!+RO))YxHMCSHtuXPk2vl%Sa`OsJ zc6Tu_%QNvaO7o5M3vzccO7`$E3@SE=a4j}ZaxXW}$PMz&N#@E)jW8>%C@C*=EeZ@u z^frrfOZ5uOtZ?_tOwrG*O!oH+cdLrbuqX{n2}HNevnVRpFSPD#l$by$SkM8$u-f$)vP$rIoT{M%a6U@Uh2>6$ zZWaY*e%WTm=3&L29!8di5e8fquAY{8+M!-mj+VJeKE)dxz1m8H%g}n3&iJs-83sPPtV`x-$h$#JFb~)O7l0)>{z0%d*xHu{Z1<( zNhU{!T#1+Ni7g+d<}Tc5nX_iI>{YANg?hfm^Y(vD4oeI)?cAO6_p9xJ_$n9G4_tl9 zhgn~6AK&z7iGnT5hTK&h%NPG<%g^R!Dh*%w_u7Q_i@#j>d}n3N;azzdX?#nKGg{?T zKVJ0=w%&GV`;-?AA71WWc=lzF^`>l-*izw!E5P!YY2g@46CVY_z+8DhsaZ9L0 z*yOTVMn#i$FR@?{_*>y4r(baUv{BbAnaL71kIcSroUau5=+FYQq)hkcS+iHwe+uKT z>bBWG+qK^Ed~UePi?cKPIUU=-)h&o$SZvoMbK}_br^a{Gn5K9-?YQ`y`|{1N5^Rhe1>fY{#=Ui7)vZP;JSikI4T+CyJptZABv};^v>uHdZ?csW`ee1r)O$BqR z{q($}=T|#<_B%=M5?IGC61-dX(D8rsf9oVT#LTGN)v0`U%bllx7!4Mjy{pBpX20U{ zOWm_wyRW=ot|)M9?%Zc7)y*jn|I3OSCELe&TnY{QxW`8O?3PI8yxX=LrMC11ettRg zl8S-uG93f|#6NGFlm1(>t>67hw%1wt=h1a*LKId!TD^DW{Mf4-`?#NY{(Qxn%`CF} z;qHo}OQz4ZPZK`y^{J2RmaG=V!;%dF|7FB1u0KDz_V0{jv$PHg_Ptk9UoeOR0CcFC Ar2qf` literal 0 HcmV?d00001 From abd5a3597031820ad46f11ad4457f090fe017c76 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 18 Apr 2026 02:42:01 -0700 Subject: [PATCH 071/102] Make Jett a Burrow admin --- contributors.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contributors.nix b/contributors.nix index 36bc1c9..95d4e59 100644 --- a/contributors.nix +++ b/contributors.nix @@ -33,7 +33,7 @@ jett = { displayName = "Jett"; canonicalEmail = "jett@burrow.net"; - isAdmin = false; + isAdmin = true; forgeAuthorized = false; bootstrapAuthentik = true; roles = [ From 4f88f0b1e09d31a490d001b5720d5b396adc71de Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 18 Apr 2026 17:09:20 -0700 Subject: [PATCH 072/102] Align Burrow operator access on forge --- contributors.nix | 3 +++ nixos/hosts/burrow-forge/default.nix | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/contributors.nix b/contributors.nix index 95d4e59..9475a27 100644 --- a/contributors.nix +++ b/contributors.nix @@ -38,6 +38,8 @@ bootstrapAuthentik = true; roles = [ "member" + "operator" + "forge-admin" ]; }; @@ -50,6 +52,7 @@ roles = [ "member" "operator" + "forge-admin" ]; }; diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 497d40e..1b46f6c 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -18,6 +18,15 @@ let } ) (lib.filterAttrs (_: identity: identity.bootstrapAuthentik or false) identities); + headscaleBootstrapUsers = lib.mapAttrsToList + ( + username: identity: { + name = username; + displayName = identity.displayName; + email = identity.canonicalEmail; + } + ) + (lib.filterAttrs (_: identity: identity.bootstrapAuthentik or false) identities); forgeAuthorizedKeys = map (username: builtins.readFile identities.${username}.sshPublicKeyPath) (builtins.attrNames (lib.filterAttrs (_: identity: identity.forgeAuthorized or false) identities)); @@ -173,5 +182,6 @@ in services.burrow.headscale = { enable = true; oidcClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; + bootstrapUsers = headscaleBootstrapUsers; }; } From 5a4fe58b86fbf70b1de85e8e1a61a75600bb5687 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 18 Apr 2026 17:47:17 -0700 Subject: [PATCH 073/102] Add Jett forge access and rekey secrets --- contributors.nix | 2 ++ nixos/hosts/burrow-forge/default.nix | 30 ++++++++++++++++++ nixos/keys/jett_at_burrow_net.pub | 1 + secrets.nix | 2 ++ .../authentik-google-account-map.json.age | Bin 968 -> 1078 bytes secrets/infra/authentik-google-client-id.age | Bin 493 -> 603 bytes .../infra/authentik-google-client-secret.age | 18 ++++++----- secrets/infra/authentik-ui-test-password.age | Bin 832 -> 672 bytes secrets/infra/authentik.env.age | Bin 732 -> 842 bytes .../infra/forgejo-nsc-autoscaler-config.age | Bin 1264 -> 1374 bytes .../infra/forgejo-nsc-dispatcher-config.age | Bin 1127 -> 1237 bytes secrets/infra/forgejo-nsc-token.age | Bin 1199 -> 1309 bytes secrets/infra/forgejo-oidc-client-secret.age | Bin 484 -> 594 bytes .../infra/headscale-oidc-client-secret.age | Bin 485 -> 595 bytes .../infra/tailscale-oidc-client-secret.age | Bin 484 -> 594 bytes 15 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 nixos/keys/jett_at_burrow_net.pub diff --git a/contributors.nix b/contributors.nix index 9475a27..df76a01 100644 --- a/contributors.nix +++ b/contributors.nix @@ -35,7 +35,9 @@ canonicalEmail = "jett@burrow.net"; isAdmin = true; forgeAuthorized = false; + forgeUnixUser = true; bootstrapAuthentik = true; + sshPublicKeyPath = ./nixos/keys/jett_at_burrow_net.pub; roles = [ "member" "operator" diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 1b46f6c..96eca4f 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -3,6 +3,7 @@ let contributors = import ../../../contributors.nix; identities = contributors.identities; + stripNewline = value: lib.replaceStrings [ "\n" ] [ "" ] value; authentikPasswordSecretPath = identity: if identity ? authentikPasswordSecret then config.age.secrets.${identity.authentikPasswordSecret}.path @@ -27,6 +28,23 @@ let } ) (lib.filterAttrs (_: identity: identity.bootstrapAuthentik or false) identities); + forgeUnixUsernames = + builtins.attrNames (lib.filterAttrs (_: identity: identity.forgeUnixUser or false) identities); + forgeUnixUsers = lib.genAttrs forgeUnixUsernames (username: + let + identity = identities.${username}; + sshKeys = lib.optional (identity ? sshPublicKeyPath) (stripNewline (builtins.readFile identity.sshPublicKeyPath)); + in + { + isNormalUser = true; + createHome = true; + home = "/home/${username}"; + shell = pkgs.bashInteractive; + extraGroups = lib.optional (identity.isAdmin or false) "wheel"; + openssh.authorizedKeys.keys = sshKeys; + }); + forgeUnixAdminUsernames = + builtins.attrNames (lib.filterAttrs (_: identity: (identity.forgeUnixUser or false) && (identity.isAdmin or false)) identities); forgeAuthorizedKeys = map (username: builtins.readFile identities.${username}.sshPublicKeyPath) (builtins.attrNames (lib.filterAttrs (_: identity: identity.forgeAuthorized or false) identities)); @@ -52,6 +70,18 @@ in "flakes" ]; + users.users = forgeUnixUsers; + + security.sudo.extraRules = lib.map (username: { + users = [ username ]; + commands = [ + { + command = "ALL"; + options = [ "NOPASSWD" ]; + } + ]; + }) forgeUnixAdminUsernames; + environment.systemPackages = lib.optionals config.services.forgejo-nsc.enable [ self.packages.${pkgs.stdenv.hostPlatform.system}.nsc ]; diff --git a/nixos/keys/jett_at_burrow_net.pub b/nixos/keys/jett_at_burrow_net.pub new file mode 100644 index 0000000..36c85ee --- /dev/null +++ b/nixos/keys/jett_at_burrow_net.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMe960j6TC869F6RvElpICxlBauIT3E0uLyy0m7n70ZC diff --git a/secrets.nix b/secrets.nix index e3fd9a2..32d7882 100644 --- a/secrets.nix +++ b/secrets.nix @@ -2,10 +2,12 @@ let conradev = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBueQxNbP2246pxr/m7au4zNVm+ShC96xuOcfEcpIjWZ"; contact = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO42guJ5QvNMw3k6YKWlQnjcTsc+X4XI9F2GBtl8aHOa"; agent = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEN0+tRJy7Y2DW0uGYHb86N2t02WyU5lDNX6FaxBF/G8 agent@burrow.net"; + jett = builtins.replaceStrings [ "\n" ] [ "" ] (builtins.readFile ./nixos/keys/jett_at_burrow_net.pub); burrowForgeHost = "age1quxf27gnun0xghlnxf3jrmqr3h3a3fzd8qxpallsaztd2u74pdfq9e7w9l"; burrowForgeRecipients = [ contact agent + jett burrowForgeHost ]; uiTestRecipients = burrowForgeRecipients ++ [ conradev ]; diff --git a/secrets/infra/authentik-google-account-map.json.age b/secrets/infra/authentik-google-account-map.json.age index b3cb6f84c8d7f174f404cabbccb26a8525167538..158814a6c51d0945bf91c2632d6504972f7bf9e9 100644 GIT binary patch delta 1049 zcmX@XzKvsoPQ7t%mZ5%7o}q<_MUKB=Sz=X*ms63aYk6r!dUi;XpHGr&c6hOyPe@|8 zFIR}awwq73p?0E=rD>sWWI(EsQF)nZg@>V$Nm`1JX{ENOZ@Oo(WxiW>K9{bYLUD11 zZfc5=si~o*f@e`wu4B4Fcx6bHQDT~Qd4zL?nPGs1e!YI6V_`w2cdZ^WI<&* zx^)KG;Q{G^3Z{YKp{{;T5kAF{UWO%!k*SGcSs7-|C82qazJU>8srtqBrV%bK{vNKu z7F-!QiN(pLrimu`B{_wWh3+PK`bpW@p3cc7!Ol*l!Ok9rZtkx7&c0=-CLrq~ARem- zN~S643Er@@Tzn-bj$RKDEIa))eq#-)zwvqa7}GH^7iFihuKeEcGNg)P7D$@;aIwi&I|6W-q7 zF7YJ&Yj@tGzABxbOOr&6UQ0xpY}&u-giyp3_3tk)eADeosTa*@wBY3neo-!Cbx)G_ zsmJ;?qEnsJJ!+S$WLw>j^E%e6$+B*1>I<>`KgC)zIuCD8+vo7BP;==MvkwK&8+(?` z4E43)UdnR+*oHHUmM-Jg3~B&4B9Y zyNk1feskLQF)TRn?)=pHUH4r-Z&H`+RQapt{o{Z8!h3E#ceKShtu}I;t7uesK9_|- z!!&kV%JDC0A8Li#84jlz*(Kg((MpgIwR*H;dH)Mb4yUEl91GUo^!ds6kw1(hWs)zy zw&s=Bn_b)gFH2sabHl`V=^AmdopUBUJ+I!=JFUI=R&&|pOF1w8*-ux~cC42_!@oZ~ z#_0OL;Fym1-5MQxUj7lVKccbid#CRyR*wFyznVh-nM6h(F)GpdaCYW~$bWnjGJVYA zKJQwz{h>wDeY*>9-4hIk zdbIzpn6OFL6dl1IkG`j$Z>wO|Jgz!vTK#Reyl?0BKFlr z4|}H-cK*5)>Rl#R_Hpf-d8I2SwF+96$!wpKcc1mQe@KPy)jfPFpI*+a$z!uRlX2o4 z+rz-tTaT_cs@#!UQN8{}n&s|S4r}LHe%{-bwqxm$+f(?tr!D@R-``Mk5P(=U#^i^PJxqaqKT_n zah`LsSy+}YS4vP$WrmTLVX1nCKB>{zQ#l=;HhAAlpj$FFBx(Wtffu`og z`Vr{YANg?hfm^Y(vD4oeI)?cAO6_p9xJ_$n9G4_tl9hgn~6AK&z7iGnT5hTK&h%NPG< z%g^R!Dh*%w_u7Q_i@#j>d}n3N;azzdX?#nKGg{?TKVJ0=w%&GV`;-?AA71WWc=lz< zPp43}`eg|#b@irflh{-$9^NX){MLLV}_u$vsScgTxaWPkdp1;da!-#zQ#=jbE^IHyrbt=J9+jyN$(O^$1f7R zTlUcLfAfFqBsj#(sI1@BseE_Kou_{o4HlfetHrKnzvA*s-LqZ0ue@KbC~$1<+-E7( z%_$H6%ZeK%+sAoa3Jv?X$42|?mPqEj+qN5}w)6#lemV1!ih=Gj9RvTwKX03p{#&xG z-~CFq*ID`J(RFJ=6jnT1y?5pO*sB}+xSx3be8rm0EVBFI?uw#Irq8xd6IMR(^{J2R kmaG=V!;%dF|7FB1u0KDz_V0{jv$PHg_Ptk9UoeOR0Gbk!f&c&j diff --git a/secrets/infra/authentik-google-client-id.age b/secrets/infra/authentik-google-client-id.age index f295804f68781d005cf5d43f5fa1e4f1dd49d320..344c73bffdc9e21ccf1560cb9da098d937c37ccb 100644 GIT binary patch delta 570 zcmaFMe4Ay0PQ7z(ntoY&kdu2+fQ7evc|o#egmZ*!XK8^`etn2xzOlEHwp+e=ps}m5ONc>v zP=$q2nn`9dS4fmek-m?qYl=sJWqG-0l2^D>mZxV(R-%!QQ9z1`V}yRFxmR$SPp&?O zbq3kt0qKDXWtM*VZWYBru0g@+-loav&K|)jB~b-=Ca%c=8D&`}Ue5JFL57JH?uDVg zT>0rPnLgRUZbcTE#+FIOl}6z?c}XF@1w|2=VO|05rrv%Y?%rOWiRnK1Am2qmJZ4gn zUz!o%UT9J7oST%C5O7m{7#n^F*3R?cPAxj$q^#F5!keK$^4l3W+^x_#@b zU+gD>ZR11rj@$OW2{djw&;kZ#!}SlxAk~mHO(ryL}WKrn>hZYloh1wm>0sl}E-elF#aTs|4~ zNv@dz9{L`cMq&Qh9{x^IrmiVzk*SHk-o{mCMh1DtnI4X2Zbezz$y~a+x(Y!ax#ksN z>Hg&*E{4WtL8e)0P9}v#g~pXWj&6mOVeY2crI{X%Mip5ek_V(AM~n%W=#7CX59 z#3RR<^Xn_NROTdF2IgP5&JecRXlAG9Wfz~f%D2smgaP yg*IIYT5fqcU?aQk>Z-TR0;T@>)0RXXn}51>-pTHJSx3#pe;XZ)NG{y;ZY}_a=%t+i diff --git a/secrets/infra/authentik-google-client-secret.age b/secrets/infra/authentik-google-client-secret.age index 43ecf0b..9a841c7 100644 --- a/secrets/infra/authentik-google-client-secret.age +++ b/secrets/infra/authentik-google-client-secret.age @@ -1,9 +1,11 @@ age-encryption.org/v1 --> ssh-ed25519 ux4N8Q 4uq5z93mRUUgcMOxP4+Yfe2Jq4tGYErwtzvtMHUvgi0 -J9DkDeSPkQbOjFM3QoV+1Kz3ZVLfR4PUxCT8Zxz+Wvk --> ssh-ed25519 IrZmAg uLEVmJ+e9ZiLas5YooR4GfgyspWTsFdMB2WPvluU/VI -7vqqQ/BIDQaOp6VDVLa5ugoRxVZZsMj116cTHY6+8KM --> X25519 9spF9eLz63UOaBfuG9vTIr6bCKwzFsWMjnaIj1PIR3Y -iGFELg2RQUT9rEal7pblQhfxtwYhxsZdXYxEhvjtHpw ---- 3TDrUnIN826N/n5gc+YY8ilMMc/6K8zGTh6FxzKC/JM -XH#IJGueֹf&1a2BJԎg=̿.*7Fb \ No newline at end of file +-> ssh-ed25519 ux4N8Q Q3rYrGroJXarMLdatYCHVERefWDyGwM0Ii/kOp5m3Fs +W3tgHNXLSVfGU5p8MhBj0mX72SNgMl8nf8sQX29yvBw +-> ssh-ed25519 IrZmAg fyFQQkd51GthNZ4R+W5Al266LnlKbr4ZoMERlCM1OTQ +rNjnHTGCfF8LkqU8mzTrHlL5G4az1k62gvH4gW8zmjc +-> ssh-ed25519 0kWPgQ OWokv9XAphqbkDi1cznb9V09VcM6Li1eIh0JpcIlVTY +TnPVlqKB78y7NPYp02UJmuRXdBMKJKCngpvo8TjpFZ8 +-> X25519 HWaWhyejjo4IjDrNsBYxU1JaGU0899FqiBYgstInuiU +enbBGnhH+uJKY3NBD6mmy09Uos+in6ytRQ5BakvTUvI +--- gOBrh88hnvlUSmnRiowJiUIwgIz5zzVKH8YCRb8Ckdw +xokPn8v򵄙HRʏoMË9&Tb]ĉ'|<Pbe \ No newline at end of file diff --git a/secrets/infra/authentik-ui-test-password.age b/secrets/infra/authentik-ui-test-password.age index e84a7becc0b8a5f9a3acd01f4a95440bb16e9fd7..773833e64364613a7f7619be1fde4a32d423c4fa 100644 GIT binary patch literal 672 zcmYdHPt{G$OD?J`D9Oyv)5|YP*Do{V(zR14F3!+RO))YxHMCSHtuXPk2vjgFb+#x@ z4hyRCcFFS&Pp=5c$;c}z3J(jj%u0$d$c3+^je`DA+$S zA}TCT+pkQ&EHf;{!!tWK*P_VWI}qJAgY58t^gxB+RL?4ZACsU^QzHY5q)P9M$VhkN z^dt+92yOky6qCxr0q>v=@yu1*zs1Qe!(9lTVRD%e| z$_h6RL#IIfq)?}vN>`9=5s*L(jBqIp3lA@Lw#*Aj^~lSt$_on2PIJpODlsuL3QIQd z$Vl@j3y$(GHa6i(&#*M}F-Q-q3@=QLGzm5E)=$of%GA$xNewP4DEA8w4T^Ge^vDSf zPc8@9fE-sPNx7!Z!EOeb{)wLD4g>$BXwx@?*Rid+DPLzwMpQF)OG>3va9C(cAXi9szG0Ya zR8^2mR8VSoi9v`#N=0J0sad39SYo14MNVXvn^9zzpI1_tqiL{VkU>(YUzT%Lm`SjK zo4>!SK^0eMXmUl6M~1tfL4HwCM21heTa>GBUP`IAL5f$PVVJvLo@-@NM0QnJReD)e zj;ps%h)H^~v0uJVcv!Y?R;VGDZ%B4}N<_LrNLEIWr-z$UkfD=dVvd(@YPMmfe^^Fd zn46z}l##DNQg(@FNT{J(c!-m0q@h<>V5pl*P_CapSCXM$ZfdrxcW9QcVVH|+WnyTK zXJlw`S!je?URX$uOSp?)nQyqML6lLDSyE1vtFN(VRZ4D&e^O3RL{x}lIag?4en~*4 zQ&qB2dP!7xV5MJ3XoY{UlUH(1K}leEa=Nc;RDN){WmJkwR6%O6MOBzbN~O24Q?Xx2 zPJW_WXeC#oYlTagdv=Adn`@Q7o1b@NXi!*Sgp;wKV?juyVRA)CPMCX=QAtElct&M# zM3h@(o|A7%l~+-sb8&g9M_?IORA^36NOqucSWZ-?kAYvIe~4EVIQrbuVXpB=&Q32& z%JMVu%XTaDGb(Wja?QyMH42Ih$_dKjip(}m3=b{IOw1|BNe+)PN-~Oa4sl5~3=55l za?Z{$Om=fK3CWJi4bAe(4GGC93(s;YPV$J#j10@k4Rba1<_a{jbo2Mf2u(5YEA=)^ z2?~kGa19U5_Vx)jDDiYREeg+02~IQ$%Lp+}Hi$GzDGv!ZF-d02=dKx zH47?HqQ3)D9%VM_YFxj^2y68FEC7UH_9>#@$d@Ih$t{E^R7rq VH7qvFO}Dk>0_Ol%KNp;t0|0|53f}+# diff --git a/secrets/infra/authentik.env.age b/secrets/infra/authentik.env.age index f9f613687871d9959a66369ee2bb51b7aee03d40..dbada85c47dfffb1804d9c0b7c0bc4196873bce0 100644 GIT binary patch delta 811 zcmcb^dWvm=PJNKReraN1SXf4+pPye)d3b50XGw&ArbVPrl~ZUyQki3VRJdh@V{ln; zF;`VdS%q(sv9o82vA$)gUuL9PWky<6PKBp_QD%;>YgtlBrdxVpkYPZ2D3`9CLUD11 zZfc5=si~o*f@e`wu4B4FWNxHmVosJvR-l=yVT4JZdA&t(cyeN%OG=_+P>`ibSU_dE zX|c9bg?^qZS3#;#pi^O3kwvn$qh)@MQAko?cu9IydR}s9wz*eQwr^FcQ+{c-Nq%xV zx^)KG;Q{G^3fh@oVct%b?rHAcK3Uo6C7yxWg=s0K#UA0=MLt&ZPw|5h00riP||v7T$q@`5@~eARa3T za}P_3^bQCK3oftp^iQu!4N1~BGx0PiEq8WuPYR06D31zu^)U=}%;zeO^7C~n4RFp4 zDJ)5`EJ+N@Pp@~5j4Cn7@eC{1b_q9($jSDswS5%k$04_s!>8f4_9|h6~DvPVy^;e!X(d?}35P zrr0$KH`{zNzdZSD^2+j00RNGWWuLyMYYE+9OR)_1%*gTiR{u<|Zqe)K!PmThonDfw zoUoMDH`w0yUjL-De^c#$wtjv%g}X=N+`>b*%e&+%ZuQ?&mx|7a(>|F~y68+n?AHZH zWWUBv+V#k%HFUG5Tm%>2hi%J0^eJ!{^0OJlHk%z<)~B-4c0$K`i(b)3&Gzxy7AL(F;5)@8-?UauJyE>yqdNhKrXMWim)v0RWAPJL)faH6(*L9kJIL~(ejnY(3rNm+KJbBd9LVOW+^pnHgui*aFMdYO}} z30H+_nwM{tZ$**2M|i5cqkFhdq+^bWxre8Zzq4qdTb5s7dRd}hWW9HUx4UtGagcwhc6w+@k$-A( zaZXfWnQMp%SH7vYX+^1JuD(}EV78BWXi<`3d6;2Xv3pcOdVobllCfV%afnfbM^$h+ z$hruKO~nP4r52@WQGUrKi3S9$r`RU-W&ZpB^^!NIBSVMP&cGkffl}=!RDUY1!Yc#My0{NhT8f*u3VocC4J3un-}`=cmDV3 z|G2NqF4nwKuU_&m);V?aoMR3l-xqmoSzNDu$o60C))Lv;xfcF)5;i+d9K5T2jA_A- zKSIl28+mMV_@Zd#Tb6Y>Fj3nknfvtgnQ|u>3QL=LbQ4awdR`1%P=Dnw8+XOmQjwkd zvhgNtMY^t6&3^@-*9%bi$$Ca?S5|sy_2Zj2W*@tIQHp4{?JZ)>&M=>AC+BR$HhaazI z$lcs?eEON7kbcv*?so*M?wABV+mqy$mbL!l^*3!_{w`)Vy%!#(&B*sSVrKTf8y7yV tiL#CQ%+#?PQ72YMPOuRrBhKsa&l3ai)Ct6QAt^TWnytfzMFS)NJ&9nvR9Uqxl3AQ zK3A}7rm=gfmw|q;qqa*>kfD3FsZ*qhqe+Q(NbF?S7uOOjv(tIARcon zOE${MO-yu4EsrY5Pfv3%3Ad~Y%k!;F&X4rV_0abXNOumbFi0yacjXGMa?0~hH3-fw zaq_ARjdZdoH>=O~4fD%LcdRJQC^9SvbPNx3bxY34_sQnc)zwwd&nj`w&kXc4cMmD` z$c@U#3JFd(Gp`8M4oY__O)brc%64=q*Y^tN*d>67FoZGXY4p7Ty~J@m%RGydXu}%y-L-K^f}shFHbr0 zZ0dujqB$7{MPxoDa<9E9u5K1{GU6DsP?)*aircQj8y6K7oW1gK>ZYi)EaBwM>zdhu zlLEfZ-=f&3_pD&u2nRo(XKTGo!VZB=Zouwk!@b>=ij8dC@tsFkFrXTHnv|#ri z^E8E;-yJ?F?*7j_j_t{d%6j=MRz>Pu-Shg*zaK9tQcj2LxaUtPj}#rT+Ny#bvP?NtcgIvXHPi`dszcW}Q~c z{YBGxLPWQx^4)Ow^HOPd&Fo;t?I$+scN}s&vQhr&?G|kxtNUVKgxM>;_0;I~luw$u zjCI-8mO~EHKNNpr+Mm8sdbz-Xn><@zY1f}hC}!MnF7IFJf_q{Yo{QYs-c~b7;@r+f z<}FrB7wr>>U0fHqc;UvLsO2XX?hTQBd+7e!ya&2Tt@HA7Ts-I4DW$BPceK+#;#RSS z|ALv^OtKO@Yt#f6TyANRHnmwcDN>97VV1w#!Wk37)=pwIIV$$|(gwHK%O(#39xd`@ z{g=eURsSOH=qt{6@l7kv{B`^uX22lwh?=;@+Uc4%>FWEc~py((mC63$H}x3L$~=Tk4y2A7!< zlMNdhSL$d6G;8_Wn(@wBvSP}QLy7(!k3-YjWOYYkewf ztgM4HvY!6jzJRSzz0T-y&9uY-2hBTG3-7raTyjloxUA!KFEpF`6!)qLJ{5DHo;$E7 z*7E9|1TiOO$8QPJ{OR+fe(js7D|B~puwHmc0(<;>9|N;1H%tt6PSpMo=@Ixpbj1;o zf8jY3bf(E1=aYQ2TF1ul(IJ-^!5uq`>r}!8%A@|CW7Bv0Eiv)mz;8~}HiK)3(^ delta 1236 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 delta 1098 zcmcc0`J7{dPQ6o=m$9d}VV0YJl9z{9g_}ibVQE-)o?mKOg}%AIM`EJ3o3TZPxnrWc z1y`0wc2!wGL{3pul23(Ou8(hD@K~7jzWPOFUajJK|duD+_ZlF_{OK4`H zXJw8>Rhh9XmusP~cA}9%xW1!nMzTe&b54L`V0nONl&^DEsb65CQGv5@UPw-4m4$00 z$hruKP3~Di86}Qp1->q&>5)aoftdyQuEvQjIq8Y|`pFh~u0GkOd1e6x1{sy1T*>A2 zsfJOhhUrl`sTOI5VWwVYLE3?aCPA5=Sw5y-`oSgohNdPJX*o%5>0G+Hx(a2+9-e81 zzNIBzc_~g6B@wAvnO?bOo?)iN{*l^F20lr-$^P0#q2_KyCR~hdd;-3qQB8k4xVT=n zY~?)q?bwHulGNfI={vs8Ej)J5ewF;;oO(IU*vAfiCw!wiR$S%$wD*-znVtCD=5Gu6 zR=V%FeKp)#_;10!xh_F21PLR`jNG!R0mY)8BDRFVnmC zbGGA!t6Tp+V4rvL)pZ?Xv)1cVcc?A6meHqmV2!NTis}!J>qDpM7TlZ|BBtu*ll4zJ z>f-?^-ukGS`z;U4Zeyt|ycqOuTDR_&79B?ZKU4fBA6H8Jbz5|m`&^FMMYo?Ew-DE# zvQ0wtf%n9`JH1>nOhPv%2Nl~_p1nMup)kF1&Q!%`U8WB-jr(4uJ$9K}vMy&|;bIYU zRfFQ}@8A0GDy&UVoclz%!7=-T_lA=ke*Ckaex52h@wXaV{bZF1{iiqVl`8#`v*Fe) zt9KrDLUy8;9(URAv(I|`T8(`l7k`M{A1|ZTZ|=MeI{sm!r^x&b3;%`9F1N{9_iAtN zAu|)-9UHVod$w1L*rmnl=5({61CnM^5{-QhX$}wO)q2C^tJ+_vU@Q;=~+V z_q*;N#rtNwn%xoiUCH67wdFBG9k0L_-7eGq&wceig!5o(C$Hh#T{D)}-R{~ha?R9_ zslugDRr77p^R?$@FxYDOX_w|dl6O*N+2CA%AvN#wozOMHQ)OGCdomjTf9Y`3eR}oW zUA@&yk3?LlQTXcLvX?o!{ciN19WQ>UF*3ZfuB-Ri7rXJ8003ti=DPp@ diff --git a/secrets/infra/forgejo-nsc-token.age b/secrets/infra/forgejo-nsc-token.age index ff8c278c3f78517fb387ac2ff5c9640754e81ffd..68b65722f7fdefd62b86b7e0b8a0586f52507286 100644 GIT binary patch delta 1281 zcmZ3_IhSjKPJLENinF6-VX;|dfPZ;$y1B7mWPo2#VOo)ASVU50P@+$nSxA(VnP*5u zAeVQPSCm(JhJI;aVtJ-%NsgD1c|}QavSXQBRce%PnTvaJL1K=nab;qLCzr0BLUD11 zZfc5=si~o*f@e`wu4B4FP@++4s8haidWw;kSD~SwV||K6TCj^}T4lDoXS%sdl0{xr zx{p zjf|`cN=yrM53MgXiz+Y94#=oX^6)o^2q?(#CI?b*9aJMvxYN4Bm9{YFg= zTf2|y_-=5R&%ZyrNoQ6-2lMY;p}eWwDhFiKlA0%Ow^{SBKJmbtqfWgup4vH7U;3x( zuIkQde@D#DBma|1h;g~+wI^*>ThHHN(RVziu}Q8<=aT$9iIT((i5>kx6)UF*bidrX zWs|tHQEuPC3jK^SYbU&r!`uld?P`ihUuo0o|QO7;O`l5(--}*-%6Z<0cm0f=t3NYMGkIJr7~OAhkk?3C zIasRx(iRQfJ$AgDCPFtdL#F6S9(^)x!S-%P`T7l?ZZ2H#pY;q!!s#zN?Hny{)M=dA zY?^jlSL+DxvDKM;f_W=k3!1#%T~C?kGV}NXPM31Em&@7|jwa6kGhBB zj@$W6M~i|kNO%aC{Bt}qbFYK>FJ+c_?+^M_mOf(4?W!vN_TjAKr~Z-EFg4?y z+P<`}Qc7lP#w+898`V$#6#EK>F?BAT(yqDn&I*eak7kIleED~!IIQ*QwOG^1700I( MT&Uo4Ki0Vr07SqoDF6Tf delta 1171 zcmbQswVrc=PJN|wR#mcbV7R4Wj+s%UcBoNgRH08nNOopaWr&w!MPQ(FW|X!|g-2$% zCs(e!zP3?XK|#K8kY`A0enyC|zDus5uUT5LYf(u>hJU(es;7}#VOf4?F_*5LLUD11 zZfc5=si~o*f@e`wu4B4_cBHALzeRAEV@On5szq3-QGI%5v7xtLc(RYNiF1j$ud%za zNrYc$P;yl{m%Dp*SY&c;erSnTP=!fVT1l3Vo2QFWxlf5-iF3MpSYBkQub)MPo4E-wy#@Xo>0eM*_7Ga)TAp!MK zQGP{1q2AhVRYj$#B`$8IUdbU3IwHTA@gSF>r zXIVB^Z&+ZG_0hU3q5MXQ@!IXHbwoPrmENW2WTwlVWWCV-Zz*@`i$@8!d2ouUX7Vx_?fXN%735`*wNX?0;EG z$1Se^p=f({okDaX_xS{cHRo6FiC6C1Hi6APr2EMkwk7)=&ish|CgS@1+@`Is_Aq^0 zyCGWVcKX}-?TZ;tzjz>>-2Wi!+idaXirQ~GXUx<)Gw;9D?KG1EBGbCInqHgg@ce#(4ll8oR_T28H_PX33Q9Be^CNCKA5FE;-8uO|X-;IxeD0m1)=!pSns$6+O74n< zKY2fwf4}b5T6rte^I6;VZ|D1FpKP^oTd?%t`VX}^mwzZ-y2G)>^Xd;a4sr3T#ijqd zw^sWoPLh6oM*HB*P^bFTObJ$z74cDT>UtKq*gbX9+}Ks$?X%oY)Mi&h5ZC1gR}aNr z>HfOG>gU6RpRBK%R((!2`d>bS0 zglT=8Z?evDDf|Dmx<$5wHP$21ab=7Ryo%Ic6zU${_ z221Z;uaM?*q$k-X8539b_AMgI4lGC*`b?*I3Ygfo!-OA1K=)kM6pD|U& zPQQIOPkY9@vs-O#ktf^Xj(;`&EgA*knq8V)OW#d>+H->E?1kJo;mNh7;SZi@HEvz7 zj_v+3{}kB;9aT2WCguv8e|>+_|26u0SZct5R}y<06W5ggFL7EkXPdHp-TN;+VKVG) U4PHDu{y$z@K6SUmQ>)r10Amgg@m#&>cadC!j zYKoDmsiCEUXHitHW4c0cws)~>p>wgTr;%xXq*H~3TfM8liIa=FXO4SNx|v6AR+w*M zx^z8TJ`5rxJskzVeuAnPI^9!pCt z%Fy>nGONh9DAljX)X&k+G4~8EboR@yG!8Ph@Q?HfaVZH(HcQU)9dHp_7}H_S{-GEOxr3gyz()m89HG4`nP_bp5*3QKkN zC~jPX_agI)U;`rEAac^Iex-tyveTY5LYB%3<@hh~^m& h*{PZ*;?2Dw=zLOV>B+mcv61REC9Ip$DjZJ delta 449 zcmcb_@`QPUZhfMcxu=0+WoAiGcz{ntUPw}6VpT|Hph01okxP)bzms2ig|>EJSdn`+ zmtkcadC!j zYKoDmsiCEUXHitHW4eN4pl6j)X1ITFsdj34Zep=xV!c6LkXx9cd3uqfslI1Wriq`c zQ)ZrbVxR?=MWAPCnoE9mcx6&jW>9#TzQ0#as(H45N`7voe_ljzUWHexx0|+Uq$k+A z2#8IV1^xy_S#Cz=fx&@hQU2*ZncLL%CjQ1AW%nYoT!-C7L`+Lkdcjas2`aZ2Fs zBfN2p9e38BF4fkPzJ5F}V}7yi5reQktDeU}Zd@mA-BTHlKew)$`nWSU{%}~UZ{oWm nzV_qUCK;>$um1ew?!+CFHr7Sj>=$_U`{;RP$!%8O9@zl^AG4tK diff --git a/secrets/infra/headscale-oidc-client-secret.age b/secrets/infra/headscale-oidc-client-secret.age index 925512cb9692b0538eddfa2c4f83fa98fe8b0c66..81cff1c5b9e23216013b14e69a1819e0a6a400ab 100644 GIT binary patch delta 562 zcmaFLe3@l}PQ687iAPwFMM`Q~Mq*L0WkG>sv5$XVd4!>(afqpNp`TAiqK}KaiD99E zD_2RNQ>c$|cvXf;j%RtUr;~e@kB4b`R&kkgMtHuNyO&91MMSc8vR_I{D3`9CLUD11 zZfc5=si~o*f@e`wu4B4FNV<=4Nm06^v9?KOSw(?oL4BEik!xytR&G>ccCc%OxszF{ zQE{?KMMh9LmtVMve|nf#R7RLrXoRnmM`B`Dwt<;*g{xOqRZ(efVThlnk-k}wi?4|z zx^)KG;Q{G^3Z+i@#cmN&K`N$871jqQLd5o{vNJQPVV_` zkz76@MFojP{zayl<&Lhd0lwNrAyJhs6<%&$5&C(7#UYUeektz3>E0o!#USeqbIytMF>rHo$;k96s_^8xz_*0CpLw>ValD+>z5PY}ZAsf5 z>$bH?mgOCq>8^j}r0y%bj(*cG`?lZnj_G*a-Swmpf39<$bE8xgnXpTp_;o zrJ=^DeuO%Cr5TxFv;#yiVlX$mtS5Cm|^pDUV6-w-=DT{J=1x!L)_w=ZrjDAmOr`S rC%GO>TATKLPE48PoNtpt9ZpUDoEB1N%4hKGN7?CFe_GV{RPF=-Z}+BS diff --git a/secrets/infra/tailscale-oidc-client-secret.age b/secrets/infra/tailscale-oidc-client-secret.age index e88c2d1b5cf2b362996e3a4403d3f70773e12a72..3c3c07468aa611765480683d977310e72ae7ca83 100644 GIT binary patch delta 561 zcmaFDe2Ha(PJMW3p?*+eX_953ex^@iQDJ~{rhi_taYac)KvYIVlDV&AQdM}BX{d3P zCs#>kiHVU>N~l3nS($m2zN@FXdAW~vYEe){MOl)0hLd4QsY#xte??)TCzr0BLUD11 zZfc5=si~o*f@e`wu4B4FmA^?;U}~neuYq5Vt8th~M1536PF1e4Z>f`$exz}LW0FaJ zc6Nw)nU9etS7NePu2W8ARjN^eMX;k~p^sa(hlN{yMxnsS#MVMaJd@mY$Z0p+13rE+#%zL6${T{$Y;pj_yugi5?b?Am2qmJQfsI zP#jqp8f@xmQW6xB;cf0}YU!4i?Pz3_p`C10RbiBF;o(>0=TqwH$rWK)T#@P&=xLno zmFkj`*rKZS`g%J7|ErptE-S+oT{H$7-Eo|SXz`7 zl$jJ6>ggHoY~dB@?Hv^4n-h}gRNz>iUgD8v*=KVN|PC!0SXJjJOBUy delta 450 zcmcb_@`QPUPQ7aapKYs+oCGP?Sr!OLl2?N?N*Wgn4OTmXk?nIhU@TLUD11 zZfc5=si~o*f@e`wu4B4_qg$T2Q-FzEc1dWYhh<=yU%gjAm`jRZvTK1?R%BtWe^F*} zS%hVTdzgU*S5!v6wzpqNwqaRlV3et8mSIJxM@n(IW0paPL2!OnroM->S%G0maagh= z$hruKO~qxV2ANS=PMKNZ-f0D9p4vWXK{>8oM#Y7BS*H3%xds_Q;Z>%7g+3wKTplU) zX=VA^7Uor{xxvnE;V!{BRoMl(Wgb4IWyuC5Srx7&2KmXEApu3s>0G+Hx(ZI&fhi{b zM!EW#mKH@hPRV{5$yK3d;aLV5CHa|tK7lTI2H|Fv-g$=l$y}oRyotMbR=wM`U+G}@ zWuF`G8kMd>jR0JGji*aSR*x1E70{}D+pvC|I From 4d3257995b2f2f4681aa41a3ec167238345bbde1 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 18 Apr 2026 19:10:18 -0700 Subject: [PATCH 074/102] Add Authentik SSO apps for Linear and 1Password --- Scripts/authentik-sync-1password-oidc.sh | 243 +++++++++++++ Scripts/authentik-sync-linear-saml.sh | 334 ++++++++++++++++++ ...ntik-backed-team-chat-and-workspace-sso.md | 152 ++++++++ nixos/hosts/burrow-forge/default.nix | 3 + nixos/modules/burrow-authentik.nix | 153 ++++++++ 5 files changed, 885 insertions(+) create mode 100755 Scripts/authentik-sync-1password-oidc.sh create mode 100755 Scripts/authentik-sync-linear-saml.sh create mode 100644 evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md diff --git a/Scripts/authentik-sync-1password-oidc.sh b/Scripts/authentik-sync-1password-oidc.sh new file mode 100755 index 0000000..f523d9a --- /dev/null +++ b/Scripts/authentik-sync-1password-oidc.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +application_slug="${AUTHENTIK_ONEPASSWORD_APPLICATION_SLUG:-onepassword}" +application_name="${AUTHENTIK_ONEPASSWORD_APPLICATION_NAME:-1Password}" +provider_name="${AUTHENTIK_ONEPASSWORD_PROVIDER_NAME:-1Password}" +template_slug="${AUTHENTIK_ONEPASSWORD_TEMPLATE_SLUG:-ts}" +client_id="${AUTHENTIK_ONEPASSWORD_CLIENT_ID:-1password.burrow.net}" +launch_url="${AUTHENTIK_ONEPASSWORD_LAUNCH_URL:-https://burrow-team.1password.com/}" +redirect_uris_json="${AUTHENTIK_ONEPASSWORD_REDIRECT_URIS_JSON:-[ + \"https://burrow-team.1password.com/sso/oidc/redirect/\", + \"onepassword://sso/oidc/redirect\" +]}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-1password-oidc.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_ONEPASSWORD_APPLICATION_SLUG + AUTHENTIK_ONEPASSWORD_APPLICATION_NAME + AUTHENTIK_ONEPASSWORD_PROVIDER_NAME + AUTHENTIK_ONEPASSWORD_TEMPLATE_SLUG + AUTHENTIK_ONEPASSWORD_CLIENT_ID + AUTHENTIK_ONEPASSWORD_LAUNCH_URL + AUTHENTIK_ONEPASSWORD_REDIRECT_URIS_JSON +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ -z "$bootstrap_token" ]]; then + echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 + exit 1 +fi + +if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then + echo "error: AUTHENTIK_ONEPASSWORD_REDIRECT_URIS_JSON must be a non-empty JSON array" >&2 + exit 1 +fi + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + + if [[ -n "$data" ]]; then + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + else + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + fi +} + +api_with_status() { + local method="$1" + local path="$2" + local data="${3:-}" + local response_file status + + response_file="$(mktemp)" + trap 'rm -f "$response_file"' RETURN + + if [[ -n "$data" ]]; then + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + )" + else + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + )" + fi + + printf '%s\n' "$status" + cat "$response_file" +} + +wait_for_authentik() { + for _ in $(seq 1 90); do + if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +wait_for_authentik + +template_provider="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -c --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \ + | head -n1 +)" + +if [[ -z "$template_provider" ]]; then + echo "error: could not resolve the Authentik OAuth provider template ${template_slug}" >&2 + exit 1 +fi + +authorization_flow="$(printf '%s\n' "$template_provider" | jq -r '.authorization_flow')" +invalidation_flow="$(printf '%s\n' "$template_provider" | jq -r '.invalidation_flow')" +property_mappings="$(printf '%s\n' "$template_provider" | jq -c '.property_mappings')" +signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')" + +provider_payload="$( + jq -n \ + --arg name "$provider_name" \ + --arg authorization_flow "$authorization_flow" \ + --arg invalidation_flow "$invalidation_flow" \ + --arg client_id "$client_id" \ + --arg signing_key "$signing_key" \ + --argjson property_mappings "$property_mappings" \ + --argjson redirect_uris "$redirect_uris_json" \ + '{ + name: $name, + authorization_flow: $authorization_flow, + invalidation_flow: $invalidation_flow, + client_type: "public", + client_id: $client_id, + include_claims_in_id_token: true, + redirect_uris: ($redirect_uris | map({matching_mode: "strict", url: .})), + property_mappings: $property_mappings, + signing_key: $signing_key, + issuer_mode: "per_provider", + sub_mode: "hashed_user_id" + }' +)" + +existing_provider="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -c \ + --arg application_slug "$application_slug" \ + --arg provider_name "$provider_name" \ + '.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \ + | head -n1 +)" + +if [[ -n "$existing_provider" ]]; then + provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')" + api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$provider_payload" >/dev/null +else + provider_pk="$( + api POST "/api/v3/providers/oauth2/" "$provider_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "${provider_pk:-}" ]]; then + echo "error: 1Password OIDC provider did not return a primary key" >&2 + exit 1 +fi + +application_payload="$( + jq -n \ + --arg name "$application_name" \ + --arg slug "$application_slug" \ + --arg provider "$provider_pk" \ + --arg launch_url "$launch_url" \ + '{ + name: $name, + slug: $slug, + provider: ($provider | tonumber), + meta_launch_url: $launch_url, + open_in_new_tab: true, + policy_engine_mode: "any" + }' +)" + +existing_application="$( + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \ + | head -n1 +)" + +if [[ -n "$existing_application" ]]; then + application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" +else + create_application_result="$( + api_with_status POST "/api/v3/core/applications/" "$application_payload" + )" + create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')" + create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')" + + if [[ "$create_application_status" =~ ^20[01]$ ]]; then + application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')" + elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e ' + (.slug // [] | index("Application with this slug already exists.")) != null + or (.provider // [] | index("Application with this provider already exists.")) != null + ' >/dev/null; then + application_pk="existing-duplicate" + else + printf '%s\n' "$create_application_body" >&2 + echo "error: could not reconcile Authentik application ${application_slug}" >&2 + exit 1 + fi +fi + +if [[ -z "${application_pk:-}" ]]; then + echo "error: 1Password OIDC application did not return a primary key" >&2 + exit 1 +fi + +for _ in $(seq 1 30); do + if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then + echo "Synced Authentik 1Password OIDC application ${application_slug} (${application_name})." + exit 0 + fi + sleep 2 +done + +echo "warning: 1Password OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2 +echo "Synced Authentik 1Password OIDC application ${application_slug} (${application_name})." diff --git a/Scripts/authentik-sync-linear-saml.sh b/Scripts/authentik-sync-linear-saml.sh new file mode 100755 index 0000000..9bead9f --- /dev/null +++ b/Scripts/authentik-sync-linear-saml.sh @@ -0,0 +1,334 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +application_slug="${AUTHENTIK_LINEAR_APPLICATION_SLUG:-linear}" +application_name="${AUTHENTIK_LINEAR_APPLICATION_NAME:-Linear}" +provider_name="${AUTHENTIK_LINEAR_PROVIDER_NAME:-Linear}" +launch_url="${AUTHENTIK_LINEAR_LAUNCH_URL:-https://linear.app/burrownet}" +acs_url="${AUTHENTIK_LINEAR_ACS_URL:-}" +audience="${AUTHENTIK_LINEAR_AUDIENCE:-}" +issuer="${AUTHENTIK_LINEAR_ISSUER:-${authentik_url}/application/saml/${application_slug}/metadata/}" +default_relay_state="${AUTHENTIK_LINEAR_DEFAULT_RELAY_STATE:-}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-linear-saml.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + AUTHENTIK_LINEAR_ACS_URL + AUTHENTIK_LINEAR_AUDIENCE + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_LINEAR_APPLICATION_SLUG + AUTHENTIK_LINEAR_APPLICATION_NAME + AUTHENTIK_LINEAR_PROVIDER_NAME + AUTHENTIK_LINEAR_LAUNCH_URL + AUTHENTIK_LINEAR_ISSUER + AUTHENTIK_LINEAR_DEFAULT_RELAY_STATE +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ -z "$bootstrap_token" ]]; then + echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 + exit 1 +fi + +if [[ -z "$acs_url" ]]; then + echo "error: AUTHENTIK_LINEAR_ACS_URL is required" >&2 + exit 1 +fi + +if [[ -z "$audience" ]]; then + echo "error: AUTHENTIK_LINEAR_AUDIENCE is required" >&2 + exit 1 +fi + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + + if [[ -n "$data" ]]; then + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + else + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + fi +} + +api_with_status() { + local method="$1" + local path="$2" + local data="${3:-}" + local response_file status + + response_file="$(mktemp)" + trap 'rm -f "$response_file"' RETURN + + if [[ -n "$data" ]]; then + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + )" + else + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + )" + fi + + printf '%s\n' "$status" + cat "$response_file" +} + +wait_for_authentik() { + for _ in $(seq 1 90); do + if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +lookup_oauth_template_field() { + local field="$1" + + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -r --arg field "$field" '.results[]? | select(.assigned_application_slug == "ts") | .[$field]' \ + | head -n1 +} + +reconcile_property_mapping() { + local name="$1" + local saml_name="$2" + local friendly_name="$3" + local expression="$4" + local payload existing_pk + + payload="$( + jq -n \ + --arg name "$name" \ + --arg saml_name "$saml_name" \ + --arg friendly_name "$friendly_name" \ + --arg expression "$expression" \ + '{ + name: $name, + saml_name: $saml_name, + friendly_name: $friendly_name, + expression: $expression + }' + )" + + existing_pk="$( + api GET "/api/v3/propertymappings/provider/saml/?page_size=200" \ + | jq -r --arg name "$name" '.results[]? | select(.name == $name) | .pk' \ + | head -n1 + )" + + if [[ -n "$existing_pk" ]]; then + api PATCH "/api/v3/propertymappings/provider/saml/${existing_pk}/" "$payload" >/dev/null + printf '%s\n' "$existing_pk" + else + api POST "/api/v3/propertymappings/provider/saml/" "$payload" | jq -r '.pk // empty' + fi +} + +wait_for_authentik + +authorization_flow="$(lookup_oauth_template_field authorization_flow)" +invalidation_flow="$(lookup_oauth_template_field invalidation_flow)" +signing_kp="$(lookup_oauth_template_field signing_key)" + +if [[ -z "$authorization_flow" || -z "$invalidation_flow" || -z "$signing_kp" ]]; then + echo "error: could not resolve Authentik provider defaults from Burrow Tailnet template" >&2 + exit 1 +fi + +email_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Linear SAML Email" \ + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" \ + "email" \ + 'return request.user.email' +)" + +name_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Linear SAML Name" \ + "name" \ + "name" \ + 'return request.user.name or request.user.username' +)" + +first_name_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Linear SAML First Name" \ + "firstName" \ + "firstName" \ + $'parts = (request.user.name or "").split(" ", 1)\nif len(parts) > 0 and parts[0]:\n return parts[0]\nreturn request.user.username' +)" + +last_name_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Linear SAML Last Name" \ + "lastName" \ + "lastName" \ + $'parts = (request.user.name or "").rsplit(" ", 1)\nif len(parts) == 2 and parts[1]:\n return parts[1]\nreturn request.user.username' +)" + +if [[ -z "$email_mapping_pk" || -z "$name_mapping_pk" || -z "$first_name_mapping_pk" || -z "$last_name_mapping_pk" ]]; then + echo "error: failed to reconcile Linear SAML property mappings" >&2 + exit 1 +fi + +provider_payload="$( + jq -n \ + --arg name "$provider_name" \ + --arg authorization_flow "$authorization_flow" \ + --arg invalidation_flow "$invalidation_flow" \ + --arg acs_url "$acs_url" \ + --arg audience "$audience" \ + --arg issuer "$issuer" \ + --arg signing_kp "$signing_kp" \ + --arg default_relay_state "$default_relay_state" \ + --arg name_id_mapping "$email_mapping_pk" \ + --arg email_mapping "$email_mapping_pk" \ + --arg name_mapping "$name_mapping_pk" \ + --arg first_name_mapping "$first_name_mapping_pk" \ + --arg last_name_mapping "$last_name_mapping_pk" \ + '{ + name: $name, + authorization_flow: $authorization_flow, + invalidation_flow: $invalidation_flow, + acs_url: $acs_url, + audience: $audience, + issuer: $issuer, + signing_kp: $signing_kp, + sign_assertion: true, + sign_response: true, + sp_binding: "post", + name_id_mapping: $name_id_mapping, + property_mappings: [ + $email_mapping, + $name_mapping, + $first_name_mapping, + $last_name_mapping + ] + } + + (if $default_relay_state == "" then {} else {default_relay_state: $default_relay_state} end)' +)" + +existing_provider="$( + api GET "/api/v3/providers/saml/?page_size=200" \ + | jq -c \ + --arg application_slug "$application_slug" \ + --arg provider_name "$provider_name" \ + '.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \ + | head -n1 +)" + +if [[ -n "$existing_provider" ]]; then + provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')" + api PATCH "/api/v3/providers/saml/${provider_pk}/" "$provider_payload" >/dev/null +else + provider_pk="$( + api POST "/api/v3/providers/saml/" "$provider_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "${provider_pk:-}" ]]; then + echo "error: Linear SAML provider did not return a primary key" >&2 + exit 1 +fi + +application_payload="$( + jq -n \ + --arg name "$application_name" \ + --arg slug "$application_slug" \ + --arg provider "$provider_pk" \ + --arg launch_url "$launch_url" \ + '{ + name: $name, + slug: $slug, + provider: ($provider | tonumber), + meta_launch_url: $launch_url, + open_in_new_tab: true, + policy_engine_mode: "any" + }' +)" + +existing_application="$( + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \ + | head -n1 +)" + +if [[ -n "$existing_application" ]]; then + application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" + api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null +else + create_application_result="$( + api_with_status POST "/api/v3/core/applications/" "$application_payload" + )" + create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')" + create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')" + + if [[ "$create_application_status" =~ ^20[01]$ ]]; then + application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')" + elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e ' + (.slug // [] | index("Application with this slug already exists.")) != null + or (.provider // [] | index("Application with this provider already exists.")) != null + ' >/dev/null; then + application_pk="existing-duplicate" + else + printf '%s\n' "$create_application_body" >&2 + echo "error: could not reconcile Authentik application ${application_slug}" >&2 + exit 1 + fi +fi + +if [[ -z "${application_pk:-}" ]]; then + echo "error: Linear SAML application did not return a primary key" >&2 + exit 1 +fi + +for _ in $(seq 1 30); do + if curl -fsS "${authentik_url}/application/saml/${application_slug}/metadata/" >/dev/null 2>&1; then + echo "Synced Authentik Linear SAML application ${application_slug} (${application_name})." + exit 0 + fi + sleep 2 +done + +echo "warning: Linear SAML metadata for ${application_slug} was not immediately readable; keeping reconciled config." >&2 +echo "Synced Authentik Linear SAML application ${application_slug} (${application_name})." diff --git a/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md b/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md new file mode 100644 index 0000000..6c11dbc --- /dev/null +++ b/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md @@ -0,0 +1,152 @@ +# `BEP-0008` - Authentik-Backed Team Chat and Workspace Identity + +```text +Status: Draft +Proposal: BEP-0008 +Authors: gpt-5.4 +Coordinator: gpt-5.4 +Reviewers: Pending +Constitution Sections: II, III, V +Implementation PRs: Pending +Decision Date: Pending +``` + +## Summary + +Burrow should add a self-hosted team chat surface at `chat.burrow.net` and +continue the project-wide move toward Authentik as the identity authority for +external work systems. The immediate targets are a self-hosted Zulip +deployment rooted in Authentik SAML, a Linear SAML configuration when the +workspace plan supports it, and a 1Password Unlock-with-SSO deployment rooted +in the same Authentik-backed OIDC authority. + +This keeps Burrow's day-to-day coordination surfaces aligned with the same +admin groups, canonical users, and secret-handling model already used for +Forgejo, Headscale, and Tailscale. It also avoids fragmenting login state +across vendor-native Google auth flows when Burrow already operates an IdP. + +## Motivation + +- Forge, Tailnet, operator identity, and Tailscale custom OIDC are already + rooted in Authentik. Team chat, work tracking, and password-manager access + should not become separate authority islands. +- Zulip provides a self-hosted chat system under Burrow's control, which fits + the constitution better than adding another hosted chat dependency. +- Linear remains a SaaS dependency, but its workspace access should still be + derived from Burrow-managed identities and domains when the vendor plan + exposes SAML configuration. +- 1Password Business is another external work surface where Burrow-controlled + identities are preferable to vendor-native Google-only auth. Its current + vendor flow is OIDC-based Unlock with SSO rather than SAML, so the proposal + needs to preserve protocol accuracy instead of flattening everything into + one SAML bucket. +- Burrow already has a canonical public identity registry and a secret-backed + external-email alias map. Reusing that structure is lower-risk than + inventing per-app user bootstrap logic. + +## Detailed Design + +- Add a Burrow-managed Zulip workload on the forge host at `chat.burrow.net`. + The deployment should be repo-owned and rebuildable from Nix, even if the + runtime uses vendor-supported container images internally. +- Zulip should authenticate through Authentik SAML rather than local passwords + as the primary path. Initial bootstrap may still keep an operational escape + hatch while the deployment is being validated. +- Add Authentik-managed SAML applications for: + - Zulip at `chat.burrow.net` + - Linear using Burrow's claimed domains and Authentik metadata +- Add an Authentik-managed OIDC application for 1Password Business under the + Burrow team sign-in address. +- Treat Zulip and Linear as downstream applications of the same identity + authority, and treat 1Password as part of that same authority even though + its vendor protocol is OIDC rather than SAML. The source of truth remains: + - public identities and admin intent in `contributors.nix` + - private alias mappings and external accounts in agenix-encrypted secrets +- Keep app-specific configuration in dedicated reconciliation code or module + options instead of hand-edited UI state. +- Prefer service-specific reconciliation over ad hoc manual setup so rebuilds + and host replacement converge automatically. +- Model 1Password according to the vendor's actual integration contract: + - OIDC Authorization Code Flow with PKCE + - public client rather than a confidential client + - no Burrow-side dependence on a stored client secret unless the vendor flow + changes + +## Security and Operational Considerations + +- Do not store external personal email mappings in public registry files. + Public tree data may include Burrow usernames and canonical `@burrow.net` + addresses, but external aliases must stay in encrypted secrets. +- Zulip internal service credentials, Django secret material, and any mail + credentials must have explicit storage and rotation paths. +- Linear SAML must not become Burrow's only admin recovery path. At least one + owner login path outside the enforced SAML flow should remain available until + rollout is proven. +- 1Password Owners cannot be forced onto Unlock with SSO during initial setup. + Burrow should preserve the owner recovery path and treat OIDC rollout as a + scoped migration for non-owner users first. +- If Zulip is deployed without production-grade outbound email at first, that + limitation must be documented and treated as an operational constraint, not a + hidden assumption. +- Rollback should be straightforward: + - disable or stop the Zulip module + - remove the Authentik SAML apps + - remove the Authentik OIDC app used for 1Password if necessary + - leave the underlying Burrow identities unchanged + +## Contributor Playbook + +- Define the app and identity intent in the repository before modifying the + forge host. +- Add or update Nix modules so `burrow-forge` can rebuild Zulip and the + corresponding Authentik SAML configuration from the tree. +- Verify: + - `chat.burrow.net` serves a working Zulip login surface + - Authentik exposes working metadata for Zulip and Linear + - Authentik exposes a working OIDC issuer for 1Password + - users in Burrow admin groups receive the expected access on first login +- Record concrete evidence for: + - host deployment generation + - Authentik reconciliation success + - Zulip login success + - Linear SAML configuration state + - 1Password Unlock with SSO configuration state + +## Alternatives Considered + +- Use Zulip Cloud instead of self-hosting. Rejected because the ask is to host + chat under `chat.burrow.net`, and Burrow already operates a forge host with a + self-managed identity plane. +- Keep Linear on Google-native login. Rejected because it leaves Burrow work + access outside the project's operator and group model. +- Treat 1Password as a SAML app for consistency. Rejected because the live + vendor flow is OIDC and Burrow should not pretend otherwise in repo-owned + infrastructure. +- Add per-app manual Authentik configuration without repository automation. + Rejected because it violates Burrow's infrastructure-in-repo commitment. + +## Impact on Other Work + +- Extends Burrow's Authentik role from control-plane identity into team-work + surfaces. +- Introduces a persistent chat workload on the forge host, with resource and + monitoring implications. +- Creates a likely follow-up for SCIM or richer group synchronization if Linear + or Zulip role mapping needs to become fully declarative later. +- Adds a second OIDC relying party beyond Forgejo, Headscale, and Tailscale, + which raises the importance of keeping Burrow's Authentik scope mappings and + redirect handling consistent across applications. + +## Decision + +Pending. + +## References + +- `CONSTITUTION.md` +- `contributors.nix` +- `evolution/proposals/BEP-0004-hosted-mail-and-saas-identity.md` +- Authentik docs: SAML provider and metadata endpoints +- Zulip docs: SAML authentication and docker deployment +- Linear docs: SAML and access control +- 1Password docs: Unlock with SSO using OpenID Connect diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 96eca4f..3f73346 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -207,6 +207,9 @@ in userGroupName = contributors.groups.users; adminGroupName = contributors.groups.admins; bootstrapUsers = bootstrapUsers; + linearAcsUrl = "https://api.linear.app/auth/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de/acs"; + linearAudience = "https://auth.linear.app/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de"; + linearDefaultRelayState = "https://linear.app/auth/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de"; }; services.burrow.headscale = { diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 2fa83da..5b04de2 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -11,6 +11,8 @@ let directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh; forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh; + onePasswordOidcSyncScript = ../../Scripts/authentik-sync-1password-oidc.sh; + linearSamlSyncScript = ../../Scripts/authentik-sync-linear-saml.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh; authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' @@ -150,6 +152,63 @@ in description = "Host-local file containing the Authentik Tailscale OIDC client secret."; }; + onePasswordDomain = lib.mkOption { + type = lib.types.str; + default = "burrow-team.1password.com"; + description = "1Password team sign-in domain used for Burrow Unlock with SSO."; + }; + + onePasswordProviderSlug = lib.mkOption { + type = lib.types.str; + default = "onepassword"; + description = "Authentik application slug for 1Password Unlock with SSO."; + }; + + onePasswordClientId = lib.mkOption { + type = lib.types.str; + default = "1password.burrow.net"; + description = "Public OIDC client ID Authentik should present to 1Password."; + }; + + onePasswordRedirectUris = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ + "https://burrow-team.1password.com/sso/oidc/redirect/" + "onepassword://sso/oidc/redirect" + ]; + description = "Allowed 1Password OIDC redirect URIs."; + }; + + linearProviderSlug = lib.mkOption { + type = lib.types.str; + default = "linear"; + description = "Authentik application slug for Linear SAML."; + }; + + linearAcsUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Linear SAML ACS URL."; + }; + + linearAudience = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Linear SAML audience/entity identifier."; + }; + + linearLaunchUrl = lib.mkOption { + type = lib.types.str; + default = "https://linear.app/burrownet"; + description = "Linear workspace URL exposed in Authentik."; + }; + + linearDefaultRelayState = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Optional Linear relay state or login URL for IdP-initiated launches."; + }; + forgejoClientId = lib.mkOption { type = lib.types.str; default = "git.burrow.net"; @@ -718,6 +777,100 @@ EOF ''; }; + systemd.services.burrow-authentik-1password-oidc = { + description = "Reconcile the Burrow Authentik 1Password OIDC application"; + after = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + onePasswordOidcSyncScript + cfg.envFile + ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + set -a + source ${lib.escapeShellArg cfg.envFile} + set +a + + export AUTHENTIK_URL=https://${cfg.domain} + export AUTHENTIK_ONEPASSWORD_APPLICATION_SLUG=${lib.escapeShellArg cfg.onePasswordProviderSlug} + export AUTHENTIK_ONEPASSWORD_APPLICATION_NAME=1Password + export AUTHENTIK_ONEPASSWORD_PROVIDER_NAME=1Password + export AUTHENTIK_ONEPASSWORD_TEMPLATE_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} + export AUTHENTIK_ONEPASSWORD_CLIENT_ID=${lib.escapeShellArg cfg.onePasswordClientId} + export AUTHENTIK_ONEPASSWORD_LAUNCH_URL=https://${cfg.onePasswordDomain}/ + export AUTHENTIK_ONEPASSWORD_REDIRECT_URIS_JSON='${builtins.toJSON cfg.onePasswordRedirectUris}' + + ${pkgs.bash}/bin/bash ${onePasswordOidcSyncScript} + ''; + }; + + systemd.services.burrow-authentik-linear-saml = lib.mkIf ( + cfg.linearAcsUrl != null && cfg.linearAudience != null + ) { + description = "Reconcile the Burrow Authentik Linear SAML application"; + after = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + linearSamlSyncScript + cfg.envFile + ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + set -a + source ${lib.escapeShellArg cfg.envFile} + set +a + + export AUTHENTIK_URL=https://${cfg.domain} + export AUTHENTIK_LINEAR_APPLICATION_SLUG=${lib.escapeShellArg cfg.linearProviderSlug} + export AUTHENTIK_LINEAR_APPLICATION_NAME=Linear + export AUTHENTIK_LINEAR_PROVIDER_NAME=Linear + export AUTHENTIK_LINEAR_ACS_URL=${lib.escapeShellArg cfg.linearAcsUrl} + export AUTHENTIK_LINEAR_AUDIENCE=${lib.escapeShellArg cfg.linearAudience} + export AUTHENTIK_LINEAR_LAUNCH_URL=${lib.escapeShellArg cfg.linearLaunchUrl} + ${lib.optionalString (cfg.linearDefaultRelayState != null) '' + export AUTHENTIK_LINEAR_DEFAULT_RELAY_STATE=${lib.escapeShellArg cfg.linearDefaultRelayState} + ''} + + ${pkgs.bash}/bin/bash ${linearSamlSyncScript} + ''; + }; + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd reverse_proxy 127.0.0.1:${toString cfg.port} From ebcfc4bf8d157395fc23bb0f0ccabb53a3910b82 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 18 Apr 2026 19:23:53 -0700 Subject: [PATCH 075/102] Add Linear SCIM role sync --- Scripts/authentik-sync-linear-scim.sh | 311 ++++++++++++++++++ contributors.nix | 5 + ...ntik-backed-team-chat-and-workspace-sso.md | 8 + nixos/hosts/burrow-forge/default.nix | 14 + nixos/modules/burrow-authentik.nix | 90 +++++ secrets.nix | 1 + secrets/infra/linear-scim-token.age | 11 + 7 files changed, 440 insertions(+) create mode 100644 Scripts/authentik-sync-linear-scim.sh create mode 100644 secrets/infra/linear-scim-token.age diff --git a/Scripts/authentik-sync-linear-scim.sh b/Scripts/authentik-sync-linear-scim.sh new file mode 100644 index 0000000..b689212 --- /dev/null +++ b/Scripts/authentik-sync-linear-scim.sh @@ -0,0 +1,311 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +application_slug="${AUTHENTIK_LINEAR_APPLICATION_SLUG:-linear}" +provider_name="${AUTHENTIK_LINEAR_SCIM_PROVIDER_NAME:-Linear SCIM}" +scim_url="${AUTHENTIK_LINEAR_SCIM_URL:-}" +scim_token_file="${AUTHENTIK_LINEAR_SCIM_TOKEN_FILE:-}" +user_identifier="${AUTHENTIK_LINEAR_SCIM_USER_IDENTIFIER:-email}" +owner_group="${AUTHENTIK_LINEAR_OWNER_GROUP:-linear-owners}" +admin_group="${AUTHENTIK_LINEAR_ADMIN_GROUP:-linear-admins}" +guest_group="${AUTHENTIK_LINEAR_GUEST_GROUP:-linear-guests}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-linear-scim.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + AUTHENTIK_LINEAR_SCIM_URL + AUTHENTIK_LINEAR_SCIM_TOKEN_FILE + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_LINEAR_APPLICATION_SLUG + AUTHENTIK_LINEAR_SCIM_PROVIDER_NAME + AUTHENTIK_LINEAR_SCIM_USER_IDENTIFIER + AUTHENTIK_LINEAR_OWNER_GROUP + AUTHENTIK_LINEAR_ADMIN_GROUP + AUTHENTIK_LINEAR_GUEST_GROUP +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ -z "$bootstrap_token" ]]; then + echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 + exit 1 +fi + +if [[ -z "$scim_url" ]]; then + echo "error: AUTHENTIK_LINEAR_SCIM_URL is required" >&2 + exit 1 +fi + +if [[ -z "$scim_token_file" || ! -s "$scim_token_file" ]]; then + echo "error: AUTHENTIK_LINEAR_SCIM_TOKEN_FILE is required and must be readable" >&2 + exit 1 +fi + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + + if [[ -n "$data" ]]; then + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + else + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + fi +} + +wait_for_authentik() { + for _ in $(seq 1 90); do + if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +lookup_group_pk() { + local group_name="$1" + + api GET "/api/v3/core/groups/?page_size=200&search=${group_name}" \ + | jq -r --arg name "$group_name" '.results[]? | select(.name == $name) | .pk // empty' \ + | head -n1 +} + +ensure_group() { + local group_name="$1" + local payload group_pk + + payload="$(jq -cn --arg name "$group_name" '{name: $name}')" + group_pk="$(lookup_group_pk "$group_name")" + + if [[ -n "$group_pk" ]]; then + api PATCH "/api/v3/core/groups/${group_pk}/" "$payload" >/dev/null + else + group_pk="$( + api POST "/api/v3/core/groups/" "$payload" \ + | jq -r '.pk // empty' + )" + fi + + if [[ -z "$group_pk" ]]; then + echo "error: could not reconcile Authentik group ${group_name}" >&2 + exit 1 + fi + + printf '%s\n' "$group_pk" +} + +lookup_application() { + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \ + | head -n1 +} + +lookup_scim_provider() { + api GET "/api/v3/providers/scim/?page_size=200" \ + | jq -c \ + --arg application_slug "$application_slug" \ + --arg provider_name "$provider_name" \ + '.results[]? | select(.assigned_backchannel_application_slug == $application_slug or .name == $provider_name)' \ + | head -n1 +} + +lookup_scim_mapping_pk() { + local managed_name="$1" + + api GET "/api/v3/propertymappings/provider/scim/?page_size=200" \ + | jq -r --arg managed "$managed_name" '.results[]? | select(.managed == $managed) | .pk // empty' \ + | head -n1 +} + +reconcile_property_mapping() { + local name="$1" + local expression="$2" + local payload existing_pk + + payload="$( + jq -n \ + --arg name "$name" \ + --arg expression "$expression" \ + '{ + name: $name, + expression: $expression + }' + )" + + existing_pk="$( + api GET "/api/v3/propertymappings/provider/scim/?page_size=200" \ + | jq -r --arg name "$name" '.results[]? | select(.name == $name) | .pk // empty' \ + | head -n1 + )" + + if [[ -n "$existing_pk" ]]; then + api PATCH "/api/v3/propertymappings/provider/scim/${existing_pk}/" "$payload" >/dev/null + printf '%s\n' "$existing_pk" + else + api POST "/api/v3/propertymappings/provider/scim/" "$payload" \ + | jq -r '.pk // empty' + fi +} + +sync_object() { + local provider_pk="$1" + local model="$2" + local object_id="$3" + + api POST "/api/v3/providers/scim/${provider_pk}/sync/object/" "$( + jq -cn \ + --arg model "$model" \ + --arg object_id "$object_id" \ + '{ + sync_object_model: $model, + sync_object_id: $object_id, + override_dry_run: false + }' + )" >/dev/null +} + +wait_for_authentik + +group_mapping_pk="$(lookup_scim_mapping_pk "goauthentik.io/providers/scim/group")" +case "$user_identifier" in + email) + user_mapping_expression=$'# Some implementations require givenName and familyName to be set\ngivenName, familyName = request.user.name, " "\nformatted = request.user.name + " "\nif " " in request.user.name:\n givenName, _, familyName = request.user.name.partition(" ")\n formatted = request.user.name\n\navatar = request.user.avatar\nphotos = None\nif "://" in avatar:\n photos = [{"value": avatar, "type": "photo"}]\n\nlocale = request.user.locale()\nif locale == "":\n locale = None\n\nemails = []\nif request.user.email != "":\n emails = [{\n "value": request.user.email,\n "type": "other",\n "primary": True,\n }]\n\nidentifier = request.user.email\nif identifier == "":\n identifier = request.user.username\n\nreturn {\n "userName": identifier,\n "name": {\n "formatted": formatted,\n "givenName": givenName,\n "familyName": familyName,\n },\n "displayName": request.user.name,\n "photos": photos,\n "locale": locale,\n "active": request.user.is_active,\n "emails": emails,\n}' + ;; + username) + user_mapping_expression=$'# Some implementations require givenName and familyName to be set\ngivenName, familyName = request.user.name, " "\nformatted = request.user.name + " "\nif " " in request.user.name:\n givenName, _, familyName = request.user.name.partition(" ")\n formatted = request.user.name\n\navatar = request.user.avatar\nphotos = None\nif "://" in avatar:\n photos = [{"value": avatar, "type": "photo"}]\n\nlocale = request.user.locale()\nif locale == "":\n locale = None\n\nemails = []\nif request.user.email != "":\n emails = [{\n "value": request.user.email,\n "type": "other",\n "primary": True,\n }]\nreturn {\n "userName": request.user.username,\n "name": {\n "formatted": formatted,\n "givenName": givenName,\n "familyName": familyName,\n },\n "displayName": request.user.name,\n "photos": photos,\n "locale": locale,\n "active": request.user.is_active,\n "emails": emails,\n}' + ;; + *) + echo "error: unsupported AUTHENTIK_LINEAR_SCIM_USER_IDENTIFIER value: ${user_identifier}" >&2 + exit 1 + ;; +esac +user_mapping_pk="$(reconcile_property_mapping "Burrow Linear SCIM User" "$user_mapping_expression")" + +if [[ -z "$user_mapping_pk" || -z "$group_mapping_pk" ]]; then + echo "error: could not resolve managed Authentik SCIM property mappings" >&2 + exit 1 +fi + +owner_group_pk="$(ensure_group "$owner_group")" +admin_group_pk="$(ensure_group "$admin_group")" +guest_group_pk="$(ensure_group "$guest_group")" + +provider_payload="$( + jq -n \ + --arg name "$provider_name" \ + --arg url "$scim_url" \ + --arg token "$(tr -d '\r\n' < "$scim_token_file")" \ + --arg user_mapping_pk "$user_mapping_pk" \ + --arg group_mapping_pk "$group_mapping_pk" \ + --arg owner_group_pk "$owner_group_pk" \ + --arg admin_group_pk "$admin_group_pk" \ + --arg guest_group_pk "$guest_group_pk" \ + '{ + name: $name, + url: $url, + token: $token, + auth_mode: "token", + verify_certificates: true, + compatibility_mode: "default", + property_mappings: [$user_mapping_pk], + property_mappings_group: [$group_mapping_pk], + group_filters: [ + $owner_group_pk, + $admin_group_pk, + $guest_group_pk + ], + dry_run: false + }' +)" + +existing_provider="$(lookup_scim_provider)" +if [[ -n "$existing_provider" ]]; then + provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')" + api PATCH "/api/v3/providers/scim/${provider_pk}/" "$provider_payload" >/dev/null +else + provider_pk="$( + api POST "/api/v3/providers/scim/" "$provider_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "${provider_pk:-}" ]]; then + echo "error: Linear SCIM provider did not return a primary key" >&2 + exit 1 +fi + +application="$(lookup_application)" +if [[ -z "$application" ]]; then + echo "error: could not resolve Authentik application ${application_slug}" >&2 + exit 1 +fi + +application_pk="$(printf '%s\n' "$application" | jq -r '.pk')" +application_payload="$( + printf '%s\n' "$application" \ + | jq \ + --arg provider_pk "$provider_pk" \ + '{ + name: .name, + slug: .slug, + provider: .provider, + backchannel_providers: ((.backchannel_providers // []) + [($provider_pk | tonumber)] | unique), + open_in_new_tab: .open_in_new_tab, + meta_launch_url: .meta_launch_url, + policy_engine_mode: .policy_engine_mode + }' +)" +api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null + +group_pks_json="$(jq -cn --arg owner "$owner_group_pk" --arg admin "$admin_group_pk" --arg guest "$guest_group_pk" '[$owner, $admin, $guest]')" +user_pks_json="$( + api GET "/api/v3/core/users/?page_size=200" \ + | jq -c \ + --argjson group_pks "$group_pks_json" \ + '[.results[]? + | select( + ([((.groups // [])[] | tostring)] as $user_groups + | ($group_pks | map(. as $wanted | ($user_groups | index($wanted)) != null) | any)) + ) + | .pk]' +)" + +while IFS= read -r group_pk; do + [[ -z "$group_pk" ]] && continue + sync_object "$provider_pk" "authentik.core.models.Group" "$group_pk" +done < <(printf '%s\n' "$group_pks_json" | jq -r '.[]') + +while IFS= read -r user_pk; do + [[ -z "$user_pk" ]] && continue + sync_object "$provider_pk" "authentik.core.models.User" "$user_pk" +done < <(printf '%s\n' "$user_pks_json" | jq -r '.[]') + +status_json="$(api GET "/api/v3/providers/scim/${provider_pk}/sync/status/")" +if ! printf '%s\n' "$status_json" | jq -e '.task_count >= 0' >/dev/null 2>&1; then + echo "error: could not read Linear SCIM sync status for provider ${provider_pk}" >&2 + exit 1 +fi + +echo "Synced Authentik Linear SCIM provider ${provider_name} (${provider_pk}) with groups ${owner_group}, ${admin_group}, ${guest_group}." diff --git a/contributors.nix b/contributors.nix index df76a01..60501d1 100644 --- a/contributors.nix +++ b/contributors.nix @@ -2,6 +2,11 @@ groups = { users = "burrow-users"; admins = "burrow-admins"; + linear = { + owners = "linear-owners"; + admins = "linear-admins"; + guests = "linear-guests"; + }; }; identities = { diff --git a/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md b/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md index 6c11dbc..63e0994 100644 --- a/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md +++ b/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md @@ -55,6 +55,8 @@ across vendor-native Google auth flows when Burrow already operates an IdP. - Add Authentik-managed SAML applications for: - Zulip at `chat.burrow.net` - Linear using Burrow's claimed domains and Authentik metadata +- Add an Authentik-managed SCIM backchannel for Linear so Burrow can push + role groups declaratively instead of hand-maintaining workspace roles. - Add an Authentik-managed OIDC application for 1Password Business under the Burrow team sign-in address. - Treat Zulip and Linear as downstream applications of the same identity @@ -66,6 +68,10 @@ across vendor-native Google auth flows when Burrow already operates an IdP. options instead of hand-edited UI state. - Prefer service-specific reconciliation over ad hoc manual setup so rebuilds and host replacement converge automatically. +- Derive Linear SCIM role groups from Burrow's canonical identity metadata. + If Burrow-wide admin intent says a user is an operator/admin, the repo-owned + configuration should map that intent onto the Linear push group without a + second manual roster. - Model 1Password according to the vendor's actual integration contract: - OIDC Authorization Code Flow with PKCE - public client rather than a confidential client @@ -82,6 +88,8 @@ across vendor-native Google auth flows when Burrow already operates an IdP. - Linear SAML must not become Burrow's only admin recovery path. At least one owner login path outside the enforced SAML flow should remain available until rollout is proven. +- Linear SCIM group push should be role-scoped and explicit. Burrow should + avoid blanket ownership mapping unless that intent is recorded in the repo. - 1Password Owners cannot be forced onto Unlock with SSO during initial setup. Burrow should preserve the owner recovery path and treat OIDC rollout as a scoped migration for non-owner users first. diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 3f73346..0121f92 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -3,6 +3,7 @@ let contributors = import ../../../contributors.nix; identities = contributors.identities; + linearGroups = contributors.groups.linear; stripNewline = value: lib.replaceStrings [ "\n" ] [ "" ] value; authentikPasswordSecretPath = identity: if identity ? authentikPasswordSecret @@ -15,6 +16,7 @@ let name = identity.displayName; email = identity.canonicalEmail; isAdmin = identity.isAdmin or false; + groups = lib.optionals (identity.isAdmin or false) [ linearGroups.owners ]; passwordFile = authentikPasswordSecretPath identity; } ) @@ -111,6 +113,12 @@ in group = "root"; mode = "0400"; }; + age.secrets.burrowLinearScimToken = { + file = ../../../secrets/infra/linear-scim-token.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; age.secrets.burrowAuthentikGoogleClientId = { file = ../../../secrets/infra/authentik-google-client-id.age; owner = "root"; @@ -210,6 +218,12 @@ in linearAcsUrl = "https://api.linear.app/auth/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de/acs"; linearAudience = "https://auth.linear.app/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de"; linearDefaultRelayState = "https://linear.app/auth/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de"; + linearScimUrl = "https://api.linear.app/auth/scim/d0ca13dc-ac41-4824-8aab-e0ca352fc3de"; + linearScimTokenFile = config.age.secrets.burrowLinearScimToken.path; + linearScimUserIdentifier = "email"; + linearOwnerGroupName = linearGroups.owners; + linearAdminGroupName = linearGroups.admins; + linearGuestGroupName = linearGroups.guests; }; services.burrow.headscale = { diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 5b04de2..772adc4 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -13,6 +13,7 @@ let tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh; onePasswordOidcSyncScript = ../../Scripts/authentik-sync-1password-oidc.sh; linearSamlSyncScript = ../../Scripts/authentik-sync-linear-saml.sh; + linearScimSyncScript = ../../Scripts/authentik-sync-linear-scim.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh; authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' @@ -209,6 +210,42 @@ in description = "Optional Linear relay state or login URL for IdP-initiated launches."; }; + linearScimUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Linear SCIM base connector URL."; + }; + + linearScimTokenFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Host-local file containing the Linear SCIM bearer token."; + }; + + linearScimUserIdentifier = lib.mkOption { + type = lib.types.str; + default = "email"; + description = "Linear SCIM unique identifier field for users."; + }; + + linearOwnerGroupName = lib.mkOption { + type = lib.types.str; + default = "linear-owners"; + description = "Authentik group name that should map to Linear owners."; + }; + + linearAdminGroupName = lib.mkOption { + type = lib.types.str; + default = "linear-admins"; + description = "Authentik group name that should map to Linear admins."; + }; + + linearGuestGroupName = lib.mkOption { + type = lib.types.str; + default = "linear-guests"; + description = "Authentik group name that should map to Linear guests."; + }; + forgejoClientId = lib.mkOption { type = lib.types.str; default = "git.burrow.net"; @@ -871,6 +908,59 @@ EOF ''; }; + systemd.services.burrow-authentik-linear-scim = lib.mkIf ( + cfg.linearScimUrl != null && cfg.linearScimTokenFile != null + ) { + description = "Reconcile the Burrow Authentik Linear SCIM provider"; + after = [ + "burrow-authentik-ready.service" + "burrow-authentik-directory.service" + "burrow-authentik-linear-saml.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "burrow-authentik-directory.service" + "burrow-authentik-linear-saml.service" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + linearScimSyncScript + cfg.envFile + cfg.linearScimTokenFile + ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + set -a + source ${lib.escapeShellArg cfg.envFile} + set +a + + export AUTHENTIK_URL=https://${cfg.domain} + export AUTHENTIK_LINEAR_APPLICATION_SLUG=${lib.escapeShellArg cfg.linearProviderSlug} + export AUTHENTIK_LINEAR_SCIM_PROVIDER_NAME="Linear SCIM" + export AUTHENTIK_LINEAR_SCIM_URL=${lib.escapeShellArg cfg.linearScimUrl} + export AUTHENTIK_LINEAR_SCIM_TOKEN_FILE=${lib.escapeShellArg cfg.linearScimTokenFile} + export AUTHENTIK_LINEAR_SCIM_USER_IDENTIFIER=${lib.escapeShellArg cfg.linearScimUserIdentifier} + export AUTHENTIK_LINEAR_OWNER_GROUP=${lib.escapeShellArg cfg.linearOwnerGroupName} + export AUTHENTIK_LINEAR_ADMIN_GROUP=${lib.escapeShellArg cfg.linearAdminGroupName} + export AUTHENTIK_LINEAR_GUEST_GROUP=${lib.escapeShellArg cfg.linearGuestGroupName} + + ${pkgs.bash}/bin/bash ${linearScimSyncScript} + ''; + }; + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd reverse_proxy 127.0.0.1:${toString cfg.port} diff --git a/secrets.nix b/secrets.nix index 32d7882..1a6dce0 100644 --- a/secrets.nix +++ b/secrets.nix @@ -23,5 +23,6 @@ in "secrets/infra/forgejo-nsc-dispatcher-config.age".publicKeys = burrowForgeRecipients; "secrets/infra/forgejo-nsc-token.age".publicKeys = burrowForgeRecipients; "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; + "secrets/infra/linear-scim-token.age".publicKeys = burrowForgeRecipients; "secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; } diff --git a/secrets/infra/linear-scim-token.age b/secrets/infra/linear-scim-token.age new file mode 100644 index 0000000..677a475 --- /dev/null +++ b/secrets/infra/linear-scim-token.age @@ -0,0 +1,11 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q 6LanICpiWi1sozNr5HJDWCGb6QFBktRQ0dH2wfFSu2g +jc83UfFoFvxAXcu4O/b6KC+1AyZq/k9IHzx6fL8DHoQ +-> ssh-ed25519 IrZmAg r1ggts4fiWOGHoD7IY+cVEgECOUFaulJ1ATSX6/wB2Q +NnKRd8FNKXpCrANK2q2mFJjWYccqInzGNHjK7oJNNS0 +-> ssh-ed25519 0kWPgQ G3i+VXIhED5crwLZoF8cTcaljYENq7K0DAy5mTHsNkk ++eJThDXro6DpNghlcziQv64rg8j0mcm3UfGVHcctI6w +-> X25519 2yw5RabY1hp/of6RLpKI2ao0AwBOzNdeOR4M9YRwmhY +vCe9r9ayAsDcLkyt4/c9EBZpU/DrkGKj8KLbSF9YCHo +--- Lgi0Th/QpSFhDP7JK+jenEIvI0aQfQ3oQ6sl2homLu4 +i?-d:͂ܝYǿ* \ No newline at end of file From 4c12dafa6ddbef682221c3a6062ba51ecd6766f4 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 18 Apr 2026 19:26:55 -0700 Subject: [PATCH 076/102] Fix Linear SAML verification and reseal SCIM token --- Scripts/authentik-sync-linear-saml.sh | 18 ++++++++++++++---- secrets/infra/linear-scim-token.age | 20 ++++++++++---------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/Scripts/authentik-sync-linear-saml.sh b/Scripts/authentik-sync-linear-saml.sh index 9bead9f..2fd1a90 100755 --- a/Scripts/authentik-sync-linear-saml.sh +++ b/Scripts/authentik-sync-linear-saml.sh @@ -323,10 +323,20 @@ if [[ -z "${application_pk:-}" ]]; then fi for _ in $(seq 1 30); do - if curl -fsS "${authentik_url}/application/saml/${application_slug}/metadata/" >/dev/null 2>&1; then - echo "Synced Authentik Linear SAML application ${application_slug} (${application_name})." - exit 0 - fi + metadata_status="$( + curl -sS \ + -o /dev/null \ + -w '%{http_code}' \ + --max-redirs 0 \ + "${authentik_url}/application/saml/${application_slug}/metadata/" \ + || true + )" + case "$metadata_status" in + 200|301|302|307|308) + echo "Synced Authentik Linear SAML application ${application_slug} (${application_name})." + exit 0 + ;; + esac sleep 2 done diff --git a/secrets/infra/linear-scim-token.age b/secrets/infra/linear-scim-token.age index 677a475..5bed53e 100644 --- a/secrets/infra/linear-scim-token.age +++ b/secrets/infra/linear-scim-token.age @@ -1,11 +1,11 @@ age-encryption.org/v1 --> ssh-ed25519 ux4N8Q 6LanICpiWi1sozNr5HJDWCGb6QFBktRQ0dH2wfFSu2g -jc83UfFoFvxAXcu4O/b6KC+1AyZq/k9IHzx6fL8DHoQ --> ssh-ed25519 IrZmAg r1ggts4fiWOGHoD7IY+cVEgECOUFaulJ1ATSX6/wB2Q -NnKRd8FNKXpCrANK2q2mFJjWYccqInzGNHjK7oJNNS0 --> ssh-ed25519 0kWPgQ G3i+VXIhED5crwLZoF8cTcaljYENq7K0DAy5mTHsNkk -+eJThDXro6DpNghlcziQv64rg8j0mcm3UfGVHcctI6w --> X25519 2yw5RabY1hp/of6RLpKI2ao0AwBOzNdeOR4M9YRwmhY -vCe9r9ayAsDcLkyt4/c9EBZpU/DrkGKj8KLbSF9YCHo ---- Lgi0Th/QpSFhDP7JK+jenEIvI0aQfQ3oQ6sl2homLu4 -i?-d:͂ܝYǿ* \ No newline at end of file +-> ssh-ed25519 ux4N8Q Tb3hxc6ZscCQpr7s8raup25FA8YAmq30jHZfOQp28Xs +L9YhaX9IVinud0IOs5K55ldGx82wjXHxnVBHZnRjiTA +-> ssh-ed25519 IrZmAg etIe6hWDP9YkqDFCWybnvsOh7h8YO+z3tKc95pG64lU +BT3rH5a+LJZWv2xtWPbMJGS2oM9v4mOI9WPmnHebiew +-> ssh-ed25519 0kWPgQ YpCf5m16VaKp7d+C3oF9MJQB/0xzCNtD7ODsTiV8t1o +xG8G/kSM+7VrWHm299A7fG/kBFnoiWZPiDZuldvimLw +-> X25519 ETltnMPR7lWbBWJvJKmNZhS7wqX0WCa4aNu8UKzxMVE +Ys57VNuclgvN1nJIrLjNrwekbosa7KK9lFt0PTpr/MQ +--- ZeUmSOf8+NycQAFRGCJHYcQvTJqSBIGKEOEdCnNfJbE +<q1.O_դ7A۷_@%/5l7JɵčA xb "B \ No newline at end of file From 6dea4e4557a86268e12230eaf1f119b6cd68145c Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 18 Apr 2026 19:30:06 -0700 Subject: [PATCH 077/102] Fix Authentik Linear application patch paths --- Scripts/authentik-sync-linear-saml.sh | 3 +-- Scripts/authentik-sync-linear-scim.sh | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Scripts/authentik-sync-linear-saml.sh b/Scripts/authentik-sync-linear-saml.sh index 2fd1a90..fbb46f2 100755 --- a/Scripts/authentik-sync-linear-saml.sh +++ b/Scripts/authentik-sync-linear-saml.sh @@ -294,8 +294,7 @@ existing_application="$( )" if [[ -n "$existing_application" ]]; then - application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" - api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null + api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null else create_application_result="$( api_with_status POST "/api/v3/core/applications/" "$application_payload" diff --git a/Scripts/authentik-sync-linear-scim.sh b/Scripts/authentik-sync-linear-scim.sh index b689212..7e0c7eb 100644 --- a/Scripts/authentik-sync-linear-scim.sh +++ b/Scripts/authentik-sync-linear-scim.sh @@ -262,7 +262,6 @@ if [[ -z "$application" ]]; then exit 1 fi -application_pk="$(printf '%s\n' "$application" | jq -r '.pk')" application_payload="$( printf '%s\n' "$application" \ | jq \ @@ -277,7 +276,7 @@ application_payload="$( policy_engine_mode: .policy_engine_mode }' )" -api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null +api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null group_pks_json="$(jq -cn --arg owner "$owner_group_pk" --arg admin "$admin_group_pk" --arg guest "$guest_group_pk" '[$owner, $admin, $guest]')" user_pks_json="$( From 7421834ebc5ee88e2dd328999223a2053bdd37e7 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 18 Apr 2026 19:32:29 -0700 Subject: [PATCH 078/102] Relax Linear Authentik sync verification --- Scripts/authentik-sync-linear-saml.sh | 1 + Scripts/authentik-sync-linear-scim.sh | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Scripts/authentik-sync-linear-saml.sh b/Scripts/authentik-sync-linear-saml.sh index fbb46f2..5da64ad 100755 --- a/Scripts/authentik-sync-linear-saml.sh +++ b/Scripts/authentik-sync-linear-saml.sh @@ -294,6 +294,7 @@ existing_application="$( )" if [[ -n "$existing_application" ]]; then + application_pk="existing" api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null else create_application_result="$( diff --git a/Scripts/authentik-sync-linear-scim.sh b/Scripts/authentik-sync-linear-scim.sh index 7e0c7eb..a82cd34 100644 --- a/Scripts/authentik-sync-linear-scim.sh +++ b/Scripts/authentik-sync-linear-scim.sh @@ -301,10 +301,9 @@ while IFS= read -r user_pk; do sync_object "$provider_pk" "authentik.core.models.User" "$user_pk" done < <(printf '%s\n' "$user_pks_json" | jq -r '.[]') -status_json="$(api GET "/api/v3/providers/scim/${provider_pk}/sync/status/")" -if ! printf '%s\n' "$status_json" | jq -e '.task_count >= 0' >/dev/null 2>&1; then - echo "error: could not read Linear SCIM sync status for provider ${provider_pk}" >&2 - exit 1 +status_json="$(api GET "/api/v3/providers/scim/${provider_pk}/sync/status/" || true)" +if ! printf '%s\n' "$status_json" | jq -e 'has("last_sync_status")' >/dev/null 2>&1; then + echo "warning: could not read Linear SCIM sync status for provider ${provider_pk}; keeping reconciled configuration." >&2 fi echo "Synced Authentik Linear SCIM provider ${provider_name} (${provider_pk}) with groups ${owner_group}, ${admin_group}, ${guest_group}." From 7d3e7a6ec56e1739526235a05d2c16857fc4cdfa Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 18 Apr 2026 19:34:26 -0700 Subject: [PATCH 079/102] Make Linear SCIM object sync best-effort --- Scripts/authentik-sync-linear-scim.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Scripts/authentik-sync-linear-scim.sh b/Scripts/authentik-sync-linear-scim.sh index a82cd34..4ef83e4 100644 --- a/Scripts/authentik-sync-linear-scim.sh +++ b/Scripts/authentik-sync-linear-scim.sh @@ -174,7 +174,7 @@ sync_object() { local model="$2" local object_id="$3" - api POST "/api/v3/providers/scim/${provider_pk}/sync/object/" "$( + if ! api POST "/api/v3/providers/scim/${provider_pk}/sync/object/" "$( jq -cn \ --arg model "$model" \ --arg object_id "$object_id" \ @@ -183,7 +183,9 @@ sync_object() { sync_object_id: $object_id, override_dry_run: false }' - )" >/dev/null + )" >/dev/null; then + echo "warning: could not trigger immediate Linear SCIM sync for ${model} ${object_id}; provider will continue with its normal sync cycle." >&2 + fi } wait_for_authentik From 44f437c33c9d2ee7f1171e070955d148824a3041 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 00:13:10 -0700 Subject: [PATCH 080/102] Expose Tailscale and add Zulip SAML deployment --- Scripts/authentik-sync-tailscale-oidc.sh | 103 +++++ Scripts/authentik-sync-zulip-saml.sh | 398 ++++++++++++++++++ ...ntik-backed-team-chat-and-workspace-sso.md | 7 +- flake.nix | 1 + nixos/hosts/burrow-forge/default.nix | 53 ++- nixos/modules/burrow-authentik.nix | 102 +++++ nixos/modules/burrow-zulip.nix | 354 ++++++++++++++++ secrets.nix | 5 + secrets/infra/zulip-memcached-password.age | 11 + secrets/infra/zulip-postgres-password.age | Bin 0 -> 578 bytes secrets/infra/zulip-rabbitmq-password.age | 11 + secrets/infra/zulip-redis-password.age | 11 + secrets/infra/zulip-secret-key.age | 11 + 13 files changed, 1064 insertions(+), 3 deletions(-) create mode 100644 Scripts/authentik-sync-zulip-saml.sh create mode 100644 nixos/modules/burrow-zulip.nix create mode 100644 secrets/infra/zulip-memcached-password.age create mode 100644 secrets/infra/zulip-postgres-password.age create mode 100644 secrets/infra/zulip-rabbitmq-password.age create mode 100644 secrets/infra/zulip-redis-password.age create mode 100644 secrets/infra/zulip-secret-key.age diff --git a/Scripts/authentik-sync-tailscale-oidc.sh b/Scripts/authentik-sync-tailscale-oidc.sh index 54564ad..9e01b97 100755 --- a/Scripts/authentik-sync-tailscale-oidc.sh +++ b/Scripts/authentik-sync-tailscale-oidc.sh @@ -10,6 +10,8 @@ template_slug="${AUTHENTIK_TAILSCALE_TEMPLATE_SLUG:-ts}" client_id="${AUTHENTIK_TAILSCALE_CLIENT_ID:-tailscale.burrow.net}" client_secret="${AUTHENTIK_TAILSCALE_CLIENT_SECRET:-}" launch_url="${AUTHENTIK_TAILSCALE_LAUNCH_URL:-https://login.tailscale.com/start/oidc}" +access_group="${AUTHENTIK_TAILSCALE_ACCESS_GROUP:-}" +default_external_application_slug="${AUTHENTIK_DEFAULT_EXTERNAL_APPLICATION_SLUG:-}" redirect_uris_json="${AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON:-[ \"https://login.tailscale.com/a/oauth_response\" ]}" @@ -31,6 +33,8 @@ Optional environment: AUTHENTIK_TAILSCALE_CLIENT_ID AUTHENTIK_TAILSCALE_LAUNCH_URL AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON + AUTHENTIK_TAILSCALE_ACCESS_GROUP + AUTHENTIK_DEFAULT_EXTERNAL_APPLICATION_SLUG EOF } @@ -123,6 +127,97 @@ wait_for_authentik() { wait_for_authentik +lookup_group_pk() { + local group_name="$1" + + api GET "/api/v3/core/groups/?page_size=200" \ + | jq -r --arg group_name "$group_name" '.results[]? | select(.name == $group_name) | .pk // empty' \ + | head -n1 +} + +lookup_application_pk() { + local slug="$1" + + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \ + | head -n1 +} + +ensure_application_group_binding() { + local application_slug="$1" + local group_name="$2" + local application_pk group_pk existing payload binding_pk + + application_pk="$(lookup_application_pk "$application_slug")" + if [[ -z "$application_pk" ]]; then + echo "warning: could not resolve Authentik application ${application_slug}; skipping application group binding" >&2 + return 0 + fi + + group_pk="$(lookup_group_pk "$group_name")" + if [[ -z "$group_pk" ]]; then + echo "error: could not resolve Authentik group ${group_name}" >&2 + exit 1 + fi + + existing="$( + api GET "/api/v3/policies/bindings/?page_size=200&target=${application_pk}" \ + | jq -c --arg group_pk "$group_pk" '.results[]? | select(.group == $group_pk)' \ + | head -n1 + )" + + payload="$( + jq -cn \ + --arg target "$application_pk" \ + --arg group "$group_pk" \ + '{ + group: $group, + target: $target, + negate: false, + enabled: true, + order: 100, + timeout: 30, + failure_result: false + }' + )" + + if [[ -n "$existing" ]]; then + binding_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" + api PATCH "/api/v3/policies/bindings/${binding_pk}/" "$payload" >/dev/null + else + api POST "/api/v3/policies/bindings/" "$payload" >/dev/null + fi +} + +ensure_default_external_application() { + local application_slug="$1" + local application_pk default_brand brand_payload + + application_pk="$(lookup_application_pk "$application_slug")" + if [[ -z "$application_pk" ]]; then + echo "error: could not resolve Authentik application ${application_slug} for brand default application" >&2 + exit 1 + fi + + default_brand="$( + api GET "/api/v3/core/brands/?page_size=200" \ + | jq -c '.results[]? | select(.default == true)' \ + | head -n1 + )" + + if [[ -z "$default_brand" ]]; then + echo "warning: could not resolve the default Authentik brand; skipping external default application" >&2 + return 0 + fi + + brand_payload="$( + printf '%s\n' "$default_brand" \ + | jq --arg application_pk "$application_pk" '.default_application = $application_pk' + )" + + api PUT "/api/v3/core/brands/$(printf '%s\n' "$default_brand" | jq -r '.brand_uuid')/" "$brand_payload" >/dev/null +} + template_provider="$( api GET "/api/v3/providers/oauth2/?page_size=200" \ | jq -c --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \ @@ -239,6 +334,14 @@ if [[ -z "${application_pk:-}" ]]; then exit 1 fi +if [[ -n "$access_group" ]]; then + ensure_application_group_binding "$application_slug" "$access_group" +fi + +if [[ -n "$default_external_application_slug" ]]; then + ensure_default_external_application "$default_external_application_slug" +fi + for _ in $(seq 1 30); do if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then echo "Synced Authentik Tailscale OIDC application ${application_slug} (${application_name})." diff --git a/Scripts/authentik-sync-zulip-saml.sh b/Scripts/authentik-sync-zulip-saml.sh new file mode 100644 index 0000000..d503ce0 --- /dev/null +++ b/Scripts/authentik-sync-zulip-saml.sh @@ -0,0 +1,398 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +application_slug="${AUTHENTIK_ZULIP_APPLICATION_SLUG:-zulip}" +application_name="${AUTHENTIK_ZULIP_APPLICATION_NAME:-Zulip}" +provider_name="${AUTHENTIK_ZULIP_PROVIDER_NAME:-Zulip}" +acs_url="${AUTHENTIK_ZULIP_ACS_URL:-https://chat.burrow.net/complete/saml/}" +audience="${AUTHENTIK_ZULIP_AUDIENCE:-https://chat.burrow.net}" +launch_url="${AUTHENTIK_ZULIP_LAUNCH_URL:-https://chat.burrow.net/}" +access_group="${AUTHENTIK_ZULIP_ACCESS_GROUP:-}" +issuer="${AUTHENTIK_ZULIP_ISSUER:-$authentik_url}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-zulip-saml.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_ZULIP_APPLICATION_SLUG + AUTHENTIK_ZULIP_APPLICATION_NAME + AUTHENTIK_ZULIP_PROVIDER_NAME + AUTHENTIK_ZULIP_ACS_URL + AUTHENTIK_ZULIP_AUDIENCE + AUTHENTIK_ZULIP_LAUNCH_URL + AUTHENTIK_ZULIP_ACCESS_GROUP + AUTHENTIK_ZULIP_ISSUER +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ -z "$bootstrap_token" ]]; then + echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 + exit 1 +fi + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + + if [[ -n "$data" ]]; then + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + else + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + fi +} + +api_with_status() { + local method="$1" + local path="$2" + local data="${3:-}" + local response_file status + + response_file="$(mktemp)" + trap 'rm -f "$response_file"' RETURN + + if [[ -n "$data" ]]; then + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + )" + else + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + )" + fi + + printf '%s\n' "$status" + cat "$response_file" +} + +wait_for_authentik() { + for _ in $(seq 1 90); do + if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +lookup_oauth_template_field() { + local field="$1" + + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -r --arg field "$field" '.results[]? | select(.assigned_application_slug == "ts") | .[$field]' \ + | head -n1 +} + +lookup_group_pk() { + local group_name="$1" + + api GET "/api/v3/core/groups/?page_size=200" \ + | jq -r --arg group_name "$group_name" '.results[]? | select(.name == $group_name) | .pk // empty' \ + | head -n1 +} + +lookup_application_pk() { + local slug="$1" + + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \ + | head -n1 +} + +ensure_application_group_binding() { + local application_slug="$1" + local group_name="$2" + local application_pk group_pk existing payload binding_pk + + application_pk="$(lookup_application_pk "$application_slug")" + if [[ -z "$application_pk" ]]; then + echo "warning: could not resolve Authentik application ${application_slug}; skipping application group binding" >&2 + return 0 + fi + + group_pk="$(lookup_group_pk "$group_name")" + if [[ -z "$group_pk" ]]; then + echo "error: could not resolve Authentik group ${group_name}" >&2 + exit 1 + fi + + existing="$( + api GET "/api/v3/policies/bindings/?page_size=200&target=${application_pk}" \ + | jq -c --arg group_pk "$group_pk" '.results[]? | select(.group == $group_pk)' \ + | head -n1 + )" + + payload="$( + jq -cn \ + --arg target "$application_pk" \ + --arg group "$group_pk" \ + '{ + group: $group, + target: $target, + negate: false, + enabled: true, + order: 100, + timeout: 30, + failure_result: false + }' + )" + + if [[ -n "$existing" ]]; then + binding_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" + api PATCH "/api/v3/policies/bindings/${binding_pk}/" "$payload" >/dev/null + else + api POST "/api/v3/policies/bindings/" "$payload" >/dev/null + fi +} + +reconcile_property_mapping() { + local name="$1" + local saml_name="$2" + local friendly_name="$3" + local expression="$4" + local payload existing_pk + + payload="$( + jq -n \ + --arg name "$name" \ + --arg saml_name "$saml_name" \ + --arg friendly_name "$friendly_name" \ + --arg expression "$expression" \ + '{ + name: $name, + saml_name: $saml_name, + friendly_name: $friendly_name, + expression: $expression + }' + )" + + existing_pk="$( + api GET "/api/v3/propertymappings/provider/saml/?page_size=200" \ + | jq -r --arg name "$name" '.results[]? | select(.name == $name) | .pk' \ + | head -n1 + )" + + if [[ -n "$existing_pk" ]]; then + api PATCH "/api/v3/propertymappings/provider/saml/${existing_pk}/" "$payload" >/dev/null + printf '%s\n' "$existing_pk" + else + api POST "/api/v3/propertymappings/provider/saml/" "$payload" | jq -r '.pk // empty' + fi +} + +wait_for_authentik + +authorization_flow="$(lookup_oauth_template_field authorization_flow)" +invalidation_flow="$(lookup_oauth_template_field invalidation_flow)" +signing_kp="$(lookup_oauth_template_field signing_key)" + +if [[ -z "$authorization_flow" || -z "$invalidation_flow" || -z "$signing_kp" ]]; then + echo "error: could not resolve Authentik provider defaults from Burrow Tailnet template" >&2 + exit 1 +fi + +email_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Zulip SAML Email" \ + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" \ + "email" \ + 'return request.user.email' +)" + +name_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Zulip SAML Name" \ + "name" \ + "name" \ + 'return request.user.name or request.user.username' +)" + +first_name_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Zulip SAML First Name" \ + "firstName" \ + "firstName" \ + $'parts = (request.user.name or "").split(" ", 1)\nif len(parts) > 0 and parts[0]:\n return parts[0]\nreturn request.user.username' +)" + +last_name_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Zulip SAML Last Name" \ + "lastName" \ + "lastName" \ + $'parts = (request.user.name or "").rsplit(" ", 1)\nif len(parts) == 2 and parts[1]:\n return parts[1]\nreturn request.user.username' +)" + +if [[ -z "$email_mapping_pk" || -z "$name_mapping_pk" || -z "$first_name_mapping_pk" || -z "$last_name_mapping_pk" ]]; then + echo "error: failed to reconcile Zulip SAML property mappings" >&2 + exit 1 +fi + +provider_payload="$( + jq -n \ + --arg name "$provider_name" \ + --arg authorization_flow "$authorization_flow" \ + --arg invalidation_flow "$invalidation_flow" \ + --arg acs_url "$acs_url" \ + --arg audience "$audience" \ + --arg issuer "$issuer" \ + --arg signing_kp "$signing_kp" \ + --arg name_id_mapping "$email_mapping_pk" \ + --arg email_mapping "$email_mapping_pk" \ + --arg name_mapping "$name_mapping_pk" \ + --arg first_name_mapping "$first_name_mapping_pk" \ + --arg last_name_mapping "$last_name_mapping_pk" \ + '{ + name: $name, + authorization_flow: $authorization_flow, + invalidation_flow: $invalidation_flow, + acs_url: $acs_url, + audience: $audience, + issuer: $issuer, + signing_kp: $signing_kp, + sign_assertion: true, + sign_response: true, + sp_binding: "post", + name_id_mapping: $name_id_mapping, + property_mappings: [ + $email_mapping, + $name_mapping, + $first_name_mapping, + $last_name_mapping + ] + }' +)" + +existing_provider="$( + api GET "/api/v3/providers/saml/?page_size=200" \ + | jq -c \ + --arg application_slug "$application_slug" \ + --arg provider_name "$provider_name" \ + '.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \ + | head -n1 +)" + +if [[ -n "$existing_provider" ]]; then + provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')" + api PATCH "/api/v3/providers/saml/${provider_pk}/" "$provider_payload" >/dev/null +else + provider_pk="$( + api POST "/api/v3/providers/saml/" "$provider_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "${provider_pk:-}" ]]; then + echo "error: Zulip SAML provider did not return a primary key" >&2 + exit 1 +fi + +application_payload="$( + jq -n \ + --arg name "$application_name" \ + --arg slug "$application_slug" \ + --arg provider "$provider_pk" \ + --arg launch_url "$launch_url" \ + '{ + name: $name, + slug: $slug, + provider: ($provider | tonumber), + meta_launch_url: $launch_url, + open_in_new_tab: true, + policy_engine_mode: "any" + }' +)" + +existing_application="$( + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \ + | head -n1 +)" + +if [[ -n "$existing_application" ]]; then + application_pk="existing" + api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null +else + create_application_result="$( + api_with_status POST "/api/v3/core/applications/" "$application_payload" + )" + create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')" + create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')" + + if [[ "$create_application_status" =~ ^20[01]$ ]]; then + application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')" + elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e ' + (.slug // [] | index("Application with this slug already exists.")) != null + or (.provider // [] | index("Application with this provider already exists.")) != null + ' >/dev/null; then + application_pk="existing-duplicate" + else + printf '%s\n' "$create_application_body" >&2 + echo "error: could not reconcile Authentik application ${application_slug}" >&2 + exit 1 + fi +fi + +if [[ -z "${application_pk:-}" ]]; then + echo "error: Zulip SAML application did not return a primary key" >&2 + exit 1 +fi + +if [[ -n "$access_group" ]]; then + ensure_application_group_binding "$application_slug" "$access_group" +fi + +for _ in $(seq 1 30); do + metadata_status="$( + curl -sS \ + -o /dev/null \ + -w '%{http_code}' \ + --max-redirs 0 \ + "${authentik_url}/application/saml/${application_slug}/metadata/" \ + || true + )" + case "$metadata_status" in + 200|301|302|307|308) + echo "Synced Authentik Zulip SAML application ${application_slug} (${application_name})." + exit 0 + ;; + esac + sleep 2 +done + +echo "warning: Zulip SAML metadata for ${application_slug} was not immediately readable; keeping reconciled config." >&2 +echo "Synced Authentik Zulip SAML application ${application_slug} (${application_name})." diff --git a/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md b/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md index 63e0994..ff6e63d 100644 --- a/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md +++ b/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md @@ -68,6 +68,9 @@ across vendor-native Google auth flows when Burrow already operates an IdP. options instead of hand-edited UI state. - Prefer service-specific reconciliation over ad hoc manual setup so rebuilds and host replacement converge automatically. +- When Burrow wants an external-user launcher surface in Authentik, configure + the brand's `default_application` explicitly instead of relying on + `/if/user/`, which otherwise remains internal-user-only. - Derive Linear SCIM role groups from Burrow's canonical identity metadata. If Burrow-wide admin intent says a user is an operator/admin, the repo-owned configuration should map that intent onto the Linear push group without a @@ -111,8 +114,10 @@ across vendor-native Google auth flows when Burrow already operates an IdP. - Verify: - `chat.burrow.net` serves a working Zulip login surface - Authentik exposes working metadata for Zulip and Linear - - Authentik exposes a working OIDC issuer for 1Password +- Authentik exposes a working OIDC issuer for 1Password - users in Burrow admin groups receive the expected access on first login + - external Burrow users landing on `auth.burrow.net` reach the intended + app launcher target instead of the internal-only Authentik user interface - Record concrete evidence for: - host deployment generation - Authentik reconciliation success diff --git a/flake.nix b/flake.nix index 1974f17..e842fba 100644 --- a/flake.nix +++ b/flake.nix @@ -214,6 +214,7 @@ nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default; nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix; nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.nix; + nixosModules.burrow-zulip = import ./nixos/modules/burrow-zulip.nix; nixosConfigurations.burrow-forge = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; specialArgs = { diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 0121f92..2d943b9 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -61,6 +61,7 @@ in self.nixosModules.burrow-forgejo-nsc self.nixosModules.burrow-authentik self.nixosModules.burrow-headscale + self.nixosModules.burrow-zulip ]; system.stateVersion = "24.11"; @@ -162,9 +163,44 @@ in mode = "0400"; }; + age.secrets.burrowZulipPostgresPassword = { + file = ../../../secrets/infra/zulip-postgres-password.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; + + age.secrets.burrowZulipMemcachedPassword = { + file = ../../../secrets/infra/zulip-memcached-password.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; + + age.secrets.burrowZulipRabbitmqPassword = { + file = ../../../secrets/infra/zulip-rabbitmq-password.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; + + age.secrets.burrowZulipRedisPassword = { + file = ../../../secrets/infra/zulip-redis-password.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; + + age.secrets.burrowZulipSecretKey = { + file = ../../../secrets/infra/zulip-secret-key.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; + networking.extraHosts = '' - 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net - ::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net + 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net chat.burrow.net nsc-autoscaler.burrow.net + ::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net chat.burrow.net nsc-autoscaler.burrow.net ''; services.burrow.forge = { @@ -208,6 +244,8 @@ in forgejoClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; tailscaleClientSecretFile = config.age.secrets.burrowTailscaleOidcClientSecret.path; + tailscaleAccessGroupName = contributors.groups.users; + defaultExternalApplicationSlug = "tailscale"; googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; googleAccountMapFile = config.age.secrets.burrowAuthentikGoogleAccountMap.path; @@ -224,6 +262,7 @@ in linearOwnerGroupName = linearGroups.owners; linearAdminGroupName = linearGroups.admins; linearGuestGroupName = linearGroups.guests; + zulipAccessGroupName = contributors.groups.users; }; services.burrow.headscale = { @@ -231,4 +270,14 @@ in oidcClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; bootstrapUsers = headscaleBootstrapUsers; }; + + services.burrow.zulip = { + enable = true; + administratorEmail = identities.contact.canonicalEmail; + postgresPasswordFile = config.age.secrets.burrowZulipPostgresPassword.path; + memcachedPasswordFile = config.age.secrets.burrowZulipMemcachedPassword.path; + rabbitmqPasswordFile = config.age.secrets.burrowZulipRabbitmqPassword.path; + redisPasswordFile = config.age.secrets.burrowZulipRedisPassword.path; + secretKeyFile = config.age.secrets.burrowZulipSecretKey.path; + }; } diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 772adc4..acf76ce 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -12,6 +12,7 @@ let forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh; onePasswordOidcSyncScript = ../../Scripts/authentik-sync-1password-oidc.sh; + zulipSamlSyncScript = ../../Scripts/authentik-sync-zulip-saml.sh; linearSamlSyncScript = ../../Scripts/authentik-sync-linear-saml.sh; linearScimSyncScript = ../../Scripts/authentik-sync-linear-scim.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; @@ -153,6 +154,18 @@ in description = "Host-local file containing the Authentik Tailscale OIDC client secret."; }; + tailscaleAccessGroupName = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Authentik group that should be allowed to launch the Tailscale application."; + }; + + defaultExternalApplicationSlug = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Authentik application slug that external users should land on instead of /if/user/."; + }; + onePasswordDomain = lib.mkOption { type = lib.types.str; default = "burrow-team.1password.com"; @@ -186,6 +199,42 @@ in description = "Authentik application slug for Linear SAML."; }; + zulipDomain = lib.mkOption { + type = lib.types.str; + default = "chat.burrow.net"; + description = "Public Zulip domain exposed through Authentik SAML."; + }; + + zulipProviderSlug = lib.mkOption { + type = lib.types.str; + default = "zulip"; + description = "Authentik application slug for Zulip SAML."; + }; + + zulipAcsUrl = lib.mkOption { + type = lib.types.str; + default = "https://${config.services.burrow.authentik.zulipDomain}/complete/saml/"; + description = "Zulip SAML ACS URL."; + }; + + zulipAudience = lib.mkOption { + type = lib.types.str; + default = "https://${config.services.burrow.authentik.zulipDomain}"; + description = "Zulip SAML audience/entity identifier."; + }; + + zulipLaunchUrl = lib.mkOption { + type = lib.types.str; + default = "https://${config.services.burrow.authentik.zulipDomain}/"; + description = "Zulip URL exposed in Authentik."; + }; + + zulipAccessGroupName = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Authentik group allowed to launch Zulip from Burrow SSO surfaces."; + }; + linearAcsUrl = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; @@ -809,6 +858,12 @@ EOF export AUTHENTIK_TAILSCALE_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.tailscaleClientSecretFile})" export AUTHENTIK_TAILSCALE_LAUNCH_URL=https://login.tailscale.com/start/oidc export AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON='["https://login.tailscale.com/a/oauth_response"]' + ${lib.optionalString (cfg.tailscaleAccessGroupName != null) '' + export AUTHENTIK_TAILSCALE_ACCESS_GROUP=${lib.escapeShellArg cfg.tailscaleAccessGroupName} + ''} + ${lib.optionalString (cfg.defaultExternalApplicationSlug != null) '' + export AUTHENTIK_DEFAULT_EXTERNAL_APPLICATION_SLUG=${lib.escapeShellArg cfg.defaultExternalApplicationSlug} + ''} ${pkgs.bash}/bin/bash ${tailscaleOidcSyncScript} ''; @@ -859,6 +914,53 @@ EOF ''; }; + systemd.services.burrow-authentik-zulip-saml = { + description = "Reconcile the Burrow Authentik Zulip SAML application"; + after = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + zulipSamlSyncScript + cfg.envFile + ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + set -a + source ${lib.escapeShellArg cfg.envFile} + set +a + + export AUTHENTIK_URL=https://${cfg.domain} + export AUTHENTIK_ZULIP_APPLICATION_SLUG=${lib.escapeShellArg cfg.zulipProviderSlug} + export AUTHENTIK_ZULIP_APPLICATION_NAME=Zulip + export AUTHENTIK_ZULIP_PROVIDER_NAME=Zulip + export AUTHENTIK_ZULIP_ACS_URL=${lib.escapeShellArg cfg.zulipAcsUrl} + export AUTHENTIK_ZULIP_AUDIENCE=${lib.escapeShellArg cfg.zulipAudience} + export AUTHENTIK_ZULIP_LAUNCH_URL=${lib.escapeShellArg cfg.zulipLaunchUrl} + ${lib.optionalString (cfg.zulipAccessGroupName != null) '' + export AUTHENTIK_ZULIP_ACCESS_GROUP=${lib.escapeShellArg cfg.zulipAccessGroupName} + ''} + + ${pkgs.bash}/bin/bash ${zulipSamlSyncScript} + ''; + }; + systemd.services.burrow-authentik-linear-saml = lib.mkIf ( cfg.linearAcsUrl != null && cfg.linearAudience != null ) { diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix new file mode 100644 index 0000000..0fcad65 --- /dev/null +++ b/nixos/modules/burrow-zulip.nix @@ -0,0 +1,354 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.burrow.zulip; + yamlFormat = pkgs.formats.yaml { }; + composeFile = yamlFormat.generate "burrow-zulip-compose.yaml" { + services = { + database = { + image = "zulip/zulip-postgresql:14"; + restart = "unless-stopped"; + secrets = [ "zulip__postgres_password" ]; + environment = { + POSTGRES_DB = "zulip"; + POSTGRES_USER = "zulip"; + POSTGRES_PASSWORD_FILE = "/run/secrets/zulip__postgres_password"; + }; + volumes = [ "postgresql-14:/var/lib/postgresql/data:rw" ]; + attach = false; + }; + memcached = { + image = "memcached:alpine"; + restart = "unless-stopped"; + command = [ + "sh" + "-euc" + '' + echo 'mech_list: plain' > "$SASL_CONF_PATH" + echo "zulip@$HOSTNAME:$(cat $MEMCACHED_PASSWORD_FILE)" > "$MEMCACHED_SASL_PWDB" + echo "zulip@localhost:$(cat $MEMCACHED_PASSWORD_FILE)" >> "$MEMCACHED_SASL_PWDB" + exec memcached -S + '' + ]; + secrets = [ "zulip__memcached_password" ]; + environment = { + SASL_CONF_PATH = "/home/memcache/memcached.conf"; + MEMCACHED_SASL_PWDB = "/home/memcache/memcached-sasl-db"; + MEMCACHED_PASSWORD_FILE = "/run/secrets/zulip__memcached_password"; + }; + attach = false; + }; + rabbitmq = { + image = "rabbitmq:4.2"; + restart = "unless-stopped"; + command = [ + "sh" + "-euc" + '' + export RABBITMQ_DEFAULT_PASS="$(cat "$RABBITMQ_PASSWORD_FILE")" + echo "default_user = $RABBITMQ_DEFAULT_USER" >> /etc/rabbitmq/rabbitmq.conf + echo "default_pass = $RABBITMQ_DEFAULT_PASS" >> /etc/rabbitmq/rabbitmq.conf + exec docker-entrypoint.sh rabbitmq-server + '' + ]; + secrets = [ "zulip__rabbitmq_password" ]; + environment = { + RABBITMQ_DEFAULT_USER = "zulip"; + RABBITMQ_PASSWORD_FILE = "/run/secrets/zulip__rabbitmq_password"; + }; + volumes = [ "rabbitmq:/var/lib/rabbitmq:rw" ]; + attach = false; + }; + redis = { + image = "redis:alpine"; + restart = "unless-stopped"; + command = [ + "sh" + "-euc" + "/usr/local/bin/docker-entrypoint.sh --requirepass \"$(cat \"$REDIS_PASSWORD_FILE\")\"" + ]; + secrets = [ "zulip__redis_password" ]; + environment = { + REDIS_PASSWORD_FILE = "/run/secrets/zulip__redis_password"; + }; + volumes = [ "redis:/data:rw" ]; + attach = false; + }; + zulip = { + image = "ghcr.io/zulip/zulip-server:11.6-1"; + restart = "unless-stopped"; + secrets = [ + "zulip__postgres_password" + "zulip__memcached_password" + "zulip__rabbitmq_password" + "zulip__redis_password" + "zulip__secret_key" + "zulip__email_password" + ]; + environment = { + SETTING_REMOTE_POSTGRES_HOST = "database"; + SETTING_MEMCACHED_LOCATION = "memcached:11211"; + SETTING_RABBITMQ_HOST = "rabbitmq"; + SETTING_REDIS_HOST = "redis"; + }; + volumes = [ "zulip:/data:rw" ]; + ulimits.nofile = { + soft = 1000000; + hard = 1048576; + }; + depends_on = [ + "database" + "memcached" + "rabbitmq" + "redis" + ]; + }; + }; + + volumes = { + zulip = { }; + postgresql-14 = { }; + rabbitmq = { }; + redis = { }; + }; + }; +in +{ + options.services.burrow.zulip = { + enable = lib.mkEnableOption "the Burrow Zulip deployment"; + + domain = lib.mkOption { + type = lib.types.str; + default = "chat.burrow.net"; + description = "Public Zulip domain."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 18090; + description = "Local loopback port Caddy should proxy to."; + }; + + dataDir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/burrow/zulip"; + description = "Host directory storing Zulip compose state and generated runtime files."; + }; + + administratorEmail = lib.mkOption { + type = lib.types.str; + default = "contact@burrow.net"; + description = "Operational Zulip administrator email."; + }; + + authentikDomain = lib.mkOption { + type = lib.types.str; + default = config.services.burrow.authentik.domain; + description = "Authentik domain Zulip should trust as its SAML IdP."; + }; + + authentikProviderSlug = lib.mkOption { + type = lib.types.str; + default = config.services.burrow.authentik.zulipProviderSlug; + description = "Authentik SAML application slug used for Zulip."; + }; + + postgresPasswordFile = lib.mkOption { + type = lib.types.str; + description = "File containing the Zulip PostgreSQL password."; + }; + + memcachedPasswordFile = lib.mkOption { + type = lib.types.str; + description = "File containing the Zulip memcached password."; + }; + + rabbitmqPasswordFile = lib.mkOption { + type = lib.types.str; + description = "File containing the Zulip RabbitMQ password."; + }; + + redisPasswordFile = lib.mkOption { + type = lib.types.str; + description = "File containing the Zulip Redis password."; + }; + + secretKeyFile = lib.mkOption { + type = lib.types.str; + description = "File containing the Zulip Django secret key."; + }; + }; + + config = lib.mkIf cfg.enable { + environment.systemPackages = [ + pkgs.podman + pkgs.podman-compose + ]; + + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' + encode gzip zstd + reverse_proxy 127.0.0.1:${toString cfg.port} + ''; + + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir} 0755 root root - -" + "d ${cfg.dataDir}/secrets 0700 root root - -" + "d ${cfg.dataDir}/logs 0755 root root - -" + ]; + + systemd.services.burrow-zulip-runtime = { + description = "Prepare Burrow Zulip compose and SAML runtime files"; + after = [ + "burrow-authentik-ready.service" + "burrow-authentik-zulip-saml.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "burrow-authentik-zulip-saml.service" + "network-online.target" + ]; + requiredBy = [ "burrow-zulip.service" ]; + before = [ "burrow-zulip.service" ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.python3 + ]; + restartTriggers = [ + composeFile + cfg.postgresPasswordFile + cfg.memcachedPasswordFile + cfg.rabbitmqPasswordFile + cfg.redisPasswordFile + cfg.secretKeyFile + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + + install -d -m 0755 ${lib.escapeShellArg cfg.dataDir} + install -d -m 0700 ${lib.escapeShellArg "${cfg.dataDir}/secrets"} + install -d -m 0755 ${lib.escapeShellArg "${cfg.dataDir}/logs"} + install -m 0644 ${composeFile} ${lib.escapeShellArg "${cfg.dataDir}/compose.yaml"} + : > ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} + chmod 0600 ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} + + metadata_xml="$(${pkgs.curl}/bin/curl -fsS https://${cfg.authentikDomain}/application/saml/${cfg.authentikProviderSlug}/metadata/)" + saml_cert="$(printf '%s' "$metadata_xml" | ${pkgs.python3}/bin/python3 -c ' +import re, sys, xml.etree.ElementTree as ET +xml = sys.stdin.read() +root = ET.fromstring(xml) +ns = {"md": "urn:oasis:names:tc:SAML:2.0:metadata", "ds": "http://www.w3.org/2000/09/xmldsig#"} +node = root.find(".//ds:X509Certificate", ns) +if node is None or not (node.text or "").strip(): + raise SystemExit("missing X509 certificate in Authentik metadata") +print((node.text or "").strip()) +')" + + cat > ${lib.escapeShellArg "${cfg.dataDir}/compose.override.yaml"} < ssh-ed25519 ux4N8Q x0r1UHgSibFIvKU34kP0+mnvQa5xXnac3P5fyqb7qFc +MfKnr5N0DV2NIoo4MFVFV0ULMayy0zzZqIq4FDzgDGc +-> ssh-ed25519 IrZmAg rzoR8knGrsTGuh9Hqg/NB0NQKI1vx1WI0ZRyrLIPwVY +7gV/d1slrIT+W0+iX5YK/uUWjHGJfee6vA+f9a35nEY +-> ssh-ed25519 0kWPgQ SyuEAfqmBAqLcuuQUHM5OzAv2hoquMMYtVdbKpBVhjI +7QqXens2363ln0euoormMh9a3Csh+nS2eBkHuQJmOWc +-> X25519 qDjNNkYBUhWTYyBhrw9tYl8a7G6TCkVZbR4aPcP+J0c +QF33V6hFUuYRj0B8Eo4jqyyvCpBbpD2ViVWoS8A8f3E +--- 1/Jb0nvWlcszMmxI0yVr6kfexDN0sSk1p+wsTUL4WvU +9a5IكV[f,Db \v&LZ7!?4=JxFeV \ No newline at end of file diff --git a/secrets/infra/zulip-postgres-password.age b/secrets/infra/zulip-postgres-password.age new file mode 100644 index 0000000000000000000000000000000000000000..b03556c4933321c6bf91d07806dbe4b15de64ad8 GIT binary patch literal 578 zcmYdHPt{G$OD?J`D9Oyv)5|YP*Do{V(zR14F3!+RO))YxHMCSHtuXPk2vqPXOG^vT z&v#A9cgZizF)T~5@bjoN&G#xS)pqkv@yn~IDh~-sb4qaxP3Ou8^3O~%2?@&faLhLk zi^@xNcXSL4vkdfgb}=(cP1Uwc)XwxaaCQkQC`Px*r?dm+|@HGJF`3~E0C)yxWv-QIK#lLz{RgPCC|??IlDO9 zt+c|_FgGOIxh%25EXCNdDA~*;%mCdsgY58t^gsoRjN;T>i!|c`<78jgq%sS)2-mz^ zeOGtqa>sD(+|u+UbC-$=^W;dsJYTL5SD%0|H)kjPC=a)&>;Uu1@-%Os;)2q&;^LfQ z%fRqLqw+$pDDU#5D6romAc2@tkm#D?UQiialHqIM?53}u?`0Bd7H*vD8c|ge5Ss4g z80K6YX;z#WT+U?@9`2QHkmBTB9uyei6)G0tE-TvUE$*AX&6{ulH`+|o0gwlQJj*O?`ohQR-9#Skee8pn39{B9-dJikjz!7 z+$v`jnKG|-&4fEsm&kZNUTwTeJL2$P#TUEhe6?Hk^}FQDm11@;jmvGOPj>#s=pfWm ky+teJ>QedHe>|S0GaPyDm^lCG5n0EANiN~rbwwWn07|vS`2YX_ literal 0 HcmV?d00001 diff --git a/secrets/infra/zulip-rabbitmq-password.age b/secrets/infra/zulip-rabbitmq-password.age new file mode 100644 index 0000000..9b1f6ec --- /dev/null +++ b/secrets/infra/zulip-rabbitmq-password.age @@ -0,0 +1,11 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q s1hLIWvkXmlIv/VeHXpDSCe+dh09mE+iZd7xJiQccy0 +8WosTJQLGRPhTR06SIDjgtXNebcf+H/pFzY/lBCjXcs +-> ssh-ed25519 IrZmAg zBNlK+o/RCTCyp8BRkoAYqsDn//kIKtYk3SICkMu3BA +EhBQy8QdSnCZKkdGzQho7zEMmAbJVoU5jZOMPN6tHG0 +-> ssh-ed25519 0kWPgQ hv06idPXqAATkLeUC5vILdEO2NXNWPczlWnwMFvOdkA +3EeajviunGlcfcF1QlRJrVA9bwPT+fJZFX0uneYVs0c +-> X25519 vm9rPYnQB16VSidi7+nr70lFaH0W/jIGY8zwUObZUV8 +jFgPy/w4j0/p1USKGjQY+coo1OUFXiIjJ5apIZCrZVI +--- Cf2c6WzLYOi8xE/sIn7ZtUqBy5AToASDUNpAxyjrI9M +:,+!ϨϬB4DmH|(9l9LPZ^zed=imz? \ No newline at end of file diff --git a/secrets/infra/zulip-redis-password.age b/secrets/infra/zulip-redis-password.age new file mode 100644 index 0000000..2aff8b6 --- /dev/null +++ b/secrets/infra/zulip-redis-password.age @@ -0,0 +1,11 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q DqDE3ZZlPUWUyyLA185xsOmfGi146SNk+hENMQXaiFY +D6FhZgynbdccPJQiFRJ18EYvCyDLz3cak0YuQa4f5p4 +-> ssh-ed25519 IrZmAg lXgVeADmgjeHeVOOIS5oHqrhkN59ZWDemMOBJo3ubH8 +AQ24P+DnxNoHEguNnLaROIW4/Sq96w/UxzzQwEOyGRc +-> ssh-ed25519 0kWPgQ 8x0pMohdACYueLY6jbNwg7MYVaZcjwBU4axthvDoFx4 +SgUVnd6MK1MccWVYOu9R3PtoMCBBNGKQ7jt5MSA+KkI +-> X25519 UaO5huJPx8d8eMUnGhbI77tZjsFlIPWEffT4fgoO22w +DVz016ibRxJoa4TDmb2m0Qu9Dn8jpjWEBVtdm2TZx0c +--- 5+MHuvC26SjEBFSmRm0kXjiI27QnJGxvPl2w13EkMrw +FoQ]ȟeU//no.XGJ Э|+ž \ No newline at end of file diff --git a/secrets/infra/zulip-secret-key.age b/secrets/infra/zulip-secret-key.age new file mode 100644 index 0000000..d903d66 --- /dev/null +++ b/secrets/infra/zulip-secret-key.age @@ -0,0 +1,11 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q ml+kmLmuRb2nMXJyhKigby2+lPddxM/U7tjhGGQ/JGk +B3UCv/3+4GHeKR964o/m0CoicHwDgWQGEarPW94tb3I +-> ssh-ed25519 IrZmAg AO0ELOuGGj+WanDZFRkHKUEJyZqJYFdhWbqmUfwbpiM +5RZMxVBvW5+TzCBFnn66ry3o5V5cJykweyoYMVBgczY +-> ssh-ed25519 0kWPgQ gqQ/S33Re2OYLz1D9LoSAoqOKxuL4aUes8r6+NyAoXw +NHo2xFsxxJO1ZjnG9r3oxMuvjOUsCyyPvcar2ejZp9w +-> X25519 vUAjBCE197YsckVNM4SYVIPBEESTWnBPCWnUlEwYs1I +L3l85DXFoAVm2ssHfjBeqRpWGlo1UGbmcNkEgoUB9fM +--- X/2O8ufjbTGrt2zCm4gSRqqoxT5v6a+13XjH4dpRsHs +Mkf"(qxF2BdMRYji ܴ<ґb_.!r+<Ussu?gD\V am(Ȉ&.& c/|w(WH4rѠ+j"B  \ No newline at end of file From 7567ab194b67d7c6fa942e6a9d6cbb90b399a184 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 00:16:51 -0700 Subject: [PATCH 081/102] Fix Tailscale default app and Zulip metadata fetch --- Scripts/authentik-sync-tailscale-oidc.sh | 1 + nixos/modules/burrow-zulip.nix | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Scripts/authentik-sync-tailscale-oidc.sh b/Scripts/authentik-sync-tailscale-oidc.sh index 9e01b97..45e654e 100755 --- a/Scripts/authentik-sync-tailscale-oidc.sh +++ b/Scripts/authentik-sync-tailscale-oidc.sh @@ -308,6 +308,7 @@ existing_application="$( if [[ -n "$existing_application" ]]; then application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" + api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null else create_application_result="$( api_with_status POST "/api/v3/core/applications/" "$application_payload" diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 0fcad65..6aaae60 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -239,7 +239,7 @@ in : > ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} chmod 0600 ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} - metadata_xml="$(${pkgs.curl}/bin/curl -fsS https://${cfg.authentikDomain}/application/saml/${cfg.authentikProviderSlug}/metadata/)" + metadata_xml="$(${pkgs.curl}/bin/curl -fsSL https://${cfg.authentikDomain}/application/saml/${cfg.authentikProviderSlug}/metadata/)" saml_cert="$(printf '%s' "$metadata_xml" | ${pkgs.python3}/bin/python3 -c ' import re, sys, xml.etree.ElementTree as ET xml = sys.stdin.read() From 8ac1a5c70e0d4b83dc1d08f22e9e9cc67c71a080 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 00:22:13 -0700 Subject: [PATCH 082/102] Use unified tailnet launcher and fix Zulip RabbitMQ --- nixos/hosts/burrow-forge/default.nix | 3 +-- nixos/modules/burrow-zulip.nix | 12 +----------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 2d943b9..f6d99f9 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -244,8 +244,7 @@ in forgejoClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; tailscaleClientSecretFile = config.age.secrets.burrowTailscaleOidcClientSecret.path; - tailscaleAccessGroupName = contributors.groups.users; - defaultExternalApplicationSlug = "tailscale"; + defaultExternalApplicationSlug = "ts"; googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; googleAccountMapFile = config.age.secrets.burrowAuthentikGoogleAccountMap.path; diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 6aaae60..e631468 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -41,20 +41,10 @@ let rabbitmq = { image = "rabbitmq:4.2"; restart = "unless-stopped"; - command = [ - "sh" - "-euc" - '' - export RABBITMQ_DEFAULT_PASS="$(cat "$RABBITMQ_PASSWORD_FILE")" - echo "default_user = $RABBITMQ_DEFAULT_USER" >> /etc/rabbitmq/rabbitmq.conf - echo "default_pass = $RABBITMQ_DEFAULT_PASS" >> /etc/rabbitmq/rabbitmq.conf - exec docker-entrypoint.sh rabbitmq-server - '' - ]; secrets = [ "zulip__rabbitmq_password" ]; environment = { RABBITMQ_DEFAULT_USER = "zulip"; - RABBITMQ_PASSWORD_FILE = "/run/secrets/zulip__rabbitmq_password"; + RABBITMQ_DEFAULT_PASS_FILE = "/run/secrets/zulip__rabbitmq_password"; }; volumes = [ "rabbitmq:/var/lib/rabbitmq:rw" ]; attach = false; From bd13ff3ee980223bf73302ae5998bc8a1a34cc01 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 00:25:16 -0700 Subject: [PATCH 083/102] Bind Zulip memcached and RabbitMQ config files --- nixos/modules/burrow-zulip.nix | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index e631468..8366ded 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -25,28 +25,25 @@ let "-euc" '' echo 'mech_list: plain' > "$SASL_CONF_PATH" - echo "zulip@$HOSTNAME:$(cat $MEMCACHED_PASSWORD_FILE)" > "$MEMCACHED_SASL_PWDB" - echo "zulip@localhost:$(cat $MEMCACHED_PASSWORD_FILE)" >> "$MEMCACHED_SASL_PWDB" + echo "zulip@$HOSTNAME:$(cat /run/burrow/memcached-password)" > "$MEMCACHED_SASL_PWDB" + echo "zulip@localhost:$(cat /run/burrow/memcached-password)" >> "$MEMCACHED_SASL_PWDB" exec memcached -S '' ]; - secrets = [ "zulip__memcached_password" ]; environment = { SASL_CONF_PATH = "/home/memcache/memcached.conf"; MEMCACHED_SASL_PWDB = "/home/memcache/memcached-sasl-db"; - MEMCACHED_PASSWORD_FILE = "/run/secrets/zulip__memcached_password"; }; + volumes = [ "./secrets/memcached-password:/run/burrow/memcached-password:ro" ]; attach = false; }; rabbitmq = { image = "rabbitmq:4.2"; restart = "unless-stopped"; - secrets = [ "zulip__rabbitmq_password" ]; - environment = { - RABBITMQ_DEFAULT_USER = "zulip"; - RABBITMQ_DEFAULT_PASS_FILE = "/run/secrets/zulip__rabbitmq_password"; - }; - volumes = [ "rabbitmq:/var/lib/rabbitmq:rw" ]; + volumes = [ + "rabbitmq:/var/lib/rabbitmq:rw" + "./rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro" + ]; attach = false; }; redis = { @@ -228,6 +225,12 @@ in install -m 0644 ${composeFile} ${lib.escapeShellArg "${cfg.dataDir}/compose.yaml"} : > ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} chmod 0600 ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} + install -m 0444 ${lib.escapeShellArg cfg.memcachedPasswordFile} ${lib.escapeShellArg "${cfg.dataDir}/secrets/memcached-password"} + cat > ${lib.escapeShellArg "${cfg.dataDir}/rabbitmq.conf"} < Date: Sun, 19 Apr 2026 00:30:08 -0700 Subject: [PATCH 084/102] Declare Zulip compose secrets --- nixos/modules/burrow-zulip.nix | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 8366ded..48a5cbf 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -248,6 +248,10 @@ print((node.text or "").strip()) secrets: zulip__postgres_password: file: ${cfg.postgresPasswordFile} + zulip__memcached_password: + file: ${cfg.memcachedPasswordFile} + zulip__rabbitmq_password: + file: ${cfg.rabbitmqPasswordFile} zulip__redis_password: file: ${cfg.redisPasswordFile} zulip__secret_key: From b8cad4c028bd1df82233b2ddc1a1cce7bef96eb8 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 00:52:16 -0700 Subject: [PATCH 085/102] Grant Tailnet access and harden Zulip bootstrap --- Scripts/authentik-sync-linear-saml.sh | 4 +- Scripts/authentik-sync-linear-scim.sh | 7 +- Scripts/authentik-sync-tailscale-oidc.sh | 2 +- Scripts/authentik-sync-zulip-saml.sh | 4 +- nixos/hosts/burrow-forge/default.nix | 1 + nixos/modules/burrow-zulip.nix | 81 +++++++++++++++++++++++- 6 files changed, 90 insertions(+), 9 deletions(-) diff --git a/Scripts/authentik-sync-linear-saml.sh b/Scripts/authentik-sync-linear-saml.sh index 5da64ad..2fd1a90 100755 --- a/Scripts/authentik-sync-linear-saml.sh +++ b/Scripts/authentik-sync-linear-saml.sh @@ -294,8 +294,8 @@ existing_application="$( )" if [[ -n "$existing_application" ]]; then - application_pk="existing" - api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null + application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" + api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null else create_application_result="$( api_with_status POST "/api/v3/core/applications/" "$application_payload" diff --git a/Scripts/authentik-sync-linear-scim.sh b/Scripts/authentik-sync-linear-scim.sh index 4ef83e4..5d42cca 100644 --- a/Scripts/authentik-sync-linear-scim.sh +++ b/Scripts/authentik-sync-linear-scim.sh @@ -278,7 +278,12 @@ application_payload="$( policy_engine_mode: .policy_engine_mode }' )" -api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null +application_pk="$(printf '%s\n' "$application" | jq -r '.pk // empty')" +if [[ -z "$application_pk" ]]; then + echo "error: could not resolve Authentik application primary key for ${application_slug}" >&2 + exit 1 +fi +api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null group_pks_json="$(jq -cn --arg owner "$owner_group_pk" --arg admin "$admin_group_pk" --arg guest "$guest_group_pk" '[$owner, $admin, $guest]')" user_pks_json="$( diff --git a/Scripts/authentik-sync-tailscale-oidc.sh b/Scripts/authentik-sync-tailscale-oidc.sh index 45e654e..fde1a01 100755 --- a/Scripts/authentik-sync-tailscale-oidc.sh +++ b/Scripts/authentik-sync-tailscale-oidc.sh @@ -308,7 +308,7 @@ existing_application="$( if [[ -n "$existing_application" ]]; then application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" - api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null + api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null else create_application_result="$( api_with_status POST "/api/v3/core/applications/" "$application_payload" diff --git a/Scripts/authentik-sync-zulip-saml.sh b/Scripts/authentik-sync-zulip-saml.sh index d503ce0..6767991 100644 --- a/Scripts/authentik-sync-zulip-saml.sh +++ b/Scripts/authentik-sync-zulip-saml.sh @@ -344,8 +344,8 @@ existing_application="$( )" if [[ -n "$existing_application" ]]; then - application_pk="existing" - api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null + application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" + api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null else create_application_result="$( api_with_status POST "/api/v3/core/applications/" "$application_payload" diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index f6d99f9..2464672 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -251,6 +251,7 @@ in googleLoginMode = "redirect"; userGroupName = contributors.groups.users; adminGroupName = contributors.groups.admins; + tailscaleAccessGroupName = contributors.groups.users; bootstrapUsers = bootstrapUsers; linearAcsUrl = "https://api.linear.app/auth/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de/acs"; linearAudience = "https://auth.linear.app/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de"; diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 48a5cbf..a408c12 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -128,6 +128,18 @@ in description = "Operational Zulip administrator email."; }; + realmName = lib.mkOption { + type = lib.types.str; + default = "Burrow"; + description = "Initial Zulip organization name for single-tenant bootstrap."; + }; + + realmOwnerName = lib.mkOption { + type = lib.types.str; + default = "Burrow"; + description = "Display name used for the initial Zulip organization owner."; + }; + authentikDomain = lib.mkOption { type = lib.types.str; default = config.services.burrow.authentik.domain; @@ -227,6 +239,7 @@ in chmod 0600 ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} install -m 0444 ${lib.escapeShellArg cfg.memcachedPasswordFile} ${lib.escapeShellArg "${cfg.dataDir}/secrets/memcached-password"} cat > ${lib.escapeShellArg "${cfg.dataDir}/rabbitmq.conf"} </dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 90 ]; then + echo "error: RabbitMQ did not become ready for Zulip bootstrap" >&2 + exit 1 + fi + sleep 2 + done + } + + ensure_zulip_volume_layout() { + local zulip_volume_mount + zulip_volume_mount="$(podman volume inspect burrow-zulip_zulip --format '{{.Mountpoint}}')" + install -d -m 0755 "$zulip_volume_mount/logs" + install -d -m 0755 "$zulip_volume_mount/logs/emails" + install -d -m 0700 "$zulip_volume_mount/secrets" + chown 1000:1000 "$zulip_volume_mount/logs" "$zulip_volume_mount/logs/emails" "$zulip_volume_mount/secrets" + + if [ ! -s "$zulip_volume_mount/secrets/bootstrap-owner-password" ]; then + umask 077 + openssl rand -base64 24 > "$zulip_volume_mount/secrets/bootstrap-owner-password" + fi + chown 1000:1000 "$zulip_volume_mount/secrets/bootstrap-owner-password" + chmod 0600 "$zulip_volume_mount/secrets/bootstrap-owner-password" + } + + bootstrap_realm_if_needed() { + local realm_exists + realm_exists="$( + compose run --rm --entrypoint bash zulip -lc \ + "su zulip -c '/home/zulip/deployments/current/manage.py list_realms'" \ + | awk '$NF == "https://${cfg.domain}" { print "yes" }' + )" + + if [ -n "$realm_exists" ]; then + return 0 + fi + + export ZULIP_REALM_NAME=${lib.escapeShellArg cfg.realmName} + export ZULIP_ADMIN_EMAIL=${lib.escapeShellArg cfg.administratorEmail} + export ZULIP_OWNER_NAME=${lib.escapeShellArg cfg.realmOwnerName} + + compose run --rm --entrypoint bash zulip -lc ' + su zulip -c "/home/zulip/deployments/current/manage.py create_realm --string-id= --password-file /data/secrets/bootstrap-owner-password --automated \"$ZULIP_REALM_NAME\" \"$ZULIP_ADMIN_EMAIL\" \"$ZULIP_OWNER_NAME\"" + ' + } + if [ ! -e .initialized ]; then - ${pkgs.podman-compose}/bin/podman-compose -p burrow-zulip pull - ${pkgs.podman-compose}/bin/podman-compose -p burrow-zulip run --rm zulip app:init + compose pull + compose up -d database memcached rabbitmq redis + wait_for_rabbitmq + compose run --rm zulip app:init touch .initialized fi - ${pkgs.podman-compose}/bin/podman-compose -p burrow-zulip up -d + compose up -d database memcached rabbitmq redis + wait_for_rabbitmq + ensure_zulip_volume_layout + bootstrap_realm_if_needed + compose up -d zulip ''; }; }; From 824bbd9d671c767acd770a01e0011a6c5f9301c5 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 00:55:07 -0700 Subject: [PATCH 086/102] Run Zulip bootstrap non-interactively --- nixos/modules/burrow-zulip.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index a408c12..238905b 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -385,7 +385,7 @@ EOF bootstrap_realm_if_needed() { local realm_exists realm_exists="$( - compose run --rm --entrypoint bash zulip -lc \ + compose run --rm -T --entrypoint bash zulip -lc \ "su zulip -c '/home/zulip/deployments/current/manage.py list_realms'" \ | awk '$NF == "https://${cfg.domain}" { print "yes" }' )" @@ -398,7 +398,7 @@ EOF export ZULIP_ADMIN_EMAIL=${lib.escapeShellArg cfg.administratorEmail} export ZULIP_OWNER_NAME=${lib.escapeShellArg cfg.realmOwnerName} - compose run --rm --entrypoint bash zulip -lc ' + compose run --rm -T --entrypoint bash zulip -lc ' su zulip -c "/home/zulip/deployments/current/manage.py create_realm --string-id= --password-file /data/secrets/bootstrap-owner-password --automated \"$ZULIP_REALM_NAME\" \"$ZULIP_ADMIN_EMAIL\" \"$ZULIP_OWNER_NAME\"" ' } @@ -407,7 +407,7 @@ EOF compose pull compose up -d database memcached rabbitmq redis wait_for_rabbitmq - compose run --rm zulip app:init + compose run --rm -T zulip app:init touch .initialized fi From b70b62dfef8e4907edeb6825a0b040a6967d5773 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 00:56:35 -0700 Subject: [PATCH 087/102] Fix Zulip bootstrap user handling --- Scripts/authentik-sync-linear-saml.sh | 4 ++-- Scripts/authentik-sync-linear-scim.sh | 7 +------ nixos/modules/burrow-zulip.nix | 8 ++++---- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/Scripts/authentik-sync-linear-saml.sh b/Scripts/authentik-sync-linear-saml.sh index 2fd1a90..5da64ad 100755 --- a/Scripts/authentik-sync-linear-saml.sh +++ b/Scripts/authentik-sync-linear-saml.sh @@ -294,8 +294,8 @@ existing_application="$( )" if [[ -n "$existing_application" ]]; then - application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" - api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null + application_pk="existing" + api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null else create_application_result="$( api_with_status POST "/api/v3/core/applications/" "$application_payload" diff --git a/Scripts/authentik-sync-linear-scim.sh b/Scripts/authentik-sync-linear-scim.sh index 5d42cca..4ef83e4 100644 --- a/Scripts/authentik-sync-linear-scim.sh +++ b/Scripts/authentik-sync-linear-scim.sh @@ -278,12 +278,7 @@ application_payload="$( policy_engine_mode: .policy_engine_mode }' )" -application_pk="$(printf '%s\n' "$application" | jq -r '.pk // empty')" -if [[ -z "$application_pk" ]]; then - echo "error: could not resolve Authentik application primary key for ${application_slug}" >&2 - exit 1 -fi -api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null +api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null group_pks_json="$(jq -cn --arg owner "$owner_group_pk" --arg admin "$admin_group_pk" --arg guest "$guest_group_pk" '[$owner, $admin, $guest]')" user_pks_json="$( diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 238905b..0db3dfd 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -385,8 +385,8 @@ EOF bootstrap_realm_if_needed() { local realm_exists realm_exists="$( - compose run --rm -T --entrypoint bash zulip -lc \ - "su zulip -c '/home/zulip/deployments/current/manage.py list_realms'" \ + compose run --rm -T -u zulip --entrypoint bash zulip -lc \ + "/home/zulip/deployments/current/manage.py list_realms" \ | awk '$NF == "https://${cfg.domain}" { print "yes" }' )" @@ -398,8 +398,8 @@ EOF export ZULIP_ADMIN_EMAIL=${lib.escapeShellArg cfg.administratorEmail} export ZULIP_OWNER_NAME=${lib.escapeShellArg cfg.realmOwnerName} - compose run --rm -T --entrypoint bash zulip -lc ' - su zulip -c "/home/zulip/deployments/current/manage.py create_realm --string-id= --password-file /data/secrets/bootstrap-owner-password --automated \"$ZULIP_REALM_NAME\" \"$ZULIP_ADMIN_EMAIL\" \"$ZULIP_OWNER_NAME\"" + compose run --rm -T -u zulip --entrypoint bash zulip -lc ' + /home/zulip/deployments/current/manage.py create_realm --string-id= --password-file /data/secrets/bootstrap-owner-password --automated "$ZULIP_REALM_NAME" "$ZULIP_ADMIN_EMAIL" "$ZULIP_OWNER_NAME" ' } From fa2806e4b36e98df780a1be557bd0d864792cf5d Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 00:59:34 -0700 Subject: [PATCH 088/102] Bootstrap Zulip from the live app container --- nixos/modules/burrow-zulip.nix | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 0db3dfd..ee6d6c7 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -384,9 +384,19 @@ EOF bootstrap_realm_if_needed() { local realm_exists + local attempts=0 + while ! podman exec burrow-zulip_zulip_1 test -r /etc/zulip/zulip-secrets.conf >/dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 90 ]; then + echo "error: Zulip did not finish generating production secrets" >&2 + exit 1 + fi + sleep 2 + done + realm_exists="$( - compose run --rm -T -u zulip --entrypoint bash zulip -lc \ - "/home/zulip/deployments/current/manage.py list_realms" \ + podman exec burrow-zulip_zulip_1 bash -lc \ + "su zulip -c '/home/zulip/deployments/current/manage.py list_realms'" \ | awk '$NF == "https://${cfg.domain}" { print "yes" }' )" @@ -398,8 +408,8 @@ EOF export ZULIP_ADMIN_EMAIL=${lib.escapeShellArg cfg.administratorEmail} export ZULIP_OWNER_NAME=${lib.escapeShellArg cfg.realmOwnerName} - compose run --rm -T -u zulip --entrypoint bash zulip -lc ' - /home/zulip/deployments/current/manage.py create_realm --string-id= --password-file /data/secrets/bootstrap-owner-password --automated "$ZULIP_REALM_NAME" "$ZULIP_ADMIN_EMAIL" "$ZULIP_OWNER_NAME" + podman exec burrow-zulip_zulip_1 bash -lc ' + su zulip -c "/home/zulip/deployments/current/manage.py create_realm --string-id= --password-file /data/secrets/bootstrap-owner-password --automated \"$ZULIP_REALM_NAME\" \"$ZULIP_ADMIN_EMAIL\" \"$ZULIP_OWNER_NAME\"" ' } @@ -414,8 +424,8 @@ EOF compose up -d database memcached rabbitmq redis wait_for_rabbitmq ensure_zulip_volume_layout - bootstrap_realm_if_needed compose up -d zulip + bootstrap_realm_if_needed ''; }; }; From 42df7b5618d2d8500d814e7c1839260a38844559 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 01:11:37 -0700 Subject: [PATCH 089/102] Run Zulip on host-managed services --- ...ntik-backed-team-chat-and-workspace-sso.md | 4 + nixos/hosts/burrow-forge/default.nix | 8 - nixos/modules/burrow-zulip.nix | 290 ++++++++++-------- 3 files changed, 170 insertions(+), 132 deletions(-) diff --git a/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md b/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md index ff6e63d..0ce03a6 100644 --- a/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md +++ b/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md @@ -49,6 +49,10 @@ across vendor-native Google auth flows when Burrow already operates an IdP. - Add a Burrow-managed Zulip workload on the forge host at `chat.burrow.net`. The deployment should be repo-owned and rebuildable from Nix, even if the runtime uses vendor-supported container images internally. +- Prefer host-managed NixOS services for Zulip's stateful dependencies + (PostgreSQL, Redis, RabbitMQ, memcached, backups) so Burrow owns the + operational surface directly rather than composing a container-side service + mesh. - Zulip should authenticate through Authentik SAML rather than local passwords as the primary path. Initial bootstrap may still keep an operational escape hatch while the deployment is being validated. diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 2464672..be97661 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -170,13 +170,6 @@ in mode = "0400"; }; - age.secrets.burrowZulipMemcachedPassword = { - file = ../../../secrets/infra/zulip-memcached-password.age; - owner = "root"; - group = "root"; - mode = "0400"; - }; - age.secrets.burrowZulipRabbitmqPassword = { file = ../../../secrets/infra/zulip-rabbitmq-password.age; owner = "root"; @@ -275,7 +268,6 @@ in enable = true; administratorEmail = identities.contact.canonicalEmail; postgresPasswordFile = config.age.secrets.burrowZulipPostgresPassword.path; - memcachedPasswordFile = config.age.secrets.burrowZulipMemcachedPassword.path; rabbitmqPasswordFile = config.age.secrets.burrowZulipRabbitmqPassword.path; redisPasswordFile = config.age.secrets.burrowZulipRedisPassword.path; secretKeyFile = config.age.secrets.burrowZulipSecretKey.path; diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index ee6d6c7..b5e72b7 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -5,99 +5,30 @@ let yamlFormat = pkgs.formats.yaml { }; composeFile = yamlFormat.generate "burrow-zulip-compose.yaml" { services = { - database = { - image = "zulip/zulip-postgresql:14"; - restart = "unless-stopped"; - secrets = [ "zulip__postgres_password" ]; - environment = { - POSTGRES_DB = "zulip"; - POSTGRES_USER = "zulip"; - POSTGRES_PASSWORD_FILE = "/run/secrets/zulip__postgres_password"; - }; - volumes = [ "postgresql-14:/var/lib/postgresql/data:rw" ]; - attach = false; - }; - memcached = { - image = "memcached:alpine"; - restart = "unless-stopped"; - command = [ - "sh" - "-euc" - '' - echo 'mech_list: plain' > "$SASL_CONF_PATH" - echo "zulip@$HOSTNAME:$(cat /run/burrow/memcached-password)" > "$MEMCACHED_SASL_PWDB" - echo "zulip@localhost:$(cat /run/burrow/memcached-password)" >> "$MEMCACHED_SASL_PWDB" - exec memcached -S - '' - ]; - environment = { - SASL_CONF_PATH = "/home/memcache/memcached.conf"; - MEMCACHED_SASL_PWDB = "/home/memcache/memcached-sasl-db"; - }; - volumes = [ "./secrets/memcached-password:/run/burrow/memcached-password:ro" ]; - attach = false; - }; - rabbitmq = { - image = "rabbitmq:4.2"; - restart = "unless-stopped"; - volumes = [ - "rabbitmq:/var/lib/rabbitmq:rw" - "./rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro" - ]; - attach = false; - }; - redis = { - image = "redis:alpine"; - restart = "unless-stopped"; - command = [ - "sh" - "-euc" - "/usr/local/bin/docker-entrypoint.sh --requirepass \"$(cat \"$REDIS_PASSWORD_FILE\")\"" - ]; - secrets = [ "zulip__redis_password" ]; - environment = { - REDIS_PASSWORD_FILE = "/run/secrets/zulip__redis_password"; - }; - volumes = [ "redis:/data:rw" ]; - attach = false; - }; zulip = { image = "ghcr.io/zulip/zulip-server:11.6-1"; restart = "unless-stopped"; + network_mode = "host"; secrets = [ "zulip__postgres_password" - "zulip__memcached_password" "zulip__rabbitmq_password" "zulip__redis_password" "zulip__secret_key" "zulip__email_password" ]; environment = { - SETTING_REMOTE_POSTGRES_HOST = "database"; - SETTING_MEMCACHED_LOCATION = "memcached:11211"; - SETTING_RABBITMQ_HOST = "rabbitmq"; - SETTING_REDIS_HOST = "redis"; + SETTING_REMOTE_POSTGRES_HOST = "127.0.0.1"; + SETTING_MEMCACHED_LOCATION = "127.0.0.1:11211"; + SETTING_RABBITMQ_HOST = "127.0.0.1"; + SETTING_REDIS_HOST = "127.0.0.1"; }; - volumes = [ "zulip:/data:rw" ]; + volumes = [ "${cfg.dataDir}/data:/data:rw" ]; ulimits.nofile = { soft = 1000000; hard = 1048576; }; - depends_on = [ - "database" - "memcached" - "rabbitmq" - "redis" - ]; }; }; - - volumes = { - zulip = { }; - postgresql-14 = { }; - rabbitmq = { }; - redis = { }; - }; }; in { @@ -157,11 +88,6 @@ in description = "File containing the Zulip PostgreSQL password."; }; - memcachedPasswordFile = lib.mkOption { - type = lib.types.str; - description = "File containing the Zulip memcached password."; - }; - rabbitmqPasswordFile = lib.mkOption { type = lib.types.str; description = "File containing the Zulip RabbitMQ password."; @@ -184,6 +110,49 @@ in pkgs.podman-compose ]; + services.postgresql = { + ensureDatabases = [ "zulip" ]; + ensureUsers = [ + { + name = "zulip"; + ensureDBOwnership = true; + } + ]; + settings = { + listen_addresses = lib.mkDefault "127.0.0.1"; + password_encryption = lib.mkDefault "scram-sha-256"; + }; + authentication = lib.mkAfter '' + host zulip zulip 127.0.0.1/32 scram-sha-256 + ''; + }; + + services.postgresqlBackup = { + enable = true; + backupAll = false; + databases = [ "zulip" ]; + }; + + services.memcached = { + enable = true; + listen = "127.0.0.1"; + port = 11211; + extraOptions = [ "-U 0" ]; + }; + + services.redis.servers.zulip = { + enable = true; + bind = "127.0.0.1"; + port = 6379; + requirePassFile = cfg.redisPasswordFile; + }; + + services.rabbitmq = { + enable = true; + listenAddress = "127.0.0.1"; + port = 5672; + }; + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd reverse_proxy 127.0.0.1:${toString cfg.port} @@ -191,18 +160,114 @@ in systemd.tmpfiles.rules = [ "d ${cfg.dataDir} 0755 root root - -" + "d ${cfg.dataDir}/data 0755 root root - -" + "d ${cfg.dataDir}/data/logs 0755 root root - -" + "d ${cfg.dataDir}/data/logs/emails 0755 root root - -" + "d ${cfg.dataDir}/data/secrets 0700 root root - -" "d ${cfg.dataDir}/secrets 0700 root root - -" "d ${cfg.dataDir}/logs 0755 root root - -" ]; + systemd.services.burrow-zulip-postgres-bootstrap = { + description = "Bootstrap PostgreSQL role for Burrow Zulip"; + after = [ "postgresql.service" ]; + wants = [ "postgresql.service" ]; + requiredBy = [ "burrow-zulip.service" ]; + before = [ "burrow-zulip.service" ]; + path = [ + config.services.postgresql.package + pkgs.bash + pkgs.coreutils + pkgs.python3 + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + + db_password="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.postgresPasswordFile})" + db_password_sql="$(printf '%s' "$db_password" | python3 -c "import sys; print(sys.stdin.read().replace(chr(39), chr(39) * 2), end=\"\")")" + setup_sql="$(mktemp)" + trap 'rm -f "$setup_sql"' EXIT + + cat > "$setup_sql" < ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} chmod 0600 ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} - install -m 0444 ${lib.escapeShellArg cfg.memcachedPasswordFile} ${lib.escapeShellArg "${cfg.dataDir}/secrets/memcached-password"} - cat > ${lib.escapeShellArg "${cfg.dataDir}/rabbitmq.conf"} </dev/null 2>&1; do - attempts=$((attempts + 1)) - if [ "$attempts" -ge 90 ]; then - echo "error: RabbitMQ did not become ready for Zulip bootstrap" >&2 - exit 1 - fi - sleep 2 - done - } + ensure_zulip_data_layout() { + local zulip_data_dir=${lib.escapeShellArg "${cfg.dataDir}/data"} - ensure_zulip_volume_layout() { - local zulip_volume_mount - zulip_volume_mount="$(podman volume inspect burrow-zulip_zulip --format '{{.Mountpoint}}')" - install -d -m 0755 "$zulip_volume_mount/logs" - install -d -m 0755 "$zulip_volume_mount/logs/emails" - install -d -m 0700 "$zulip_volume_mount/secrets" - chown 1000:1000 "$zulip_volume_mount/logs" "$zulip_volume_mount/logs/emails" "$zulip_volume_mount/secrets" + install -d -m 0755 "$zulip_data_dir/logs" + install -d -m 0755 "$zulip_data_dir/logs/emails" + install -d -m 0700 "$zulip_data_dir/secrets" + chown 1000:1000 "$zulip_data_dir/logs" "$zulip_data_dir/logs/emails" "$zulip_data_dir/secrets" - if [ ! -s "$zulip_volume_mount/secrets/bootstrap-owner-password" ]; then + if [ ! -s "$zulip_data_dir/secrets/bootstrap-owner-password" ]; then umask 077 - openssl rand -base64 24 > "$zulip_volume_mount/secrets/bootstrap-owner-password" + openssl rand -base64 24 > "$zulip_data_dir/secrets/bootstrap-owner-password" fi - chown 1000:1000 "$zulip_volume_mount/secrets/bootstrap-owner-password" - chmod 0600 "$zulip_volume_mount/secrets/bootstrap-owner-password" + chown 1000:1000 "$zulip_data_dir/secrets/bootstrap-owner-password" + chmod 0600 "$zulip_data_dir/secrets/bootstrap-owner-password" } bootstrap_realm_if_needed() { @@ -415,15 +461,11 @@ EOF if [ ! -e .initialized ]; then compose pull - compose up -d database memcached rabbitmq redis - wait_for_rabbitmq compose run --rm -T zulip app:init touch .initialized fi - compose up -d database memcached rabbitmq redis - wait_for_rabbitmq - ensure_zulip_volume_layout + ensure_zulip_data_layout compose up -d zulip bootstrap_realm_if_needed ''; From 601bedcc59532f183fa5009b81aef3efa4974c0e Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 01:19:01 -0700 Subject: [PATCH 090/102] Fix Zulip Postgres bootstrap runtime --- nixos/modules/burrow-zulip.nix | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index b5e72b7..3417925 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -179,6 +179,7 @@ in pkgs.bash pkgs.coreutils pkgs.python3 + pkgs.shadow ]; serviceConfig = { Type = "oneshot"; @@ -204,7 +205,7 @@ END ALTER ROLE zulip WITH LOGIN PASSWORD '$db_password_sql'; SQL - su postgres -s ${pkgs.bash}/bin/bash -c "psql -v ON_ERROR_STOP=1 -f '$setup_sql'" + ${pkgs.shadow}/bin/su postgres -s ${pkgs.bash}/bin/bash -c "psql -v ON_ERROR_STOP=1 -f '$setup_sql'" ''; }; From 2ef804fa1051268d25f3a26cdaa39cee73b9a259 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 01:20:55 -0700 Subject: [PATCH 091/102] Use runuser for Zulip Postgres bootstrap --- nixos/modules/burrow-zulip.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 3417925..23ce77b 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -179,7 +179,7 @@ in pkgs.bash pkgs.coreutils pkgs.python3 - pkgs.shadow + pkgs.util-linux ]; serviceConfig = { Type = "oneshot"; @@ -205,7 +205,7 @@ END ALTER ROLE zulip WITH LOGIN PASSWORD '$db_password_sql'; SQL - ${pkgs.shadow}/bin/su postgres -s ${pkgs.bash}/bin/bash -c "psql -v ON_ERROR_STOP=1 -f '$setup_sql'" + ${pkgs.util-linux}/bin/runuser -u postgres -- psql -v ON_ERROR_STOP=1 -f "$setup_sql" ''; }; From 142c2ef77807f9071ae2326e54fc7e7c338b1b52 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 01:22:32 -0700 Subject: [PATCH 092/102] Allow postgres bootstrap to read generated SQL --- nixos/modules/burrow-zulip.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 23ce77b..7d93705 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -204,6 +204,7 @@ END \$\$; ALTER ROLE zulip WITH LOGIN PASSWORD '$db_password_sql'; SQL + chmod 0644 "$setup_sql" ${pkgs.util-linux}/bin/runuser -u postgres -- psql -v ON_ERROR_STOP=1 -f "$setup_sql" ''; From 2af7618f5265471f4048db49eb1353924cf322f6 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 01:31:45 -0700 Subject: [PATCH 093/102] Fix tailscale landing and zulip bootstrap --- Scripts/authentik-sync-tailscale-oidc.sh | 16 +++++++++++++++- nixos/hosts/burrow-forge/default.nix | 2 +- nixos/modules/burrow-zulip.nix | 24 +++++++++++++++++------- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/Scripts/authentik-sync-tailscale-oidc.sh b/Scripts/authentik-sync-tailscale-oidc.sh index fde1a01..58fe7e4 100755 --- a/Scripts/authentik-sync-tailscale-oidc.sh +++ b/Scripts/authentik-sync-tailscale-oidc.sh @@ -137,10 +137,24 @@ lookup_group_pk() { lookup_application_pk() { local slug="$1" + local application_pk lookup_result lookup_status - api GET "/api/v3/core/applications/?page_size=200" \ + application_pk="$( + api GET "/api/v3/core/applications/?page_size=200" \ | jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \ | head -n1 + )" + + if [[ -n "$application_pk" ]]; then + printf '%s\n' "$application_pk" + return 0 + fi + + lookup_result="$(api_with_status GET "/api/v3/core/applications/${slug}/")" + lookup_status="$(printf '%s\n' "$lookup_result" | sed -n '1p')" + if [[ "$lookup_status" =~ ^20[01]$ ]]; then + printf '%s\n' "$lookup_result" | sed '1d' | jq -r '.pk // empty' + fi } ensure_application_group_binding() { diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index be97661..c4fc92e 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -237,7 +237,7 @@ in forgejoClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; tailscaleClientSecretFile = config.age.secrets.burrowTailscaleOidcClientSecret.path; - defaultExternalApplicationSlug = "ts"; + defaultExternalApplicationSlug = "tailscale"; googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; googleAccountMapFile = config.age.secrets.burrowAuthentikGoogleAccountMap.path; diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 7d93705..0096b65 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -404,7 +404,8 @@ EOF Group = "root"; WorkingDirectory = cfg.dataDir; RemainAfterExit = true; - ExecStop = "${pkgs.bash}/bin/bash -lc 'cd ${lib.escapeShellArg cfg.dataDir} && ${pkgs.podman-compose}/bin/podman-compose -p burrow-zulip down'"; + TimeoutStopSec = "20s"; + ExecStop = "${pkgs.bash}/bin/bash -lc 'set -euo pipefail; if ${pkgs.podman}/bin/podman container exists burrow-zulip_zulip_1; then ${pkgs.podman}/bin/podman stop --ignore --time 10 burrow-zulip_zulip_1 >/dev/null || true; ${pkgs.podman}/bin/podman rm -f --ignore burrow-zulip_zulip_1 >/dev/null || true; fi'"; }; script = '' set -euo pipefail @@ -452,13 +453,22 @@ EOF return 0 fi - export ZULIP_REALM_NAME=${lib.escapeShellArg cfg.realmName} - export ZULIP_ADMIN_EMAIL=${lib.escapeShellArg cfg.administratorEmail} - export ZULIP_OWNER_NAME=${lib.escapeShellArg cfg.realmOwnerName} + local realm_name=${lib.escapeShellArg cfg.realmName} + local admin_email=${lib.escapeShellArg cfg.administratorEmail} + local owner_name=${lib.escapeShellArg cfg.realmOwnerName} + local create_realm_cmd - podman exec burrow-zulip_zulip_1 bash -lc ' - su zulip -c "/home/zulip/deployments/current/manage.py create_realm --string-id= --password-file /data/secrets/bootstrap-owner-password --automated \"$ZULIP_REALM_NAME\" \"$ZULIP_ADMIN_EMAIL\" \"$ZULIP_OWNER_NAME\"" - ' + printf -v create_realm_cmd '%q ' \ + /home/zulip/deployments/current/manage.py \ + create_realm \ + --string-id= \ + --password-file /data/secrets/bootstrap-owner-password \ + --automated \ + "$realm_name" \ + "$admin_email" \ + "$owner_name" + + podman exec burrow-zulip_zulip_1 su zulip -c "$create_realm_cmd" } if [ ! -e .initialized ]; then From 4c3dcdd17b7d1feb2487cc8119e6c19b2c4dfa4f Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 01:43:43 -0700 Subject: [PATCH 094/102] Force https-only Zulip SAML login --- nixos/modules/burrow-zulip.nix | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 0096b65..25d553d 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -340,13 +340,18 @@ services: SETTING_ZULIP_ADMINISTRATOR: "${cfg.administratorEmail}" TRUST_GATEWAY_IP: "True" SETTING_SEND_LOGIN_EMAILS: "False" - ZULIP_AUTH_BACKENDS: "EmailAuthBackend,SAMLAuthBackend" + ZULIP_AUTH_BACKENDS: "SAMLAuthBackend" CONFIG_application_server__http_only: true CONFIG_application_server__nginx_listen_port: ${toString cfg.port} CONFIG_application_server__queue_workers_multiprocess: false ZULIP_CUSTOM_SETTINGS: | EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend" EMAIL_FILE_PATH = "/data/logs/emails" + EXTERNAL_URI_SCHEME = "https://" + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + USE_X_FORWARDED_HOST = True + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True SOCIAL_AUTH_SAML_ORG_INFO = { "en-US": { "displayname": "Burrow Zulip", From 78d83c50790b5882228f2e343a7663bbf70eb51e Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 01:49:25 -0700 Subject: [PATCH 095/102] Pin Zulip SAML ACS to https --- nixos/modules/burrow-zulip.nix | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 25d553d..e26cc3d 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -352,6 +352,15 @@ services: USE_X_FORWARDED_HOST = True SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True + SOCIAL_AUTH_REDIRECT_IS_HTTPS = True + SOCIAL_AUTH_SAML_REDIRECT_IS_HTTPS = True + SOCIAL_AUTH_SAML_SP_ENTITY_ID = "https://${cfg.domain}" + SOCIAL_AUTH_SAML_SP_EXTRA = { + "assertionConsumerService": { + "url": "https://${cfg.domain}/complete/saml/", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", + }, + } SOCIAL_AUTH_SAML_ORG_INFO = { "en-US": { "displayname": "Burrow Zulip", From 5598fc18fc6bf168a80dc123e164c7615002bfa0 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 03:37:42 -0700 Subject: [PATCH 096/102] Enable Zulip SAML auto signup --- nixos/modules/burrow-zulip.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index e26cc3d..ef1f190 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -373,6 +373,7 @@ services: "entity_id": "https://${cfg.authentikDomain}", "url": "https://${cfg.authentikDomain}/application/saml/${cfg.authentikProviderSlug}/sso/binding/redirect/", "display_name": "burrow.net", + "auto_signup": True, "x509cert": """$saml_cert""", "attr_user_permanent_id": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "attr_username": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", From eb9327a99fcb18ecc763644a0ce2b0068a7b0dd9 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 03:43:57 -0700 Subject: [PATCH 097/102] Map Burrow admins to Zulip owners --- Scripts/authentik-sync-zulip-saml.sh | 16 +++++++++++++++- nixos/modules/burrow-authentik.nix | 1 + nixos/modules/burrow-zulip.nix | 8 ++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Scripts/authentik-sync-zulip-saml.sh b/Scripts/authentik-sync-zulip-saml.sh index 6767991..cd18752 100644 --- a/Scripts/authentik-sync-zulip-saml.sh +++ b/Scripts/authentik-sync-zulip-saml.sh @@ -10,6 +10,7 @@ acs_url="${AUTHENTIK_ZULIP_ACS_URL:-https://chat.burrow.net/complete/saml/}" audience="${AUTHENTIK_ZULIP_AUDIENCE:-https://chat.burrow.net}" launch_url="${AUTHENTIK_ZULIP_LAUNCH_URL:-https://chat.burrow.net/}" access_group="${AUTHENTIK_ZULIP_ACCESS_GROUP:-}" +admin_group="${AUTHENTIK_ZULIP_ADMIN_GROUP:-}" issuer="${AUTHENTIK_ZULIP_ISSUER:-$authentik_url}" usage() { @@ -28,6 +29,7 @@ Optional environment: AUTHENTIK_ZULIP_AUDIENCE AUTHENTIK_ZULIP_LAUNCH_URL AUTHENTIK_ZULIP_ACCESS_GROUP + AUTHENTIK_ZULIP_ADMIN_GROUP AUTHENTIK_ZULIP_ISSUER EOF } @@ -257,6 +259,17 @@ last_name_mapping_pk="$( $'parts = (request.user.name or "").rsplit(" ", 1)\nif len(parts) == 2 and parts[1]:\n return parts[1]\nreturn request.user.username' )" +role_mapping_pk="" +if [[ -n "$admin_group" ]]; then + role_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Zulip SAML Role" \ + "zulip_role" \ + "zulip_role" \ + $'admin_group = "'$admin_group$'"\nif any(group.name == admin_group for group in request.user.ak_groups.all()):\n return "owner"\nreturn None' + )" +fi + if [[ -z "$email_mapping_pk" || -z "$name_mapping_pk" || -z "$first_name_mapping_pk" || -z "$last_name_mapping_pk" ]]; then echo "error: failed to reconcile Zulip SAML property mappings" >&2 exit 1 @@ -276,6 +289,7 @@ provider_payload="$( --arg name_mapping "$name_mapping_pk" \ --arg first_name_mapping "$first_name_mapping_pk" \ --arg last_name_mapping "$last_name_mapping_pk" \ + --arg role_mapping "$role_mapping_pk" \ '{ name: $name, authorization_flow: $authorization_flow, @@ -293,7 +307,7 @@ provider_payload="$( $name_mapping, $first_name_mapping, $last_name_mapping - ] + ] + (if $role_mapping != "" then [$role_mapping] else [] end) }' )" diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index acf76ce..977b641 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -956,6 +956,7 @@ EOF ${lib.optionalString (cfg.zulipAccessGroupName != null) '' export AUTHENTIK_ZULIP_ACCESS_GROUP=${lib.escapeShellArg cfg.zulipAccessGroupName} ''} + export AUTHENTIK_ZULIP_ADMIN_GROUP=${lib.escapeShellArg cfg.adminGroupName} ${pkgs.bash}/bin/bash ${zulipSamlSyncScript} ''; diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index ef1f190..a7adb48 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -374,6 +374,7 @@ services: "url": "https://${cfg.authentikDomain}/application/saml/${cfg.authentikProviderSlug}/sso/binding/redirect/", "display_name": "burrow.net", "auto_signup": True, + "extra_attrs": ["zulip_role"], "x509cert": """$saml_cert""", "attr_user_permanent_id": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "attr_username": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", @@ -382,6 +383,13 @@ services: "attr_last_name": "lastName", }, } + SOCIAL_AUTH_SYNC_ATTRS_DICT = { + "authentik": { + "saml": { + "role": "zulip_role", + }, + }, + } EOF ''; }; From 6cd0f3b1aeaf5a9d3dffe85719b4226bec44fa04 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 13:59:01 -0700 Subject: [PATCH 098/102] Fix Zulip SAML callback scheme handling --- nixos/modules/burrow-zulip.nix | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index a7adb48..9a805c4 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -307,6 +307,34 @@ SQL install -m 0644 ${composeFile} ${lib.escapeShellArg "${cfg.dataDir}/compose.yaml"} : > ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} chmod 0600 ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} + cat > ${lib.escapeShellArg "${cfg.dataDir}/uwsgi_params"} <<'EOF' +uwsgi_param QUERY_STRING $query_string; +uwsgi_param REQUEST_METHOD $request_method; +uwsgi_param CONTENT_TYPE $content_type; +uwsgi_param CONTENT_LENGTH $content_length; +uwsgi_param REQUEST_URI $request_uri; +uwsgi_param PATH_INFO $document_uri; +uwsgi_param DOCUMENT_ROOT $document_root; +uwsgi_param SERVER_PROTOCOL $server_protocol; +uwsgi_param REQUEST_SCHEME $trusted_x_forwarded_proto; +uwsgi_param HTTPS on; +uwsgi_param REMOTE_ADDR $remote_addr; +uwsgi_param REMOTE_PORT $remote_port; +uwsgi_param SERVER_ADDR $server_addr; +uwsgi_param SERVER_PORT $server_port; +uwsgi_param SERVER_NAME $server_name; +uwsgi_param HTTP_X_REAL_IP $remote_addr; +uwsgi_param HTTP_X_FORWARDED_PROTO $trusted_x_forwarded_proto; +uwsgi_param HTTP_X_FORWARDED_SSL ""; +uwsgi_param HTTP_X_PROXY_MISCONFIGURATION $x_proxy_misconfiguration; + +# This value is the default, and is provided for explicitness; it must +# be longer than the configured 55s "harakiri" timeout in uwsgi +uwsgi_read_timeout 60s; + +uwsgi_pass django; +EOF + chmod 0644 ${lib.escapeShellArg "${cfg.dataDir}/uwsgi_params"} metadata_xml="$(${pkgs.curl}/bin/curl -fsSL https://${cfg.authentikDomain}/application/saml/${cfg.authentikProviderSlug}/metadata/)" saml_cert="$(printf '%s' "$metadata_xml" | ${pkgs.python3}/bin/python3 -c ' @@ -390,6 +418,8 @@ services: }, }, } + volumes: + - ${cfg.dataDir}/uwsgi_params:/etc/nginx/uwsgi_params:ro EOF ''; }; From 836ccc93cd1d63dd844f79cbb364558e6bc9be29 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 14:04:42 -0700 Subject: [PATCH 099/102] Patch Zulip uwsgi scheme at runtime --- nixos/modules/burrow-zulip.nix | 62 ++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 9a805c4..3149a88 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -307,34 +307,6 @@ SQL install -m 0644 ${composeFile} ${lib.escapeShellArg "${cfg.dataDir}/compose.yaml"} : > ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} chmod 0600 ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} - cat > ${lib.escapeShellArg "${cfg.dataDir}/uwsgi_params"} <<'EOF' -uwsgi_param QUERY_STRING $query_string; -uwsgi_param REQUEST_METHOD $request_method; -uwsgi_param CONTENT_TYPE $content_type; -uwsgi_param CONTENT_LENGTH $content_length; -uwsgi_param REQUEST_URI $request_uri; -uwsgi_param PATH_INFO $document_uri; -uwsgi_param DOCUMENT_ROOT $document_root; -uwsgi_param SERVER_PROTOCOL $server_protocol; -uwsgi_param REQUEST_SCHEME $trusted_x_forwarded_proto; -uwsgi_param HTTPS on; -uwsgi_param REMOTE_ADDR $remote_addr; -uwsgi_param REMOTE_PORT $remote_port; -uwsgi_param SERVER_ADDR $server_addr; -uwsgi_param SERVER_PORT $server_port; -uwsgi_param SERVER_NAME $server_name; -uwsgi_param HTTP_X_REAL_IP $remote_addr; -uwsgi_param HTTP_X_FORWARDED_PROTO $trusted_x_forwarded_proto; -uwsgi_param HTTP_X_FORWARDED_SSL ""; -uwsgi_param HTTP_X_PROXY_MISCONFIGURATION $x_proxy_misconfiguration; - -# This value is the default, and is provided for explicitness; it must -# be longer than the configured 55s "harakiri" timeout in uwsgi -uwsgi_read_timeout 60s; - -uwsgi_pass django; -EOF - chmod 0644 ${lib.escapeShellArg "${cfg.dataDir}/uwsgi_params"} metadata_xml="$(${pkgs.curl}/bin/curl -fsSL https://${cfg.authentikDomain}/application/saml/${cfg.authentikProviderSlug}/metadata/)" saml_cert="$(printf '%s' "$metadata_xml" | ${pkgs.python3}/bin/python3 -c ' @@ -418,8 +390,6 @@ services: }, }, } - volumes: - - ${cfg.dataDir}/uwsgi_params:/etc/nginx/uwsgi_params:ro EOF ''; }; @@ -484,6 +454,37 @@ EOF chmod 0600 "$zulip_data_dir/secrets/bootstrap-owner-password" } + patch_uwsgi_scheme_handling() { + podman exec burrow-zulip_zulip_1 bash -lc "cat > /etc/nginx/uwsgi_params <<'EOF' +uwsgi_param QUERY_STRING \$query_string; +uwsgi_param REQUEST_METHOD \$request_method; +uwsgi_param CONTENT_TYPE \$content_type; +uwsgi_param CONTENT_LENGTH \$content_length; +uwsgi_param REQUEST_URI \$request_uri; +uwsgi_param PATH_INFO \$document_uri; +uwsgi_param DOCUMENT_ROOT \$document_root; +uwsgi_param SERVER_PROTOCOL \$server_protocol; +uwsgi_param REQUEST_SCHEME \$trusted_x_forwarded_proto; +uwsgi_param HTTPS on; +uwsgi_param REMOTE_ADDR \$remote_addr; +uwsgi_param REMOTE_PORT \$remote_port; +uwsgi_param SERVER_ADDR \$server_addr; +uwsgi_param SERVER_PORT \$server_port; +uwsgi_param SERVER_NAME \$server_name; +uwsgi_param HTTP_X_REAL_IP \$remote_addr; +uwsgi_param HTTP_X_FORWARDED_PROTO \$trusted_x_forwarded_proto; +uwsgi_param HTTP_X_FORWARDED_SSL \"\"; +uwsgi_param HTTP_X_PROXY_MISCONFIGURATION \$x_proxy_misconfiguration; + +# This value is the default, and is provided for explicitness; it must +# be longer than the configured 55s harakiri timeout in uwsgi +uwsgi_read_timeout 60s; + +uwsgi_pass django; +EOF +supervisorctl restart nginx zulip-django >/dev/null" + } + bootstrap_realm_if_needed() { local realm_exists local attempts=0 @@ -532,6 +533,7 @@ EOF ensure_zulip_data_layout compose up -d zulip + patch_uwsgi_scheme_handling bootstrap_realm_if_needed ''; }; From 75401107134cc6a34e94fdef36d162079d22fc1c Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 14:09:26 -0700 Subject: [PATCH 100/102] Wait for Zulip supervisor before nginx patching --- nixos/modules/burrow-zulip.nix | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 3149a88..9298571 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -455,6 +455,16 @@ EOF } patch_uwsgi_scheme_handling() { + local attempts=0 + while ! podman exec burrow-zulip_zulip_1 supervisorctl status >/dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 90 ]; then + echo "error: Zulip supervisor did not become ready for nginx patching" >&2 + exit 1 + fi + sleep 2 + done + podman exec burrow-zulip_zulip_1 bash -lc "cat > /etc/nginx/uwsgi_params <<'EOF' uwsgi_param QUERY_STRING \$query_string; uwsgi_param REQUEST_METHOD \$request_method; @@ -533,8 +543,8 @@ supervisorctl restart nginx zulip-django >/dev/null" ensure_zulip_data_layout compose up -d zulip - patch_uwsgi_scheme_handling bootstrap_realm_if_needed + patch_uwsgi_scheme_handling ''; }; }; From 9244a0476ab164959e2a1c5eead7317b2ede55bc Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 19 Apr 2026 14:37:18 -0700 Subject: [PATCH 101/102] Fix Zulip SAML provisioning --- nixos/modules/burrow-zulip.nix | 62 +++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix index 9298571..9670694 100644 --- a/nixos/modules/burrow-zulip.nix +++ b/nixos/modules/burrow-zulip.nix @@ -2,6 +2,11 @@ let cfg = config.services.burrow.zulip; + realmSignupDomain = + let + parts = lib.splitString "@" cfg.administratorEmail; + in + if builtins.length parts == 2 then builtins.elemAt parts 1 else cfg.domain; yamlFormat = pkgs.formats.yaml { }; composeFile = yamlFormat.generate "burrow-zulip-compose.yaml" { services = { @@ -352,6 +357,7 @@ services: USE_X_FORWARDED_HOST = True SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True + CSRF_TRUSTED_ORIGINS = ["https://${cfg.domain}"] SOCIAL_AUTH_REDIRECT_IS_HTTPS = True SOCIAL_AUTH_SAML_REDIRECT_IS_HTTPS = True SOCIAL_AUTH_SAML_SP_ENTITY_ID = "https://${cfg.domain}" @@ -384,7 +390,7 @@ services: }, } SOCIAL_AUTH_SYNC_ATTRS_DICT = { - "authentik": { + "": { "saml": { "role": "zulip_role", }, @@ -454,18 +460,38 @@ EOF chmod 0600 "$zulip_data_dir/secrets/bootstrap-owner-password" } - patch_uwsgi_scheme_handling() { + wait_for_zulip_supervisor() { local attempts=0 while ! podman exec burrow-zulip_zulip_1 supervisorctl status >/dev/null 2>&1; do attempts=$((attempts + 1)) if [ "$attempts" -ge 90 ]; then - echo "error: Zulip supervisor did not become ready for nginx patching" >&2 + echo "error: Zulip supervisor did not become ready" >&2 exit 1 fi sleep 2 done + } - podman exec burrow-zulip_zulip_1 bash -lc "cat > /etc/nginx/uwsgi_params <<'EOF' + patch_uwsgi_scheme_handling() { + wait_for_zulip_supervisor + podman exec burrow-zulip_zulip_1 bash -lc "cat > /etc/nginx/zulip-include/trusted-proto <<'EOF' +map \$remote_addr \$trusted_x_forwarded_proto { + default \$scheme; + 127.0.0.1 \$http_x_forwarded_proto; + ::1 \$http_x_forwarded_proto; + 172.31.1.1 \$http_x_forwarded_proto; +} +map \$remote_addr \$trusted_x_forwarded_for { + default \"\"; + 127.0.0.1 \$http_x_forwarded_for; + ::1 \$http_x_forwarded_for; + 172.31.1.1 \$http_x_forwarded_for; +} +map \$remote_addr \$x_proxy_misconfiguration { + default \"\"; +} +EOF +cat > /etc/nginx/uwsgi_params <<'EOF' uwsgi_param QUERY_STRING \$query_string; uwsgi_param REQUEST_METHOD \$request_method; uwsgi_param CONTENT_TYPE \$content_type; @@ -496,16 +522,8 @@ supervisorctl restart nginx zulip-django >/dev/null" } bootstrap_realm_if_needed() { + wait_for_zulip_supervisor local realm_exists - local attempts=0 - while ! podman exec burrow-zulip_zulip_1 test -r /etc/zulip/zulip-secrets.conf >/dev/null 2>&1; do - attempts=$((attempts + 1)) - if [ "$attempts" -ge 90 ]; then - echo "error: Zulip did not finish generating production secrets" >&2 - exit 1 - fi - sleep 2 - done realm_exists="$( podman exec burrow-zulip_zulip_1 bash -lc \ @@ -535,6 +553,23 @@ supervisorctl restart nginx zulip-django >/dev/null" podman exec burrow-zulip_zulip_1 su zulip -c "$create_realm_cmd" } + reconcile_realm_policy() { + wait_for_zulip_supervisor + local realm_id + realm_id="$( + podman exec burrow-zulip_zulip_1 bash -lc \ + "su zulip -c '/home/zulip/deployments/current/manage.py list_realms'" \ + | awk '$NF == "https://${cfg.domain}" { print $1 }' + )" + + podman exec burrow-zulip_zulip_1 su zulip -c \ + "/home/zulip/deployments/current/manage.py realm_domain --op add -r $realm_id ${realmSignupDomain} --allow-subdomains --automated" \ + >/dev/null 2>&1 || true + + podman exec burrow-zulip_zulip_1 su zulip -c \ + "/home/zulip/deployments/current/manage.py shell -c 'from zerver.models import Realm; realm = Realm.objects.get(id=$realm_id); realm.invite_required = False; realm.save(update_fields=[\"invite_required\"])'" + } + if [ ! -e .initialized ]; then compose pull compose run --rm -T zulip app:init @@ -544,6 +579,7 @@ supervisorctl restart nginx zulip-django >/dev/null" ensure_zulip_data_layout compose up -d zulip bootstrap_realm_if_needed + reconcile_realm_policy patch_uwsgi_scheme_handling ''; }; From 97c569fb35688a5b89f0a20f938a7bb6d1afe8d7 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sun, 3 May 2026 17:36:55 -0700 Subject: [PATCH 102/102] Align GTK app with Apple home surface Add the GTK home screen, local account store, daemon gRPC wrapper, and embedded Linux daemon startup path so the Linux app follows the Apple client UX and daemon boundary. Document the GTK parity expectations and update the daemon IPC and Tailnet BEPs with the cross-platform client model. --- burrow-gtk/Cargo.toml | 2 + burrow-gtk/src/account_store.rs | 139 ++ burrow-gtk/src/components/app.rs | 139 +- burrow-gtk/src/components/home_screen.rs | 1178 +++++++++++++++++ burrow-gtk/src/components/mod.rs | 10 +- burrow-gtk/src/daemon_api.rs | 420 ++++++ burrow-gtk/src/main.rs | 6 +- burrow/src/daemon/apple.rs | 39 +- burrow/src/lib.rs | 8 +- docs/GTK_APP.md | 22 +- .../BEP-0005-daemon-ipc-and-apple-boundary.md | 3 + ...6-tailnet-authority-first-control-plane.md | 5 +- 12 files changed, 1861 insertions(+), 110 deletions(-) create mode 100644 burrow-gtk/src/account_store.rs create mode 100644 burrow-gtk/src/components/home_screen.rs create mode 100644 burrow-gtk/src/daemon_api.rs diff --git a/burrow-gtk/Cargo.toml b/burrow-gtk/Cargo.toml index 21cb52e..b12577a 100644 --- a/burrow-gtk/Cargo.toml +++ b/burrow-gtk/Cargo.toml @@ -11,6 +11,8 @@ relm4 = { version = "0.6", features = ["libadwaita", "gnome_44"]} burrow = { version = "*", path = "../burrow/" } tokio = { version = "1.35.0", features = ["time", "sync"] } gettext-rs = { version = "0.7.0", features = ["gettext-system"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" [build-dependencies] anyhow = "1.0" diff --git a/burrow-gtk/src/account_store.rs b/burrow-gtk/src/account_store.rs new file mode 100644 index 0000000..6aee78b --- /dev/null +++ b/burrow-gtk/src/account_store.rs @@ -0,0 +1,139 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountRecord { + pub id: String, + pub kind: AccountKind, + pub title: String, + pub authority: Option, + pub account: String, + pub identity: String, + pub hostname: Option, + pub tailnet: Option, + pub note: Option, + pub created_at: u64, + pub updated_at: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AccountKind { + WireGuard, + Tor, + Tailnet, +} + +impl AccountKind { + pub fn title(self) -> &'static str { + match self { + Self::WireGuard => "WireGuard", + Self::Tor => "Tor", + Self::Tailnet => "Tailnet", + } + } + + fn sort_rank(self) -> u8 { + match self { + Self::Tailnet => 0, + Self::Tor => 1, + Self::WireGuard => 2, + } + } +} + +pub fn load() -> Result> { + let path = storage_path()?; + if !path.exists() { + return Ok(Vec::new()); + } + let data = + std::fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?; + serde_json::from_slice(&data).with_context(|| format!("failed to parse {}", path.display())) +} + +pub fn upsert(mut record: AccountRecord) -> Result> { + let mut accounts = load()?; + let now = timestamp(); + record.updated_at = now; + if record.created_at == 0 { + record.created_at = now; + } + + if let Some(index) = accounts.iter().position(|account| account.id == record.id) { + accounts[index] = record; + } else { + accounts.push(record); + } + accounts.sort_by(|lhs, rhs| { + lhs.kind + .sort_rank() + .cmp(&rhs.kind.sort_rank()) + .then_with(|| lhs.title.to_lowercase().cmp(&rhs.title.to_lowercase())) + }); + persist(&accounts)?; + Ok(accounts) +} + +pub fn new_record( + kind: AccountKind, + title: String, + authority: Option, + account: String, + identity: String, + hostname: Option, + tailnet: Option, + note: Option, +) -> AccountRecord { + let now = timestamp(); + AccountRecord { + id: format!("{}-{now}", kind.title().to_ascii_lowercase()), + kind, + title, + authority, + account, + identity, + hostname, + tailnet, + note, + created_at: now, + updated_at: now, + } +} + +fn persist(accounts: &[AccountRecord]) -> Result<()> { + let path = storage_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let data = serde_json::to_vec_pretty(accounts).context("failed to encode account store")?; + std::fs::write(&path, data).with_context(|| format!("failed to write {}", path.display())) +} + +fn storage_path() -> Result { + if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") { + return Ok(PathBuf::from(data_home) + .join("burrow") + .join("accounts.json")); + } + if let Some(home) = std::env::var_os("HOME") { + return Ok(PathBuf::from(home) + .join(".local") + .join("share") + .join("burrow") + .join("accounts.json")); + } + Ok(std::env::temp_dir().join("burrow-accounts.json")) +} + +fn timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} diff --git a/burrow-gtk/src/components/app.rs b/burrow-gtk/src/components/app.rs index 62c98c0..7354825 100644 --- a/burrow-gtk/src/components/app.rs +++ b/burrow-gtk/src/components/app.rs @@ -1,24 +1,19 @@ use super::*; use anyhow::Context; -use std::time::Duration; - -const RECONNECT_POLL_TIME: Duration = Duration::from_secs(5); pub struct App { - daemon_client: Arc>>, - settings_screen: Controller, - switch_screen: AsyncController, + _home_screen: AsyncController, } #[derive(Debug)] pub enum AppMsg { None, - PostInit, } impl App { pub fn run() { let app = RelmApp::new(config::ID); + relm4::set_global_css(APP_CSS); Self::setup_gresources().unwrap(); Self::setup_i18n().unwrap(); @@ -49,7 +44,7 @@ impl AsyncComponent for App { view! { adw::Window { set_title: Some("Burrow"), - set_default_size: (640, 480), + set_default_size: (900, 760), } } @@ -58,100 +53,84 @@ impl AsyncComponent for App { root: Self::Root, sender: AsyncComponentSender, ) -> AsyncComponentParts { - let daemon_client = Arc::new(Mutex::new(DaemonClient::new().await.ok())); - - let switch_screen = switch_screen::SwitchScreen::builder() - .launch(switch_screen::SwitchScreenInit { - daemon_client: Arc::clone(&daemon_client), - }) - .forward(sender.input_sender(), |_| AppMsg::None); - - let settings_screen = settings_screen::SettingsScreen::builder() - .launch(settings_screen::SettingsScreenInit { - daemon_client: Arc::clone(&daemon_client), - }) + let home_screen = home_screen::HomeScreen::builder() + .launch(()) .forward(sender.input_sender(), |_| AppMsg::None); let widgets = view_output!(); - let view_stack = adw::ViewStack::new(); - view_stack.add_titled(switch_screen.widget(), None, "Switch"); - view_stack.add_titled(settings_screen.widget(), None, "Settings"); - - let view_switcher_bar = adw::ViewSwitcherBar::builder().stack(&view_stack).build(); - view_switcher_bar.set_reveal(true); - - // When libadwaita 1.4 support becomes more avaliable, this approach is more appropriate - // - // let toolbar = adw::ToolbarView::new(); - // toolbar.add_top_bar( - // &adw::HeaderBar::builder() - // .title_widget(>k::Label::new(Some("Burrow"))) - // .build(), - // ); - // toolbar.add_bottom_bar(&view_switcher_bar); - // toolbar.set_content(Some(&view_stack)); - // root.set_content(Some(&toolbar)); - let content = gtk::Box::new(gtk::Orientation::Vertical, 0); content.append( &adw::HeaderBar::builder() .title_widget(>k::Label::new(Some("Burrow"))) .build(), ); - content.append(&view_stack); - content.append(&view_switcher_bar); + content.append(home_screen.widget()); root.set_content(Some(&content)); - sender.input(AppMsg::PostInit); - - let model = App { - daemon_client, - switch_screen, - settings_screen, - }; + let model = App { _home_screen: home_screen }; AsyncComponentParts { model, widgets } } async fn update( &mut self, - _msg: Self::Input, + msg: Self::Input, _sender: AsyncComponentSender, _root: &Self::Root, ) { - loop { - tokio::time::sleep(RECONNECT_POLL_TIME).await; - { - let mut daemon_client = self.daemon_client.lock().await; - let mut disconnected_daemon_client = false; - - if let Some(daemon_client) = daemon_client.as_mut() { - if let Err(_e) = daemon_client.send_command(DaemonCommand::ServerInfo).await { - disconnected_daemon_client = true; - self.switch_screen - .emit(switch_screen::SwitchScreenMsg::DaemonDisconnect); - self.settings_screen - .emit(settings_screen::SettingsScreenMsg::DaemonStateChange) - } - } - - if disconnected_daemon_client || daemon_client.is_none() { - match DaemonClient::new().await { - Ok(new_daemon_client) => { - *daemon_client = Some(new_daemon_client); - self.switch_screen - .emit(switch_screen::SwitchScreenMsg::DaemonReconnect); - self.settings_screen - .emit(settings_screen::SettingsScreenMsg::DaemonStateChange) - } - Err(_e) => { - // TODO: Handle Error - } - } - } - } + match msg { + AppMsg::None => {} } } } + +const APP_CSS: &str = r#" +.empty-state { + border-radius: 18px; + padding: 22px; + background: alpha(@card_bg_color, 0.72); +} + +.summary-card { + border-radius: 18px; + padding: 14px; + background: alpha(@card_bg_color, 0.72); +} + +.network-card { + border-radius: 10px; + padding: 16px; + box-shadow: 0 2px 6px alpha(black, 0.14); +} + +.wireguard-card { + background: linear-gradient(135deg, #3277d8, #174ea6); +} + +.tailnet-card { + background: linear-gradient(135deg, #31b891, #147d69); +} + +.network-card-kind, +.network-card-title, +.network-card-detail { + color: white; +} + +.network-card-kind { + opacity: 0.86; + font-weight: 700; +} + +.network-card-title { + font-size: 1.22em; + font-weight: 700; +} + +.network-card-detail { + opacity: 0.92; + font-family: monospace; +} +"#; diff --git a/burrow-gtk/src/components/home_screen.rs b/burrow-gtk/src/components/home_screen.rs new file mode 100644 index 0000000..0bfdda2 --- /dev/null +++ b/burrow-gtk/src/components/home_screen.rs @@ -0,0 +1,1178 @@ +use super::*; +use crate::account_store::{self, AccountKind, AccountRecord}; +use std::time::Duration; + +pub struct HomeScreen { + daemon_banner: adw::Banner, + network_status: gtk::Label, + network_cards: gtk::Box, + account_status: gtk::Label, + account_rows: gtk::Box, + tunnel_status: gtk::Label, + tunnel_button: gtk::Button, + tunnel_state: Option, + tailnet_session_id: Option, + tailnet_running: bool, +} + +#[derive(Debug)] +pub enum HomeScreenMsg { + EnsureDaemon, + Refresh, + TunnelAction, + OpenWireGuard, + OpenTor, + OpenTailnet, + AddWireGuard { + title: String, + account: String, + identity: String, + config: String, + }, + SaveTor { + title: String, + account: String, + identity: String, + note: String, + }, + DiscoverTailnet(String), + ProbeTailnet(String), + StartTailnetLogin { + authority: String, + account: String, + identity: String, + hostname: Option, + }, + PollTailnetLogin, + CancelTailnetLogin, + AddTailnet { + authority: String, + account: String, + identity: String, + hostname: Option, + tailnet: Option, + }, +} + +#[relm4::component(pub, async)] +impl AsyncComponent for HomeScreen { + type Init = (); + type Input = HomeScreenMsg; + type Output = (); + type CommandOutput = (); + + view! { + gtk::ScrolledWindow { + set_vexpand: true, + + adw::Clamp { + set_maximum_size: 900, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 24, + set_margin_all: 24, + + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_spacing: 16, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 6, + set_hexpand: true, + + gtk::Label { + add_css_class: "title-1", + set_xalign: 0.0, + set_label: "Burrow", + }, + + gtk::Label { + add_css_class: "heading", + add_css_class: "dim-label", + set_xalign: 0.0, + set_label: "Networks and accounts", + }, + }, + + #[name(add_button)] + gtk::MenuButton { + add_css_class: "flat", + set_icon_name: "list-add-symbolic", + set_tooltip_text: Some("Add"), + set_valign: Align::Start, + }, + }, + + #[name(daemon_banner)] + adw::Banner { + set_title: "Starting Burrow daemon", + set_revealed: false, + }, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 12, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 4, + + gtk::Label { + add_css_class: "title-2", + set_xalign: 0.0, + set_label: "Networks", + }, + + #[name(network_status)] + gtk::Label { + add_css_class: "dim-label", + set_xalign: 0.0, + set_wrap: true, + set_label: "Stored daemon networks and their active account selectors", + }, + }, + + gtk::ScrolledWindow { + set_policy: (gtk::PolicyType::Automatic, gtk::PolicyType::Never), + set_min_content_height: 190, + + #[name(network_cards)] + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_spacing: 14, + }, + }, + }, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 12, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 4, + + gtk::Label { + add_css_class: "title-2", + set_xalign: 0.0, + set_label: "Accounts", + }, + + gtk::Label { + add_css_class: "dim-label", + set_xalign: 0.0, + set_wrap: true, + set_label: "Per-network identities and sign-in state", + }, + }, + + #[name(account_rows)] + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 8, + set_margin_all: 0, + set_valign: Align::Center, + }, + + #[name(account_status)] + gtk::Label { + add_css_class: "dim-label", + set_xalign: 0.0, + set_wrap: true, + set_label: "", + }, + }, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 8, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 4, + + gtk::Label { + add_css_class: "title-2", + set_xalign: 0.0, + set_label: "Tunnel", + }, + + gtk::Label { + add_css_class: "dim-label", + set_xalign: 0.0, + set_label: "Current daemon tunnel state", + }, + }, + + #[name(tunnel_status)] + gtk::Label { + set_xalign: 0.0, + set_label: "Checking daemon status", + }, + + #[name(tunnel_button)] + gtk::Button { + add_css_class: "suggested-action", + set_label: "Start", + set_halign: Align::Start, + connect_clicked => HomeScreenMsg::TunnelAction, + }, + }, + } + } + } + } + + async fn init( + _: Self::Init, + _root: Self::Root, + sender: AsyncComponentSender, + ) -> AsyncComponentParts { + let widgets = view_output!(); + configure_add_popover(&widgets.add_button, &sender); + + let refresh_sender = sender.input_sender().clone(); + relm4::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(5)).await; + refresh_sender.emit(HomeScreenMsg::Refresh); + } + }); + + let model = HomeScreen { + daemon_banner: widgets.daemon_banner.clone(), + network_status: widgets.network_status.clone(), + network_cards: widgets.network_cards.clone(), + account_status: widgets.account_status.clone(), + account_rows: widgets.account_rows.clone(), + tunnel_status: widgets.tunnel_status.clone(), + tunnel_button: widgets.tunnel_button.clone(), + tunnel_state: None, + tailnet_session_id: None, + tailnet_running: false, + }; + + sender.input(HomeScreenMsg::EnsureDaemon); + + AsyncComponentParts { model, widgets } + } + + async fn update( + &mut self, + msg: Self::Input, + sender: AsyncComponentSender, + root: &Self::Root, + ) { + match msg { + HomeScreenMsg::EnsureDaemon => self.ensure_daemon().await, + HomeScreenMsg::Refresh => self.refresh().await, + HomeScreenMsg::TunnelAction => self.perform_tunnel_action().await, + HomeScreenMsg::OpenWireGuard => open_wireguard_window(root, &sender), + HomeScreenMsg::OpenTor => open_tor_window(root, &sender), + HomeScreenMsg::OpenTailnet => open_tailnet_window(root, &sender), + HomeScreenMsg::AddWireGuard { + title, + account, + identity, + config, + } => self.add_wireguard(title, account, identity, config).await, + HomeScreenMsg::SaveTor { title, account, identity, note } => { + self.save_tor(title, account, identity, note) + } + HomeScreenMsg::DiscoverTailnet(email) => self.discover_tailnet(email).await, + HomeScreenMsg::ProbeTailnet(authority) => self.probe_tailnet(authority).await, + HomeScreenMsg::StartTailnetLogin { + authority, + account, + identity, + hostname, + } => { + self.start_tailnet_login(authority, account, identity, hostname, sender) + .await; + } + HomeScreenMsg::PollTailnetLogin => self.poll_tailnet_login(sender).await, + HomeScreenMsg::CancelTailnetLogin => self.cancel_tailnet_login().await, + HomeScreenMsg::AddTailnet { + authority, + account, + identity, + hostname, + tailnet, + } => { + self.add_tailnet(authority, account, identity, hostname, tailnet) + .await; + } + } + } +} + +impl HomeScreen { + async fn ensure_daemon(&mut self) { + self.daemon_banner.set_title("Starting Burrow daemon"); + self.daemon_banner.set_revealed(true); + match daemon_api::ensure_daemon().await { + Ok(()) => { + self.daemon_banner.set_revealed(false); + self.refresh().await; + } + Err(error) => { + self.daemon_banner + .set_title(&format!("Burrow daemon is not reachable: {error}")); + self.daemon_banner.set_revealed(true); + self.tunnel_state = None; + self.tunnel_status.set_label("Daemon unavailable"); + self.tunnel_button.set_label("Enable"); + self.tunnel_button.set_sensitive(true); + self.network_status + .set_label("Stored daemon networks are unavailable until the daemon starts."); + self.render_networks(&[]); + } + } + } + + async fn refresh(&mut self) { + match daemon_api::tunnel_state().await { + Ok(state) => { + self.daemon_banner.set_revealed(false); + self.tunnel_state = Some(state); + match state { + daemon_api::TunnelState::Running => { + self.tunnel_status.set_label("Connected"); + self.tunnel_button.set_label("Stop"); + } + daemon_api::TunnelState::Stopped => { + self.tunnel_status.set_label("Disconnected"); + self.tunnel_button.set_label("Start"); + } + } + self.tunnel_button.set_sensitive(true); + } + Err(error) => { + self.tunnel_state = None; + self.daemon_banner + .set_title(&format!("Burrow daemon is not reachable: {error}")); + self.daemon_banner.set_revealed(true); + self.tunnel_status.set_label("Unknown"); + self.tunnel_button.set_label("Enable"); + self.tunnel_button.set_sensitive(true); + } + } + + match daemon_api::list_networks().await { + Ok(networks) => { + self.render_networks(&networks); + self.network_status.set_label(if networks.is_empty() { + "Stored daemon networks and their active account selectors" + } else { + "Stored daemon networks and their active account selectors" + }); + } + Err(error) => { + self.render_networks(&[]); + self.network_status + .set_label(&format!("Unable to read daemon networks: {error}")); + } + } + + match account_store::load() { + Ok(accounts) => { + self.account_status.set_label(""); + self.render_accounts(&accounts); + } + Err(error) => { + self.render_accounts(&[]); + self.account_status + .set_label(&format!("Unable to read account store: {error}")); + } + } + } + + async fn perform_tunnel_action(&mut self) { + match self.tunnel_state { + Some(daemon_api::TunnelState::Running) => { + self.tunnel_button.set_sensitive(false); + self.tunnel_status.set_label("Disconnecting..."); + if let Err(error) = daemon_api::stop_tunnel().await { + self.tunnel_status + .set_label(&format!("Stop failed: {error}")); + } + self.refresh().await; + } + Some(daemon_api::TunnelState::Stopped) => { + self.tunnel_button.set_sensitive(false); + self.tunnel_status.set_label("Connecting..."); + if let Err(error) = daemon_api::start_tunnel().await { + self.tunnel_status + .set_label(&format!("Start failed: {error}")); + } + self.refresh().await; + } + None => self.ensure_daemon().await, + } + } + + async fn add_wireguard( + &mut self, + title: String, + account: String, + identity: String, + config: String, + ) { + if config.trim().is_empty() { + self.network_status + .set_label("Paste a WireGuard configuration before adding a network."); + return; + } + match daemon_api::add_wireguard(config).await { + Ok(id) => { + let title = daemon_api::normalized(&title, &format!("WireGuard {id}")); + let record = account_store::new_record( + AccountKind::WireGuard, + title, + None, + daemon_api::normalized(&account, "default"), + daemon_api::normalized(&identity, &format!("network-{id}")), + None, + None, + Some(format!("Linked to daemon network #{id}.")), + ); + match account_store::upsert(record) { + Ok(accounts) => self.render_accounts(&accounts), + Err(error) => self + .account_status + .set_label(&format!("WireGuard account save failed: {error}")), + } + self.network_status + .set_label(&format!("Added WireGuard network #{id}.")); + self.refresh().await; + } + Err(error) => self + .network_status + .set_label(&format!("Unable to add WireGuard network: {error}")), + } + } + + fn save_tor(&mut self, title: String, account: String, identity: String, note: String) { + let record = account_store::new_record( + AccountKind::Tor, + daemon_api::normalized( + &title, + &format!("Tor {}", daemon_api::normalized(&identity, "linux")), + ), + Some("arti://local".to_owned()), + daemon_api::normalized(&account, "default"), + daemon_api::normalized(&identity, "linux"), + None, + None, + Some(note), + ); + match account_store::upsert(record) { + Ok(accounts) => { + self.account_status.set_label("Saved Tor account."); + self.render_accounts(&accounts); + } + Err(error) => self + .account_status + .set_label(&format!("Unable to save Tor account: {error}")), + } + } + + async fn discover_tailnet(&mut self, email: String) { + let Ok(email) = daemon_api::require_value(&email, "Email address") else { + self.account_status + .set_label("Enter an email address before Tailnet discovery."); + return; + }; + + self.account_status.set_label("Finding Tailnet server..."); + match daemon_api::discover_tailnet(email).await { + Ok(discovery) => { + let kind = if discovery.managed { + "managed authority" + } else { + "custom authority" + }; + let issuer = discovery + .oidc_issuer + .map(|issuer| format!(" OIDC: {issuer}.")) + .unwrap_or_default(); + self.account_status.set_label(&format!( + "Discovered {kind}: {}.{issuer}", + discovery.authority + )); + } + Err(error) => self + .account_status + .set_label(&format!("Tailnet discovery failed: {error}")), + } + } + + async fn probe_tailnet(&mut self, authority: String) { + let Ok(authority) = daemon_api::require_value(&authority, "Tailnet server URL") else { + self.account_status + .set_label("Enter a Tailnet server URL before checking it."); + return; + }; + + self.account_status.set_label("Checking Tailnet server..."); + match daemon_api::probe_tailnet(authority).await { + Ok(probe) => { + let detail = probe + .detail + .unwrap_or_else(|| format!("HTTP {}", probe.status_code)); + self.account_status + .set_label(&format!("{}: {detail}", probe.summary)); + } + Err(error) => self + .account_status + .set_label(&format!("Tailnet probe failed: {error}")), + } + } + + async fn start_tailnet_login( + &mut self, + authority: String, + account: String, + identity: String, + hostname: Option, + sender: AsyncComponentSender, + ) { + let Ok(authority) = daemon_api::require_value(&authority, "Tailnet server URL") else { + self.account_status + .set_label("Enter a Tailnet server URL before sign-in."); + return; + }; + + self.account_status.set_label("Starting Tailnet sign-in..."); + match daemon_api::start_tailnet_login(authority, account, identity, hostname).await { + Ok(status) => { + self.apply_login_status(&status); + if let Some(auth_url) = status.auth_url.as_deref() { + if let Err(error) = open_auth_url(auth_url) { + self.account_status.set_label(&format!( + "{} Open this URL manually: {auth_url}. Browser launch failed: {error}", + self.account_status.text() + )); + } + } + if !status.running { + sender.input(HomeScreenMsg::PollTailnetLogin); + } + } + Err(error) => self + .account_status + .set_label(&format!("Tailnet sign-in failed: {error}")), + } + } + + async fn poll_tailnet_login(&mut self, sender: AsyncComponentSender) { + let Some(session_id) = self.tailnet_session_id.clone() else { + return; + }; + if self.tailnet_running { + return; + } + + tokio::time::sleep(Duration::from_secs(1)).await; + match daemon_api::tailnet_login_status(session_id).await { + Ok(status) => { + self.apply_login_status(&status); + if !status.running { + sender.input(HomeScreenMsg::PollTailnetLogin); + } + } + Err(error) => { + self.account_status + .set_label(&format!("Tailnet sign-in status failed: {error}")); + self.tailnet_session_id = None; + } + } + } + + async fn cancel_tailnet_login(&mut self) { + let Some(session_id) = self.tailnet_session_id.clone() else { + self.account_status + .set_label("No Tailnet sign-in is active."); + return; + }; + match daemon_api::cancel_tailnet_login(session_id).await { + Ok(()) => { + self.tailnet_session_id = None; + self.tailnet_running = false; + self.account_status.set_label("Tailnet sign-in cancelled."); + } + Err(error) => self + .account_status + .set_label(&format!("Unable to cancel Tailnet sign-in: {error}")), + } + } + + async fn add_tailnet( + &mut self, + authority: String, + account: String, + identity: String, + hostname: Option, + tailnet: Option, + ) { + let Ok(authority) = daemon_api::require_value(&authority, "Tailnet server URL") else { + self.account_status + .set_label("Enter a Tailnet server URL before saving."); + return; + }; + if self.tailnet_session_id.is_some() && !self.tailnet_running { + self.account_status + .set_label("Finish browser sign-in before saving this Tailnet account."); + return; + } + + let stored_authority = daemon_api::normalized_optional(&authority) + .unwrap_or_else(|| daemon_api::default_tailnet_authority().to_owned()); + let stored_account = daemon_api::normalized(&account, "default"); + let stored_identity = daemon_api::normalized(&identity, "linux"); + let stored_hostname = hostname.clone(); + let stored_tailnet = tailnet.clone(); + + match daemon_api::add_tailnet(authority, account, identity, hostname, tailnet).await { + Ok(id) => { + let title = stored_tailnet + .clone() + .or(stored_hostname.clone()) + .unwrap_or_else(|| format!("Tailnet {id}")); + let record = account_store::new_record( + AccountKind::Tailnet, + title, + Some(stored_authority), + stored_account, + stored_identity, + stored_hostname, + stored_tailnet, + Some(format!("Linked to daemon network #{id}.")), + ); + match account_store::upsert(record) { + Ok(accounts) => self.render_accounts(&accounts), + Err(error) => self + .account_status + .set_label(&format!("Tailnet account save failed: {error}")), + } + self.account_status + .set_label(&format!("Saved Tailnet account and network #{id}.")); + self.refresh().await; + } + Err(error) => self + .account_status + .set_label(&format!("Unable to save Tailnet account: {error}")), + } + } + + fn apply_login_status(&mut self, status: &daemon_api::TailnetLoginStatus) { + self.tailnet_session_id = Some(status.session_id.clone()); + self.tailnet_running = status.running; + + let mut parts = Vec::new(); + if status.running { + parts.push("Signed In".to_owned()); + } else if status.needs_login { + parts.push("Browser Sign-In Required".to_owned()); + } else { + parts.push("Checking Sign-In".to_owned()); + } + if !status.backend_state.is_empty() { + parts.push(format!("State: {}", status.backend_state)); + } + if let Some(tailnet_name) = &status.tailnet_name { + parts.push(format!("Tailnet: {tailnet_name}")); + } + if let Some(self_dns_name) = &status.self_dns_name { + parts.push(self_dns_name.clone()); + } + if !status.tailnet_ips.is_empty() { + parts.push(status.tailnet_ips.join(", ")); + } + if !status.health.is_empty() { + parts.push(status.health.join(" / ")); + } + self.account_status.set_label(&parts.join("\n")); + } + + fn render_networks(&self, networks: &[daemon_api::NetworkSummary]) { + while let Some(child) = self.network_cards.first_child() { + self.network_cards.remove(&child); + } + + if networks.is_empty() { + self.network_cards.append(&empty_networks_view()); + return; + } + + for network in networks { + self.network_cards.append(&network_card(network)); + } + } + + fn render_accounts(&self, accounts: &[AccountRecord]) { + while let Some(child) = self.account_rows.first_child() { + self.account_rows.remove(&child); + } + + if accounts.is_empty() { + self.account_rows.append(&empty_accounts_view()); + return; + } + + for account in accounts { + self.account_rows.append(&account_card(account)); + } + } +} + +fn configure_add_popover(button: >k::MenuButton, sender: &AsyncComponentSender) { + let popover = gtk::Popover::new(); + let box_ = gtk::Box::new(gtk::Orientation::Vertical, 4); + box_.set_margin_all(6); + + for (label, msg) in [ + ("Add WireGuard Network", HomeScreenMsg::OpenWireGuard), + ("Save Tor Account", HomeScreenMsg::OpenTor), + ("Add Tailnet Account", HomeScreenMsg::OpenTailnet), + ] { + let item = gtk::Button::with_label(label); + item.add_css_class("flat"); + item.set_halign(Align::Fill); + let input = sender.input_sender().clone(); + item.connect_clicked(move |_| input.emit(msg_from_template(&msg))); + box_.append(&item); + } + + popover.set_child(Some(&box_)); + button.set_popover(Some(&popover)); +} + +fn msg_from_template(msg: &HomeScreenMsg) -> HomeScreenMsg { + match msg { + HomeScreenMsg::OpenWireGuard => HomeScreenMsg::OpenWireGuard, + HomeScreenMsg::OpenTor => HomeScreenMsg::OpenTor, + HomeScreenMsg::OpenTailnet => HomeScreenMsg::OpenTailnet, + _ => unreachable!(), + } +} + +fn network_card(network: &daemon_api::NetworkSummary) -> gtk::Box { + let card = gtk::Box::new(gtk::Orientation::Vertical, 10); + card.add_css_class("network-card"); + if network.title.to_ascii_lowercase().contains("wireguard") { + card.add_css_class("wireguard-card"); + } else { + card.add_css_class("tailnet-card"); + } + card.set_size_request(360, 175); + card.set_margin_bottom(8); + + let kind = if network.title.to_ascii_lowercase().contains("wireguard") { + "WireGuard" + } else { + "Tailnet" + }; + let kind_label = gtk::Label::new(Some(kind)); + kind_label.add_css_class("network-card-kind"); + kind_label.set_xalign(0.0); + + let title = gtk::Label::new(Some(&network.title)); + title.add_css_class("network-card-title"); + title.set_xalign(0.0); + title.set_wrap(true); + + let spacer = gtk::Box::new(gtk::Orientation::Vertical, 0); + spacer.set_vexpand(true); + + let detail = gtk::Label::new(Some(&network.detail)); + detail.add_css_class("network-card-detail"); + detail.set_xalign(0.0); + detail.set_wrap(true); + detail.set_lines(4); + + card.append(&kind_label); + card.append(&title); + card.append(&spacer); + card.append(&detail); + card +} + +fn empty_networks_view() -> gtk::Box { + let box_ = gtk::Box::new(gtk::Orientation::Vertical, 6); + box_.add_css_class("empty-state"); + box_.set_size_request(520, 175); + box_.set_hexpand(true); + + let title = gtk::Label::new(Some("No Networks Yet")); + title.add_css_class("title-3"); + title.set_xalign(0.0); + let detail = gtk::Label::new(Some( + "Add a WireGuard network, or save a Tailnet account so Burrow can store a managed network when the daemon is reachable.", + )); + detail.add_css_class("dim-label"); + detail.set_wrap(true); + detail.set_xalign(0.0); + + box_.append(&title); + box_.append(&detail); + box_ +} + +fn empty_accounts_view() -> gtk::Box { + let box_ = gtk::Box::new(gtk::Orientation::Vertical, 6); + box_.add_css_class("empty-state"); + box_.set_hexpand(true); + + let title = gtk::Label::new(Some("No Accounts Yet")); + title.add_css_class("title-3"); + title.set_justify(gtk::Justification::Center); + let detail = gtk::Label::new(Some( + "Save a Tor account or sign in to Tailnet to keep network identities ready on this device.", + )); + detail.add_css_class("dim-label"); + detail.set_wrap(true); + detail.set_justify(gtk::Justification::Center); + + box_.append(&title); + box_.append(&detail); + box_ +} + +fn account_card(account: &AccountRecord) -> gtk::Box { + let card = gtk::Box::new(gtk::Orientation::Vertical, 8); + card.add_css_class("summary-card"); + card.set_hexpand(true); + + let header = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let title = gtk::Label::new(Some(&account.title)); + title.add_css_class("title-3"); + title.set_xalign(0.0); + title.set_hexpand(true); + let kind = gtk::Label::new(Some(account.kind.title())); + kind.add_css_class("dim-label"); + header.append(&title); + header.append(&kind); + card.append(&header); + + append_account_value(&card, "Account", &account.account); + append_account_value(&card, "Identity", &account.identity); + if let Some(authority) = &account.authority { + append_account_value(&card, "Authority", authority); + } + if let Some(hostname) = &account.hostname { + append_account_value(&card, "Hostname", hostname); + } + if let Some(tailnet) = &account.tailnet { + append_account_value(&card, "Tailnet", tailnet); + } + if let Some(note) = &account.note { + let note_label = gtk::Label::new(Some(note)); + note_label.add_css_class("dim-label"); + note_label.set_wrap(true); + note_label.set_xalign(0.0); + card.append(¬e_label); + } + + card +} + +fn append_account_value(card: >k::Box, label: &str, value: &str) { + let row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let key = gtk::Label::new(Some(label)); + key.add_css_class("dim-label"); + key.set_xalign(0.0); + key.set_width_chars(9); + let value = gtk::Label::new(Some(value)); + value.set_xalign(0.0); + value.set_wrap(true); + value.set_hexpand(true); + row.append(&key); + row.append(&value); + card.append(&row); +} + +fn open_wireguard_window(root: >k::ScrolledWindow, sender: &AsyncComponentSender) { + let window = sheet_window(root, "WireGuard", 560, 620); + let content = sheet_content( + &window, + "Import WireGuard", + "Import a tunnel and optional account metadata.", + ); + + let title = gtk::Entry::new(); + title.set_placeholder_text(Some("Title")); + let account = gtk::Entry::new(); + account.set_placeholder_text(Some("Account")); + let identity = gtk::Entry::new(); + identity.set_placeholder_text(Some("Identity")); + let text = gtk::TextView::new(); + text.set_monospace(true); + text.set_wrap_mode(gtk::WrapMode::WordChar); + + let editor = gtk::ScrolledWindow::new(); + editor.set_min_content_height(220); + editor.set_child(Some(&text)); + + content.append(§ion_label("Identity")); + content.append(&title); + content.append(&account); + content.append(&identity); + content.append(§ion_label("WireGuard Configuration")); + content.append(&editor); + + let add = gtk::Button::with_label("Add Network"); + add.add_css_class("suggested-action"); + let input = sender.input_sender().clone(); + let window_for_click = window.clone(); + add.connect_clicked(move |_| { + input.emit(HomeScreenMsg::AddWireGuard { + title: title.text().to_string(), + account: account.text().to_string(), + identity: identity.text().to_string(), + config: text_view_text(&text), + }); + window_for_click.close(); + }); + content.append(&add); + + window.set_child(Some(&content)); + window.present(); +} + +fn open_tor_window(root: >k::ScrolledWindow, sender: &AsyncComponentSender) { + let window = sheet_window(root, "Tor", 520, 540); + let content = sheet_content( + &window, + "Configure Tor", + "Store Arti account and identity preferences.", + ); + + let title = entry_with_text("Title", "Default Tor"); + let account = entry_with_text("Account", "default"); + let identity = entry_with_text("Identity", "linux"); + let addresses = entry_with_text("Virtual Addresses", "100.64.0.2/32"); + let dns = entry_with_text("DNS Resolvers", "1.1.1.1, 1.0.0.1"); + let mtu = entry_with_text("MTU", "1400"); + let listen = entry_with_text("Transparent Listener", "127.0.0.1:9040"); + + content.append(§ion_label("Identity")); + content.append(&title); + content.append(&account); + content.append(&identity); + content.append(§ion_label("Tor Preferences")); + content.append(&addresses); + content.append(&dns); + content.append(&mtu); + content.append(&listen); + + let save = gtk::Button::with_label("Save Account"); + save.add_css_class("suggested-action"); + let input = sender.input_sender().clone(); + let window_for_click = window.clone(); + save.connect_clicked(move |_| { + let note = [ + format!( + "Addresses: {}", + normalized_entry(&addresses, "100.64.0.2/32") + ), + format!("DNS: {}", normalized_entry(&dns, "1.1.1.1, 1.0.0.1")), + format!("MTU: {}", normalized_entry(&mtu, "1400")), + format!("Listen: {}", normalized_entry(&listen, "127.0.0.1:9040")), + ] + .join(" - "); + input.emit(HomeScreenMsg::SaveTor { + title: normalized_entry(&title, "Default Tor"), + account: normalized_entry(&account, "default"), + identity: normalized_entry(&identity, "linux"), + note, + }); + window_for_click.close(); + }); + content.append(&save); + + window.set_child(Some(&content)); + window.present(); +} + +fn open_tailnet_window(root: >k::ScrolledWindow, sender: &AsyncComponentSender) { + let window = sheet_window(root, "Tailnet", 560, 680); + let content = sheet_content( + &window, + "Connect Tailnet", + "Save Tailnet authority, identity defaults, and login material.", + ); + + let email = gtk::Entry::new(); + email.set_placeholder_text(Some("Email address")); + let authority = entry_with_text("Server URL", daemon_api::default_tailnet_authority()); + let tailnet = gtk::Entry::new(); + tailnet.set_placeholder_text(Some("Tailnet")); + let account = entry_with_text("Account", "default"); + let identity = entry_with_text("Identity", "linux"); + let hostname = entry_with_text("Hostname", &hostname_fallback()); + + content.append(§ion_label("Connection")); + content.append(&email); + content.append(&authority); + content.append(&tailnet); + content.append(§ion_label("Identity")); + content.append(&account); + content.append(&identity); + content.append(&hostname); + + let actions = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let discover = gtk::Button::with_label("Refresh Server Lookup"); + let probe = gtk::Button::with_label("Check Server"); + let sign_in = gtk::Button::with_label("Start Sign-In"); + actions.append(&discover); + actions.append(&probe); + actions.append(&sign_in); + content.append(§ion_label("Authentication")); + content.append(&actions); + + let input = sender.input_sender().clone(); + let email_for_click = email.clone(); + discover.connect_clicked(move |_| { + input.emit(HomeScreenMsg::DiscoverTailnet( + email_for_click.text().to_string(), + )); + }); + + let input = sender.input_sender().clone(); + let authority_for_probe = authority.clone(); + probe.connect_clicked(move |_| { + input.emit(HomeScreenMsg::ProbeTailnet( + authority_for_probe.text().to_string(), + )); + }); + + let input = sender.input_sender().clone(); + let authority_for_login = authority.clone(); + let account_for_login = account.clone(); + let identity_for_login = identity.clone(); + let hostname_for_login = hostname.clone(); + sign_in.connect_clicked(move |_| { + input.emit(HomeScreenMsg::StartTailnetLogin { + authority: authority_for_login.text().to_string(), + account: normalized_entry(&account_for_login, "default"), + identity: normalized_entry(&identity_for_login, "linux"), + hostname: daemon_api::normalized_optional(&hostname_for_login.text()), + }); + }); + + let save = gtk::Button::with_label("Save Account"); + save.add_css_class("suggested-action"); + let input = sender.input_sender().clone(); + let window_for_click = window.clone(); + save.connect_clicked(move |_| { + input.emit(HomeScreenMsg::AddTailnet { + authority: authority.text().to_string(), + account: normalized_entry(&account, "default"), + identity: normalized_entry(&identity, "linux"), + hostname: daemon_api::normalized_optional(&hostname.text()), + tailnet: daemon_api::normalized_optional(&tailnet.text()), + }); + window_for_click.close(); + }); + + let cancel = gtk::Button::with_label("Cancel Sign-In"); + let input = sender.input_sender().clone(); + cancel.connect_clicked(move |_| { + input.emit(HomeScreenMsg::CancelTailnetLogin); + }); + + content.append(&save); + content.append(&cancel); + + window.set_child(Some(&content)); + window.present(); +} + +fn sheet_window(root: >k::ScrolledWindow, title: &str, width: i32, height: i32) -> gtk::Window { + let window = gtk::Window::builder() + .title(title) + .default_width(width) + .default_height(height) + .modal(true) + .build(); + if let Some(root) = root.root() { + if let Ok(parent) = root.downcast::() { + window.set_transient_for(Some(&parent)); + } + } + window +} + +fn sheet_content(window: >k::Window, title: &str, detail: &str) -> gtk::Box { + let content = gtk::Box::new(gtk::Orientation::Vertical, 12); + content.set_margin_all(18); + + let summary = gtk::Box::new(gtk::Orientation::Horizontal, 12); + summary.add_css_class("summary-card"); + + let copy = gtk::Box::new(gtk::Orientation::Vertical, 4); + copy.set_hexpand(true); + + let title_label = gtk::Label::new(Some(title)); + title_label.add_css_class("title-3"); + title_label.set_xalign(0.0); + + let detail_label = gtk::Label::new(Some(detail)); + detail_label.add_css_class("dim-label"); + detail_label.set_wrap(true); + detail_label.set_xalign(0.0); + + copy.append(&title_label); + copy.append(&detail_label); + summary.append(©); + + let close = gtk::Button::builder() + .icon_name("window-close-symbolic") + .tooltip_text("Close") + .valign(Align::Start) + .build(); + close.add_css_class("flat"); + let window_for_click = window.clone(); + close.connect_clicked(move |_| window_for_click.close()); + summary.append(&close); + + content.append(&summary); + content +} + +fn section_label(label: &str) -> gtk::Label { + let section = gtk::Label::new(Some(label)); + section.add_css_class("heading"); + section.set_xalign(0.0); + section +} + +fn entry_with_text(placeholder: &str, value: &str) -> gtk::Entry { + let entry = gtk::Entry::new(); + entry.set_placeholder_text(Some(placeholder)); + entry.set_text(value); + entry +} + +fn normalized_entry(entry: >k::Entry, fallback: &str) -> String { + daemon_api::normalized(&entry.text(), fallback) +} + +fn hostname_fallback() -> String { + std::env::var("HOSTNAME").unwrap_or_else(|_| "linux".to_owned()) +} + +fn text_view_text(text_view: >k::TextView) -> String { + let buffer = text_view.buffer(); + buffer + .text(&buffer.start_iter(), &buffer.end_iter(), true) + .to_string() +} + +fn open_auth_url(url: &str) -> anyhow::Result<()> { + gtk::gio::AppInfo::launch_default_for_uri(url, None::<>k::gio::AppLaunchContext>) + .map_err(anyhow::Error::from) +} diff --git a/burrow-gtk/src/components/mod.rs b/burrow-gtk/src/components/mod.rs index b134809..8e60fa7 100644 --- a/burrow-gtk/src/components/mod.rs +++ b/burrow-gtk/src/components/mod.rs @@ -1,6 +1,6 @@ use super::*; +use crate::daemon_api; use adw::prelude::*; -use burrow::{DaemonClient, DaemonCommand, DaemonResponseData}; use gtk::Align; use relm4::{ component::{ @@ -9,13 +9,9 @@ use relm4::{ }, prelude::*, }; -use std::sync::Arc; -use tokio::sync::Mutex; mod app; -mod settings; -mod settings_screen; -mod switch_screen; +mod home_screen; pub use app::*; -pub use settings::{DaemonGroupMsg, DiagGroupMsg}; +pub use home_screen::{HomeScreen, HomeScreenMsg}; diff --git a/burrow-gtk/src/daemon_api.rs b/burrow-gtk/src/daemon_api.rs new file mode 100644 index 0000000..4ff8bf5 --- /dev/null +++ b/burrow-gtk/src/daemon_api.rs @@ -0,0 +1,420 @@ +use anyhow::{anyhow, Context, Result}; +use burrow::{ + control::{TailnetConfig, TailnetProvider}, + grpc_defs::{ + Empty, Network, NetworkType, State, TailnetDiscoverRequest, TailnetLoginCancelRequest, + TailnetLoginStartRequest, TailnetLoginStatusRequest, TailnetProbeRequest, + }, + BurrowClient, +}; +use std::{path::PathBuf, sync::OnceLock}; +use tokio::time::{timeout, Duration}; + +const RPC_TIMEOUT: Duration = Duration::from_secs(3); +const MANAGED_TAILSCALE_AUTHORITY: &str = "https://controlplane.tailscale.com"; +static EMBEDDED_DAEMON_STARTED: OnceLock<()> = OnceLock::new(); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TunnelState { + Running, + Stopped, +} + +#[derive(Debug, Clone)] +pub struct NetworkSummary { + pub id: i32, + pub title: String, + pub detail: String, +} + +#[derive(Debug, Clone)] +pub struct TailnetDiscovery { + pub authority: String, + pub managed: bool, + pub oidc_issuer: Option, +} + +#[derive(Debug, Clone)] +pub struct TailnetProbe { + pub summary: String, + pub detail: Option, + pub status_code: i32, +} + +#[derive(Debug, Clone)] +pub struct TailnetLoginStatus { + pub session_id: String, + pub backend_state: String, + pub auth_url: Option, + pub running: bool, + pub needs_login: bool, + pub tailnet_name: Option, + pub self_dns_name: Option, + pub tailnet_ips: Vec, + pub health: Vec, +} + +pub fn default_tailnet_authority() -> &'static str { + MANAGED_TAILSCALE_AUTHORITY +} + +pub fn configure_client_paths() -> Result<()> { + if std::env::var_os("BURROW_SOCKET_PATH").is_none() { + std::env::set_var("BURROW_SOCKET_PATH", default_socket_path()?); + } + Ok(()) +} + +pub async fn ensure_daemon() -> Result<()> { + configure_client_paths()?; + if daemon_available().await { + return Ok(()); + } + + let socket_path = socket_path()?; + let db_path = database_path()?; + ensure_parent(&socket_path)?; + ensure_parent(&db_path)?; + + if EMBEDDED_DAEMON_STARTED.get().is_none() { + tokio::task::spawn_blocking(move || { + burrow::spawn_in_process_with_paths(Some(socket_path), Some(db_path)); + }) + .await + .context("failed to join embedded daemon startup")?; + let _ = EMBEDDED_DAEMON_STARTED.set(()); + } + + tunnel_state() + .await + .map(|_| ()) + .context("Burrow daemon started but did not accept tunnel status RPCs") +} + +pub fn infer_tailnet_provider(authority: &str) -> TailnetProvider { + let normalized = authority.trim().trim_end_matches('/').to_ascii_lowercase(); + if normalized == "controlplane.tailscale.com" + || normalized == "http://controlplane.tailscale.com" + || normalized == MANAGED_TAILSCALE_AUTHORITY + { + TailnetProvider::Tailscale + } else { + TailnetProvider::Headscale + } +} + +pub async fn daemon_available() -> bool { + tunnel_state().await.is_ok() +} + +fn socket_path() -> Result { + if let Some(path) = std::env::var_os("BURROW_SOCKET_PATH") { + return Ok(PathBuf::from(path)); + } + default_socket_path() +} + +fn default_socket_path() -> Result { + if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { + return Ok(PathBuf::from(runtime_dir).join("burrow.sock")); + } + let uid = std::env::var("UID").unwrap_or_else(|_| "1000".to_owned()); + Ok(PathBuf::from(format!("/tmp/burrow-{uid}.sock"))) +} + +fn database_path() -> Result { + if let Some(path) = std::env::var_os("BURROW_DB_PATH") { + return Ok(PathBuf::from(path)); + } + if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") { + return Ok(PathBuf::from(data_home).join("burrow").join("burrow.db")); + } + if let Some(home) = std::env::var_os("HOME") { + return Ok(PathBuf::from(home) + .join(".local") + .join("share") + .join("burrow") + .join("burrow.db")); + } + Ok(std::env::temp_dir().join("burrow.db")) +} + +fn ensure_parent(path: &PathBuf) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + Ok(()) +} + +pub async fn tunnel_state() -> Result { + let mut client = BurrowClient::from_uds().await?; + let mut stream = timeout(RPC_TIMEOUT, client.tunnel_client.tunnel_status(Empty {})) + .await + .context("timed out connecting to Burrow daemon")?? + .into_inner(); + let status = timeout(RPC_TIMEOUT, stream.message()) + .await + .context("timed out reading Burrow tunnel status")?? + .context("Burrow daemon ended the status stream without a state")?; + Ok(match status.state() { + State::Running => TunnelState::Running, + State::Stopped => TunnelState::Stopped, + }) +} + +pub async fn start_tunnel() -> Result<()> { + let mut client = BurrowClient::from_uds().await?; + timeout(RPC_TIMEOUT, client.tunnel_client.tunnel_start(Empty {})) + .await + .context("timed out starting Burrow tunnel")??; + Ok(()) +} + +pub async fn stop_tunnel() -> Result<()> { + let mut client = BurrowClient::from_uds().await?; + timeout(RPC_TIMEOUT, client.tunnel_client.tunnel_stop(Empty {})) + .await + .context("timed out stopping Burrow tunnel")??; + Ok(()) +} + +pub async fn list_networks() -> Result> { + let mut client = BurrowClient::from_uds().await?; + let mut stream = timeout(RPC_TIMEOUT, client.networks_client.network_list(Empty {})) + .await + .context("timed out connecting to Burrow network list")?? + .into_inner(); + let response = timeout(RPC_TIMEOUT, stream.message()) + .await + .context("timed out reading Burrow network list")?? + .context("Burrow daemon ended the network stream without a snapshot")?; + Ok(response.network.iter().map(summarize_network).collect()) +} + +pub async fn add_wireguard(config: String) -> Result { + add_network(NetworkType::WireGuard, config.into_bytes()).await +} + +pub async fn add_tailnet( + authority: String, + account: String, + identity: String, + hostname: Option, + tailnet: Option, +) -> Result { + let provider = infer_tailnet_provider(&authority); + let config = TailnetConfig { + provider, + authority: Some(authority), + account: Some(account), + identity: Some(identity), + hostname, + tailnet, + }; + let payload = serde_json::to_vec_pretty(&config)?; + add_network(NetworkType::Tailnet, payload).await +} + +pub async fn discover_tailnet(email: String) -> Result { + let mut client = BurrowClient::from_uds().await?; + let response = timeout( + RPC_TIMEOUT, + client + .tailnet_client + .discover(TailnetDiscoverRequest { email }), + ) + .await + .context("timed out discovering Tailnet authority")?? + .into_inner(); + + Ok(TailnetDiscovery { + authority: response.authority, + managed: response.managed, + oidc_issuer: optional(response.oidc_issuer), + }) +} + +pub async fn probe_tailnet(authority: String) -> Result { + let mut client = BurrowClient::from_uds().await?; + let response = timeout( + RPC_TIMEOUT, + client + .tailnet_client + .probe(TailnetProbeRequest { authority }), + ) + .await + .context("timed out probing Tailnet authority")?? + .into_inner(); + + Ok(TailnetProbe { + summary: response.summary, + detail: optional(response.detail), + status_code: response.status_code, + }) +} + +pub async fn start_tailnet_login( + authority: String, + account_name: String, + identity_name: String, + hostname: Option, +) -> Result { + let mut client = BurrowClient::from_uds().await?; + let response = timeout( + RPC_TIMEOUT, + client.tailnet_client.login_start(TailnetLoginStartRequest { + account_name, + identity_name, + hostname: hostname.unwrap_or_default(), + authority, + }), + ) + .await + .context("timed out starting Tailnet sign-in")?? + .into_inner(); + Ok(decode_tailnet_status(response)) +} + +pub async fn tailnet_login_status(session_id: String) -> Result { + let mut client = BurrowClient::from_uds().await?; + let response = timeout( + RPC_TIMEOUT, + client + .tailnet_client + .login_status(TailnetLoginStatusRequest { session_id }), + ) + .await + .context("timed out reading Tailnet sign-in status")?? + .into_inner(); + Ok(decode_tailnet_status(response)) +} + +pub async fn cancel_tailnet_login(session_id: String) -> Result<()> { + let mut client = BurrowClient::from_uds().await?; + timeout( + RPC_TIMEOUT, + client + .tailnet_client + .login_cancel(TailnetLoginCancelRequest { session_id }), + ) + .await + .context("timed out cancelling Tailnet sign-in")??; + Ok(()) +} + +async fn add_network(network_type: NetworkType, payload: Vec) -> Result { + let id = next_network_id().await?; + let mut client = BurrowClient::from_uds().await?; + timeout( + RPC_TIMEOUT, + client.networks_client.network_add(Network { + id, + r#type: network_type.into(), + payload, + }), + ) + .await + .context("timed out saving network to Burrow daemon")??; + Ok(id) +} + +async fn next_network_id() -> Result { + let networks = list_networks().await?; + Ok(networks.iter().map(|network| network.id).max().unwrap_or(0) + 1) +} + +fn summarize_network(network: &Network) -> NetworkSummary { + match network.r#type() { + NetworkType::WireGuard => summarize_wireguard(network), + NetworkType::Tailnet => summarize_tailnet(network), + } +} + +fn summarize_wireguard(network: &Network) -> NetworkSummary { + let payload = String::from_utf8_lossy(&network.payload); + let detail = payload + .lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.starts_with('[')) + .unwrap_or("Stored WireGuard configuration") + .to_owned(); + NetworkSummary { + id: network.id, + title: format!("WireGuard {}", network.id), + detail, + } +} + +fn summarize_tailnet(network: &Network) -> NetworkSummary { + match TailnetConfig::from_slice(&network.payload) { + Ok(config) => { + let title = config + .tailnet + .clone() + .or(config.hostname.clone()) + .unwrap_or_else(|| "Tailnet".to_owned()); + let authority = config + .authority + .unwrap_or_else(|| "default authority".to_owned()); + let account = config.account.unwrap_or_else(|| "default".to_owned()); + NetworkSummary { + id: network.id, + title, + detail: format!("{authority} - account {account}"), + } + } + Err(error) => NetworkSummary { + id: network.id, + title: "Tailnet".to_owned(), + detail: format!("Unable to read Tailnet payload: {error}"), + }, + } +} + +fn decode_tailnet_status( + response: burrow::grpc_defs::TailnetLoginStatusResponse, +) -> TailnetLoginStatus { + TailnetLoginStatus { + session_id: response.session_id, + backend_state: response.backend_state, + auth_url: optional(response.auth_url), + running: response.running, + needs_login: response.needs_login, + tailnet_name: optional(response.tailnet_name), + self_dns_name: optional(response.self_dns_name), + tailnet_ips: response.tailnet_ips, + health: response.health, + } +} + +fn optional(value: String) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} + +pub fn normalized(value: &str, fallback: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + fallback.to_owned() + } else { + trimmed.to_owned() + } +} + +pub fn normalized_optional(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} + +pub fn require_value(value: &str, label: &str) -> Result { + normalized_optional(value).ok_or_else(|| anyhow!("{label} is required")) +} diff --git a/burrow-gtk/src/main.rs b/burrow-gtk/src/main.rs index 6f91e2a..b47b63e 100644 --- a/burrow-gtk/src/main.rs +++ b/burrow-gtk/src/main.rs @@ -1,11 +1,15 @@ use anyhow::Result; pub mod components; -mod diag; +mod account_store; +mod daemon_api; // Generated using meson mod config; fn main() { + if let Err(error) = daemon_api::configure_client_paths() { + eprintln!("failed to configure Burrow daemon paths: {error}"); + } components::App::run(); } diff --git a/burrow/src/daemon/apple.rs b/burrow/src/daemon/apple.rs index c60f131..f369ea9 100644 --- a/burrow/src/daemon/apple.rs +++ b/burrow/src/daemon/apple.rs @@ -1,11 +1,11 @@ use std::{ ffi::{c_char, CStr}, path::PathBuf, - sync::Arc, + sync::{Arc, Mutex}, thread, }; -use once_cell::sync::OnceCell; +use once_cell::sync::{Lazy, OnceCell}; use tokio::{ runtime::{Builder, Handle}, sync::Notify, @@ -14,25 +14,35 @@ use tracing::error; use crate::daemon::daemon_main; -static BURROW_NOTIFY: OnceCell> = OnceCell::new(); static BURROW_HANDLE: OnceCell = OnceCell::new(); +static BURROW_READY: OnceCell<()> = OnceCell::new(); +static BURROW_SPAWN_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); #[no_mangle] pub unsafe extern "C" fn spawn_in_process(path: *const c_char, db_path: *const c_char) { + let path_buf = if path.is_null() { + None + } else { + Some(PathBuf::from(CStr::from_ptr(path).to_str().unwrap())) + }; + let db_path_buf = if db_path.is_null() { + None + } else { + Some(PathBuf::from(CStr::from_ptr(db_path).to_str().unwrap())) + }; + spawn_in_process_with_paths(path_buf, db_path_buf); +} + +pub fn spawn_in_process_with_paths(path_buf: Option, db_path_buf: Option) { crate::tracing::initialize(); - let notify = BURROW_NOTIFY.get_or_init(|| Arc::new(Notify::new())); + let _guard = BURROW_SPAWN_LOCK.lock().unwrap(); + if BURROW_READY.get().is_some() { + return; + } + + let notify = Arc::new(Notify::new()); let handle = BURROW_HANDLE.get_or_init(|| { - let path_buf = if path.is_null() { - None - } else { - Some(PathBuf::from(CStr::from_ptr(path).to_str().unwrap())) - }; - let db_path_buf = if db_path.is_null() { - None - } else { - Some(PathBuf::from(CStr::from_ptr(db_path).to_str().unwrap())) - }; let sender = notify.clone(); let (handle_tx, handle_rx) = tokio::sync::oneshot::channel(); @@ -62,4 +72,5 @@ pub unsafe extern "C" fn spawn_in_process(path: *const c_char, db_path: *const c let receiver = notify.clone(); handle.block_on(async move { receiver.notified().await }); + let _ = BURROW_READY.set(()); } diff --git a/burrow/src/lib.rs b/burrow/src/lib.rs index 15b6a19..7867d18 100644 --- a/burrow/src/lib.rs +++ b/burrow/src/lib.rs @@ -16,10 +16,10 @@ pub(crate) mod tracing; #[cfg(target_os = "linux")] pub mod usernet; -#[cfg(target_vendor = "apple")] -pub use daemon::apple::spawn_in_process; +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +pub use daemon::apple::{spawn_in_process, spawn_in_process_with_paths}; #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub use daemon::{ - rpc::DaemonResponse, rpc::ServerInfo, DaemonClient, DaemonCommand, DaemonResponseData, - DaemonStartOptions, + rpc::grpc_defs, rpc::BurrowClient, rpc::DaemonResponse, rpc::ServerInfo, DaemonClient, + DaemonCommand, DaemonResponseData, DaemonStartOptions, }; diff --git a/docs/GTK_APP.md b/docs/GTK_APP.md index ef73d2b..582b0a2 100644 --- a/docs/GTK_APP.md +++ b/docs/GTK_APP.md @@ -15,7 +15,7 @@ Note that the flatpak version can compile but will not run properly! 1. Install build dependencies ``` - sudo apt install -y clang meson cmake pkg-config libgtk-4-dev libadwaita-1-dev gettext desktop-file-utils + sudo apt install -y clang meson cmake pkg-config libssl-dev libgtk-4-dev libadwaita-1-dev gettext desktop-file-utils ``` 2. Install flatpak builder (Optional) @@ -38,7 +38,7 @@ Note that the flatpak version can compile but will not run properly! 1. Install build dependencies ``` - sudo dnf install -y clang ninja-build cmake meson gtk4-devel glib2-devel libadwaita-devel desktop-file-utils libappstream-glib + sudo dnf install -y clang ninja-build cmake meson openssl-devel gtk4-devel glib2-devel libadwaita-devel desktop-file-utils libappstream-glib ``` 2. Install flatpak builder (Optional) @@ -61,7 +61,7 @@ Note that the flatpak version can compile but will not run properly! 1. Install build dependencies ``` - sudo xbps-install -Sy gcc clang meson cmake pkg-config gtk4-devel gettext desktop-file-utils gtk4-update-icon-cache appstream-glib + sudo xbps-install -Sy gcc clang meson cmake pkg-config openssl-devel gtk4-devel gettext desktop-file-utils gtk4-update-icon-cache appstream-glib ``` 2. Install flatpak builder (Optional) @@ -88,6 +88,12 @@ flatpak install --user \ ## Building +With Nix, enter the focused GTK shell before running the Meson build: + +```bash +nix develop .#gtk +``` +
General @@ -139,6 +145,16 @@ flatpak install --user \ ## Running +The GTK app mirrors the Apple home surface: a Burrow header, Networks carousel, +Accounts section, Tunnel action, and the same add flows for WireGuard, Tor, and +Tailnet. It talks to the daemon over the same gRPC API used by Apple clients for +network storage, tunnel state, Tailnet discovery, authority probing, browser +sign-in, and Tailnet payloads. + +On Linux the GTK app first looks for a daemon on the configured gRPC socket. If +none is reachable, it starts an embedded user-scoped daemon with a socket under +`XDG_RUNTIME_DIR` and a database under `XDG_DATA_HOME` before refreshing the UI. +
General diff --git a/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md b/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md index 1227444..a34a609 100644 --- a/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md +++ b/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md @@ -44,6 +44,7 @@ Burrow should formalize one Apple/runtime boundary: Apple clients speak only to - Keeping control-plane I/O out of Swift UI reduces accidental secret, token, and callback sprawl across app code. - The daemon boundary makes testing and kill-switch behavior tractable because runtime integration is localized. - Apple daemon lifecycle ownership must be explicit: either the app ensures the daemon is running before RPC or the extension owns it and the UI surfaces daemon-unavailable state clearly. +- Non-Apple presentation clients should follow the same daemon-first lifecycle pattern: connect to a managed daemon when present, or start a user-scoped embedded daemon before issuing RPCs, without adding platform-local control-plane paths. ## Contributor Playbook @@ -54,6 +55,7 @@ Burrow should formalize one Apple/runtime boundary: Apple clients speak only to - daemon unavailable behavior - successful RPC path - error propagation through the UI +- Keep Linux GTK and Apple clients visually and functionally aligned around the same daemon-backed home surface: Networks, Accounts, Tunnel, and add flows should remain corresponding views over the daemon API. ## Alternatives Considered @@ -63,6 +65,7 @@ Burrow should formalize one Apple/runtime boundary: Apple clients speak only to ## Impact on Other Work - Governs the Tailnet refactor and future Apple runtime work. +- Governs Linux GTK daemon startup parity where the same daemon API is reused from a user-scoped presentation process. - Interacts with BEP-0002 control-plane bootstrap and BEP-0003 transport refactoring. ## Decision diff --git a/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md b/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md index fea4aba..36458ef 100644 --- a/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md +++ b/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md @@ -37,6 +37,7 @@ Burrow should treat Tailnet as one protocol family. Tailscale-managed and self-h - Burrow-owned authority when explicitly applicable - Discovery returns authority and related metadata; editing the authority is the mechanism that moves a configuration from managed default to custom control server. - The daemon and control layer own provider inference; the UI should primarily present “Tailnet” plus the selected authority. +- Platform clients consume the same daemon gRPC surface for Tailnet discovery, authority probing, browser sign-in, and saved network payloads. macOS/iOS SwiftUI and Linux GTK may differ in presentation and local credential stores, but neither should introduce a second control-plane path. ## Security and Operational Considerations @@ -48,6 +49,7 @@ Burrow should treat Tailnet as one protocol family. Tailscale-managed and self-h - Remove provider pickers from Tailnet UI unless a concrete protocol difference requires one. - Store the authority explicitly in payloads and infer provider internally only when needed. +- Keep Linux GTK and Apple clients at functional parity by routing Tailnet add/discover/probe/login through `TailnetControl` and `Networks` RPCs instead of platform-local HTTP or legacy JSON daemon commands. - Prefer tests that validate authority normalization and discovery behavior over UI-provider branching. ## Alternatives Considered @@ -58,7 +60,7 @@ Burrow should treat Tailnet as one protocol family. Tailscale-managed and self-h ## Impact on Other Work - Refines BEP-0002’s Tailscale-shaped control-plane work. -- Constrains the Tailnet Apple refactor and future daemon control-plane storage. +- Constrains the Tailnet Apple and Linux GTK refactors plus future daemon control-plane storage. ## Decision @@ -68,4 +70,5 @@ Pending. - `burrow/src/control/` - `Apple/UI/Networks/` +- `burrow-gtk/src/` - `proto/burrow.proto`