Compare commits

...

12 Commits

Author SHA1 Message Date
Matt Hill
ea130079ab Merge branch 'feat/preferred-port-design' of github.com:Start9Labs/start-os into feature/outbound-gateways 2026-02-12 08:26:34 -07:00
Aiden McClelland
2a54625f43 feat: replace InterfaceFilter with ForwardRequirements, add WildcardListener, complete alpha.20 bump
- Replace DynInterfaceFilter with ForwardRequirements for per-IP forward
  precision with source-subnet iptables filtering for private forwards
- Add WildcardListener (binds [::]:port) to replace the per-gateway
  NetworkInterfaceListener/SelfContainedNetworkInterfaceListener/
  UpgradableListener infrastructure
- Update forward-port script with src_subnet and excluded_src env vars
- Remove unused filter types and listener infrastructure from gateway.rs
- Add availablePorts migration (IdPool -> BTreeMap<u16, bool>) to alpha.20
- Complete version bump to 0.4.0-alpha.20 in SDK and web
2026-02-11 18:10:27 -07:00
Aiden McClelland
4e638fb58e feat: implement preferred port allocation and per-address enable/disable
- Add AvailablePorts::try_alloc() with SSL tracking (BTreeMap<u16, bool>)
- Add DerivedAddressInfo on BindInfo with private_disabled/public_enabled/possible sets
- Add Bindings wrapper with Map impl for patchdb indexed access
- Flatten HostAddress from single-variant enum to struct
- Replace set-gateway-enabled RPC with set-address-enabled
- Remove hostname_info from Host; computed addresses now in BindInfo.addresses.possible
- Compute possible addresses inline in NetServiceData::update()
- Update DB migration, SDK types, frontend, and container-runtime
2026-02-10 17:38:51 -07:00
Aiden McClelland
73274ef6e0 docs: update TODO.md with DerivedAddressInfo design, remove completed tor task 2026-02-10 14:45:50 -07:00
Aiden McClelland
e1915bf497 chore: format RPCSpec.md markdown table 2026-02-10 13:38:40 -07:00
Aiden McClelland
8204074bdf chore: flatten HostnameInfo from enum to struct
HostnameInfo only had one variant (Ip) after removing Tor. Flatten it
into a plain struct with fields gateway, public, hostname. Remove all
kind === 'ip' type guards and narrowing across SDK, frontend, and
container runtime. Update DB migration to strip the kind field.
2026-02-10 13:38:12 -07:00
Aiden McClelland
2ee403e7de chore: remove tor from startos core
Tor is being moved from a built-in OS feature to a service. This removes
the Arti-based Tor client, onion address management, hidden service
creation, and all related code from the core backend, frontend, and SDK.

- Delete core/src/net/tor/ module (~2060 lines)
- Remove OnionAddress, TorSecretKey, TorController from all consumers
- Remove HostnameInfo::Onion and HostAddress::Onion variants
- Remove onion CRUD RPC endpoints and tor subcommand
- Remove tor key handling from account and backup/restore
- Remove ~12 tor-related Cargo dependencies (arti-client, torut, etc.)
- Remove tor UI components, API methods, mock data, and routes
- Remove OnionHostname and tor patterns/regexes from SDK
- Add v0_4_0_alpha_20 database migration to strip onion data
- Bump version to 0.4.0-alpha.20
2026-02-10 13:28:24 -07:00
Matt Hill
cc5f316514 frontend plus some be types 2026-02-09 22:00:39 -07:00
Aiden McClelland
1974dfd66f docs: move address enable/disable to overflow menu, add SSL indicator, defer UI placement decisions 2026-02-09 13:29:49 -07:00
Aiden McClelland
2e03a95e47 docs: overhaul interfaces page design with view/manage split and per-address controls 2026-02-09 13:10:57 -07:00
Aiden McClelland
8f809dab21 docs: add user-controlled public/private and port forward mapping to design 2026-02-08 11:17:43 -07:00
Aiden McClelland
c0b2cbe1c8 docs: update preferred external port design in TODO 2026-02-06 09:30:35 -07:00
95 changed files with 5277 additions and 10953 deletions

View File

@@ -1,5 +1,6 @@
{ {
"attribution": { "attribution": {
"commit": "" "commit": "",
"pr": ""
} }
} }

View File

@@ -29,6 +29,24 @@ make update-startbox REMOTE=start9@<ip> # Fastest iteration (binary + UI)
make test-core # Run Rust tests make test-core # Run Rust tests
``` ```
### Verifying code changes
When making changes across multiple layers (Rust, SDK, web, container-runtime), verify in this order:
1. **Rust**: `cargo check -p start-os` — verifies core compiles
2. **TS bindings**: `make ts-bindings` — regenerates TypeScript types from Rust `#[ts(export)]` structs
- Runs `./core/build/build-ts.sh` to export ts-rs types to `core/bindings/`
- Syncs `core/bindings/``sdk/base/lib/osBindings/` via rsync
- If you manually edit files in `sdk/base/lib/osBindings/`, you must still rebuild the SDK (step 3)
3. **SDK bundle**: `cd sdk && make baseDist dist` — compiles SDK source into packages
- `baseDist/` is consumed by `/web` (via `@start9labs/start-sdk-base`)
- `dist/` is consumed by `/container-runtime` (via `@start9labs/start-sdk`)
- Web and container-runtime reference the **built** SDK, not source files
4. **Web type check**: `cd web && npm run check` — type-checks all Angular projects
5. **Container runtime type check**: `cd container-runtime && npm run check` — type-checks the runtime
**Important**: Editing `sdk/base/lib/osBindings/*.ts` alone is NOT sufficient — you must rebuild the SDK bundle (step 3) before web/container-runtime can see the changes.
## Architecture ## Architecture
### Core (`/core`) ### Core (`/core`)

View File

@@ -6,4 +6,223 @@ Pending tasks for AI agents. Remove items when completed.
- [ ] Architecture - Web (`/web`) - @MattDHill - [ ] Architecture - Web (`/web`) - @MattDHill
## Features
- [ ] Support preferred external ports besides 443 - @dr-bonez
**Problem**: Currently, port 443 is the only preferred external port that is actually honored. When a
service requests `preferred_external_port: 8443` (or any non-443 value) for SSL, the system ignores
the preference and assigns a dynamic-range port (49152-65535). The `preferred_external_port` is only
used as a label for Tor mappings and as a trigger for the port-443 special case in `update()`.
**Goal**: Honor `preferred_external_port` for both SSL and non-SSL binds when the requested port is
available, with proper conflict resolution and fallback to dynamic-range allocation.
### Design
**Key distinction**: There are two separate concepts for SSL port usage:
1. **Port ownership** (`assigned_ssl_port`) — A port exclusively owned by a binding, allocated from
`AvailablePorts`. Used for server hostnames (`.local`, mDNS, etc.) and iptables forwards.
2. **Domain SSL port** — The port used for domain-based vhost entries. A binding does NOT need to own
a port to have a domain vhost on it. The VHostController already supports multiple hostnames on the
same port via SNI. Any binding can create a domain vhost entry on any SSL port that the
VHostController has a listener for, regardless of who "owns" that port.
For example: the OS owns port 443 as its `assigned_ssl_port`. A service with
`preferred_external_port: 443` won't get 443 as its `assigned_ssl_port` (it's taken), but it CAN
still have domain vhost entries on port 443 — SNI routes by hostname.
#### 1. Preferred Port Allocation for Ownership ✅ DONE
`AvailablePorts::try_alloc(port) -> Option<u16>` added to `forward.rs`. `BindInfo::new()` and
`BindInfo::update()` attempt the preferred port first, falling back to dynamic-range allocation.
#### 2. Per-Address Enable/Disable ✅ DONE
Gateway-level `private_disabled`/`public_enabled` on `NetInfo` replaced with per-address
`DerivedAddressInfo` on `BindInfo`. `hostname_info` removed from `Host` — computed addresses now
live in `BindInfo.addresses.possible`.
**`DerivedAddressInfo` struct** (on `BindInfo`):
```rust
pub struct DerivedAddressInfo {
pub private_disabled: BTreeSet<HostnameInfo>,
pub public_enabled: BTreeSet<HostnameInfo>,
pub possible: BTreeSet<HostnameInfo>, // COMPUTED by update()
}
```
`DerivedAddressInfo::enabled()` returns `possible` filtered by the two sets. `HostnameInfo` derives
`Ord` for `BTreeSet` usage. `AddressFilter` (implementing `InterfaceFilter`) derives enabled
gateway set from `DerivedAddressInfo` for vhost/forward filtering.
**RPC endpoint**: `set-gateway-enabled` replaced with `set-address-enabled` (on both
`server.host.binding` and `package.host.binding`).
**How disabling works per address type** (enforcement deferred to Section 3):
- **WAN/LAN IP:port**: Will be enforced via **source-IP gating** in the vhost layer (Section 3).
- **Hostname-based addresses** (`.local`, domains): Disabled by **not creating the vhost/SNI
entry** for that hostname.
#### 3. Eliminate the Port 5443 Hack: Source-IP-Based WAN Blocking (`vhost.rs`, `net_controller.rs`)
**Current problem**: The `if ssl.preferred_external_port == 443` branch (line 341 of
`net_controller.rs`) creates a bespoke dual-vhost setup: port 5443 for private-only access and port
443 for public (or public+private). This exists because both public and private traffic arrive on the
same port 443 listener, and the current `InterfaceFilter`/`PublicFilter` model distinguishes
public/private by which *network interface* the connection arrived on — which doesn't work when both
traffic types share a listener.
**Solution**: Determine public vs private based on **source IP** at the vhost level. Traffic arriving
from the gateway IP should be treated as public (the gateway may MASQUERADE/NAT internet traffic, so
anything from the gateway is potentially public). Traffic from LAN IPs is private.
This applies to **all** vhost targets, not just port 443:
- **Add a `public` field to `ProxyTarget`** (or an enum: `Public`, `Private`, `Both`) indicating
what traffic this target accepts, derived from the binding's user-controlled `public` field.
- **Modify `VHostTarget::filter()`** (`vhost.rs:342`): Instead of (or in addition to) checking the
network interface via `GatewayInfo`, check the source IP of the TCP connection against known gateway
IPs. If the source IP matches a gateway or IP outside the subnet, the connection is public;
otherwise it's private. Use this to gate against the target's `public` field.
- **Eliminate the 5443 port entirely**: A single vhost entry on port 443 (or any shared SSL port) can
serve both public and private traffic, with per-target source-IP gating determining which backend
handles which connections.
#### 4. Port Forward Mapping in Patch-DB
When a binding is marked `public = true`, StartOS must record the required port forwards in patch-db
so the frontend can display them to the user. The user then configures these on their router manually.
For each public binding, store:
- The external port the router should forward (the actual vhost port used for domains, or the
`assigned_port` / `assigned_ssl_port` for non-domain access)
- The protocol (TCP/UDP)
- The StartOS LAN IP as the forward target
- Which service/binding this forward is for (for display purposes)
This mapping should be in the public database model so the frontend can read and display it.
#### 5. Simplify `update()` Domain Vhost Logic (`net_controller.rs`)
With source-IP gating in the vhost controller:
- **Remove the `== 443` special case** and the 5443 secondary vhost.
- For **server hostnames** (`.local`, mDNS, embassy, startos, localhost): use `assigned_ssl_port`
(the port the binding owns).
- For **domain-based vhost entries**: attempt to use `preferred_external_port` as the vhost port.
This succeeds if the port is either unused or already has an SSL listener (SNI handles sharing).
It fails only if the port is already in use by a non-SSL binding, or is a restricted port. On
failure, fall back to `assigned_ssl_port`.
- The binding's `public` field determines the `ProxyTarget`'s public/private gating.
- Hostname info must exactly match the actual vhost port used: for server hostnames, report
`ssl_port: assigned_ssl_port`. For domains, report `ssl_port: preferred_external_port` if it was
successfully used for the domain vhost, otherwise report `ssl_port: assigned_ssl_port`.
#### 6. Frontend: Interfaces Page Overhaul (View/Manage Split)
The current interfaces page is a single page showing gateways (with toggle), addresses, public
domains, and private domains. It gets split into two pages: **View** and **Manage**.
**SDK**: `preferredExternalPort` is already exposed. No additional SDK changes needed.
##### View Page
Displays all computed addresses for the interface (from `BindInfo.addresses`) as a flat list. For each
address, show: URL, type (IPv4, IPv6, .local, domain), access level (public/private),
gateway name, SSL indicator, enable/disable state, port forward info for public addresses, and a test button
for reachability (see Section 7).
No gateway-level toggles. The old `gateways.component.ts` toggle UI is removed.
**Note**: Exact UI element placement (where toggles, buttons, info badges go) is sensitive.
Prompt the user for specific placement decisions during implementation.
##### Manage Page
Simple CRUD interface for configuring which addresses exist. Two sections:
- **Public domains**: Add/remove. Uses existing RPC endpoints:
- `{server,package}.host.address.domain.public.add`
- `{server,package}.host.address.domain.public.remove`
- **Private domains**: Add/remove. Uses existing RPC endpoints:
- `{server,package}.host.address.domain.private.add`
- `{server,package}.host.address.domain.private.remove`
##### Key Frontend Files to Modify
| File | Change |
|------|--------|
| `web/projects/ui/src/app/routes/portal/components/interfaces/` | Overhaul: split into view/manage |
| `web/projects/ui/src/app/routes/portal/components/interfaces/gateways.component.ts` | Remove (replaced by per-address toggles on View page) |
| `web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts` | Update `MappedServiceInterface` to compute enabled addresses from `DerivedAddressInfo` |
| `web/projects/ui/src/app/routes/portal/components/interfaces/addresses/` | Refactor for View page with overflow menu (enable/disable) and test buttons |
| `web/projects/ui/src/app/routes/portal/routes/services/services.routes.ts` | Add routes for view/manage sub-pages |
| `web/projects/ui/src/app/routes/portal/routes/system/system.routes.ts` | Add routes for view/manage sub-pages |
#### 7. Reachability Test Endpoint
New RPC endpoint that tests whether an address is actually reachable, with diagnostic info on
failure.
**RPC endpoint** (`binding.rs` or new file):
- **`test-address`** — Test reachability of a specific address.
```ts
interface BindingTestAddressParams {
internalPort: number
address: HostnameInfo
}
```
The backend simply performs the raw checks and returns the results. The **frontend** owns all
interpretation — it already knows the address type, expected IP, expected port, etc. from the
`HostnameInfo` data, so it can compare against the backend results and construct fix messaging.
```ts
interface TestAddressResult {
dns: string[] | null // resolved IPs, null if not a domain address or lookup failed
portOpen: boolean | null // TCP connect result, null if not applicable
}
```
This yields two RPC methods:
- `server.host.binding.test-address`
- `package.host.binding.test-address`
The frontend already has the full `HostnameInfo` context (expected IP, domain, port, gateway,
public/private). It compares the backend's raw results against the expected state and constructs
localized fix instructions. For example:
- `dns` returned but doesn't contain the expected WAN IP → "Update DNS A record for {domain}
to {wanIp}"
- `dns` is `null` for a domain address → "DNS lookup failed for {domain}"
- `portOpen` is `false` → "Configure port forward on your router: external {port} TCP →
{lanIp}:{port}"
### Key Files
| File | Role |
|------|------|
| `core/src/net/forward.rs` | `AvailablePorts` — port pool allocation, `try_alloc()` for preferred ports |
| `core/src/net/host/binding.rs` | `Bindings` (Map wrapper for patchdb), `BindInfo`/`NetInfo`/`DerivedAddressInfo`/`AddressFilter` — per-address enable/disable, `set-address-enabled` RPC |
| `core/src/net/net_controller.rs:259` | `NetServiceData::update()` — computes `DerivedAddressInfo.possible`, vhost/forward/DNS reconciliation, 5443 hack removal |
| `core/src/net/vhost.rs` | `VHostController` / `ProxyTarget` — source-IP gating for public/private |
| `core/src/net/gateway.rs` | `InterfaceFilter` trait and filter types (`AddressFilter`, `PublicFilter`, etc.) |
| `core/src/net/service_interface.rs` | `HostnameInfo` — derives `Ord` for `BTreeSet` usage |
| `core/src/net/host/address.rs` | `HostAddress` (flattened struct), domain CRUD endpoints |
| `sdk/base/lib/interfaces/Host.ts` | SDK `MultiHost.bindPort()` — no changes needed |
| `core/src/db/model/public.rs` | Public DB model — port forward mapping |
- [ ] Auto-configure port forwards via UPnP/NAT-PMP/PCP - @dr-bonez
**Blocked by**: "Support preferred external ports besides 443" (must be implemented and tested
end-to-end first).
**Goal**: When a binding is marked public, automatically configure port forwards on the user's router
using UPnP, NAT-PMP, or PCP, instead of requiring manual router configuration. Fall back to
displaying manual instructions (the port forward mapping from patch-db) when auto-configuration is
unavailable or fails.

View File

@@ -5,7 +5,7 @@ if [ -z "$sip" ] || [ -z "$dip" ] || [ -z "$dprefix" ] || [ -z "$sport" ] || [ -
exit 1 exit 1
fi fi
NAME="F$(echo "$sip:$sport -> $dip/$dprefix:$dport" | sha256sum | head -c 15)" NAME="F$(echo "$sip:$sport -> $dip/$dprefix:$dport ${src_subnet:-any} ${excluded_src:-none}" | sha256sum | head -c 15)"
for kind in INPUT FORWARD ACCEPT; do for kind in INPUT FORWARD ACCEPT; do
if ! iptables -C $kind -j "${NAME}_${kind}" 2> /dev/null; then if ! iptables -C $kind -j "${NAME}_${kind}" 2> /dev/null; then
@@ -36,8 +36,22 @@ if [ "$UNDO" = 1 ]; then
fi fi
# DNAT: rewrite destination for incoming packets (external traffic) # DNAT: rewrite destination for incoming packets (external traffic)
iptables -t nat -A ${NAME}_PREROUTING -d "$sip" -p tcp --dport "$sport" -j DNAT --to-destination "$dip:$dport" # When src_subnet is set, only forward traffic from that subnet (private forwards)
iptables -t nat -A ${NAME}_PREROUTING -d "$sip" -p udp --dport "$sport" -j DNAT --to-destination "$dip:$dport" # excluded_src: comma-separated gateway/router IPs to reject (they may masquerade internet traffic)
if [ -n "$src_subnet" ]; then
if [ -n "$excluded_src" ]; then
IFS=',' read -ra EXCLUDED <<< "$excluded_src"
for excl in "${EXCLUDED[@]}"; do
iptables -t nat -A ${NAME}_PREROUTING -s "$excl" -d "$sip" -p tcp --dport "$sport" -j RETURN
iptables -t nat -A ${NAME}_PREROUTING -s "$excl" -d "$sip" -p udp --dport "$sport" -j RETURN
done
fi
iptables -t nat -A ${NAME}_PREROUTING -s "$src_subnet" -d "$sip" -p tcp --dport "$sport" -j DNAT --to-destination "$dip:$dport"
iptables -t nat -A ${NAME}_PREROUTING -s "$src_subnet" -d "$sip" -p udp --dport "$sport" -j DNAT --to-destination "$dip:$dport"
else
iptables -t nat -A ${NAME}_PREROUTING -d "$sip" -p tcp --dport "$sport" -j DNAT --to-destination "$dip:$dport"
iptables -t nat -A ${NAME}_PREROUTING -d "$sip" -p udp --dport "$sport" -j DNAT --to-destination "$dip:$dport"
fi
# DNAT: rewrite destination for locally-originated packets (hairpin from host itself) # DNAT: rewrite destination for locally-originated packets (hairpin from host itself)
iptables -t nat -A ${NAME}_OUTPUT -d "$sip" -p tcp --dport "$sport" -j DNAT --to-destination "$dip:$dport" iptables -t nat -A ${NAME}_OUTPUT -d "$sip" -p tcp --dport "$sport" -j DNAT --to-destination "$dip:$dport"
@@ -52,4 +66,4 @@ iptables -t nat -A ${NAME}_POSTROUTING -d "$dip" -p udp --dport "$dport" -j MASQ
iptables -A ${NAME}_FORWARD -d $dip -p tcp --dport $dport -m state --state NEW -j ACCEPT iptables -A ${NAME}_FORWARD -d $dip -p tcp --dport $dport -m state --state NEW -j ACCEPT
iptables -A ${NAME}_FORWARD -d $dip -p udp --dport $dport -m state --state NEW -j ACCEPT iptables -A ${NAME}_FORWARD -d $dip -p udp --dport $dport -m state --state NEW -j ACCEPT
exit $err exit $err

View File

@@ -139,8 +139,8 @@ Evaluate a script in the runtime context. Used for debugging.
The `execute` and `sandbox` methods route to procedures based on the `procedure` path: The `execute` and `sandbox` methods route to procedures based on the `procedure` path:
| Procedure | Description | | Procedure | Description |
|-----------|-------------| | -------------------------- | ---------------------------- |
| `/backup/create` | Create a backup | | `/backup/create` | Create a backup |
| `/actions/{name}/getInput` | Get input spec for an action | | `/actions/{name}/getInput` | Get input spec for an action |
| `/actions/{name}/run` | Run an action with input | | `/actions/{name}/run` | Run an action with input |

View File

@@ -82,18 +82,15 @@ export class DockerProcedureContainer extends Drop {
}), }),
) )
} else if (volumeMount.type === "certificate") { } else if (volumeMount.type === "certificate") {
const hostInfo = await effects.getHostInfo({
hostId: volumeMount["interface-id"],
})
const hostnames = [ const hostnames = [
`${packageId}.embassy`, `${packageId}.embassy`,
...new Set( ...new Set(
Object.values( Object.values(hostInfo?.bindings || {})
( .flatMap((b) => b.addresses.possible)
await effects.getHostInfo({ .map((h) => h.hostname.value),
hostId: volumeMount["interface-id"],
})
)?.hostnameInfo || {},
)
.flatMap((h) => h)
.flatMap((h) => (h.kind === "onion" ? [h.hostname.value] : [])),
).values(), ).values(),
] ]
const certChain = await effects.getSslCertificate({ const certChain = await effects.getSslCertificate({

View File

@@ -1244,12 +1244,8 @@ async function updateConfig(
? "" ? ""
: catchFn( : catchFn(
() => () =>
(specValue.target === "lan-address" filled.addressInfo!.filter({ kind: "mdns" })!.hostnames[0]
? filled.addressInfo!.filter({ kind: "mdns" }) || .hostname.value,
filled.addressInfo!.onion
: filled.addressInfo!.onion ||
filled.addressInfo!.filter({ kind: "mdns" })
).hostnames[0].hostname.value,
) || "" ) || ""
mutConfigValue[key] = url mutConfigValue[key] = url
} }

2724
core/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@ license = "MIT"
name = "start-os" name = "start-os"
readme = "README.md" readme = "README.md"
repository = "https://github.com/Start9Labs/start-os" repository = "https://github.com/Start9Labs/start-os"
version = "0.4.0-alpha.19" # VERSION_BUMP version = "0.4.0-alpha.20" # VERSION_BUMP
[lib] [lib]
name = "startos" name = "startos"
@@ -42,17 +42,6 @@ name = "tunnelbox"
path = "src/main/tunnelbox.rs" path = "src/main/tunnelbox.rs"
[features] [features]
arti = [
"arti-client",
"safelog",
"tor-cell",
"tor-hscrypto",
"tor-hsservice",
"tor-keymgr",
"tor-llcrypto",
"tor-proto",
"tor-rtcompat",
]
beta = [] beta = []
console = ["console-subscriber", "tokio/tracing"] console = ["console-subscriber", "tokio/tracing"]
default = [] default = []
@@ -62,16 +51,6 @@ unstable = ["backtrace-on-stack-overflow"]
[dependencies] [dependencies]
aes = { version = "0.7.5", features = ["ctr"] } aes = { version = "0.7.5", features = ["ctr"] }
arti-client = { version = "0.33", features = [
"compression",
"ephemeral-keystore",
"experimental-api",
"onion-service-client",
"onion-service-service",
"rustls",
"static",
"tokio",
], default-features = false, git = "https://github.com/Start9Labs/arti.git", branch = "patch/disable-exit", optional = true }
async-acme = { version = "0.6.0", git = "https://github.com/dr-bonez/async-acme.git", features = [ async-acme = { version = "0.6.0", git = "https://github.com/dr-bonez/async-acme.git", features = [
"use_rustls", "use_rustls",
"use_tokio", "use_tokio",
@@ -100,7 +79,6 @@ console-subscriber = { version = "0.5.0", optional = true }
const_format = "0.2.34" const_format = "0.2.34"
cookie = "0.18.0" cookie = "0.18.0"
cookie_store = "0.22.0" cookie_store = "0.22.0"
curve25519-dalek = "4.1.3"
der = { version = "0.7.9", features = ["derive", "pem"] } der = { version = "0.7.9", features = ["derive", "pem"] }
digest = "0.10.7" digest = "0.10.7"
divrem = "1.0.0" divrem = "1.0.0"
@@ -216,7 +194,6 @@ rpassword = "7.2.0"
rust-argon2 = "3.0.0" rust-argon2 = "3.0.0"
rust-i18n = "3.1.5" rust-i18n = "3.1.5"
rpc-toolkit = { git = "https://github.com/Start9Labs/rpc-toolkit.git" } rpc-toolkit = { git = "https://github.com/Start9Labs/rpc-toolkit.git" }
safelog = { version = "0.4.8", git = "https://github.com/Start9Labs/arti.git", branch = "patch/disable-exit", optional = true }
semver = { version = "1.0.20", features = ["serde"] } semver = { version = "1.0.20", features = ["serde"] }
serde = { version = "1.0", features = ["derive", "rc"] } serde = { version = "1.0", features = ["derive", "rc"] }
serde_cbor = { package = "ciborium", version = "0.2.1" } serde_cbor = { package = "ciborium", version = "0.2.1" }
@@ -244,23 +221,6 @@ tokio-stream = { version = "0.1.14", features = ["io-util", "net", "sync"] }
tokio-tar = { git = "https://github.com/dr-bonez/tokio-tar.git" } tokio-tar = { git = "https://github.com/dr-bonez/tokio-tar.git" }
tokio-tungstenite = { version = "0.26.2", features = ["native-tls", "url"] } tokio-tungstenite = { version = "0.26.2", features = ["native-tls", "url"] }
tokio-util = { version = "0.7.9", features = ["io"] } tokio-util = { version = "0.7.9", features = ["io"] }
tor-cell = { version = "0.33", git = "https://github.com/Start9Labs/arti.git", branch = "patch/disable-exit", optional = true }
tor-hscrypto = { version = "0.33", features = [
"full",
], git = "https://github.com/Start9Labs/arti.git", branch = "patch/disable-exit", optional = true }
tor-hsservice = { version = "0.33", git = "https://github.com/Start9Labs/arti.git", branch = "patch/disable-exit", optional = true }
tor-keymgr = { version = "0.33", features = [
"ephemeral-keystore",
], git = "https://github.com/Start9Labs/arti.git", branch = "patch/disable-exit", optional = true }
tor-llcrypto = { version = "0.33", features = [
"full",
], git = "https://github.com/Start9Labs/arti.git", branch = "patch/disable-exit", optional = true }
tor-proto = { version = "0.33", git = "https://github.com/Start9Labs/arti.git", branch = "patch/disable-exit", optional = true }
tor-rtcompat = { version = "0.33", features = [
"rustls",
"tokio",
], git = "https://github.com/Start9Labs/arti.git", branch = "patch/disable-exit", optional = true }
torut = "0.2.1"
tower-service = "0.3.3" tower-service = "0.3.3"
tracing = "0.1.39" tracing = "0.1.39"
tracing-error = "0.2.0" tracing-error = "0.2.0"

View File

@@ -8,7 +8,6 @@ use openssl::x509::X509;
use crate::db::model::DatabaseModel; use crate::db::model::DatabaseModel;
use crate::hostname::{Hostname, generate_hostname, generate_id}; use crate::hostname::{Hostname, generate_hostname, generate_id};
use crate::net::ssl::{gen_nistp256, make_root_cert}; use crate::net::ssl::{gen_nistp256, make_root_cert};
use crate::net::tor::TorSecretKey;
use crate::prelude::*; use crate::prelude::*;
use crate::util::serde::Pem; use crate::util::serde::Pem;
@@ -26,7 +25,6 @@ pub struct AccountInfo {
pub server_id: String, pub server_id: String,
pub hostname: Hostname, pub hostname: Hostname,
pub password: String, pub password: String,
pub tor_keys: Vec<TorSecretKey>,
pub root_ca_key: PKey<Private>, pub root_ca_key: PKey<Private>,
pub root_ca_cert: X509, pub root_ca_cert: X509,
pub ssh_key: ssh_key::PrivateKey, pub ssh_key: ssh_key::PrivateKey,
@@ -36,7 +34,6 @@ impl AccountInfo {
pub fn new(password: &str, start_time: SystemTime) -> Result<Self, Error> { pub fn new(password: &str, start_time: SystemTime) -> Result<Self, Error> {
let server_id = generate_id(); let server_id = generate_id();
let hostname = generate_hostname(); let hostname = generate_hostname();
let tor_key = vec![TorSecretKey::generate()];
let root_ca_key = gen_nistp256()?; let root_ca_key = gen_nistp256()?;
let root_ca_cert = make_root_cert(&root_ca_key, &hostname, start_time)?; let root_ca_cert = make_root_cert(&root_ca_key, &hostname, start_time)?;
let ssh_key = ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::random( let ssh_key = ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::random(
@@ -48,7 +45,6 @@ impl AccountInfo {
server_id, server_id,
hostname, hostname,
password: hash_password(password)?, password: hash_password(password)?,
tor_keys: tor_key,
root_ca_key, root_ca_key,
root_ca_cert, root_ca_cert,
ssh_key, ssh_key,
@@ -61,17 +57,6 @@ impl AccountInfo {
let hostname = Hostname(db.as_public().as_server_info().as_hostname().de()?); let hostname = Hostname(db.as_public().as_server_info().as_hostname().de()?);
let password = db.as_private().as_password().de()?; let password = db.as_private().as_password().de()?;
let key_store = db.as_private().as_key_store(); let key_store = db.as_private().as_key_store();
let tor_addrs = db
.as_public()
.as_server_info()
.as_network()
.as_host()
.as_onions()
.de()?;
let tor_keys = tor_addrs
.into_iter()
.map(|tor_addr| key_store.as_onion().get_key(&tor_addr))
.collect::<Result<_, _>>()?;
let cert_store = key_store.as_local_certs(); let cert_store = key_store.as_local_certs();
let root_ca_key = cert_store.as_root_key().de()?.0; let root_ca_key = cert_store.as_root_key().de()?.0;
let root_ca_cert = cert_store.as_root_cert().de()?.0; let root_ca_cert = cert_store.as_root_cert().de()?.0;
@@ -82,7 +67,6 @@ impl AccountInfo {
server_id, server_id,
hostname, hostname,
password, password,
tor_keys,
root_ca_key, root_ca_key,
root_ca_cert, root_ca_cert,
ssh_key, ssh_key,
@@ -97,17 +81,6 @@ impl AccountInfo {
server_info server_info
.as_pubkey_mut() .as_pubkey_mut()
.ser(&self.ssh_key.public_key().to_openssh()?)?; .ser(&self.ssh_key.public_key().to_openssh()?)?;
server_info
.as_network_mut()
.as_host_mut()
.as_onions_mut()
.ser(
&self
.tor_keys
.iter()
.map(|tor_key| tor_key.onion_address())
.collect(),
)?;
server_info.as_password_hash_mut().ser(&self.password)?; server_info.as_password_hash_mut().ser(&self.password)?;
db.as_private_mut().as_password_mut().ser(&self.password)?; db.as_private_mut().as_password_mut().ser(&self.password)?;
db.as_private_mut() db.as_private_mut()
@@ -117,9 +90,6 @@ impl AccountInfo {
.as_developer_key_mut() .as_developer_key_mut()
.ser(Pem::new_ref(&self.developer_key))?; .ser(Pem::new_ref(&self.developer_key))?;
let key_store = db.as_private_mut().as_key_store_mut(); let key_store = db.as_private_mut().as_key_store_mut();
for tor_key in &self.tor_keys {
key_store.as_onion_mut().insert_key(tor_key)?;
}
let cert_store = key_store.as_local_certs_mut(); let cert_store = key_store.as_local_certs_mut();
if cert_store.as_root_cert().de()?.0 != self.root_ca_cert { if cert_store.as_root_cert().de()?.0 != self.root_ca_cert {
cert_store cert_store
@@ -148,11 +118,5 @@ impl AccountInfo {
self.hostname.no_dot_host_name(), self.hostname.no_dot_host_name(),
self.hostname.local_domain_name(), self.hostname.local_domain_name(),
] ]
.into_iter()
.chain(
self.tor_keys
.iter()
.map(|k| InternedString::from_display(&k.onion_address())),
)
} }
} }

View File

@@ -7,9 +7,7 @@ use ssh_key::private::Ed25519Keypair;
use crate::account::AccountInfo; use crate::account::AccountInfo;
use crate::hostname::{Hostname, generate_hostname, generate_id}; use crate::hostname::{Hostname, generate_hostname, generate_id};
use crate::net::tor::TorSecretKey;
use crate::prelude::*; use crate::prelude::*;
use crate::util::crypto::ed25519_expand_key;
use crate::util::serde::{Base32, Base64, Pem}; use crate::util::serde::{Base32, Base64, Pem};
pub struct OsBackup { pub struct OsBackup {
@@ -85,10 +83,6 @@ impl OsBackupV0 {
&mut ssh_key::rand_core::OsRng::default(), &mut ssh_key::rand_core::OsRng::default(),
ssh_key::Algorithm::Ed25519, ssh_key::Algorithm::Ed25519,
)?, )?,
tor_keys: TorSecretKey::from_bytes(self.tor_key.0)
.ok()
.into_iter()
.collect(),
developer_key: ed25519_dalek::SigningKey::generate( developer_key: ed25519_dalek::SigningKey::generate(
&mut ssh_key::rand_core::OsRng::default(), &mut ssh_key::rand_core::OsRng::default(),
), ),
@@ -119,10 +113,6 @@ impl OsBackupV1 {
root_ca_key: self.root_ca_key.0, root_ca_key: self.root_ca_key.0,
root_ca_cert: self.root_ca_cert.0, root_ca_cert: self.root_ca_cert.0,
ssh_key: ssh_key::PrivateKey::from(Ed25519Keypair::from_seed(&self.net_key.0)), ssh_key: ssh_key::PrivateKey::from(Ed25519Keypair::from_seed(&self.net_key.0)),
tor_keys: TorSecretKey::from_bytes(ed25519_expand_key(&self.net_key.0))
.ok()
.into_iter()
.collect(),
developer_key: ed25519_dalek::SigningKey::from_bytes(&self.net_key), developer_key: ed25519_dalek::SigningKey::from_bytes(&self.net_key),
}, },
ui: self.ui, ui: self.ui,
@@ -140,7 +130,6 @@ struct OsBackupV2 {
root_ca_key: Pem<PKey<Private>>, // PEM Encoded OpenSSL Key root_ca_key: Pem<PKey<Private>>, // PEM Encoded OpenSSL Key
root_ca_cert: Pem<X509>, // PEM Encoded OpenSSL X509 Certificate root_ca_cert: Pem<X509>, // PEM Encoded OpenSSL X509 Certificate
ssh_key: Pem<ssh_key::PrivateKey>, // PEM Encoded OpenSSH Key ssh_key: Pem<ssh_key::PrivateKey>, // PEM Encoded OpenSSH Key
tor_keys: Vec<TorSecretKey>, // Base64 Encoded Ed25519 Expanded Secret Key
compat_s9pk_key: Pem<ed25519_dalek::SigningKey>, // PEM Encoded ED25519 Key compat_s9pk_key: Pem<ed25519_dalek::SigningKey>, // PEM Encoded ED25519 Key
ui: Value, // JSON Value ui: Value, // JSON Value
} }
@@ -154,7 +143,6 @@ impl OsBackupV2 {
root_ca_key: self.root_ca_key.0, root_ca_key: self.root_ca_key.0,
root_ca_cert: self.root_ca_cert.0, root_ca_cert: self.root_ca_cert.0,
ssh_key: self.ssh_key.0, ssh_key: self.ssh_key.0,
tor_keys: self.tor_keys,
developer_key: self.compat_s9pk_key.0, developer_key: self.compat_s9pk_key.0,
}, },
ui: self.ui, ui: self.ui,
@@ -167,7 +155,6 @@ impl OsBackupV2 {
root_ca_key: Pem(backup.account.root_ca_key.clone()), root_ca_key: Pem(backup.account.root_ca_key.clone()),
root_ca_cert: Pem(backup.account.root_ca_cert.clone()), root_ca_cert: Pem(backup.account.root_ca_cert.clone()),
ssh_key: Pem(backup.account.ssh_key.clone()), ssh_key: Pem(backup.account.ssh_key.clone()),
tor_keys: backup.account.tor_keys.clone(),
compat_s9pk_key: Pem(backup.account.developer_key.clone()), compat_s9pk_key: Pem(backup.account.developer_key.clone()),
ui: backup.ui.clone(), ui: backup.ui.clone(),
} }

View File

@@ -9,7 +9,7 @@ use crate::disk::fsck::RepairStrategy;
use crate::disk::main::DEFAULT_PASSWORD; use crate::disk::main::DEFAULT_PASSWORD;
use crate::firmware::{check_for_firmware_update, update_firmware}; use crate::firmware::{check_for_firmware_update, update_firmware};
use crate::init::{InitPhases, STANDBY_MODE_PATH}; use crate::init::{InitPhases, STANDBY_MODE_PATH};
use crate::net::gateway::UpgradableListener; use crate::net::gateway::WildcardListener;
use crate::net::web_server::WebServer; use crate::net::web_server::WebServer;
use crate::prelude::*; use crate::prelude::*;
use crate::progress::FullProgressTracker; use crate::progress::FullProgressTracker;
@@ -19,7 +19,7 @@ use crate::{DATA_DIR, PLATFORM};
#[instrument(skip_all)] #[instrument(skip_all)]
async fn setup_or_init( async fn setup_or_init(
server: &mut WebServer<UpgradableListener>, server: &mut WebServer<WildcardListener>,
config: &ServerConfig, config: &ServerConfig,
) -> Result<Result<(RpcContext, FullProgressTracker), Shutdown>, Error> { ) -> Result<Result<(RpcContext, FullProgressTracker), Shutdown>, Error> {
if let Some(firmware) = check_for_firmware_update() if let Some(firmware) = check_for_firmware_update()
@@ -204,7 +204,7 @@ async fn setup_or_init(
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn main( pub async fn main(
server: &mut WebServer<UpgradableListener>, server: &mut WebServer<WildcardListener>,
config: &ServerConfig, config: &ServerConfig,
) -> Result<Result<(RpcContext, FullProgressTracker), Shutdown>, Error> { ) -> Result<Result<(RpcContext, FullProgressTracker), Shutdown>, Error> {
if &*PLATFORM == "raspberrypi" && tokio::fs::metadata(STANDBY_MODE_PATH).await.is_ok() { if &*PLATFORM == "raspberrypi" && tokio::fs::metadata(STANDBY_MODE_PATH).await.is_ok() {

View File

@@ -12,7 +12,7 @@ use tracing::instrument;
use crate::context::config::ServerConfig; use crate::context::config::ServerConfig;
use crate::context::rpc::InitRpcContextPhases; use crate::context::rpc::InitRpcContextPhases;
use crate::context::{DiagnosticContext, InitContext, RpcContext}; use crate::context::{DiagnosticContext, InitContext, RpcContext};
use crate::net::gateway::{BindTcp, SelfContainedNetworkInterfaceListener, UpgradableListener}; use crate::net::gateway::WildcardListener;
use crate::net::static_server::refresher; use crate::net::static_server::refresher;
use crate::net::web_server::{Acceptor, WebServer}; use crate::net::web_server::{Acceptor, WebServer};
use crate::prelude::*; use crate::prelude::*;
@@ -23,7 +23,7 @@ use crate::util::logger::LOGGER;
#[instrument(skip_all)] #[instrument(skip_all)]
async fn inner_main( async fn inner_main(
server: &mut WebServer<UpgradableListener>, server: &mut WebServer<WildcardListener>,
config: &ServerConfig, config: &ServerConfig,
) -> Result<Option<Shutdown>, Error> { ) -> Result<Option<Shutdown>, Error> {
let rpc_ctx = if !tokio::fs::metadata("/run/startos/initialized") let rpc_ctx = if !tokio::fs::metadata("/run/startos/initialized")
@@ -148,7 +148,7 @@ pub fn main(args: impl IntoIterator<Item = OsString>) {
.expect(&t!("bins.startd.failed-to-initialize-runtime")); .expect(&t!("bins.startd.failed-to-initialize-runtime"));
let res = rt.block_on(async { let res = rt.block_on(async {
let mut server = WebServer::new( let mut server = WebServer::new(
Acceptor::bind_upgradable(SelfContainedNetworkInterfaceListener::bind(BindTcp, 80)), Acceptor::new(WildcardListener::new(80)?),
refresher(), refresher(),
); );
match inner_main(&mut server, &config).await { match inner_main(&mut server, &config).await {

View File

@@ -13,7 +13,7 @@ use visit_rs::Visit;
use crate::context::CliContext; use crate::context::CliContext;
use crate::context::config::ClientConfig; use crate::context::config::ClientConfig;
use crate::net::gateway::{Bind, BindTcp}; use tokio::net::TcpListener;
use crate::net::tls::TlsListener; use crate::net::tls::TlsListener;
use crate::net::web_server::{Accept, Acceptor, MetadataVisitor, WebServer}; use crate::net::web_server::{Accept, Acceptor, MetadataVisitor, WebServer};
use crate::prelude::*; use crate::prelude::*;
@@ -57,7 +57,12 @@ async fn inner_main(config: &TunnelConfig) -> Result<(), Error> {
if !a.contains_key(&key) { if !a.contains_key(&key) {
match (|| { match (|| {
Ok::<_, Error>(TlsListener::new( Ok::<_, Error>(TlsListener::new(
BindTcp.bind(addr)?, TcpListener::from_std(
mio::net::TcpListener::bind(addr)
.with_kind(ErrorKind::Network)?
.into(),
)
.with_kind(ErrorKind::Network)?,
TunnelCertHandler { TunnelCertHandler {
db: https_db.clone(), db: https_db.clone(),
crypto_provider: Arc::new(tokio_rustls::rustls::crypto::ring::default_provider()), crypto_provider: Arc::new(tokio_rustls::rustls::crypto::ring::default_provider()),

View File

@@ -34,7 +34,7 @@ use crate::disk::mount::guard::MountGuard;
use crate::init::{InitResult, check_time_is_synchronized}; use crate::init::{InitResult, check_time_is_synchronized};
use crate::install::PKG_ARCHIVE_DIR; use crate::install::PKG_ARCHIVE_DIR;
use crate::lxc::LxcManager; use crate::lxc::LxcManager;
use crate::net::gateway::UpgradableListener; use crate::net::gateway::WildcardListener;
use crate::net::net_controller::{NetController, NetService}; use crate::net::net_controller::{NetController, NetService};
use crate::net::socks::DEFAULT_SOCKS_LISTEN; use crate::net::socks::DEFAULT_SOCKS_LISTEN;
use crate::net::utils::{find_eth_iface, find_wifi_iface}; use crate::net::utils::{find_eth_iface, find_wifi_iface};
@@ -132,7 +132,7 @@ pub struct RpcContext(Arc<RpcContextSeed>);
impl RpcContext { impl RpcContext {
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn init( pub async fn init(
webserver: &WebServerAcceptorSetter<UpgradableListener>, webserver: &WebServerAcceptorSetter<WildcardListener>,
config: &ServerConfig, config: &ServerConfig,
disk_guid: InternedString, disk_guid: InternedString,
init_result: Option<InitResult>, init_result: Option<InitResult>,
@@ -167,7 +167,7 @@ impl RpcContext {
} else { } else {
let net_ctrl = let net_ctrl =
Arc::new(NetController::init(db.clone(), &account.hostname, socks_proxy).await?); Arc::new(NetController::init(db.clone(), &account.hostname, socks_proxy).await?);
webserver.try_upgrade(|a| net_ctrl.net_iface.watcher.upgrade_listener(a))?; webserver.send_modify(|wl| wl.set_ip_info(net_ctrl.net_iface.watcher.subscribe()));
let os_net_service = net_ctrl.os_bindings().await?; let os_net_service = net_ctrl.os_bindings().await?;
(net_ctrl, os_net_service) (net_ctrl, os_net_service)
}; };

View File

@@ -20,7 +20,7 @@ use crate::context::RpcContext;
use crate::context::config::ServerConfig; use crate::context::config::ServerConfig;
use crate::disk::mount::guard::{MountGuard, TmpMountGuard}; use crate::disk::mount::guard::{MountGuard, TmpMountGuard};
use crate::hostname::Hostname; use crate::hostname::Hostname;
use crate::net::gateway::UpgradableListener; use crate::net::gateway::WildcardListener;
use crate::net::web_server::{WebServer, WebServerAcceptorSetter}; use crate::net::web_server::{WebServer, WebServerAcceptorSetter};
use crate::prelude::*; use crate::prelude::*;
use crate::progress::FullProgressTracker; use crate::progress::FullProgressTracker;
@@ -51,7 +51,7 @@ pub struct SetupResult {
} }
pub struct SetupContextSeed { pub struct SetupContextSeed {
pub webserver: WebServerAcceptorSetter<UpgradableListener>, pub webserver: WebServerAcceptorSetter<WildcardListener>,
pub config: SyncMutex<ServerConfig>, pub config: SyncMutex<ServerConfig>,
pub disable_encryption: bool, pub disable_encryption: bool,
pub progress: FullProgressTracker, pub progress: FullProgressTracker,
@@ -70,7 +70,7 @@ pub struct SetupContext(Arc<SetupContextSeed>);
impl SetupContext { impl SetupContext {
#[instrument(skip_all)] #[instrument(skip_all)]
pub fn init( pub fn init(
webserver: &WebServer<UpgradableListener>, webserver: &WebServer<WildcardListener>,
config: ServerConfig, config: ServerConfig,
) -> Result<Self, Error> { ) -> Result<Self, Error> {
let (shutdown, _) = tokio::sync::broadcast::channel(1); let (shutdown, _) = tokio::sync::broadcast::channel(1);

View File

@@ -18,7 +18,7 @@ use crate::s9pk::manifest::{LocaleString, Manifest};
use crate::status::StatusInfo; use crate::status::StatusInfo;
use crate::util::DataUrl; use crate::util::DataUrl;
use crate::util::serde::{Pem, is_partial_of}; use crate::util::serde::{Pem, is_partial_of};
use crate::{ActionId, HealthCheckId, HostId, PackageId, ReplayId, ServiceInterfaceId}; use crate::{ActionId, GatewayId, HealthCheckId, HostId, PackageId, ReplayId, ServiceInterfaceId};
#[derive(Debug, Default, Deserialize, Serialize, TS)] #[derive(Debug, Default, Deserialize, Serialize, TS)]
#[ts(export)] #[ts(export)]
@@ -381,6 +381,9 @@ pub struct PackageDataEntry {
pub hosts: Hosts, pub hosts: Hosts,
#[ts(type = "string[]")] #[ts(type = "string[]")]
pub store_exposed_dependents: Vec<JsonPointer>, pub store_exposed_dependents: Vec<JsonPointer>,
#[serde(default)]
#[ts(type = "string | null")]
pub outbound_gateway: Option<GatewayId>,
} }
impl AsRef<PackageDataEntry> for PackageDataEntry { impl AsRef<PackageDataEntry> for PackageDataEntry {
fn as_ref(&self) -> &PackageDataEntry { fn as_ref(&self) -> &PackageDataEntry {

View File

@@ -20,7 +20,7 @@ use crate::db::model::Database;
use crate::db::model::package::AllPackageData; use crate::db::model::package::AllPackageData;
use crate::net::acme::AcmeProvider; use crate::net::acme::AcmeProvider;
use crate::net::host::Host; use crate::net::host::Host;
use crate::net::host::binding::{AddSslOptions, BindInfo, BindOptions, NetInfo}; use crate::net::host::binding::{AddSslOptions, BindInfo, BindOptions, Bindings, DerivedAddressInfo, NetInfo};
use crate::net::utils::ipv6_is_local; use crate::net::utils::ipv6_is_local;
use crate::net::vhost::AlpnInfo; use crate::net::vhost::AlpnInfo;
use crate::prelude::*; use crate::prelude::*;
@@ -63,36 +63,35 @@ impl Public {
post_init_migration_todos: BTreeMap::new(), post_init_migration_todos: BTreeMap::new(),
network: NetworkInfo { network: NetworkInfo {
host: Host { host: Host {
bindings: [( bindings: Bindings(
80, [(
BindInfo { 80,
enabled: false, BindInfo {
options: BindOptions { enabled: false,
preferred_external_port: 80, options: BindOptions {
add_ssl: Some(AddSslOptions { preferred_external_port: 80,
preferred_external_port: 443, add_ssl: Some(AddSslOptions {
add_x_forwarded_headers: false, preferred_external_port: 443,
alpn: Some(AlpnInfo::Specified(vec![ add_x_forwarded_headers: false,
MaybeUtf8String("h2".into()), alpn: Some(AlpnInfo::Specified(vec![
MaybeUtf8String("http/1.1".into()), MaybeUtf8String("h2".into()),
])), MaybeUtf8String("http/1.1".into()),
}), ])),
secure: None, }),
secure: None,
},
net: NetInfo {
assigned_port: None,
assigned_ssl_port: Some(443),
},
addresses: DerivedAddressInfo::default(),
}, },
net: NetInfo { )]
assigned_port: None, .into_iter()
assigned_ssl_port: Some(443), .collect(),
private_disabled: OrdSet::new(), ),
public_enabled: OrdSet::new(),
},
},
)]
.into_iter()
.collect(),
onions: account.tor_keys.iter().map(|k| k.onion_address()).collect(),
public_domains: BTreeMap::new(), public_domains: BTreeMap::new(),
private_domains: BTreeSet::new(), private_domains: BTreeSet::new(),
hostname_info: BTreeMap::new(),
}, },
wifi: WifiInfo { wifi: WifiInfo {
enabled: true, enabled: true,
@@ -117,6 +116,7 @@ impl Public {
acme acme
}, },
dns: Default::default(), dns: Default::default(),
default_outbound: None,
}, },
status_info: ServerStatus { status_info: ServerStatus {
backup_progress: None, backup_progress: None,
@@ -220,6 +220,9 @@ pub struct NetworkInfo {
pub acme: BTreeMap<AcmeProvider, AcmeSettings>, pub acme: BTreeMap<AcmeProvider, AcmeSettings>,
#[serde(default)] #[serde(default)]
pub dns: DnsSettings, pub dns: DnsSettings,
#[serde(default)]
#[ts(type = "string | null")]
pub default_outbound: Option<GatewayId>,
} }
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)] #[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
@@ -239,39 +242,42 @@ pub struct DnsSettings {
#[ts(export)] #[ts(export)]
pub struct NetworkInterfaceInfo { pub struct NetworkInterfaceInfo {
pub name: Option<InternedString>, pub name: Option<InternedString>,
#[ts(skip)]
pub public: Option<bool>, pub public: Option<bool>,
pub secure: Option<bool>, pub secure: Option<bool>,
pub ip_info: Option<Arc<IpInfo>>, pub ip_info: Option<Arc<IpInfo>>,
#[serde(default, rename = "type")]
pub gateway_type: Option<GatewayType>,
} }
impl NetworkInterfaceInfo { impl NetworkInterfaceInfo {
pub fn public(&self) -> bool { pub fn public(&self) -> bool {
self.public.unwrap_or_else(|| { self.public.unwrap_or_else(|| {
!self.ip_info.as_ref().map_or(true, |ip_info| { !self.ip_info.as_ref().map_or(true, |ip_info| {
let ip4s = ip_info let ip4s = ip_info
.subnets .subnets
.iter() .iter()
.filter_map(|ipnet| { .filter_map(|ipnet| {
if let IpAddr::V4(ip4) = ipnet.addr() { if let IpAddr::V4(ip4) = ipnet.addr() {
Some(ip4) Some(ip4)
} else {
None
}
})
.collect::<BTreeSet<_>>();
if !ip4s.is_empty() {
return ip4s
.iter()
.all(|ip4| ip4.is_loopback() || ip4.is_private() || ip4.is_link_local());
}
ip_info.subnets.iter().all(|ipnet| {
if let IpAddr::V6(ip6) = ipnet.addr() {
ipv6_is_local(ip6)
} else { } else {
true None
} }
}) })
.collect::<BTreeSet<_>>();
if !ip4s.is_empty() {
return ip4s
.iter()
.all(|ip4| ip4.is_loopback() || ip4.is_private() || ip4.is_link_local());
}
ip_info.subnets.iter().all(|ipnet| {
if let IpAddr::V6(ip6) = ipnet.addr() {
ipv6_is_local(ip6)
} else {
true
}
}) })
}) })
})
} }
pub fn secure(&self) -> bool { pub fn secure(&self) -> bool {
@@ -310,6 +316,15 @@ pub enum NetworkInterfaceType {
Loopback, Loopback,
} }
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS, clap::ValueEnum)]
#[ts(export)]
#[serde(rename_all = "kebab-case")]
pub enum GatewayType {
#[default]
InboundOutbound,
OutboundOnly,
}
#[derive(Debug, Deserialize, Serialize, HasModel, TS)] #[derive(Debug, Deserialize, Serialize, HasModel, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[model = "Model<Self>"] #[model = "Model<Self>"]

View File

@@ -42,11 +42,11 @@ pub enum ErrorKind {
ParseUrl = 19, ParseUrl = 19,
DiskNotAvailable = 20, DiskNotAvailable = 20,
BlockDevice = 21, BlockDevice = 21,
InvalidOnionAddress = 22, // InvalidOnionAddress = 22,
Pack = 23, Pack = 23,
ValidateS9pk = 24, ValidateS9pk = 24,
DiskCorrupted = 25, // Remove DiskCorrupted = 25, // Remove
Tor = 26, // Tor = 26,
ConfigGen = 27, ConfigGen = 27,
ParseNumber = 28, ParseNumber = 28,
Database = 29, Database = 29,
@@ -126,11 +126,11 @@ impl ErrorKind {
ParseUrl => t!("error.parse-url"), ParseUrl => t!("error.parse-url"),
DiskNotAvailable => t!("error.disk-not-available"), DiskNotAvailable => t!("error.disk-not-available"),
BlockDevice => t!("error.block-device"), BlockDevice => t!("error.block-device"),
InvalidOnionAddress => t!("error.invalid-onion-address"), // InvalidOnionAddress => t!("error.invalid-onion-address"),
Pack => t!("error.pack"), Pack => t!("error.pack"),
ValidateS9pk => t!("error.validate-s9pk"), ValidateS9pk => t!("error.validate-s9pk"),
DiskCorrupted => t!("error.disk-corrupted"), // Remove DiskCorrupted => t!("error.disk-corrupted"), // Remove
Tor => t!("error.tor"), // Tor => t!("error.tor"),
ConfigGen => t!("error.config-gen"), ConfigGen => t!("error.config-gen"),
ParseNumber => t!("error.parse-number"), ParseNumber => t!("error.parse-number"),
Database => t!("error.database"), Database => t!("error.database"),
@@ -370,17 +370,6 @@ impl From<reqwest::Error> for Error {
Error::new(e, kind) Error::new(e, kind)
} }
} }
#[cfg(feature = "arti")]
impl From<arti_client::Error> for Error {
fn from(e: arti_client::Error) -> Self {
Error::new(e, ErrorKind::Tor)
}
}
impl From<torut::control::ConnError> for Error {
fn from(e: torut::control::ConnError) -> Self {
Error::new(e, ErrorKind::Tor)
}
}
impl From<zbus::Error> for Error { impl From<zbus::Error> for Error {
fn from(e: zbus::Error) -> Self { fn from(e: zbus::Error) -> Self {
Error::new(e, ErrorKind::DBus) Error::new(e, ErrorKind::DBus)

View File

@@ -20,7 +20,7 @@ use crate::db::model::public::ServerStatus;
use crate::developer::OS_DEVELOPER_KEY_PATH; use crate::developer::OS_DEVELOPER_KEY_PATH;
use crate::hostname::Hostname; use crate::hostname::Hostname;
use crate::middleware::auth::local::LocalAuthContext; use crate::middleware::auth::local::LocalAuthContext;
use crate::net::gateway::UpgradableListener; use crate::net::gateway::WildcardListener;
use crate::net::net_controller::{NetController, NetService}; use crate::net::net_controller::{NetController, NetService};
use crate::net::socks::DEFAULT_SOCKS_LISTEN; use crate::net::socks::DEFAULT_SOCKS_LISTEN;
use crate::net::utils::find_wifi_iface; use crate::net::utils::find_wifi_iface;
@@ -144,7 +144,7 @@ pub async fn run_script<P: AsRef<Path>>(path: P, mut progress: PhaseProgressTrac
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn init( pub async fn init(
webserver: &WebServerAcceptorSetter<UpgradableListener>, webserver: &WebServerAcceptorSetter<WildcardListener>,
cfg: &ServerConfig, cfg: &ServerConfig,
InitPhases { InitPhases {
preinit, preinit,
@@ -218,7 +218,7 @@ pub async fn init(
) )
.await?, .await?,
); );
webserver.try_upgrade(|a| net_ctrl.net_iface.watcher.upgrade_listener(a))?; webserver.send_modify(|wl| wl.set_ip_info(net_ctrl.net_iface.watcher.subscribe()));
let os_net_service = net_ctrl.os_bindings().await?; let os_net_service = net_ctrl.os_bindings().await?;
start_net.complete(); start_net.complete();

View File

@@ -4,8 +4,8 @@ use std::sync::{Arc, Weak};
use std::time::Duration; use std::time::Duration;
use futures::channel::oneshot; use futures::channel::oneshot;
use id_pool::IdPool;
use iddqd::{IdOrdItem, IdOrdMap}; use iddqd::{IdOrdItem, IdOrdMap};
use rand::Rng;
use imbl::OrdMap; use imbl::OrdMap;
use rpc_toolkit::{Context, HandlerArgs, HandlerExt, ParentHandler, from_fn_async}; use rpc_toolkit::{Context, HandlerArgs, HandlerExt, ParentHandler, from_fn_async};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -15,7 +15,6 @@ use tokio::sync::mpsc;
use crate::GatewayId; use crate::GatewayId;
use crate::context::{CliContext, RpcContext}; use crate::context::{CliContext, RpcContext};
use crate::db::model::public::NetworkInterfaceInfo; use crate::db::model::public::NetworkInterfaceInfo;
use crate::net::gateway::{DynInterfaceFilter, InterfaceFilter};
use crate::prelude::*; use crate::prelude::*;
use crate::util::Invoke; use crate::util::Invoke;
use crate::util::future::NonDetachingJoinHandle; use crate::util::future::NonDetachingJoinHandle;
@@ -23,25 +22,76 @@ use crate::util::serde::{HandlerExtSerde, display_serializable};
use crate::util::sync::Watch; use crate::util::sync::Watch;
pub const START9_BRIDGE_IFACE: &str = "lxcbr0"; pub const START9_BRIDGE_IFACE: &str = "lxcbr0";
pub const FIRST_DYNAMIC_PRIVATE_PORT: u16 = 49152; const EPHEMERAL_PORT_START: u16 = 49152;
// vhost.rs:89 — not allowed: <=1024, >=32768, 5355, 5432, 9050, 6010, 9051, 5353
const RESTRICTED_PORTS: &[u16] = &[5353, 5355, 5432, 6010, 9050, 9051];
fn is_restricted(port: u16) -> bool {
port <= 1024 || RESTRICTED_PORTS.contains(&port)
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ForwardRequirements {
pub public_gateways: BTreeSet<GatewayId>,
pub private_ips: BTreeSet<IpAddr>,
pub secure: bool,
}
impl std::fmt::Display for ForwardRequirements {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"ForwardRequirements {{ public: {:?}, private: {:?}, secure: {} }}",
self.public_gateways, self.private_ips, self.secure
)
}
}
/// Source-IP filter for private forwards: restricts traffic to a subnet
/// while excluding gateway/router IPs that may masquerade internet traffic.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct SourceFilter {
/// Network CIDR to allow (e.g. "192.168.1.0/24")
subnet: String,
/// Comma-separated gateway IPs to exclude (they may masquerade internet traffic)
excluded: String,
}
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
pub struct AvailablePorts(IdPool); pub struct AvailablePorts(BTreeMap<u16, bool>);
impl AvailablePorts { impl AvailablePorts {
pub fn new() -> Self { pub fn new() -> Self {
Self(IdPool::new_ranged(FIRST_DYNAMIC_PRIVATE_PORT..u16::MAX)) Self(BTreeMap::new())
} }
pub fn alloc(&mut self) -> Result<u16, Error> { pub fn alloc(&mut self, ssl: bool) -> Result<u16, Error> {
self.0.request_id().ok_or_else(|| { let mut rng = rand::rng();
Error::new( for _ in 0..1000 {
eyre!("{}", t!("net.forward.no-dynamic-ports-available")), let port = rng.random_range(EPHEMERAL_PORT_START..u16::MAX);
ErrorKind::Network, if !self.0.contains_key(&port) {
) self.0.insert(port, ssl);
}) return Ok(port);
}
}
Err(Error::new(
eyre!("{}", t!("net.forward.no-dynamic-ports-available")),
ErrorKind::Network,
))
}
/// Try to allocate a specific port. Returns Some(port) if available, None if taken/restricted.
pub fn try_alloc(&mut self, port: u16, ssl: bool) -> Option<u16> {
if is_restricted(port) || self.0.contains_key(&port) {
return None;
}
self.0.insert(port, ssl);
Some(port)
}
/// Returns whether a given allocated port is SSL.
pub fn is_ssl(&self, port: u16) -> bool {
self.0.get(&port).copied().unwrap_or(false)
} }
pub fn free(&mut self, ports: impl IntoIterator<Item = u16>) { pub fn free(&mut self, ports: impl IntoIterator<Item = u16>) {
for port in ports { for port in ports {
self.0.return_id(port).unwrap_or_default(); self.0.remove(&port);
} }
} }
} }
@@ -61,10 +111,10 @@ pub fn forward_api<C: Context>() -> ParentHandler<C> {
} }
let mut table = Table::new(); let mut table = Table::new();
table.add_row(row![bc => "FROM", "TO", "FILTER"]); table.add_row(row![bc => "FROM", "TO", "REQS"]);
for (external, target) in res.0 { for (external, target) in res.0 {
table.add_row(row![external, target.target, target.filter]); table.add_row(row![external, target.target, target.reqs]);
} }
table.print_tty(false)?; table.print_tty(false)?;
@@ -79,6 +129,7 @@ struct ForwardMapping {
source: SocketAddrV4, source: SocketAddrV4,
target: SocketAddrV4, target: SocketAddrV4,
target_prefix: u8, target_prefix: u8,
src_filter: Option<SourceFilter>,
rc: Weak<()>, rc: Weak<()>,
} }
@@ -93,9 +144,10 @@ impl PortForwardState {
source: SocketAddrV4, source: SocketAddrV4,
target: SocketAddrV4, target: SocketAddrV4,
target_prefix: u8, target_prefix: u8,
src_filter: Option<SourceFilter>,
) -> Result<Arc<()>, Error> { ) -> Result<Arc<()>, Error> {
if let Some(existing) = self.mappings.get_mut(&source) { if let Some(existing) = self.mappings.get_mut(&source) {
if existing.target == target { if existing.target == target && existing.src_filter == src_filter {
if let Some(existing_rc) = existing.rc.upgrade() { if let Some(existing_rc) = existing.rc.upgrade() {
return Ok(existing_rc); return Ok(existing_rc);
} else { } else {
@@ -104,21 +156,28 @@ impl PortForwardState {
return Ok(rc); return Ok(rc);
} }
} else { } else {
// Different target, need to remove old and add new // Different target or src_filter, need to remove old and add new
if let Some(mapping) = self.mappings.remove(&source) { if let Some(mapping) = self.mappings.remove(&source) {
unforward(mapping.source, mapping.target, mapping.target_prefix).await?; unforward(
mapping.source,
mapping.target,
mapping.target_prefix,
mapping.src_filter.as_ref(),
)
.await?;
} }
} }
} }
let rc = Arc::new(()); let rc = Arc::new(());
forward(source, target, target_prefix).await?; forward(source, target, target_prefix, src_filter.as_ref()).await?;
self.mappings.insert( self.mappings.insert(
source, source,
ForwardMapping { ForwardMapping {
source, source,
target, target,
target_prefix, target_prefix,
src_filter,
rc: Arc::downgrade(&rc), rc: Arc::downgrade(&rc),
}, },
); );
@@ -136,7 +195,13 @@ impl PortForwardState {
for source in to_remove { for source in to_remove {
if let Some(mapping) = self.mappings.remove(&source) { if let Some(mapping) = self.mappings.remove(&source) {
unforward(mapping.source, mapping.target, mapping.target_prefix).await?; unforward(
mapping.source,
mapping.target,
mapping.target_prefix,
mapping.src_filter.as_ref(),
)
.await?;
} }
} }
Ok(()) Ok(())
@@ -157,9 +222,14 @@ impl Drop for PortForwardState {
let mappings = std::mem::take(&mut self.mappings); let mappings = std::mem::take(&mut self.mappings);
tokio::spawn(async move { tokio::spawn(async move {
for (_, mapping) in mappings { for (_, mapping) in mappings {
unforward(mapping.source, mapping.target, mapping.target_prefix) unforward(
.await mapping.source,
.log_err(); mapping.target,
mapping.target_prefix,
mapping.src_filter.as_ref(),
)
.await
.log_err();
} }
}); });
} }
@@ -171,6 +241,7 @@ enum PortForwardCommand {
source: SocketAddrV4, source: SocketAddrV4,
target: SocketAddrV4, target: SocketAddrV4,
target_prefix: u8, target_prefix: u8,
src_filter: Option<SourceFilter>,
respond: oneshot::Sender<Result<Arc<()>, Error>>, respond: oneshot::Sender<Result<Arc<()>, Error>>,
}, },
Gc { Gc {
@@ -257,9 +328,12 @@ impl PortForwardController {
source, source,
target, target,
target_prefix, target_prefix,
src_filter,
respond, respond,
} => { } => {
let result = state.add_forward(source, target, target_prefix).await; let result = state
.add_forward(source, target, target_prefix, src_filter)
.await;
respond.send(result).ok(); respond.send(result).ok();
} }
PortForwardCommand::Gc { respond } => { PortForwardCommand::Gc { respond } => {
@@ -284,6 +358,7 @@ impl PortForwardController {
source: SocketAddrV4, source: SocketAddrV4,
target: SocketAddrV4, target: SocketAddrV4,
target_prefix: u8, target_prefix: u8,
src_filter: Option<SourceFilter>,
) -> Result<Arc<()>, Error> { ) -> Result<Arc<()>, Error> {
let (send, recv) = oneshot::channel(); let (send, recv) = oneshot::channel();
self.req self.req
@@ -291,6 +366,7 @@ impl PortForwardController {
source, source,
target, target,
target_prefix, target_prefix,
src_filter,
respond: send, respond: send,
}) })
.map_err(err_has_exited)?; .map_err(err_has_exited)?;
@@ -321,14 +397,14 @@ struct InterfaceForwardRequest {
external: u16, external: u16,
target: SocketAddrV4, target: SocketAddrV4,
target_prefix: u8, target_prefix: u8,
filter: DynInterfaceFilter, reqs: ForwardRequirements,
rc: Arc<()>, rc: Arc<()>,
} }
#[derive(Clone)] #[derive(Clone)]
struct InterfaceForwardEntry { struct InterfaceForwardEntry {
external: u16, external: u16,
filter: BTreeMap<DynInterfaceFilter, (SocketAddrV4, u8, Weak<()>)>, targets: BTreeMap<ForwardRequirements, (SocketAddrV4, u8, Weak<()>)>,
// Maps source SocketAddr -> strong reference for the forward created in PortForwardController // Maps source SocketAddr -> strong reference for the forward created in PortForwardController
forwards: BTreeMap<SocketAddrV4, Arc<()>>, forwards: BTreeMap<SocketAddrV4, Arc<()>>,
} }
@@ -346,7 +422,7 @@ impl InterfaceForwardEntry {
fn new(external: u16) -> Self { fn new(external: u16) -> Self {
Self { Self {
external, external,
filter: BTreeMap::new(), targets: BTreeMap::new(),
forwards: BTreeMap::new(), forwards: BTreeMap::new(),
} }
} }
@@ -358,28 +434,50 @@ impl InterfaceForwardEntry {
) -> Result<(), Error> { ) -> Result<(), Error> {
let mut keep = BTreeSet::<SocketAddrV4>::new(); let mut keep = BTreeSet::<SocketAddrV4>::new();
for (iface, info) in ip_info.iter() { for (gw_id, info) in ip_info.iter() {
if let Some((target, target_prefix)) = self if let Some(ip_info) = &info.ip_info {
.filter for subnet in ip_info.subnets.iter() {
.iter() if let IpAddr::V4(ip) = subnet.addr() {
.filter(|(_, (_, _, rc))| rc.strong_count() > 0) let addr = SocketAddrV4::new(ip, self.external);
.find(|(filter, _)| filter.filter(iface, info)) if keep.contains(&addr) {
.map(|(_, (target, target_prefix, _))| (*target, *target_prefix)) continue;
{
if let Some(ip_info) = &info.ip_info {
for addr in ip_info.subnets.iter().filter_map(|net| {
if let IpAddr::V4(ip) = net.addr() {
Some(SocketAddrV4::new(ip, self.external))
} else {
None
} }
}) {
keep.insert(addr); for (reqs, (target, target_prefix, rc)) in self.targets.iter() {
if !self.forwards.contains_key(&addr) { if rc.strong_count() == 0 {
let rc = port_forward continue;
.add_forward(addr, target, target_prefix) }
if !reqs.secure && !info.secure() {
continue;
}
let src_filter =
if reqs.public_gateways.contains(gw_id) {
None
} else if reqs.private_ips.contains(&IpAddr::V4(ip)) {
let excluded = ip_info
.lan_ip
.iter()
.filter_map(|ip| match ip {
IpAddr::V4(v4) => Some(v4.to_string()),
_ => None,
})
.collect::<Vec<_>>()
.join(",");
Some(SourceFilter {
subnet: subnet.trunc().to_string(),
excluded,
})
} else {
continue;
};
keep.insert(addr);
let fwd_rc = port_forward
.add_forward(addr, *target, *target_prefix, src_filter)
.await?; .await?;
self.forwards.insert(addr, rc); self.forwards.insert(addr, fwd_rc);
break;
} }
} }
} }
@@ -398,7 +496,7 @@ impl InterfaceForwardEntry {
external, external,
target, target,
target_prefix, target_prefix,
filter, reqs,
mut rc, mut rc,
}: InterfaceForwardRequest, }: InterfaceForwardRequest,
ip_info: &OrdMap<GatewayId, NetworkInterfaceInfo>, ip_info: &OrdMap<GatewayId, NetworkInterfaceInfo>,
@@ -412,8 +510,8 @@ impl InterfaceForwardEntry {
} }
let entry = self let entry = self
.filter .targets
.entry(filter) .entry(reqs)
.or_insert_with(|| (target, target_prefix, Arc::downgrade(&rc))); .or_insert_with(|| (target, target_prefix, Arc::downgrade(&rc)));
if entry.0 != target { if entry.0 != target {
entry.0 = target; entry.0 = target;
@@ -436,7 +534,7 @@ impl InterfaceForwardEntry {
ip_info: &OrdMap<GatewayId, NetworkInterfaceInfo>, ip_info: &OrdMap<GatewayId, NetworkInterfaceInfo>,
port_forward: &PortForwardController, port_forward: &PortForwardController,
) -> Result<(), Error> { ) -> Result<(), Error> {
self.filter.retain(|_, (_, _, rc)| rc.strong_count() > 0); self.targets.retain(|_, (_, _, rc)| rc.strong_count() > 0);
self.update(ip_info, port_forward).await self.update(ip_info, port_forward).await
} }
@@ -495,7 +593,7 @@ pub struct ForwardTable(pub BTreeMap<u16, ForwardTarget>);
pub struct ForwardTarget { pub struct ForwardTarget {
pub target: SocketAddrV4, pub target: SocketAddrV4,
pub target_prefix: u8, pub target_prefix: u8,
pub filter: String, pub reqs: String,
} }
impl From<&InterfaceForwardState> for ForwardTable { impl From<&InterfaceForwardState> for ForwardTable {
@@ -506,16 +604,16 @@ impl From<&InterfaceForwardState> for ForwardTable {
.iter() .iter()
.flat_map(|entry| { .flat_map(|entry| {
entry entry
.filter .targets
.iter() .iter()
.filter(|(_, (_, _, rc))| rc.strong_count() > 0) .filter(|(_, (_, _, rc))| rc.strong_count() > 0)
.map(|(filter, (target, target_prefix, _))| { .map(|(reqs, (target, target_prefix, _))| {
( (
entry.external, entry.external,
ForwardTarget { ForwardTarget {
target: *target, target: *target,
target_prefix: *target_prefix, target_prefix: *target_prefix,
filter: format!("{:#?}", filter), reqs: format!("{reqs}"),
}, },
) )
}) })
@@ -534,16 +632,6 @@ enum InterfaceForwardCommand {
DumpTable(oneshot::Sender<ForwardTable>), DumpTable(oneshot::Sender<ForwardTable>),
} }
#[test]
fn test() {
use crate::net::gateway::SecureFilter;
assert_ne!(
false.into_dyn(),
SecureFilter { secure: false }.into_dyn().into_dyn()
);
}
pub struct InterfacePortForwardController { pub struct InterfacePortForwardController {
req: mpsc::UnboundedSender<InterfaceForwardCommand>, req: mpsc::UnboundedSender<InterfaceForwardCommand>,
_thread: NonDetachingJoinHandle<()>, _thread: NonDetachingJoinHandle<()>,
@@ -593,7 +681,7 @@ impl InterfacePortForwardController {
pub async fn add( pub async fn add(
&self, &self,
external: u16, external: u16,
filter: DynInterfaceFilter, reqs: ForwardRequirements,
target: SocketAddrV4, target: SocketAddrV4,
target_prefix: u8, target_prefix: u8,
) -> Result<Arc<()>, Error> { ) -> Result<Arc<()>, Error> {
@@ -605,7 +693,7 @@ impl InterfacePortForwardController {
external, external,
target, target,
target_prefix, target_prefix,
filter, reqs,
rc, rc,
}, },
send, send,
@@ -637,15 +725,21 @@ async fn forward(
source: SocketAddrV4, source: SocketAddrV4,
target: SocketAddrV4, target: SocketAddrV4,
target_prefix: u8, target_prefix: u8,
src_filter: Option<&SourceFilter>,
) -> Result<(), Error> { ) -> Result<(), Error> {
Command::new("/usr/lib/startos/scripts/forward-port") let mut cmd = Command::new("/usr/lib/startos/scripts/forward-port");
.env("sip", source.ip().to_string()) cmd.env("sip", source.ip().to_string())
.env("dip", target.ip().to_string()) .env("dip", target.ip().to_string())
.env("dprefix", target_prefix.to_string()) .env("dprefix", target_prefix.to_string())
.env("sport", source.port().to_string()) .env("sport", source.port().to_string())
.env("dport", target.port().to_string()) .env("dport", target.port().to_string());
.invoke(ErrorKind::Network) if let Some(filter) = src_filter {
.await?; cmd.env("src_subnet", &filter.subnet);
if !filter.excluded.is_empty() {
cmd.env("excluded_src", &filter.excluded);
}
}
cmd.invoke(ErrorKind::Network).await?;
Ok(()) Ok(())
} }
@@ -653,15 +747,21 @@ async fn unforward(
source: SocketAddrV4, source: SocketAddrV4,
target: SocketAddrV4, target: SocketAddrV4,
target_prefix: u8, target_prefix: u8,
src_filter: Option<&SourceFilter>,
) -> Result<(), Error> { ) -> Result<(), Error> {
Command::new("/usr/lib/startos/scripts/forward-port") let mut cmd = Command::new("/usr/lib/startos/scripts/forward-port");
.env("UNDO", "1") cmd.env("UNDO", "1")
.env("sip", source.ip().to_string()) .env("sip", source.ip().to_string())
.env("dip", target.ip().to_string()) .env("dip", target.ip().to_string())
.env("dprefix", target_prefix.to_string()) .env("dprefix", target_prefix.to_string())
.env("sport", source.port().to_string()) .env("sport", source.port().to_string())
.env("dport", target.port().to_string()) .env("dport", target.port().to_string());
.invoke(ErrorKind::Network) if let Some(filter) = src_filter {
.await?; cmd.env("src_subnet", &filter.subnet);
if !filter.excluded.is_empty() {
cmd.env("excluded_src", &filter.excluded);
}
}
cmd.invoke(ErrorKind::Network).await?;
Ok(()) Ok(())
} }

View File

@@ -1,14 +1,11 @@
use std::any::Any;
use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt;
use std::future::Future; use std::future::Future;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV6}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::{Arc, Weak}; use std::sync::Arc;
use std::task::{Poll, ready}; use std::task::Poll;
use std::time::Duration; use std::time::Duration;
use clap::Parser; use clap::Parser;
use futures::future::Either;
use futures::{FutureExt, Stream, StreamExt, TryStreamExt}; use futures::{FutureExt, Stream, StreamExt, TryStreamExt};
use imbl::{OrdMap, OrdSet}; use imbl::{OrdMap, OrdSet};
use imbl_value::InternedString; use imbl_value::InternedString;
@@ -36,15 +33,14 @@ use crate::db::model::Database;
use crate::db::model::public::{IpInfo, NetworkInterfaceInfo, NetworkInterfaceType}; use crate::db::model::public::{IpInfo, NetworkInterfaceInfo, NetworkInterfaceType};
use crate::net::forward::START9_BRIDGE_IFACE; use crate::net::forward::START9_BRIDGE_IFACE;
use crate::net::gateway::device::DeviceProxy; use crate::net::gateway::device::DeviceProxy;
use crate::net::utils::ipv6_is_link_local; use crate::net::web_server::{Accept, AcceptStream, MetadataVisitor, TcpMetadata};
use crate::net::web_server::{Accept, AcceptStream, Acceptor, MetadataVisitor};
use crate::prelude::*; use crate::prelude::*;
use crate::util::Invoke; use crate::util::Invoke;
use crate::util::collections::OrdMapIterMut; use crate::util::collections::OrdMapIterMut;
use crate::util::future::{NonDetachingJoinHandle, Until}; use crate::util::future::{NonDetachingJoinHandle, Until};
use crate::util::io::open_file; use crate::util::io::open_file;
use crate::util::serde::{HandlerExtSerde, display_serializable}; use crate::util::serde::{HandlerExtSerde, display_serializable};
use crate::util::sync::{SyncMutex, Watch}; use crate::util::sync::Watch;
pub fn gateway_api<C: Context>() -> ParentHandler<C> { pub fn gateway_api<C: Context>() -> ParentHandler<C> {
ParentHandler::new() ParentHandler::new()
@@ -758,13 +754,14 @@ async fn watch_ip(
write_to.send_if_modified( write_to.send_if_modified(
|m: &mut OrdMap<GatewayId, NetworkInterfaceInfo>| { |m: &mut OrdMap<GatewayId, NetworkInterfaceInfo>| {
let (name, public, secure, prev_wan_ip) = m let (name, public, secure, gateway_type, prev_wan_ip) = m
.get(&iface) .get(&iface)
.map_or((None, None, None, None), |i| { .map_or((None, None, None, None, None), |i| {
( (
i.name.clone(), i.name.clone(),
i.public, i.public,
i.secure, i.secure,
i.gateway_type,
i.ip_info i.ip_info
.as_ref() .as_ref()
.and_then(|i| i.wan_ip), .and_then(|i| i.wan_ip),
@@ -779,6 +776,7 @@ async fn watch_ip(
public, public,
secure, secure,
ip_info: Some(ip_info.clone()), ip_info: Some(ip_info.clone()),
gateway_type,
}, },
) )
.filter(|old| &old.ip_info == &Some(ip_info)) .filter(|old| &old.ip_info == &Some(ip_info))
@@ -838,7 +836,6 @@ pub struct NetworkInterfaceWatcher {
activated: Watch<BTreeMap<GatewayId, bool>>, activated: Watch<BTreeMap<GatewayId, bool>>,
ip_info: Watch<OrdMap<GatewayId, NetworkInterfaceInfo>>, ip_info: Watch<OrdMap<GatewayId, NetworkInterfaceInfo>>,
_watcher: NonDetachingJoinHandle<()>, _watcher: NonDetachingJoinHandle<()>,
listeners: SyncMutex<BTreeMap<u16, Weak<()>>>,
} }
impl NetworkInterfaceWatcher { impl NetworkInterfaceWatcher {
pub fn new( pub fn new(
@@ -858,7 +855,6 @@ impl NetworkInterfaceWatcher {
watcher(ip_info, activated).await watcher(ip_info, activated).await
}) })
.into(), .into(),
listeners: SyncMutex::new(BTreeMap::new()),
} }
} }
@@ -885,51 +881,6 @@ impl NetworkInterfaceWatcher {
pub fn ip_info(&self) -> OrdMap<GatewayId, NetworkInterfaceInfo> { pub fn ip_info(&self) -> OrdMap<GatewayId, NetworkInterfaceInfo> {
self.ip_info.read() self.ip_info.read()
} }
pub fn bind<B: Bind>(&self, bind: B, port: u16) -> Result<NetworkInterfaceListener<B>, Error> {
let arc = Arc::new(());
self.listeners.mutate(|l| {
if l.get(&port).filter(|w| w.strong_count() > 0).is_some() {
return Err(Error::new(
std::io::Error::from_raw_os_error(libc::EADDRINUSE),
ErrorKind::Network,
));
}
l.insert(port, Arc::downgrade(&arc));
Ok(())
})?;
let ip_info = self.ip_info.clone_unseen();
Ok(NetworkInterfaceListener {
_arc: arc,
ip_info,
listeners: ListenerMap::new(bind, port),
})
}
pub fn upgrade_listener<B: Bind>(
&self,
SelfContainedNetworkInterfaceListener {
mut listener,
..
}: SelfContainedNetworkInterfaceListener<B>,
) -> Result<NetworkInterfaceListener<B>, Error> {
let port = listener.listeners.port;
let arc = &listener._arc;
self.listeners.mutate(|l| {
if l.get(&port).filter(|w| w.strong_count() > 0).is_some() {
return Err(Error::new(
std::io::Error::from_raw_os_error(libc::EADDRINUSE),
ErrorKind::Network,
));
}
l.insert(port, Arc::downgrade(arc));
Ok(())
})?;
let ip_info = self.ip_info.clone_unseen();
ip_info.mark_changed();
listener.change_ip_info_source(ip_info);
Ok(listener)
}
} }
pub struct NetworkInterfaceController { pub struct NetworkInterfaceController {
@@ -1237,235 +1188,6 @@ impl NetworkInterfaceController {
} }
} }
pub trait InterfaceFilter: Any + Clone + std::fmt::Debug + Eq + Ord + Send + Sync {
fn filter(&self, id: &GatewayId, info: &NetworkInterfaceInfo) -> bool;
fn eq(&self, other: &dyn Any) -> bool {
Some(self) == other.downcast_ref::<Self>()
}
fn cmp(&self, other: &dyn Any) -> std::cmp::Ordering {
match (self as &dyn Any).type_id().cmp(&other.type_id()) {
std::cmp::Ordering::Equal => {
std::cmp::Ord::cmp(self, other.downcast_ref::<Self>().unwrap())
}
ord => ord,
}
}
fn as_any(&self) -> &dyn Any {
self
}
fn into_dyn(self) -> DynInterfaceFilter {
DynInterfaceFilter::new(self)
}
}
impl InterfaceFilter for bool {
fn filter(&self, _: &GatewayId, _: &NetworkInterfaceInfo) -> bool {
*self
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct TypeFilter(pub NetworkInterfaceType);
impl InterfaceFilter for TypeFilter {
fn filter(&self, _: &GatewayId, info: &NetworkInterfaceInfo) -> bool {
info.ip_info.as_ref().and_then(|i| i.device_type) == Some(self.0)
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct IdFilter(pub GatewayId);
impl InterfaceFilter for IdFilter {
fn filter(&self, id: &GatewayId, _: &NetworkInterfaceInfo) -> bool {
id == &self.0
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct PublicFilter {
pub public: bool,
}
impl InterfaceFilter for PublicFilter {
fn filter(&self, _: &GatewayId, info: &NetworkInterfaceInfo) -> bool {
self.public == info.public()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct SecureFilter {
pub secure: bool,
}
impl InterfaceFilter for SecureFilter {
fn filter(&self, _: &GatewayId, info: &NetworkInterfaceInfo) -> bool {
self.secure || info.secure()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct AndFilter<A, B>(pub A, pub B);
impl<A: InterfaceFilter, B: InterfaceFilter> InterfaceFilter for AndFilter<A, B> {
fn filter(&self, id: &GatewayId, info: &NetworkInterfaceInfo) -> bool {
self.0.filter(id, info) && self.1.filter(id, info)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct OrFilter<A, B>(pub A, pub B);
impl<A: InterfaceFilter, B: InterfaceFilter> InterfaceFilter for OrFilter<A, B> {
fn filter(&self, id: &GatewayId, info: &NetworkInterfaceInfo) -> bool {
self.0.filter(id, info) || self.1.filter(id, info)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct AnyFilter(pub BTreeSet<DynInterfaceFilter>);
impl InterfaceFilter for AnyFilter {
fn filter(&self, id: &GatewayId, info: &NetworkInterfaceInfo) -> bool {
self.0.iter().any(|f| InterfaceFilter::filter(f, id, info))
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct AllFilter(pub BTreeSet<DynInterfaceFilter>);
impl InterfaceFilter for AllFilter {
fn filter(&self, id: &GatewayId, info: &NetworkInterfaceInfo) -> bool {
self.0.iter().all(|f| InterfaceFilter::filter(f, id, info))
}
}
pub trait DynInterfaceFilterT: std::fmt::Debug + Any + Send + Sync {
fn filter(&self, id: &GatewayId, info: &NetworkInterfaceInfo) -> bool;
fn eq(&self, other: &dyn Any) -> bool;
fn cmp(&self, other: &dyn Any) -> std::cmp::Ordering;
fn as_any(&self) -> &dyn Any;
}
impl<T: InterfaceFilter> DynInterfaceFilterT for T {
fn filter(&self, id: &GatewayId, info: &NetworkInterfaceInfo) -> bool {
InterfaceFilter::filter(self, id, info)
}
fn eq(&self, other: &dyn Any) -> bool {
InterfaceFilter::eq(self, other)
}
fn cmp(&self, other: &dyn Any) -> std::cmp::Ordering {
InterfaceFilter::cmp(self, other)
}
fn as_any(&self) -> &dyn Any {
InterfaceFilter::as_any(self)
}
}
#[test]
fn test_interface_filter_eq() {
let dyn_t = true.into_dyn();
assert!(DynInterfaceFilterT::eq(
&dyn_t,
DynInterfaceFilterT::as_any(&true),
))
}
#[derive(Clone, Debug)]
pub struct DynInterfaceFilter(Arc<dyn DynInterfaceFilterT>);
impl InterfaceFilter for DynInterfaceFilter {
fn filter(&self, id: &GatewayId, info: &NetworkInterfaceInfo) -> bool {
self.0.filter(id, info)
}
fn eq(&self, other: &dyn Any) -> bool {
self.0.eq(other)
}
fn cmp(&self, other: &dyn Any) -> std::cmp::Ordering {
self.0.cmp(other)
}
fn as_any(&self) -> &dyn Any {
self.0.as_any()
}
fn into_dyn(self) -> DynInterfaceFilter {
self
}
}
impl DynInterfaceFilter {
fn new<T: InterfaceFilter>(value: T) -> Self {
Self(Arc::new(value))
}
}
impl PartialEq for DynInterfaceFilter {
fn eq(&self, other: &Self) -> bool {
DynInterfaceFilterT::eq(&*self.0, DynInterfaceFilterT::as_any(&*other.0))
}
}
impl Eq for DynInterfaceFilter {}
impl PartialOrd for DynInterfaceFilter {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.0.cmp(other.0.as_any()))
}
}
impl Ord for DynInterfaceFilter {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.0.cmp(other.0.as_any())
}
}
struct ListenerMap<B: Bind> {
prev_filter: DynInterfaceFilter,
bind: B,
port: u16,
listeners: BTreeMap<SocketAddr, B::Accept>,
}
impl<B: Bind> ListenerMap<B> {
fn new(bind: B, port: u16) -> Self {
Self {
prev_filter: false.into_dyn(),
bind,
port,
listeners: BTreeMap::new(),
}
}
#[instrument(skip(self))]
fn update(
&mut self,
ip_info: &OrdMap<GatewayId, NetworkInterfaceInfo>,
filter: &impl InterfaceFilter,
) -> Result<(), Error> {
let mut keep = BTreeSet::<SocketAddr>::new();
for (_, info) in ip_info
.iter()
.filter(|(id, info)| filter.filter(*id, *info))
{
if let Some(ip_info) = &info.ip_info {
for ipnet in &ip_info.subnets {
let addr = match ipnet.addr() {
IpAddr::V6(ip6) => SocketAddrV6::new(
ip6,
self.port,
0,
if ipv6_is_link_local(ip6) {
ip_info.scope_id
} else {
0
},
)
.into(),
ip => SocketAddr::new(ip, self.port),
};
keep.insert(addr);
if !self.listeners.contains_key(&addr) {
self.listeners.insert(addr, self.bind.bind(addr)?);
}
}
}
}
self.listeners.retain(|key, _| keep.contains(key));
self.prev_filter = filter.clone().into_dyn();
Ok(())
}
fn poll_accept(
&mut self,
cx: &mut std::task::Context<'_>,
) -> Poll<Result<(SocketAddr, <B::Accept as Accept>::Metadata, AcceptStream), Error>> {
let (metadata, stream) = ready!(self.listeners.poll_accept(cx)?);
Poll::Ready(Ok((metadata.key, metadata.inner, stream)))
}
}
pub fn lookup_info_by_addr( pub fn lookup_info_by_addr(
ip_info: &OrdMap<GatewayId, NetworkInterfaceInfo>, ip_info: &OrdMap<GatewayId, NetworkInterfaceInfo>,
addr: SocketAddr, addr: SocketAddr,
@@ -1477,28 +1199,6 @@ pub fn lookup_info_by_addr(
}) })
} }
pub trait Bind {
type Accept: Accept;
fn bind(&mut self, addr: SocketAddr) -> Result<Self::Accept, Error>;
}
#[derive(Clone, Copy, Default)]
pub struct BindTcp;
impl Bind for BindTcp {
type Accept = TcpListener;
fn bind(&mut self, addr: SocketAddr) -> Result<Self::Accept, Error> {
TcpListener::from_std(
mio::net::TcpListener::bind(addr)
.with_kind(ErrorKind::Network)?
.into(),
)
.with_kind(ErrorKind::Network)
}
}
pub trait FromGatewayInfo {
fn from_gateway_info(id: &GatewayId, info: &NetworkInterfaceInfo) -> Self;
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct GatewayInfo { pub struct GatewayInfo {
pub id: GatewayId, pub id: GatewayId,
@@ -1509,212 +1209,88 @@ impl<V: MetadataVisitor> Visit<V> for GatewayInfo {
visitor.visit(self) visitor.visit(self)
} }
} }
impl FromGatewayInfo for GatewayInfo {
fn from_gateway_info(id: &GatewayId, info: &NetworkInterfaceInfo) -> Self {
Self {
id: id.clone(),
info: info.clone(),
}
}
}
pub struct NetworkInterfaceListener<B: Bind = BindTcp> { /// Metadata for connections accepted by WildcardListener or VHostBindListener.
pub ip_info: Watch<OrdMap<GatewayId, NetworkInterfaceInfo>>, #[derive(Clone, Debug, VisitFields)]
listeners: ListenerMap<B>, pub struct NetworkInterfaceListenerAcceptMetadata {
_arc: Arc<()>, pub inner: TcpMetadata,
}
impl<B: Bind> NetworkInterfaceListener<B> {
pub(super) fn new(
mut ip_info: Watch<OrdMap<GatewayId, NetworkInterfaceInfo>>,
bind: B,
port: u16,
) -> Self {
ip_info.mark_unseen();
Self {
ip_info,
listeners: ListenerMap::new(bind, port),
_arc: Arc::new(()),
}
}
pub fn port(&self) -> u16 {
self.listeners.port
}
#[cfg_attr(feature = "unstable", inline(never))]
pub fn poll_accept<M: FromGatewayInfo>(
&mut self,
cx: &mut std::task::Context<'_>,
filter: &impl InterfaceFilter,
) -> Poll<Result<(M, <B::Accept as Accept>::Metadata, AcceptStream), Error>> {
while self.ip_info.poll_changed(cx).is_ready()
|| !DynInterfaceFilterT::eq(&self.listeners.prev_filter, filter.as_any())
{
self.ip_info
.peek_and_mark_seen(|ip_info| self.listeners.update(ip_info, filter))?;
}
let (addr, inner, stream) = ready!(self.listeners.poll_accept(cx)?);
Poll::Ready(Ok((
self.ip_info
.peek(|ip_info| {
lookup_info_by_addr(ip_info, addr)
.map(|(id, info)| M::from_gateway_info(id, info))
})
.or_not_found(lazy_format!("gateway for {addr}"))?,
inner,
stream,
)))
}
pub fn change_ip_info_source(
&mut self,
mut ip_info: Watch<OrdMap<GatewayId, NetworkInterfaceInfo>>,
) {
ip_info.mark_unseen();
self.ip_info = ip_info;
}
pub async fn accept<M: FromGatewayInfo>(
&mut self,
filter: &impl InterfaceFilter,
) -> Result<(M, <B::Accept as Accept>::Metadata, AcceptStream), Error> {
futures::future::poll_fn(|cx| self.poll_accept(cx, filter)).await
}
pub fn check_filter(&self) -> impl FnOnce(SocketAddr, &DynInterfaceFilter) -> bool + 'static {
let ip_info = self.ip_info.clone();
move |addr, filter| {
ip_info.peek(|i| {
lookup_info_by_addr(i, addr).map_or(false, |(id, info)| {
InterfaceFilter::filter(filter, id, info)
})
})
}
}
}
#[derive(VisitFields)]
pub struct NetworkInterfaceListenerAcceptMetadata<B: Bind> {
pub inner: <B::Accept as Accept>::Metadata,
pub info: GatewayInfo, pub info: GatewayInfo,
} }
impl<B: Bind> fmt::Debug for NetworkInterfaceListenerAcceptMetadata<B> { impl<V: MetadataVisitor> Visit<V> for NetworkInterfaceListenerAcceptMetadata {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("NetworkInterfaceListenerAcceptMetadata")
.field("inner", &self.inner)
.field("info", &self.info)
.finish()
}
}
impl<B: Bind> Clone for NetworkInterfaceListenerAcceptMetadata<B>
where
<B::Accept as Accept>::Metadata: Clone,
{
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
info: self.info.clone(),
}
}
}
impl<B, V> Visit<V> for NetworkInterfaceListenerAcceptMetadata<B>
where
B: Bind,
<B::Accept as Accept>::Metadata: Visit<V> + Clone + Send + Sync + 'static,
V: MetadataVisitor,
{
fn visit(&self, visitor: &mut V) -> V::Result { fn visit(&self, visitor: &mut V) -> V::Result {
self.visit_fields(visitor).collect() self.visit_fields(visitor).collect()
} }
} }
impl<B: Bind> Accept for NetworkInterfaceListener<B> { /// A simple TCP listener on 0.0.0.0:port that looks up GatewayInfo from the
type Metadata = NetworkInterfaceListenerAcceptMetadata<B>; /// connection's local address on each accepted connection.
pub struct WildcardListener {
listener: TcpListener,
ip_info: Watch<OrdMap<GatewayId, NetworkInterfaceInfo>>,
/// Handle to the self-contained watcher task started in `new()`.
/// Dropped (and thus aborted) when `set_ip_info` replaces the ip_info source.
_watcher: Option<NonDetachingJoinHandle<()>>,
}
impl WildcardListener {
pub fn new(port: u16) -> Result<Self, Error> {
let listener = TcpListener::from_std(
mio::net::TcpListener::bind(SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), port))
.with_kind(ErrorKind::Network)?
.into(),
)
.with_kind(ErrorKind::Network)?;
let ip_info = Watch::new(OrdMap::new());
let watcher_handle =
tokio::spawn(watcher(ip_info.clone(), Watch::new(BTreeMap::new()))).into();
Ok(Self {
listener,
ip_info,
_watcher: Some(watcher_handle),
})
}
/// Replace the ip_info source with the one from the NetworkInterfaceController.
/// Aborts the self-contained watcher task.
pub fn set_ip_info(&mut self, ip_info: Watch<OrdMap<GatewayId, NetworkInterfaceInfo>>) {
self.ip_info = ip_info;
self._watcher = None;
}
}
impl Accept for WildcardListener {
type Metadata = NetworkInterfaceListenerAcceptMetadata;
fn poll_accept( fn poll_accept(
&mut self, &mut self,
cx: &mut std::task::Context<'_>, cx: &mut std::task::Context<'_>,
) -> Poll<Result<(Self::Metadata, AcceptStream), Error>> { ) -> Poll<Result<(Self::Metadata, AcceptStream), Error>> {
NetworkInterfaceListener::poll_accept(self, cx, &true).map(|res| { if let Poll::Ready((stream, peer_addr)) = TcpListener::poll_accept(&self.listener, cx)? {
res.map(|(info, inner, stream)| { if let Err(e) = socket2::SockRef::from(&stream).set_keepalive(true) {
( tracing::error!("Failed to set tcp keepalive: {e}");
NetworkInterfaceListenerAcceptMetadata { inner, info }, tracing::debug!("{e:?}");
stream, }
) let local_addr = stream.local_addr()?;
}) let info = self
}) .ip_info
} .peek(|ip_info| {
} lookup_info_by_addr(ip_info, local_addr).map(|(id, info)| GatewayInfo {
id: id.clone(),
pub struct SelfContainedNetworkInterfaceListener<B: Bind = BindTcp> { info: info.clone(),
_watch_thread: NonDetachingJoinHandle<()>, })
listener: NetworkInterfaceListener<B>, })
} .unwrap_or_else(|| GatewayInfo {
impl<B: Bind> SelfContainedNetworkInterfaceListener<B> { id: InternedString::from_static("").into(),
pub fn bind(bind: B, port: u16) -> Self { info: NetworkInterfaceInfo::default(),
let ip_info = Watch::new(OrdMap::new()); });
let _watch_thread = return Poll::Ready(Ok((
tokio::spawn(watcher(ip_info.clone(), Watch::new(BTreeMap::new()))).into(); NetworkInterfaceListenerAcceptMetadata {
Self { inner: TcpMetadata {
_watch_thread, local_addr,
listener: NetworkInterfaceListener::new(ip_info, bind, port), peer_addr,
},
info,
},
Box::pin(stream),
)));
} }
Poll::Pending
} }
} }
impl<B: Bind> Accept for SelfContainedNetworkInterfaceListener<B> {
type Metadata = <NetworkInterfaceListener<B> as Accept>::Metadata;
fn poll_accept(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(Self::Metadata, AcceptStream), Error>> {
Accept::poll_accept(&mut self.listener, cx)
}
}
pub type UpgradableListener<B = BindTcp> =
Option<Either<SelfContainedNetworkInterfaceListener<B>, NetworkInterfaceListener<B>>>;
impl<B> Acceptor<UpgradableListener<B>>
where
B: Bind + Send + Sync + 'static,
B::Accept: Send + Sync,
{
pub fn bind_upgradable(listener: SelfContainedNetworkInterfaceListener<B>) -> Self {
Self::new(Some(Either::Left(listener)))
}
}
#[test]
fn test_filter() {
use crate::net::host::binding::NetInfo;
let wg1 = "wg1".parse::<GatewayId>().unwrap();
assert!(!InterfaceFilter::filter(
&AndFilter(
NetInfo {
private_disabled: [wg1.clone()].into_iter().collect(),
public_enabled: Default::default(),
assigned_port: None,
assigned_ssl_port: None,
},
AndFilter(IdFilter(wg1.clone()), PublicFilter { public: false }),
)
.into_dyn(),
&wg1,
&NetworkInterfaceInfo {
name: None,
public: None,
secure: None,
ip_info: Some(Arc::new(IpInfo {
name: "".into(),
scope_id: 3,
device_type: Some(NetworkInterfaceType::Wireguard),
subnets: ["10.59.0.2/24".parse::<IpNet>().unwrap()]
.into_iter()
.collect(),
lan_ip: Default::default(),
wan_ip: None,
ntp_servers: Default::default(),
dns_servers: Default::default(),
})),
},
));
}

View File

@@ -12,23 +12,15 @@ use crate::context::{CliContext, RpcContext};
use crate::db::model::DatabaseModel; use crate::db::model::DatabaseModel;
use crate::net::acme::AcmeProvider; use crate::net::acme::AcmeProvider;
use crate::net::host::{HostApiKind, all_hosts}; use crate::net::host::{HostApiKind, all_hosts};
use crate::net::tor::OnionAddress;
use crate::prelude::*; use crate::prelude::*;
use crate::util::serde::{HandlerExtSerde, display_serializable}; use crate::util::serde::{HandlerExtSerde, display_serializable};
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "camelCase")]
#[serde(rename_all_fields = "camelCase")] pub struct HostAddress {
#[serde(tag = "kind")] pub address: InternedString,
pub enum HostAddress { pub public: Option<PublicDomainConfig>,
Onion { pub private: bool,
address: OnionAddress,
},
Domain {
address: InternedString,
public: Option<PublicDomainConfig>,
private: bool,
},
} }
#[derive(Debug, Clone, Deserialize, Serialize, TS)] #[derive(Debug, Clone, Deserialize, Serialize, TS)]
@@ -38,18 +30,7 @@ pub struct PublicDomainConfig {
} }
fn handle_duplicates(db: &mut DatabaseModel) -> Result<(), Error> { fn handle_duplicates(db: &mut DatabaseModel) -> Result<(), Error> {
let mut onions = BTreeSet::<OnionAddress>::new();
let mut domains = BTreeSet::<InternedString>::new(); let mut domains = BTreeSet::<InternedString>::new();
let check_onion = |onions: &mut BTreeSet<OnionAddress>, onion: OnionAddress| {
if onions.contains(&onion) {
return Err(Error::new(
eyre!("onion address {onion} is already in use"),
ErrorKind::InvalidRequest,
));
}
onions.insert(onion);
Ok(())
};
let check_domain = |domains: &mut BTreeSet<InternedString>, domain: InternedString| { let check_domain = |domains: &mut BTreeSet<InternedString>, domain: InternedString| {
if domains.contains(&domain) { if domains.contains(&domain) {
return Err(Error::new( return Err(Error::new(
@@ -68,9 +49,6 @@ fn handle_duplicates(db: &mut DatabaseModel) -> Result<(), Error> {
not_in_use.push(host); not_in_use.push(host);
continue; continue;
} }
for onion in host.as_onions().de()? {
check_onion(&mut onions, onion)?;
}
let public = host.as_public_domains().keys()?; let public = host.as_public_domains().keys()?;
for domain in &public { for domain in &public {
check_domain(&mut domains, domain.clone())?; check_domain(&mut domains, domain.clone())?;
@@ -82,16 +60,11 @@ fn handle_duplicates(db: &mut DatabaseModel) -> Result<(), Error> {
} }
} }
for host in not_in_use { for host in not_in_use {
host.as_onions_mut()
.mutate(|o| Ok(o.retain(|o| !onions.contains(o))))?;
host.as_public_domains_mut() host.as_public_domains_mut()
.mutate(|d| Ok(d.retain(|d, _| !domains.contains(d))))?; .mutate(|d| Ok(d.retain(|d, _| !domains.contains(d))))?;
host.as_private_domains_mut() host.as_private_domains_mut()
.mutate(|d| Ok(d.retain(|d| !domains.contains(d))))?; .mutate(|d| Ok(d.retain(|d| !domains.contains(d))))?;
for onion in host.as_onions().de()? {
check_onion(&mut onions, onion)?;
}
let public = host.as_public_domains().keys()?; let public = host.as_public_domains().keys()?;
for domain in &public { for domain in &public {
check_domain(&mut domains, domain.clone())?; check_domain(&mut domains, domain.clone())?;
@@ -159,29 +132,6 @@ pub fn address_api<C: Context, Kind: HostApiKind>()
) )
.with_inherited(Kind::inheritance), .with_inherited(Kind::inheritance),
) )
.subcommand(
"onion",
ParentHandler::<C, Empty, Kind::Inheritance>::new()
.subcommand(
"add",
from_fn_async(add_onion::<Kind>)
.with_metadata("sync_db", Value::Bool(true))
.with_inherited(|_, a| a)
.no_display()
.with_about("about.add-address-to-host")
.with_call_remote::<CliContext>(),
)
.subcommand(
"remove",
from_fn_async(remove_onion::<Kind>)
.with_metadata("sync_db", Value::Bool(true))
.with_inherited(|_, a| a)
.no_display()
.with_about("about.remove-address-from-host")
.with_call_remote::<CliContext>(),
)
.with_inherited(Kind::inheritance),
)
.subcommand( .subcommand(
"list", "list",
from_fn_async(list_addresses::<Kind>) from_fn_async(list_addresses::<Kind>)
@@ -197,32 +147,18 @@ pub fn address_api<C: Context, Kind: HostApiKind>()
let mut table = Table::new(); let mut table = Table::new();
table.add_row(row![bc => "ADDRESS", "PUBLIC", "ACME PROVIDER"]); table.add_row(row![bc => "ADDRESS", "PUBLIC", "ACME PROVIDER"]);
for address in &res { for entry in &res {
match address { if let Some(PublicDomainConfig { gateway, acme }) = &entry.public {
HostAddress::Onion { address } => { table.add_row(row![
table.add_row(row![address, true, "N/A"]); entry.address,
} &format!(
HostAddress::Domain { "{} ({gateway})",
address, if entry.private { "YES" } else { "ONLY" }
public: Some(PublicDomainConfig { gateway, acme }), ),
private, acme.as_ref().map(|a| a.0.as_str()).unwrap_or("NONE")
} => { ]);
table.add_row(row![ } else {
address, table.add_row(row![entry.address, &format!("NO"), "N/A"]);
&format!(
"{} ({gateway})",
if *private { "YES" } else { "ONLY" }
),
acme.as_ref().map(|a| a.0.as_str()).unwrap_or("NONE")
]);
}
HostAddress::Domain {
address,
public: None,
..
} => {
table.add_row(row![address, &format!("NO"), "N/A"]);
}
} }
} }
@@ -351,55 +287,6 @@ pub async fn remove_private_domain<Kind: HostApiKind>(
Ok(()) Ok(())
} }
#[derive(Deserialize, Serialize, Parser)]
pub struct OnionParams {
#[arg(help = "help.arg.onion-address")]
pub onion: String,
}
pub async fn add_onion<Kind: HostApiKind>(
ctx: RpcContext,
OnionParams { onion }: OnionParams,
inheritance: Kind::Inheritance,
) -> Result<(), Error> {
let onion = onion.parse::<OnionAddress>()?;
ctx.db
.mutate(|db| {
db.as_private().as_key_store().as_onion().get_key(&onion)?;
Kind::host_for(&inheritance, db)?
.as_onions_mut()
.mutate(|a| Ok(a.insert(onion)))?;
handle_duplicates(db)
})
.await
.result?;
Kind::sync_host(&ctx, inheritance).await?;
Ok(())
}
pub async fn remove_onion<Kind: HostApiKind>(
ctx: RpcContext,
OnionParams { onion }: OnionParams,
inheritance: Kind::Inheritance,
) -> Result<(), Error> {
let onion = onion.parse::<OnionAddress>()?;
ctx.db
.mutate(|db| {
Kind::host_for(&inheritance, db)?
.as_onions_mut()
.mutate(|a| Ok(a.remove(&onion)))
})
.await
.result?;
Kind::sync_host(&ctx, inheritance).await?;
Ok(())
}
pub async fn list_addresses<Kind: HostApiKind>( pub async fn list_addresses<Kind: HostApiKind>(
ctx: RpcContext, ctx: RpcContext,
_: Empty, _: Empty,

View File

@@ -3,21 +3,20 @@ use std::str::FromStr;
use clap::Parser; use clap::Parser;
use clap::builder::ValueParserFactory; use clap::builder::ValueParserFactory;
use imbl::OrdSet;
use rpc_toolkit::{Context, Empty, HandlerArgs, HandlerExt, ParentHandler, from_fn_async}; use rpc_toolkit::{Context, Empty, HandlerArgs, HandlerExt, ParentHandler, from_fn_async};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ts_rs::TS; use ts_rs::TS;
use crate::context::{CliContext, RpcContext}; use crate::context::{CliContext, RpcContext};
use crate::db::model::public::NetworkInterfaceInfo; use crate::db::prelude::Map;
use crate::net::forward::AvailablePorts; use crate::net::forward::AvailablePorts;
use crate::net::gateway::InterfaceFilter;
use crate::net::host::HostApiKind; use crate::net::host::HostApiKind;
use crate::net::service_interface::HostnameInfo;
use crate::net::vhost::AlpnInfo; use crate::net::vhost::AlpnInfo;
use crate::prelude::*; use crate::prelude::*;
use crate::util::FromStrParser; use crate::util::FromStrParser;
use crate::util::serde::{HandlerExtSerde, display_serializable}; use crate::util::serde::{HandlerExtSerde, display_serializable};
use crate::{GatewayId, HostId}; use crate::HostId;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS)]
#[ts(export)] #[ts(export)]
@@ -45,25 +44,82 @@ impl FromStr for BindId {
} }
} }
#[derive(Debug, Deserialize, Serialize, TS)] #[derive(Debug, Default, Clone, Deserialize, Serialize, TS, HasModel)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export)] #[ts(export)]
#[model = "Model<Self>"]
pub struct DerivedAddressInfo {
/// User-controlled: private addresses the user has disabled
pub private_disabled: BTreeSet<HostnameInfo>,
/// User-controlled: public addresses the user has enabled
pub public_enabled: BTreeSet<HostnameInfo>,
/// COMPUTED: NetServiceData::update — all possible addresses for this binding
pub possible: BTreeSet<HostnameInfo>,
}
impl DerivedAddressInfo {
/// Returns addresses that are currently enabled.
/// Private addresses are enabled by default (disabled if in private_disabled).
/// Public addresses are disabled by default (enabled if in public_enabled).
pub fn enabled(&self) -> BTreeSet<&HostnameInfo> {
self.possible
.iter()
.filter(|h| {
if h.public {
self.public_enabled.contains(h)
} else {
!self.private_disabled.contains(h)
}
})
.collect()
}
}
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
#[model = "Model<Self>"]
#[ts(export)]
pub struct Bindings(pub BTreeMap<u16, BindInfo>);
impl Map for Bindings {
type Key = u16;
type Value = BindInfo;
fn key_str(key: &Self::Key) -> Result<impl AsRef<str>, Error> {
Self::key_string(key)
}
fn key_string(key: &Self::Key) -> Result<InternedString, Error> {
Ok(InternedString::from_display(key))
}
}
impl std::ops::Deref for Bindings {
type Target = BTreeMap<u16, BindInfo>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::ops::DerefMut for Bindings {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
#[derive(Debug, Deserialize, Serialize, HasModel, TS)]
#[serde(rename_all = "camelCase")]
#[model = "Model<Self>"]
#[ts(export)]
pub struct BindInfo { pub struct BindInfo {
pub enabled: bool, pub enabled: bool,
pub options: BindOptions, pub options: BindOptions,
pub net: NetInfo, pub net: NetInfo,
pub addresses: DerivedAddressInfo,
} }
#[derive(Clone, Debug, Deserialize, Serialize, TS, PartialEq, Eq, PartialOrd, Ord)] #[derive(Clone, Debug, Deserialize, Serialize, TS, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export)] #[ts(export)]
pub struct NetInfo { pub struct NetInfo {
#[ts(as = "BTreeSet::<GatewayId>")]
#[serde(default)]
pub private_disabled: OrdSet<GatewayId>,
#[ts(as = "BTreeSet::<GatewayId>")]
#[serde(default)]
pub public_enabled: OrdSet<GatewayId>,
pub assigned_port: Option<u16>, pub assigned_port: Option<u16>,
pub assigned_ssl_port: Option<u16>, pub assigned_ssl_port: Option<u16>,
} }
@@ -71,25 +127,28 @@ impl BindInfo {
pub fn new(available_ports: &mut AvailablePorts, options: BindOptions) -> Result<Self, Error> { pub fn new(available_ports: &mut AvailablePorts, options: BindOptions) -> Result<Self, Error> {
let mut assigned_port = None; let mut assigned_port = None;
let mut assigned_ssl_port = None; let mut assigned_ssl_port = None;
if options.add_ssl.is_some() { if let Some(ssl) = &options.add_ssl {
assigned_ssl_port = Some(available_ports.alloc()?); assigned_ssl_port = available_ports
.try_alloc(ssl.preferred_external_port, true)
.or_else(|| Some(available_ports.alloc(true).ok()?));
} }
if options if options
.secure .secure
.map_or(true, |s| !(s.ssl && options.add_ssl.is_some())) .map_or(true, |s| !(s.ssl && options.add_ssl.is_some()))
{ {
assigned_port = Some(available_ports.alloc()?); assigned_port = available_ports
.try_alloc(options.preferred_external_port, false)
.or_else(|| Some(available_ports.alloc(false).ok()?));
} }
Ok(Self { Ok(Self {
enabled: true, enabled: true,
options, options,
net: NetInfo { net: NetInfo {
private_disabled: OrdSet::new(),
public_enabled: OrdSet::new(),
assigned_port, assigned_port,
assigned_ssl_port, assigned_ssl_port,
}, },
addresses: DerivedAddressInfo::default(),
}) })
} }
pub fn update( pub fn update(
@@ -97,7 +156,11 @@ impl BindInfo {
available_ports: &mut AvailablePorts, available_ports: &mut AvailablePorts,
options: BindOptions, options: BindOptions,
) -> Result<Self, Error> { ) -> Result<Self, Error> {
let Self { net: mut lan, .. } = self; let Self {
net: mut lan,
addresses,
..
} = self;
if options if options
.secure .secure
.map_or(true, |s| !(s.ssl && options.add_ssl.is_some())) .map_or(true, |s| !(s.ssl && options.add_ssl.is_some()))
@@ -105,19 +168,26 @@ impl BindInfo {
{ {
lan.assigned_port = if let Some(port) = lan.assigned_port.take() { lan.assigned_port = if let Some(port) = lan.assigned_port.take() {
Some(port) Some(port)
} else if let Some(port) =
available_ports.try_alloc(options.preferred_external_port, false)
{
Some(port)
} else { } else {
Some(available_ports.alloc()?) Some(available_ports.alloc(false)?)
}; };
} else { } else {
if let Some(port) = lan.assigned_port.take() { if let Some(port) = lan.assigned_port.take() {
available_ports.free([port]); available_ports.free([port]);
} }
} }
if options.add_ssl.is_some() { if let Some(ssl) = &options.add_ssl {
lan.assigned_ssl_port = if let Some(port) = lan.assigned_ssl_port.take() { lan.assigned_ssl_port = if let Some(port) = lan.assigned_ssl_port.take() {
Some(port) Some(port)
} else if let Some(port) = available_ports.try_alloc(ssl.preferred_external_port, true)
{
Some(port)
} else { } else {
Some(available_ports.alloc()?) Some(available_ports.alloc(true)?)
}; };
} else { } else {
if let Some(port) = lan.assigned_ssl_port.take() { if let Some(port) = lan.assigned_ssl_port.take() {
@@ -128,22 +198,17 @@ impl BindInfo {
enabled: true, enabled: true,
options, options,
net: lan, net: lan,
addresses: DerivedAddressInfo {
private_disabled: addresses.private_disabled,
public_enabled: addresses.public_enabled,
possible: BTreeSet::new(),
},
}) })
} }
pub fn disable(&mut self) { pub fn disable(&mut self) {
self.enabled = false; self.enabled = false;
} }
} }
impl InterfaceFilter for NetInfo {
fn filter(&self, id: &GatewayId, info: &NetworkInterfaceInfo) -> bool {
info.ip_info.is_some()
&& if info.public() {
self.public_enabled.contains(id)
} else {
!self.private_disabled.contains(id)
}
}
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, TS)] #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, TS)]
#[ts(export)] #[ts(export)]
@@ -188,7 +253,7 @@ pub fn binding<C: Context, Kind: HostApiKind>()
let mut table = Table::new(); let mut table = Table::new();
table.add_row(row![bc => "INTERNAL PORT", "ENABLED", "EXTERNAL PORT", "EXTERNAL SSL PORT"]); table.add_row(row![bc => "INTERNAL PORT", "ENABLED", "EXTERNAL PORT", "EXTERNAL SSL PORT"]);
for (internal, info) in res { for (internal, info) in res.iter() {
table.add_row(row![ table.add_row(row![
internal, internal,
info.enabled, info.enabled,
@@ -213,12 +278,12 @@ pub fn binding<C: Context, Kind: HostApiKind>()
.with_call_remote::<CliContext>(), .with_call_remote::<CliContext>(),
) )
.subcommand( .subcommand(
"set-gateway-enabled", "set-address-enabled",
from_fn_async(set_gateway_enabled::<Kind>) from_fn_async(set_address_enabled::<Kind>)
.with_metadata("sync_db", Value::Bool(true)) .with_metadata("sync_db", Value::Bool(true))
.with_inherited(Kind::inheritance) .with_inherited(Kind::inheritance)
.no_display() .no_display()
.with_about("about.set-gateway-enabled-for-binding") .with_about("about.set-address-enabled-for-binding")
.with_call_remote::<CliContext>(), .with_call_remote::<CliContext>(),
) )
} }
@@ -227,7 +292,7 @@ pub async fn list_bindings<Kind: HostApiKind>(
ctx: RpcContext, ctx: RpcContext,
_: Empty, _: Empty,
inheritance: Kind::Inheritance, inheritance: Kind::Inheritance,
) -> Result<BTreeMap<u16, BindInfo>, Error> { ) -> Result<Bindings, Error> {
Kind::host_for(&inheritance, &mut ctx.db.peek().await)? Kind::host_for(&inheritance, &mut ctx.db.peek().await)?
.as_bindings() .as_bindings()
.de() .de()
@@ -236,50 +301,44 @@ pub async fn list_bindings<Kind: HostApiKind>(
#[derive(Deserialize, Serialize, Parser, TS)] #[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export)] #[ts(export)]
pub struct BindingGatewaySetEnabledParams { pub struct BindingSetAddressEnabledParams {
#[arg(help = "help.arg.internal-port")] #[arg(help = "help.arg.internal-port")]
internal_port: u16, internal_port: u16,
#[arg(help = "help.arg.gateway-id")] #[arg(long, help = "help.arg.address")]
gateway: GatewayId, address: String,
#[arg(long, help = "help.arg.binding-enabled")] #[arg(long, help = "help.arg.binding-enabled")]
enabled: Option<bool>, enabled: Option<bool>,
} }
pub async fn set_gateway_enabled<Kind: HostApiKind>( pub async fn set_address_enabled<Kind: HostApiKind>(
ctx: RpcContext, ctx: RpcContext,
BindingGatewaySetEnabledParams { BindingSetAddressEnabledParams {
internal_port, internal_port,
gateway, address,
enabled, enabled,
}: BindingGatewaySetEnabledParams, }: BindingSetAddressEnabledParams,
inheritance: Kind::Inheritance, inheritance: Kind::Inheritance,
) -> Result<(), Error> { ) -> Result<(), Error> {
let enabled = enabled.unwrap_or(true); let enabled = enabled.unwrap_or(true);
let gateway_public = ctx let address: HostnameInfo =
.net_controller serde_json::from_str(&address).with_kind(ErrorKind::Deserialization)?;
.net_iface
.watcher
.ip_info()
.get(&gateway)
.or_not_found(&gateway)?
.public();
ctx.db ctx.db
.mutate(|db| { .mutate(|db| {
Kind::host_for(&inheritance, db)? Kind::host_for(&inheritance, db)?
.as_bindings_mut() .as_bindings_mut()
.mutate(|b| { .mutate(|b| {
let net = &mut b.get_mut(&internal_port).or_not_found(internal_port)?.net; let bind = b.get_mut(&internal_port).or_not_found(internal_port)?;
if gateway_public { if address.public {
if enabled { if enabled {
net.public_enabled.insert(gateway); bind.addresses.public_enabled.insert(address.clone());
} else { } else {
net.public_enabled.remove(&gateway); bind.addresses.public_enabled.remove(&address);
} }
} else { } else {
if enabled { if enabled {
net.private_disabled.remove(&gateway); bind.addresses.private_disabled.remove(&address);
} else { } else {
net.private_disabled.insert(gateway); bind.addresses.private_disabled.insert(address.clone());
} }
} }
Ok(()) Ok(())

View File

@@ -13,9 +13,7 @@ use crate::context::RpcContext;
use crate::db::model::DatabaseModel; use crate::db::model::DatabaseModel;
use crate::net::forward::AvailablePorts; use crate::net::forward::AvailablePorts;
use crate::net::host::address::{HostAddress, PublicDomainConfig, address_api}; use crate::net::host::address::{HostAddress, PublicDomainConfig, address_api};
use crate::net::host::binding::{BindInfo, BindOptions, binding}; use crate::net::host::binding::{BindInfo, BindOptions, Bindings, binding};
use crate::net::service_interface::HostnameInfo;
use crate::net::tor::OnionAddress;
use crate::prelude::*; use crate::prelude::*;
use crate::{HostId, PackageId}; use crate::{HostId, PackageId};
@@ -27,13 +25,9 @@ pub mod binding;
#[model = "Model<Self>"] #[model = "Model<Self>"]
#[ts(export)] #[ts(export)]
pub struct Host { pub struct Host {
pub bindings: BTreeMap<u16, BindInfo>, pub bindings: Bindings,
#[ts(type = "string[]")]
pub onions: BTreeSet<OnionAddress>,
pub public_domains: BTreeMap<InternedString, PublicDomainConfig>, pub public_domains: BTreeMap<InternedString, PublicDomainConfig>,
pub private_domains: BTreeSet<InternedString>, pub private_domains: BTreeSet<InternedString>,
/// COMPUTED: NetService::update
pub hostname_info: BTreeMap<u16, Vec<HostnameInfo>>, // internal port -> Hostnames
} }
impl AsRef<Host> for Host { impl AsRef<Host> for Host {
@@ -46,24 +40,18 @@ impl Host {
Self::default() Self::default()
} }
pub fn addresses<'a>(&'a self) -> impl Iterator<Item = HostAddress> + 'a { pub fn addresses<'a>(&'a self) -> impl Iterator<Item = HostAddress> + 'a {
self.onions self.public_domains
.iter() .iter()
.cloned() .map(|(address, config)| HostAddress {
.map(|address| HostAddress::Onion { address }) address: address.clone(),
.chain( public: Some(config.clone()),
self.public_domains private: self.private_domains.contains(address),
.iter() })
.map(|(address, config)| HostAddress::Domain {
address: address.clone(),
public: Some(config.clone()),
private: self.private_domains.contains(address),
}),
)
.chain( .chain(
self.private_domains self.private_domains
.iter() .iter()
.filter(|a| !self.public_domains.contains_key(*a)) .filter(|a| !self.public_domains.contains_key(*a))
.map(|address| HostAddress::Domain { .map(|address| HostAddress {
address: address.clone(), address: address.clone(),
public: None, public: None,
private: true, private: true,
@@ -112,22 +100,7 @@ pub fn host_for<'a>(
.as_hosts_mut(), .as_hosts_mut(),
) )
} }
let tor_key = if host_info(db, package_id)?.as_idx(host_id).is_none() { host_info(db, package_id)?.upsert(host_id, || Ok(Host::new()))
Some(
db.as_private_mut()
.as_key_store_mut()
.as_onion_mut()
.new_key()?,
)
} else {
None
};
host_info(db, package_id)?.upsert(host_id, || {
let mut h = Host::new();
h.onions
.insert(tor_key.or_not_found("generated tor key")?.onion_address());
Ok(h)
})
} }
pub fn all_hosts(db: &mut DatabaseModel) -> impl Iterator<Item = Result<&mut Model<Host>, Error>> { pub fn all_hosts(db: &mut DatabaseModel) -> impl Iterator<Item = Result<&mut Model<Host>, Error>> {

View File

@@ -3,28 +3,21 @@ use serde::{Deserialize, Serialize};
use crate::account::AccountInfo; use crate::account::AccountInfo;
use crate::net::acme::AcmeCertStore; use crate::net::acme::AcmeCertStore;
use crate::net::ssl::CertStore; use crate::net::ssl::CertStore;
use crate::net::tor::OnionStore;
use crate::prelude::*; use crate::prelude::*;
#[derive(Debug, Deserialize, Serialize, HasModel)] #[derive(Debug, Deserialize, Serialize, HasModel)]
#[model = "Model<Self>"] #[model = "Model<Self>"]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct KeyStore { pub struct KeyStore {
pub onion: OnionStore,
pub local_certs: CertStore, pub local_certs: CertStore,
#[serde(default)] #[serde(default)]
pub acme: AcmeCertStore, pub acme: AcmeCertStore,
} }
impl KeyStore { impl KeyStore {
pub fn new(account: &AccountInfo) -> Result<Self, Error> { pub fn new(account: &AccountInfo) -> Result<Self, Error> {
let mut res = Self { Ok(Self {
onion: OnionStore::new(),
local_certs: CertStore::new(account)?, local_certs: CertStore::new(account)?,
acme: AcmeCertStore::new(), acme: AcmeCertStore::new(),
}; })
for tor_key in account.tor_keys.iter().cloned() {
res.onion.insert(tor_key);
}
Ok(res)
} }
} }

View File

@@ -14,7 +14,6 @@ pub mod socks;
pub mod ssl; pub mod ssl;
pub mod static_server; pub mod static_server;
pub mod tls; pub mod tls;
pub mod tor;
pub mod tunnel; pub mod tunnel;
pub mod utils; pub mod utils;
pub mod vhost; pub mod vhost;
@@ -23,7 +22,6 @@ pub mod wifi;
pub fn net_api<C: Context>() -> ParentHandler<C> { pub fn net_api<C: Context>() -> ParentHandler<C> {
ParentHandler::new() ParentHandler::new()
.subcommand("tor", tor::tor_api::<C>().with_about("about.tor-commands"))
.subcommand( .subcommand(
"acme", "acme",
acme::acme_api::<C>().with_about("about.setup-acme-certificate"), acme::acme_api::<C>().with_about("about.setup-acme-certificate"),

View File

@@ -3,7 +3,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::{Arc, Weak}; use std::sync::{Arc, Weak};
use color_eyre::eyre::eyre; use color_eyre::eyre::eyre;
use imbl::{OrdMap, vector}; use imbl::vector;
use imbl_value::InternedString; use imbl_value::InternedString;
use ipnet::IpNet; use ipnet::IpNet;
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -16,17 +16,15 @@ use crate::db::model::public::NetworkInterfaceType;
use crate::error::ErrorCollection; use crate::error::ErrorCollection;
use crate::hostname::Hostname; use crate::hostname::Hostname;
use crate::net::dns::DnsController; use crate::net::dns::DnsController;
use crate::net::forward::{InterfacePortForwardController, START9_BRIDGE_IFACE, add_iptables_rule}; use crate::net::forward::{
use crate::net::gateway::{ ForwardRequirements, InterfacePortForwardController, START9_BRIDGE_IFACE, add_iptables_rule,
AndFilter, DynInterfaceFilter, IdFilter, InterfaceFilter, NetworkInterfaceController, OrFilter,
PublicFilter, SecureFilter, TypeFilter,
}; };
use crate::net::gateway::NetworkInterfaceController;
use crate::net::host::address::HostAddress; use crate::net::host::address::HostAddress;
use crate::net::host::binding::{AddSslOptions, BindId, BindOptions}; use crate::net::host::binding::{AddSslOptions, BindId, BindOptions};
use crate::net::host::{Host, Hosts, host_for}; use crate::net::host::{Host, Hosts, host_for};
use crate::net::service_interface::{GatewayInfo, HostnameInfo, IpHostname, OnionHostname}; use crate::net::service_interface::{GatewayInfo, HostnameInfo, IpHostname};
use crate::net::socks::SocksController; use crate::net::socks::SocksController;
use crate::net::tor::{OnionAddress, TorController, TorSecretKey};
use crate::net::utils::ipv6_is_local; use crate::net::utils::ipv6_is_local;
use crate::net::vhost::{AlpnInfo, DynVHostTarget, ProxyTarget, VHostController}; use crate::net::vhost::{AlpnInfo, DynVHostTarget, ProxyTarget, VHostController};
use crate::prelude::*; use crate::prelude::*;
@@ -36,7 +34,6 @@ use crate::{GatewayId, HOST_IP, HostId, OptionExt, PackageId};
pub struct NetController { pub struct NetController {
pub(crate) db: TypedPatchDb<Database>, pub(crate) db: TypedPatchDb<Database>,
pub(super) tor: TorController,
pub(super) vhost: VHostController, pub(super) vhost: VHostController,
pub(super) tls_client_config: Arc<TlsClientConfig>, pub(super) tls_client_config: Arc<TlsClientConfig>,
pub(crate) net_iface: Arc<NetworkInterfaceController>, pub(crate) net_iface: Arc<NetworkInterfaceController>,
@@ -54,8 +51,7 @@ impl NetController {
socks_listen: SocketAddr, socks_listen: SocketAddr,
) -> Result<Self, Error> { ) -> Result<Self, Error> {
let net_iface = Arc::new(NetworkInterfaceController::new(db.clone())); let net_iface = Arc::new(NetworkInterfaceController::new(db.clone()));
let tor = TorController::new()?; let socks = SocksController::new(socks_listen)?;
let socks = SocksController::new(socks_listen, tor.clone())?;
let crypto_provider = Arc::new(tokio_rustls::rustls::crypto::ring::default_provider()); let crypto_provider = Arc::new(tokio_rustls::rustls::crypto::ring::default_provider());
let tls_client_config = Arc::new(crate::net::tls::client_config( let tls_client_config = Arc::new(crate::net::tls::client_config(
crypto_provider.clone(), crypto_provider.clone(),
@@ -87,7 +83,6 @@ impl NetController {
.await?; .await?;
Ok(Self { Ok(Self {
db: db.clone(), db: db.clone(),
tor,
vhost: VHostController::new(db.clone(), net_iface.clone(), crypto_provider), vhost: VHostController::new(db.clone(), net_iface.clone(), crypto_provider),
tls_client_config, tls_client_config,
dns: DnsController::init(db, &net_iface.watcher).await?, dns: DnsController::init(db, &net_iface.watcher).await?,
@@ -165,10 +160,9 @@ impl NetController {
#[derive(Default, Debug)] #[derive(Default, Debug)]
struct HostBinds { struct HostBinds {
forwards: BTreeMap<u16, (SocketAddrV4, DynInterfaceFilter, Arc<()>)>, forwards: BTreeMap<u16, (SocketAddrV4, ForwardRequirements, Arc<()>)>,
vhosts: BTreeMap<(Option<InternedString>, u16), (ProxyTarget, Arc<()>)>, vhosts: BTreeMap<(Option<InternedString>, u16), (ProxyTarget, Arc<()>)>,
private_dns: BTreeMap<InternedString, Arc<()>>, private_dns: BTreeMap<InternedString, Arc<()>>,
tor: BTreeMap<OnionAddress, (OrdMap<u16, SocketAddr>, Vec<Arc<()>>)>,
} }
pub struct NetServiceData { pub struct NetServiceData {
@@ -207,7 +201,7 @@ impl NetServiceData {
.as_entries_mut()? .as_entries_mut()?
{ {
host.as_bindings_mut().mutate(|b| { host.as_bindings_mut().mutate(|b| {
for (internal_port, info) in b { for (internal_port, info) in b.iter_mut() {
if !except.contains(&BindId { if !except.contains(&BindId {
id: host_id.clone(), id: host_id.clone(),
internal_port: *internal_port, internal_port: *internal_port,
@@ -238,7 +232,7 @@ impl NetServiceData {
.as_network_mut() .as_network_mut()
.as_host_mut(); .as_host_mut();
host.as_bindings_mut().mutate(|b| { host.as_bindings_mut().mutate(|b| {
for (internal_port, info) in b { for (internal_port, info) in b.iter_mut() {
if !except.contains(&BindId { if !except.contains(&BindId {
id: HostId::default(), id: HostId::default(),
internal_port: *internal_port, internal_port: *internal_port,
@@ -256,425 +250,295 @@ impl NetServiceData {
} }
} }
async fn update(&mut self, ctrl: &NetController, id: HostId, host: Host) -> Result<(), Error> { async fn update(
let mut forwards: BTreeMap<u16, (SocketAddrV4, DynInterfaceFilter)> = BTreeMap::new(); &mut self,
ctrl: &NetController,
id: HostId,
mut host: Host,
) -> Result<(), Error> {
let mut forwards: BTreeMap<u16, (SocketAddrV4, ForwardRequirements)> = BTreeMap::new();
let mut vhosts: BTreeMap<(Option<InternedString>, u16), ProxyTarget> = BTreeMap::new(); let mut vhosts: BTreeMap<(Option<InternedString>, u16), ProxyTarget> = BTreeMap::new();
let mut private_dns: BTreeSet<InternedString> = BTreeSet::new(); let mut private_dns: BTreeSet<InternedString> = BTreeSet::new();
let mut tor: BTreeMap<OnionAddress, (TorSecretKey, OrdMap<u16, SocketAddr>)> =
BTreeMap::new();
let mut hostname_info: BTreeMap<u16, Vec<HostnameInfo>> = BTreeMap::new();
let binds = self.binds.entry(id.clone()).or_default(); let binds = self.binds.entry(id.clone()).or_default();
let peek = ctrl.db.peek().await; let peek = ctrl.db.peek().await;
// LAN
let server_info = peek.as_public().as_server_info(); let server_info = peek.as_public().as_server_info();
let net_ifaces = ctrl.net_iface.watcher.ip_info(); let net_ifaces = ctrl.net_iface.watcher.ip_info();
let hostname = server_info.as_hostname().de()?; let hostname = server_info.as_hostname().de()?;
for (port, bind) in &host.bindings { let host_addresses: Vec<_> = host.addresses().collect();
// Collect private DNS entries (domains without public config)
for HostAddress {
address, public, ..
} in &host_addresses
{
if public.is_none() {
private_dns.insert(address.clone());
}
}
// ── Phase 1: Compute possible addresses ──
for (_port, bind) in host.bindings.iter_mut() {
if !bind.enabled { if !bind.enabled {
continue; continue;
} }
if bind.net.assigned_port.is_some() || bind.net.assigned_ssl_port.is_some() { if bind.net.assigned_port.is_none() && bind.net.assigned_ssl_port.is_none() {
let mut hostnames = BTreeSet::new(); continue;
if let Some(ssl) = &bind.options.add_ssl { }
let external = bind
.net bind.addresses.possible.clear();
.assigned_ssl_port for (gateway_id, info) in net_ifaces
.or_not_found("assigned ssl port")?; .iter()
let addr = (self.ip, *port).into(); .filter(|(_, info)| {
let connect_ssl = if let Some(alpn) = ssl.alpn.clone() { info.ip_info.as_ref().map_or(false, |i| {
Err(alpn) !matches!(i.device_type, Some(NetworkInterfaceType::Bridge))
} else { })
if bind.options.secure.as_ref().map_or(false, |s| s.ssl) { })
Ok(()) .filter(|(_, info)| info.ip_info.is_some())
} else { {
Err(AlpnInfo::Reflect) let gateway = GatewayInfo {
} id: gateway_id.clone(),
}; name: info
for hostname in ctrl.server_hostnames.iter().cloned() { .name
vhosts.insert( .clone()
(hostname, external), .or_else(|| info.ip_info.as_ref().map(|i| i.name.clone()))
ProxyTarget { .unwrap_or_else(|| gateway_id.clone().into()),
filter: bind.net.clone().into_dyn(), public: info.public(),
acme: None, };
addr, let port = bind.net.assigned_port.filter(|_| {
add_x_forwarded_headers: ssl.add_x_forwarded_headers, bind.options.secure.map_or(false, |s| {
connect_ssl: connect_ssl !(s.ssl && bind.options.add_ssl.is_some()) || info.secure()
.clone() })
.map(|_| ctrl.tls_client_config.clone()), });
}, // .local addresses (private only, non-public, non-wireguard gateways)
); if !info.public()
} && info.ip_info.as_ref().map_or(false, |i| {
for address in host.addresses() { i.device_type != Some(NetworkInterfaceType::Wireguard)
match address {
HostAddress::Onion { address } => {
let hostname = InternedString::from_display(&address);
if hostnames.insert(hostname.clone()) {
vhosts.insert(
(Some(hostname), external),
ProxyTarget {
filter: OrFilter(
TypeFilter(NetworkInterfaceType::Loopback),
IdFilter(GatewayId::from(InternedString::from(
START9_BRIDGE_IFACE,
))),
)
.into_dyn(),
acme: None,
addr,
add_x_forwarded_headers: ssl.add_x_forwarded_headers,
connect_ssl: connect_ssl
.clone()
.map(|_| ctrl.tls_client_config.clone()),
},
); // TODO: wrap onion ssl stream directly in tor ctrl
}
}
HostAddress::Domain {
address,
public,
private,
} => {
if hostnames.insert(address.clone()) {
let address = Some(address.clone());
if ssl.preferred_external_port == 443 {
if let Some(public) = &public {
vhosts.insert(
(address.clone(), 5443),
ProxyTarget {
filter: AndFilter(
bind.net.clone(),
AndFilter(
IdFilter(public.gateway.clone()),
PublicFilter { public: false },
),
)
.into_dyn(),
acme: public.acme.clone(),
addr,
add_x_forwarded_headers: ssl
.add_x_forwarded_headers,
connect_ssl: connect_ssl
.clone()
.map(|_| ctrl.tls_client_config.clone()),
},
);
vhosts.insert(
(address.clone(), 443),
ProxyTarget {
filter: AndFilter(
bind.net.clone(),
if private {
OrFilter(
IdFilter(public.gateway.clone()),
PublicFilter { public: false },
)
.into_dyn()
} else {
AndFilter(
IdFilter(public.gateway.clone()),
PublicFilter { public: true },
)
.into_dyn()
},
)
.into_dyn(),
acme: public.acme.clone(),
addr,
add_x_forwarded_headers: ssl
.add_x_forwarded_headers,
connect_ssl: connect_ssl
.clone()
.map(|_| ctrl.tls_client_config.clone()),
},
);
} else {
vhosts.insert(
(address.clone(), 443),
ProxyTarget {
filter: AndFilter(
bind.net.clone(),
PublicFilter { public: false },
)
.into_dyn(),
acme: None,
addr,
add_x_forwarded_headers: ssl
.add_x_forwarded_headers,
connect_ssl: connect_ssl
.clone()
.map(|_| ctrl.tls_client_config.clone()),
},
);
}
} else {
if let Some(public) = public {
vhosts.insert(
(address.clone(), external),
ProxyTarget {
filter: AndFilter(
bind.net.clone(),
if private {
OrFilter(
IdFilter(public.gateway.clone()),
PublicFilter { public: false },
)
.into_dyn()
} else {
IdFilter(public.gateway.clone())
.into_dyn()
},
)
.into_dyn(),
acme: public.acme.clone(),
addr,
add_x_forwarded_headers: ssl
.add_x_forwarded_headers,
connect_ssl: connect_ssl
.clone()
.map(|_| ctrl.tls_client_config.clone()),
},
);
} else {
vhosts.insert(
(address.clone(), external),
ProxyTarget {
filter: AndFilter(
bind.net.clone(),
PublicFilter { public: false },
)
.into_dyn(),
acme: None,
addr,
add_x_forwarded_headers: ssl
.add_x_forwarded_headers,
connect_ssl: connect_ssl
.clone()
.map(|_| ctrl.tls_client_config.clone()),
},
);
}
}
}
}
}
}
}
if bind
.options
.secure
.map_or(true, |s| !(s.ssl && bind.options.add_ssl.is_some()))
{
let external = bind.net.assigned_port.or_not_found("assigned lan port")?;
forwards.insert(
external,
(
SocketAddrV4::new(self.ip, *port),
AndFilter(
SecureFilter {
secure: bind.options.secure.is_some(),
},
bind.net.clone(),
)
.into_dyn(),
),
);
}
let mut bind_hostname_info: Vec<HostnameInfo> =
hostname_info.remove(port).unwrap_or_default();
for (gateway_id, info) in net_ifaces
.iter()
.filter(|(_, info)| {
info.ip_info.as_ref().map_or(false, |i| {
!matches!(i.device_type, Some(NetworkInterfaceType::Bridge))
})
}) })
.filter(|(id, info)| bind.net.filter(id, info))
{ {
let gateway = GatewayInfo { bind.addresses.possible.insert(HostnameInfo {
id: gateway_id.clone(), gateway: gateway.clone(),
name: info public: false,
.name hostname: IpHostname::Local {
.clone() value: InternedString::from_display(&{
.or_else(|| info.ip_info.as_ref().map(|i| i.name.clone())) let hostname = &hostname;
.unwrap_or_else(|| gateway_id.clone().into()), lazy_format!("{hostname}.local")
public: info.public(), }),
}; port,
let port = bind.net.assigned_port.filter(|_| { ssl_port: bind.net.assigned_ssl_port,
bind.options.secure.map_or(false, |s| { },
!(s.ssl && bind.options.add_ssl.is_some()) || info.secure()
})
}); });
if !info.public() }
&& info.ip_info.as_ref().map_or(false, |i| { // Domain addresses
i.device_type != Some(NetworkInterfaceType::Wireguard) for HostAddress {
}) address,
{ public,
bind_hostname_info.push(HostnameInfo::Ip { private,
} in host_addresses.iter().cloned()
{
let private = private && !info.public();
let public =
public.as_ref().map_or(false, |p| &p.gateway == gateway_id);
if public || private {
let (domain_port, domain_ssl_port) = if bind
.options
.add_ssl
.as_ref()
.map_or(false, |ssl| ssl.preferred_external_port == 443)
{
(None, Some(443))
} else {
(port, bind.net.assigned_ssl_port)
};
bind.addresses.possible.insert(HostnameInfo {
gateway: gateway.clone(), gateway: gateway.clone(),
public: false, public,
hostname: IpHostname::Local { hostname: IpHostname::Domain {
value: InternedString::from_display(&{ value: address.clone(),
let hostname = &hostname; port: domain_port,
lazy_format!("{hostname}.local") ssl_port: domain_ssl_port,
}), },
});
}
}
// IP addresses
if let Some(ip_info) = &info.ip_info {
let public = info.public();
if let Some(wan_ip) = ip_info.wan_ip {
bind.addresses.possible.insert(HostnameInfo {
gateway: gateway.clone(),
public: true,
hostname: IpHostname::Ipv4 {
value: wan_ip,
port, port,
ssl_port: bind.net.assigned_ssl_port, ssl_port: bind.net.assigned_ssl_port,
}, },
}); });
} }
for address in host.addresses() { for ipnet in &ip_info.subnets {
if let HostAddress::Domain { match ipnet {
address, IpNet::V4(net) => {
public, if !public {
private, bind.addresses.possible.insert(HostnameInfo {
} = address
{
if public.is_none() {
private_dns.insert(address.clone());
}
let private = private && !info.public();
let public =
public.as_ref().map_or(false, |p| &p.gateway == gateway_id);
if public || private {
if bind
.options
.add_ssl
.as_ref()
.map_or(false, |ssl| ssl.preferred_external_port == 443)
{
bind_hostname_info.push(HostnameInfo::Ip {
gateway: gateway.clone(), gateway: gateway.clone(),
public, public,
hostname: IpHostname::Domain { hostname: IpHostname::Ipv4 {
value: address.clone(), value: net.addr(),
port: None,
ssl_port: Some(443),
},
});
} else {
bind_hostname_info.push(HostnameInfo::Ip {
gateway: gateway.clone(),
public,
hostname: IpHostname::Domain {
value: address.clone(),
port, port,
ssl_port: bind.net.assigned_ssl_port, ssl_port: bind.net.assigned_ssl_port,
}, },
}); });
} }
} }
IpNet::V6(net) => {
bind.addresses.possible.insert(HostnameInfo {
gateway: gateway.clone(),
public: public && !ipv6_is_local(net.addr()),
hostname: IpHostname::Ipv6 {
value: net.addr(),
scope_id: ip_info.scope_id,
port,
ssl_port: bind.net.assigned_ssl_port,
},
});
}
} }
} }
if let Some(ip_info) = &info.ip_info { }
let public = info.public(); }
if let Some(wan_ip) = ip_info.wan_ip { }
bind_hostname_info.push(HostnameInfo::Ip {
gateway: gateway.clone(), // ── Phase 2: Build controller entries from enabled addresses ──
public: true, for (port, bind) in host.bindings.iter() {
hostname: IpHostname::Ipv4 { if !bind.enabled {
value: wan_ip, continue;
port, }
ssl_port: bind.net.assigned_ssl_port, if bind.net.assigned_port.is_none() && bind.net.assigned_ssl_port.is_none() {
continue;
}
let enabled_addresses = bind.addresses.enabled();
let addr: SocketAddr = (self.ip, *port).into();
// SSL vhosts
if let Some(ssl) = &bind.options.add_ssl {
let connect_ssl = if let Some(alpn) = ssl.alpn.clone() {
Err(alpn)
} else if bind.options.secure.as_ref().map_or(false, |s| s.ssl) {
Ok(())
} else {
Err(AlpnInfo::Reflect)
};
if let Some(assigned_ssl_port) = bind.net.assigned_ssl_port {
// Collect private IPs from enabled private addresses' gateways
let server_private_ips: BTreeSet<IpAddr> = enabled_addresses
.iter()
.filter(|a| !a.public)
.filter_map(|a| {
net_ifaces
.get(&a.gateway.id)
.and_then(|info| info.ip_info.as_ref())
})
.flat_map(|ip_info| ip_info.subnets.iter().map(|s| s.addr()))
.collect();
// Server hostname vhosts (on assigned_ssl_port) — private only
if !server_private_ips.is_empty() {
for hostname in ctrl.server_hostnames.iter().cloned() {
vhosts.insert(
(hostname, assigned_ssl_port),
ProxyTarget {
public: BTreeSet::new(),
private: server_private_ips.clone(),
acme: None,
addr,
add_x_forwarded_headers: ssl.add_x_forwarded_headers,
connect_ssl: connect_ssl
.clone()
.map(|_| ctrl.tls_client_config.clone()),
}, },
}); );
} }
for ipnet in &ip_info.subnets { }
match ipnet { }
IpNet::V4(net) => {
if !public { // Domain vhosts: group by (domain, ssl_port), merge public/private sets
bind_hostname_info.push(HostnameInfo::Ip { for addr_info in &enabled_addresses {
gateway: gateway.clone(), if let IpHostname::Domain {
public, value: domain,
hostname: IpHostname::Ipv4 { ssl_port: Some(domain_ssl_port),
value: net.addr(), ..
port, } = &addr_info.hostname
ssl_port: bind.net.assigned_ssl_port, {
}, let key = (Some(domain.clone()), *domain_ssl_port);
}); let target = vhosts.entry(key).or_insert_with(|| ProxyTarget {
public: BTreeSet::new(),
private: BTreeSet::new(),
acme: host_addresses
.iter()
.find(|a| &a.address == domain)
.and_then(|a| a.public.as_ref())
.and_then(|p| p.acme.clone()),
addr,
add_x_forwarded_headers: ssl.add_x_forwarded_headers,
connect_ssl: connect_ssl
.clone()
.map(|_| ctrl.tls_client_config.clone()),
});
if addr_info.public {
target.public.insert(addr_info.gateway.id.clone());
} else {
// Add interface IPs for this gateway to private set
if let Some(info) = net_ifaces.get(&addr_info.gateway.id) {
if let Some(ip_info) = &info.ip_info {
for subnet in &ip_info.subnets {
target.private.insert(subnet.addr());
} }
} }
IpNet::V6(net) => {
bind_hostname_info.push(HostnameInfo::Ip {
gateway: gateway.clone(),
public: public && !ipv6_is_local(net.addr()),
hostname: IpHostname::Ipv6 {
value: net.addr(),
scope_id: ip_info.scope_id,
port,
ssl_port: bind.net.assigned_ssl_port,
},
});
}
} }
} }
} }
} }
hostname_info.insert(*port, bind_hostname_info);
} }
}
struct TorHostnamePorts { // Non-SSL forwards
non_ssl: Option<u16>, if bind
ssl: Option<u16>, .options
} .secure
let mut tor_hostname_ports = BTreeMap::<u16, TorHostnamePorts>::new(); .map_or(true, |s| !(s.ssl && bind.options.add_ssl.is_some()))
let mut tor_binds = OrdMap::<u16, SocketAddr>::new();
for (internal, info) in &host.bindings {
if !info.enabled {
continue;
}
tor_binds.insert(
info.options.preferred_external_port,
SocketAddr::from((self.ip, *internal)),
);
if let (Some(ssl), Some(ssl_internal)) =
(&info.options.add_ssl, info.net.assigned_ssl_port)
{ {
tor_binds.insert( let external = bind.net.assigned_port.or_not_found("assigned lan port")?;
ssl.preferred_external_port, let fwd_public: BTreeSet<GatewayId> = enabled_addresses
SocketAddr::from(([127, 0, 0, 1], ssl_internal)), .iter()
); .filter(|a| a.public)
tor_hostname_ports.insert( .map(|a| a.gateway.id.clone())
*internal, .collect();
TorHostnamePorts { let fwd_private: BTreeSet<IpAddr> = enabled_addresses
non_ssl: Some(info.options.preferred_external_port) .iter()
.filter(|p| *p != ssl.preferred_external_port), .filter(|a| !a.public)
ssl: Some(ssl.preferred_external_port), .filter_map(|a| {
}, net_ifaces
); .get(&a.gateway.id)
} else { .and_then(|i| i.ip_info.as_ref())
tor_hostname_ports.insert( })
*internal, .flat_map(|ip| ip.subnets.iter().map(|s| s.addr()))
TorHostnamePorts { .collect();
non_ssl: Some(info.options.preferred_external_port), forwards.insert(
ssl: None, external,
}, (
SocketAddrV4::new(self.ip, *port),
ForwardRequirements {
public_gateways: fwd_public,
private_ips: fwd_private,
secure: bind.options.secure.is_some(),
},
),
); );
} }
} }
for tor_addr in host.onions.iter() { // ── Phase 3: Reconcile ──
let key = peek
.as_private()
.as_key_store()
.as_onion()
.get_key(tor_addr)?;
tor.insert(key.onion_address(), (key, tor_binds.clone()));
for (internal, ports) in &tor_hostname_ports {
let mut bind_hostname_info = hostname_info.remove(internal).unwrap_or_default();
bind_hostname_info.push(HostnameInfo::Onion {
hostname: OnionHostname {
value: InternedString::from_display(tor_addr),
port: ports.non_ssl,
ssl_port: ports.ssl,
},
});
hostname_info.insert(*internal, bind_hostname_info);
}
}
let all = binds let all = binds
.forwards .forwards
.keys() .keys()
@@ -683,8 +547,8 @@ impl NetServiceData {
.collect::<BTreeSet<_>>(); .collect::<BTreeSet<_>>();
for external in all { for external in all {
let mut prev = binds.forwards.remove(&external); let mut prev = binds.forwards.remove(&external);
if let Some((internal, filter)) = forwards.remove(&external) { if let Some((internal, reqs)) = forwards.remove(&external) {
prev = prev.filter(|(i, f, _)| i == &internal && *f == filter); prev = prev.filter(|(i, r, _)| i == &internal && *r == reqs);
binds.forwards.insert( binds.forwards.insert(
external, external,
if let Some(prev) = prev { if let Some(prev) = prev {
@@ -692,11 +556,11 @@ impl NetServiceData {
} else { } else {
( (
internal, internal,
filter.clone(), reqs.clone(),
ctrl.forward ctrl.forward
.add( .add(
external, external,
filter, reqs,
internal, internal,
net_ifaces net_ifaces
.iter() .iter()
@@ -763,40 +627,18 @@ impl NetServiceData {
} }
ctrl.dns.gc_private_domains(&rm)?; ctrl.dns.gc_private_domains(&rm)?;
let all = binds
.tor
.keys()
.chain(tor.keys())
.cloned()
.collect::<BTreeSet<_>>();
for onion in all {
let mut prev = binds.tor.remove(&onion);
if let Some((key, tor_binds)) = tor.remove(&onion).filter(|(_, b)| !b.is_empty()) {
prev = prev.filter(|(b, _)| b == &tor_binds);
binds.tor.insert(
onion,
if let Some(prev) = prev {
prev
} else {
let service = ctrl.tor.service(key)?;
let rcs = service.proxy_all(tor_binds.iter().map(|(k, v)| (*k, *v)));
(tor_binds, rcs)
},
);
} else {
if let Some((_, rc)) = prev {
drop(rc);
ctrl.tor.gc(Some(onion)).await?;
}
}
}
let res = ctrl let res = ctrl
.db .db
.mutate(|db| { .mutate(|db| {
host_for(db, self.id.as_ref(), &id)? let bindings = host_for(db, self.id.as_ref(), &id)?.as_bindings_mut();
.as_hostname_info_mut() for (port, bind) in host.bindings.0 {
.ser(&hostname_info) if let Some(b) = bindings.as_idx_mut(&port) {
b.as_addresses_mut()
.as_possible_mut()
.ser(&bind.addresses.possible)?;
}
}
Ok(())
}) })
.await; .await;
res.result?; res.result?;

View File

@@ -6,31 +6,21 @@ use ts_rs::TS;
use crate::{GatewayId, HostId, ServiceInterfaceId}; use crate::{GatewayId, HostId, ServiceInterfaceId};
#[derive(Clone, Debug, Deserialize, Serialize, TS)] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)]
#[ts(export)] #[ts(export)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[serde(rename_all_fields = "camelCase")] pub struct HostnameInfo {
#[serde(tag = "kind")] pub gateway: GatewayInfo,
pub enum HostnameInfo { pub public: bool,
Ip { pub hostname: IpHostname,
gateway: GatewayInfo,
public: bool,
hostname: IpHostname,
},
Onion {
hostname: OnionHostname,
},
} }
impl HostnameInfo { impl HostnameInfo {
pub fn to_san_hostname(&self) -> InternedString { pub fn to_san_hostname(&self) -> InternedString {
match self { self.hostname.to_san_hostname()
Self::Ip { hostname, .. } => hostname.to_san_hostname(),
Self::Onion { hostname } => hostname.to_san_hostname(),
}
} }
} }
#[derive(Clone, Debug, Deserialize, Serialize, TS)] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)]
#[ts(export)] #[ts(export)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct GatewayInfo { pub struct GatewayInfo {
@@ -39,22 +29,7 @@ pub struct GatewayInfo {
pub public: bool, pub public: bool,
} }
#[derive(Clone, Debug, Deserialize, Serialize, TS)] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct OnionHostname {
#[ts(type = "string")]
pub value: InternedString,
pub port: Option<u16>,
pub ssl_port: Option<u16>,
}
impl OnionHostname {
pub fn to_san_hostname(&self) -> InternedString {
self.value.clone()
}
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)] #[ts(export)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[serde(rename_all_fields = "camelCase")] #[serde(rename_all_fields = "camelCase")]

View File

@@ -8,7 +8,6 @@ use socks5_impl::server::{AuthAdaptor, ClientConnection, Server};
use tokio::net::{TcpListener, TcpStream}; use tokio::net::{TcpListener, TcpStream};
use crate::HOST_IP; use crate::HOST_IP;
use crate::net::tor::TorController;
use crate::prelude::*; use crate::prelude::*;
use crate::util::actor::background::BackgroundJobQueue; use crate::util::actor::background::BackgroundJobQueue;
use crate::util::future::NonDetachingJoinHandle; use crate::util::future::NonDetachingJoinHandle;
@@ -22,7 +21,7 @@ pub struct SocksController {
_thread: NonDetachingJoinHandle<()>, _thread: NonDetachingJoinHandle<()>,
} }
impl SocksController { impl SocksController {
pub fn new(listen: SocketAddr, tor: TorController) -> Result<Self, Error> { pub fn new(listen: SocketAddr) -> Result<Self, Error> {
Ok(Self { Ok(Self {
_thread: tokio::spawn(async move { _thread: tokio::spawn(async move {
let auth: AuthAdaptor<()> = Arc::new(NoAuth); let auth: AuthAdaptor<()> = Arc::new(NoAuth);
@@ -45,7 +44,6 @@ impl SocksController {
loop { loop {
match server.accept().await { match server.accept().await {
Ok((stream, _)) => { Ok((stream, _)) => {
let tor = tor.clone();
bg.add_job(async move { bg.add_job(async move {
if let Err(e) = async { if let Err(e) = async {
match stream match stream
@@ -57,40 +55,6 @@ impl SocksController {
.await .await
.with_kind(ErrorKind::Network)? .with_kind(ErrorKind::Network)?
{ {
ClientConnection::Connect(
reply,
Address::DomainAddress(domain, port),
) if domain.ends_with(".onion") => {
if let Ok(mut target) = tor
.connect_onion(&domain.parse()?, port)
.await
{
let mut sock = reply
.reply(
Reply::Succeeded,
Address::unspecified(),
)
.await
.with_kind(ErrorKind::Network)?;
tokio::io::copy_bidirectional(
&mut sock,
&mut target,
)
.await
.with_kind(ErrorKind::Network)?;
} else {
let mut sock = reply
.reply(
Reply::HostUnreachable,
Address::unspecified(),
)
.await
.with_kind(ErrorKind::Network)?;
sock.shutdown()
.await
.with_kind(ErrorKind::Network)?;
}
}
ClientConnection::Connect(reply, addr) => { ClientConnection::Connect(reply, addr) => {
if let Ok(mut target) = match addr { if let Ok(mut target) = match addr {
Address::DomainAddress(domain, port) => { Address::DomainAddress(domain, port) => {

View File

@@ -1,964 +0,0 @@
use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use std::net::SocketAddr;
use std::str::FromStr;
use std::sync::{Arc, Weak};
use std::time::{Duration, Instant};
use arti_client::config::onion_service::OnionServiceConfigBuilder;
use arti_client::{TorClient, TorClientConfig};
use base64::Engine;
use clap::Parser;
use color_eyre::eyre::eyre;
use futures::{FutureExt, StreamExt};
use imbl_value::InternedString;
use itertools::Itertools;
use rpc_toolkit::{Context, Empty, HandlerExt, ParentHandler, from_fn_async};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::Notify;
use tor_cell::relaycell::msg::Connected;
use tor_hscrypto::pk::{HsId, HsIdKeypair};
use tor_hsservice::status::State as ArtiOnionServiceState;
use tor_hsservice::{HsNickname, RunningOnionService};
use tor_keymgr::config::ArtiKeystoreKind;
use tor_proto::client::stream::IncomingStreamRequest;
use tor_rtcompat::tokio::TokioRustlsRuntime;
use ts_rs::TS;
use crate::context::{CliContext, RpcContext};
use crate::prelude::*;
use crate::util::actor::background::BackgroundJobQueue;
use crate::util::future::{NonDetachingJoinHandle, Until};
use crate::util::io::ReadWriter;
use crate::util::serde::{
BASE64, Base64, HandlerExtSerde, WithIoFormat, deserialize_from_str, display_serializable,
serialize_display,
};
use crate::util::sync::{SyncMutex, SyncRwLock, Watch};
const BOOTSTRAP_PROGRESS_TIMEOUT: Duration = Duration::from_secs(300);
const HS_BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(300);
const RETRY_COOLDOWN: Duration = Duration::from_secs(15);
const HEALTH_CHECK_FAILURE_ALLOWANCE: usize = 5;
const HEALTH_CHECK_COOLDOWN: Duration = Duration::from_secs(120);
#[derive(Debug, Clone, Copy)]
pub struct OnionAddress(pub HsId);
impl std::fmt::Display for OnionAddress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
safelog::DisplayRedacted::fmt_unredacted(&self.0, f)
}
}
impl FromStr for OnionAddress {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(
if s.ends_with(".onion") {
Cow::Borrowed(s)
} else {
Cow::Owned(format!("{s}.onion"))
}
.parse::<HsId>()
.with_kind(ErrorKind::Tor)?,
))
}
}
impl Serialize for OnionAddress {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serialize_display(self, serializer)
}
}
impl<'de> Deserialize<'de> for OnionAddress {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserialize_from_str(deserializer)
}
}
impl PartialEq for OnionAddress {
fn eq(&self, other: &Self) -> bool {
self.0.as_ref() == other.0.as_ref()
}
}
impl Eq for OnionAddress {}
impl PartialOrd for OnionAddress {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
self.0.as_ref().partial_cmp(other.0.as_ref())
}
}
impl Ord for OnionAddress {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.0.as_ref().cmp(other.0.as_ref())
}
}
pub struct TorSecretKey(pub HsIdKeypair);
impl TorSecretKey {
pub fn onion_address(&self) -> OnionAddress {
OnionAddress(HsId::from(self.0.as_ref().public().to_bytes()))
}
pub fn from_bytes(bytes: [u8; 64]) -> Result<Self, Error> {
Ok(Self(
tor_llcrypto::pk::ed25519::ExpandedKeypair::from_secret_key_bytes(bytes)
.ok_or_else(|| {
Error::new(
eyre!("{}", t!("net.tor.invalid-ed25519-key")),
ErrorKind::Tor,
)
})?
.into(),
))
}
pub fn generate() -> Self {
Self(
tor_llcrypto::pk::ed25519::ExpandedKeypair::from(
&tor_llcrypto::pk::ed25519::Keypair::generate(&mut rand::rng()),
)
.into(),
)
}
}
impl Clone for TorSecretKey {
fn clone(&self) -> Self {
Self(HsIdKeypair::from(
tor_llcrypto::pk::ed25519::ExpandedKeypair::from_secret_key_bytes(
self.0.as_ref().to_secret_key_bytes(),
)
.unwrap(),
))
}
}
impl std::fmt::Display for TorSecretKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
BASE64.encode(self.0.as_ref().to_secret_key_bytes())
)
}
}
impl FromStr for TorSecretKey {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_bytes(Base64::<[u8; 64]>::from_str(s)?.0)
}
}
impl Serialize for TorSecretKey {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serialize_display(self, serializer)
}
}
impl<'de> Deserialize<'de> for TorSecretKey {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserialize_from_str(deserializer)
}
}
#[derive(Default, Deserialize, Serialize)]
pub struct OnionStore(BTreeMap<OnionAddress, TorSecretKey>);
impl Map for OnionStore {
type Key = OnionAddress;
type Value = TorSecretKey;
fn key_str(key: &Self::Key) -> Result<impl AsRef<str>, Error> {
Self::key_string(key)
}
fn key_string(key: &Self::Key) -> Result<imbl_value::InternedString, Error> {
Ok(InternedString::from_display(key))
}
}
impl OnionStore {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, key: TorSecretKey) {
self.0.insert(key.onion_address(), key);
}
}
impl Model<OnionStore> {
pub fn new_key(&mut self) -> Result<TorSecretKey, Error> {
let key = TorSecretKey::generate();
self.insert(&key.onion_address(), &key)?;
Ok(key)
}
pub fn insert_key(&mut self, key: &TorSecretKey) -> Result<(), Error> {
self.insert(&key.onion_address(), &key)
}
pub fn get_key(&self, address: &OnionAddress) -> Result<TorSecretKey, Error> {
self.as_idx(address)
.or_not_found(lazy_format!("private key for {address}"))?
.de()
}
}
impl std::fmt::Debug for OnionStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
struct OnionStoreMap<'a>(&'a BTreeMap<OnionAddress, TorSecretKey>);
impl<'a> std::fmt::Debug for OnionStoreMap<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
#[derive(Debug)]
struct KeyFor(#[allow(unused)] OnionAddress);
let mut map = f.debug_map();
for (k, v) in self.0 {
map.key(k);
map.value(&KeyFor(v.onion_address()));
}
map.finish()
}
}
f.debug_tuple("OnionStore")
.field(&OnionStoreMap(&self.0))
.finish()
}
}
pub fn tor_api<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand(
"list-services",
from_fn_async(list_services)
.with_display_serializable()
.with_custom_display_fn(|handle, result| display_services(handle.params, result))
.with_about("about.display-tor-v3-onion-addresses")
.with_call_remote::<CliContext>(),
)
.subcommand(
"reset",
from_fn_async(reset)
.no_display()
.with_about("about.reset-tor-daemon")
.with_call_remote::<CliContext>(),
)
.subcommand(
"key",
key::<C>().with_about("about.manage-onion-service-key-store"),
)
}
pub fn key<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand(
"generate",
from_fn_async(generate_key)
.with_about("about.generate-onion-service-key-add-to-store")
.with_call_remote::<CliContext>(),
)
.subcommand(
"add",
from_fn_async(add_key)
.with_about("about.add-onion-service-key-to-store")
.with_call_remote::<CliContext>(),
)
.subcommand(
"list",
from_fn_async(list_keys)
.with_custom_display_fn(|_, res| {
for addr in res {
println!("{addr}");
}
Ok(())
})
.with_about("about.list-onion-services-with-keys-in-store")
.with_call_remote::<CliContext>(),
)
}
pub async fn generate_key(ctx: RpcContext) -> Result<OnionAddress, Error> {
ctx.db
.mutate(|db| {
Ok(db
.as_private_mut()
.as_key_store_mut()
.as_onion_mut()
.new_key()?
.onion_address())
})
.await
.result
}
#[derive(Deserialize, Serialize, Parser)]
pub struct AddKeyParams {
#[arg(help = "help.arg.onion-secret-key")]
pub key: Base64<[u8; 64]>,
}
pub async fn add_key(
ctx: RpcContext,
AddKeyParams { key }: AddKeyParams,
) -> Result<OnionAddress, Error> {
let key = TorSecretKey::from_bytes(key.0)?;
ctx.db
.mutate(|db| {
db.as_private_mut()
.as_key_store_mut()
.as_onion_mut()
.insert_key(&key)
})
.await
.result?;
Ok(key.onion_address())
}
pub async fn list_keys(ctx: RpcContext) -> Result<BTreeSet<OnionAddress>, Error> {
ctx.db
.peek()
.await
.into_private()
.into_key_store()
.into_onion()
.keys()
}
#[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[command(rename_all = "kebab-case")]
pub struct ResetParams {
#[arg(
name = "wipe-state",
short = 'w',
long = "wipe-state",
help = "help.arg.wipe-tor-state"
)]
wipe_state: bool,
}
pub async fn reset(ctx: RpcContext, ResetParams { wipe_state }: ResetParams) -> Result<(), Error> {
ctx.net_controller.tor.reset(wipe_state).await
}
pub fn display_services(
params: WithIoFormat<Empty>,
services: BTreeMap<OnionAddress, OnionServiceInfo>,
) -> Result<(), Error> {
use prettytable::*;
if let Some(format) = params.format {
return display_serializable(format, services);
}
let mut table = Table::new();
table.add_row(row![bc => "ADDRESS", "STATE", "BINDINGS"]);
for (service, info) in services {
let row = row![
&service.to_string(),
&format!("{:?}", info.state),
&info
.bindings
.into_iter()
.map(|(port, addr)| lazy_format!("{port} -> {addr}"))
.join("; ")
];
table.add_row(row);
}
table.print_tty(false)?;
Ok(())
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum OnionServiceState {
Shutdown,
Bootstrapping,
DegradedReachable,
DegradedUnreachable,
Running,
Recovering,
Broken,
}
impl From<ArtiOnionServiceState> for OnionServiceState {
fn from(value: ArtiOnionServiceState) -> Self {
match value {
ArtiOnionServiceState::Shutdown => Self::Shutdown,
ArtiOnionServiceState::Bootstrapping => Self::Bootstrapping,
ArtiOnionServiceState::DegradedReachable => Self::DegradedReachable,
ArtiOnionServiceState::DegradedUnreachable => Self::DegradedUnreachable,
ArtiOnionServiceState::Running => Self::Running,
ArtiOnionServiceState::Recovering => Self::Recovering,
ArtiOnionServiceState::Broken => Self::Broken,
_ => unreachable!(),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OnionServiceInfo {
pub state: OnionServiceState,
pub bindings: BTreeMap<u16, SocketAddr>,
}
pub async fn list_services(
ctx: RpcContext,
_: Empty,
) -> Result<BTreeMap<OnionAddress, OnionServiceInfo>, Error> {
ctx.net_controller.tor.list_services().await
}
#[derive(Clone)]
pub struct TorController(Arc<TorControllerInner>);
struct TorControllerInner {
client: Watch<(usize, TorClient<TokioRustlsRuntime>)>,
_bootstrapper: NonDetachingJoinHandle<()>,
services: SyncMutex<BTreeMap<OnionAddress, OnionService>>,
reset: Arc<Notify>,
}
impl TorController {
pub fn new() -> Result<Self, Error> {
let mut config = TorClientConfig::builder();
config
.storage()
.keystore()
.primary()
.kind(ArtiKeystoreKind::Ephemeral.into());
let client = Watch::new((
0,
TorClient::with_runtime(TokioRustlsRuntime::current()?)
.config(config.build().with_kind(ErrorKind::Tor)?)
.local_resource_timeout(Duration::from_secs(0))
.create_unbootstrapped()?,
));
let reset = Arc::new(Notify::new());
let bootstrapper_reset = reset.clone();
let bootstrapper_client = client.clone();
let bootstrapper = tokio::spawn(async move {
loop {
let (epoch, client): (usize, _) = bootstrapper_client.read();
if let Err(e) = Until::new()
.with_async_fn(|| bootstrapper_reset.notified().map(Ok))
.run(async {
let mut events = client.bootstrap_events();
let bootstrap_fut =
client.bootstrap().map(|res| res.with_kind(ErrorKind::Tor));
let failure_fut = async {
let mut prev_frac = 0_f32;
let mut prev_inst = Instant::now();
while let Some(event) =
tokio::time::timeout(BOOTSTRAP_PROGRESS_TIMEOUT, events.next())
.await
.with_kind(ErrorKind::Tor)?
{
if event.ready_for_traffic() {
return Ok::<_, Error>(());
}
let frac = event.as_frac();
if frac == prev_frac {
if prev_inst.elapsed() > BOOTSTRAP_PROGRESS_TIMEOUT {
return Err(Error::new(
eyre!(
"{}",
t!(
"net.tor.bootstrap-no-progress",
duration = crate::util::serde::Duration::from(
BOOTSTRAP_PROGRESS_TIMEOUT
)
.to_string()
)
),
ErrorKind::Tor,
));
}
} else {
prev_frac = frac;
prev_inst = Instant::now();
}
}
futures::future::pending().await
};
if let Err::<(), Error>(e) = tokio::select! {
res = bootstrap_fut => res,
res = failure_fut => res,
} {
tracing::error!(
"{}",
t!("net.tor.bootstrap-error", error = e.to_string())
);
tracing::debug!("{e:?}");
} else {
bootstrapper_client.send_modify(|_| ());
for _ in 0..HEALTH_CHECK_FAILURE_ALLOWANCE {
if let Err::<(), Error>(e) = async {
loop {
let (bg, mut runner) = BackgroundJobQueue::new();
runner
.run_while(async {
const PING_BUF_LEN: usize = 8;
let key = TorSecretKey::generate();
let onion = key.onion_address();
let (hs, stream) = client
.launch_onion_service_with_hsid(
OnionServiceConfigBuilder::default()
.nickname(
onion
.to_string()
.trim_end_matches(".onion")
.parse::<HsNickname>()
.with_kind(ErrorKind::Tor)?,
)
.build()
.with_kind(ErrorKind::Tor)?,
key.clone().0,
)
.with_kind(ErrorKind::Tor)?;
bg.add_job(async move {
if let Err(e) = async {
let mut stream =
tor_hsservice::handle_rend_requests(
stream,
);
while let Some(req) = stream.next().await {
let mut stream = req
.accept(Connected::new_empty())
.await
.with_kind(ErrorKind::Tor)?;
let mut buf = [0; PING_BUF_LEN];
stream.read_exact(&mut buf).await?;
stream.write_all(&buf).await?;
stream.flush().await?;
stream.shutdown().await?;
}
Ok::<_, Error>(())
}
.await
{
tracing::error!(
"{}",
t!(
"net.tor.health-error",
error = e.to_string()
)
);
tracing::debug!("{e:?}");
}
});
tokio::time::timeout(HS_BOOTSTRAP_TIMEOUT, async {
let mut status = hs.status_events();
while let Some(status) = status.next().await {
if status.state().is_fully_reachable() {
return Ok(());
}
}
Err(Error::new(
eyre!(
"{}",
t!("net.tor.status-stream-ended")
),
ErrorKind::Tor,
))
})
.await
.with_kind(ErrorKind::Tor)??;
let mut stream = client
.connect((onion.to_string(), 8080))
.await?;
let mut ping_buf = [0; PING_BUF_LEN];
rand::fill(&mut ping_buf);
stream.write_all(&ping_buf).await?;
stream.flush().await?;
let mut ping_res = [0; PING_BUF_LEN];
stream.read_exact(&mut ping_res).await?;
ensure_code!(
ping_buf == ping_res,
ErrorKind::Tor,
"ping buffer mismatch"
);
stream.shutdown().await?;
Ok::<_, Error>(())
})
.await?;
tokio::time::sleep(HEALTH_CHECK_COOLDOWN).await;
}
}
.await
{
tracing::error!(
"{}",
t!("net.tor.client-health-error", error = e.to_string())
);
tracing::debug!("{e:?}");
}
}
tracing::error!(
"{}",
t!(
"net.tor.health-check-failed-recycling",
count = HEALTH_CHECK_FAILURE_ALLOWANCE
)
);
}
Ok(())
})
.await
{
tracing::error!(
"{}",
t!("net.tor.bootstrapper-error", error = e.to_string())
);
tracing::debug!("{e:?}");
}
if let Err::<(), Error>(e) = async {
tokio::time::sleep(RETRY_COOLDOWN).await;
bootstrapper_client.send((
epoch.wrapping_add(1),
TorClient::with_runtime(TokioRustlsRuntime::current()?)
.config(config.build().with_kind(ErrorKind::Tor)?)
.local_resource_timeout(Duration::from_secs(0))
.create_unbootstrapped_async()
.await?,
));
tracing::debug!("TorClient recycled");
Ok(())
}
.await
{
tracing::error!(
"{}",
t!("net.tor.client-creation-error", error = e.to_string())
);
tracing::debug!("{e:?}");
}
}
})
.into();
Ok(Self(Arc::new(TorControllerInner {
client,
_bootstrapper: bootstrapper,
services: SyncMutex::new(BTreeMap::new()),
reset,
})))
}
pub fn service(&self, key: TorSecretKey) -> Result<OnionService, Error> {
self.0.services.mutate(|s| {
use std::collections::btree_map::Entry;
let addr = key.onion_address();
match s.entry(addr) {
Entry::Occupied(e) => Ok(e.get().clone()),
Entry::Vacant(e) => Ok(e
.insert(OnionService::launch(self.0.client.clone(), key)?)
.clone()),
}
})
}
pub async fn gc(&self, addr: Option<OnionAddress>) -> Result<(), Error> {
if let Some(addr) = addr {
if let Some(s) = self.0.services.mutate(|s| {
let rm = if let Some(s) = s.get(&addr) {
!s.gc()
} else {
false
};
if rm { s.remove(&addr) } else { None }
}) {
s.shutdown().await
} else {
Ok(())
}
} else {
for s in self.0.services.mutate(|s| {
let mut rm = Vec::new();
s.retain(|_, s| {
if s.gc() {
true
} else {
rm.push(s.clone());
false
}
});
rm
}) {
s.shutdown().await?;
}
Ok(())
}
}
pub async fn reset(&self, wipe_state: bool) -> Result<(), Error> {
self.0.reset.notify_waiters();
Ok(())
}
pub async fn list_services(&self) -> Result<BTreeMap<OnionAddress, OnionServiceInfo>, Error> {
Ok(self
.0
.services
.peek(|s| s.iter().map(|(a, s)| (a.clone(), s.info())).collect()))
}
pub async fn connect_onion(
&self,
addr: &OnionAddress,
port: u16,
) -> Result<Box<dyn ReadWriter + Unpin + Send + Sync + 'static>, Error> {
if let Some(target) = self.0.services.peek(|s| {
s.get(addr).and_then(|s| {
s.0.bindings.peek(|b| {
b.get(&port).and_then(|b| {
b.iter()
.find(|(_, rc)| rc.strong_count() > 0)
.map(|(a, _)| *a)
})
})
})
}) {
let tcp_stream = TcpStream::connect(target)
.await
.with_kind(ErrorKind::Network)?;
if let Err(e) = socket2::SockRef::from(&tcp_stream).set_keepalive(true) {
tracing::error!(
"{}",
t!("net.tor.failed-to-set-tcp-keepalive", error = e.to_string())
);
tracing::debug!("{e:?}");
}
Ok(Box::new(tcp_stream))
} else {
let mut client = self.0.client.clone();
client
.wait_for(|(_, c)| c.bootstrap_status().ready_for_traffic())
.await;
let stream = client
.read()
.1
.connect((addr.to_string(), port))
.await
.with_kind(ErrorKind::Tor)?;
Ok(Box::new(stream))
}
}
}
#[derive(Clone)]
pub struct OnionService(Arc<OnionServiceData>);
struct OnionServiceData {
service: Arc<SyncMutex<Option<Arc<RunningOnionService>>>>,
bindings: Arc<SyncRwLock<BTreeMap<u16, BTreeMap<SocketAddr, Weak<()>>>>>,
_thread: NonDetachingJoinHandle<()>,
}
impl OnionService {
fn launch(
mut client: Watch<(usize, TorClient<TokioRustlsRuntime>)>,
key: TorSecretKey,
) -> Result<Self, Error> {
let service = Arc::new(SyncMutex::new(None));
let bindings = Arc::new(SyncRwLock::new(BTreeMap::<
u16,
BTreeMap<SocketAddr, Weak<()>>,
>::new()));
Ok(Self(Arc::new(OnionServiceData {
service: service.clone(),
bindings: bindings.clone(),
_thread: tokio::spawn(async move {
let (bg, mut runner) = BackgroundJobQueue::new();
runner
.run_while(async {
loop {
if let Err(e) = async {
client.wait_for(|(_,c)| c.bootstrap_status().ready_for_traffic()).await;
let epoch = client.peek(|(e, c)| {
ensure_code!(c.bootstrap_status().ready_for_traffic(), ErrorKind::Tor, "TorClient recycled");
Ok::<_, Error>(*e)
})?;
let addr = key.onion_address();
let (new_service, stream) = client.peek(|(_, c)| {
c.launch_onion_service_with_hsid(
OnionServiceConfigBuilder::default()
.nickname(
addr
.to_string()
.trim_end_matches(".onion")
.parse::<HsNickname>()
.with_kind(ErrorKind::Tor)?,
)
.build()
.with_kind(ErrorKind::Tor)?,
key.clone().0,
)
.with_kind(ErrorKind::Tor)
})?;
let mut status_stream = new_service.status_events();
let mut status = new_service.status();
if status.state().is_fully_reachable() {
tracing::debug!("{addr} is fully reachable");
} else {
tracing::debug!("{addr} is not fully reachable");
}
bg.add_job(async move {
while let Some(new_status) = status_stream.next().await {
if status.state().is_fully_reachable() && !new_status.state().is_fully_reachable() {
tracing::debug!("{addr} is no longer fully reachable");
} else if !status.state().is_fully_reachable() && new_status.state().is_fully_reachable() {
tracing::debug!("{addr} is now fully reachable");
}
status = new_status;
// TODO: health daemon?
}
});
service.replace(Some(new_service));
let mut stream = tor_hsservice::handle_rend_requests(stream);
while let Some(req) = tokio::select! {
req = stream.next() => req,
_ = client.wait_for(|(e, _)| *e != epoch) => None
} {
bg.add_job({
let bg = bg.clone();
let bindings = bindings.clone();
async move {
if let Err(e) = async {
let IncomingStreamRequest::Begin(begin) =
req.request()
else {
return req
.reject(tor_cell::relaycell::msg::End::new_with_reason(
tor_cell::relaycell::msg::EndReason::DONE,
))
.await
.with_kind(ErrorKind::Tor);
};
let Some(target) = bindings.peek(|b| {
b.get(&begin.port()).and_then(|a| {
a.iter()
.find(|(_, rc)| rc.strong_count() > 0)
.map(|(addr, _)| *addr)
})
}) else {
return req
.reject(tor_cell::relaycell::msg::End::new_with_reason(
tor_cell::relaycell::msg::EndReason::DONE,
))
.await
.with_kind(ErrorKind::Tor);
};
bg.add_job(async move {
if let Err(e) = async {
let mut outgoing =
TcpStream::connect(target)
.await
.with_kind(ErrorKind::Network)?;
if let Err(e) = socket2::SockRef::from(&outgoing).set_keepalive(true) {
tracing::error!("{}", t!("net.tor.failed-to-set-tcp-keepalive", error = e.to_string()));
tracing::debug!("{e:?}");
}
let mut incoming = req
.accept(Connected::new_empty())
.await
.with_kind(ErrorKind::Tor)?;
if let Err(e) =
tokio::io::copy_bidirectional(
&mut outgoing,
&mut incoming,
)
.await
{
tracing::trace!("Tor Stream Error: {e}");
tracing::trace!("{e:?}");
}
Ok::<_, Error>(())
}
.await
{
tracing::trace!("Tor Stream Error: {e}");
tracing::trace!("{e:?}");
}
});
Ok::<_, Error>(())
}
.await
{
tracing::trace!("Tor Request Error: {e}");
tracing::trace!("{e:?}");
}
}
});
}
Ok::<_, Error>(())
}
.await
{
tracing::error!("{}", t!("net.tor.client-error", error = e.to_string()));
tracing::debug!("{e:?}");
}
}
})
.await
})
.into(),
})))
}
pub async fn proxy_all<Rcs: FromIterator<Arc<()>>>(
&self,
bindings: impl IntoIterator<Item = (u16, SocketAddr)>,
) -> Result<Rcs, Error> {
Ok(self.0.bindings.mutate(|b| {
bindings
.into_iter()
.map(|(port, target)| {
let entry = b.entry(port).or_default().entry(target).or_default();
if let Some(rc) = entry.upgrade() {
rc
} else {
let rc = Arc::new(());
*entry = Arc::downgrade(&rc);
rc
}
})
.collect()
}))
}
pub fn gc(&self) -> bool {
self.0.bindings.mutate(|b| {
b.retain(|_, targets| {
targets.retain(|_, rc| rc.strong_count() > 0);
!targets.is_empty()
});
!b.is_empty()
})
}
pub async fn shutdown(self) -> Result<(), Error> {
self.0.service.replace(None);
self.0._thread.abort();
Ok(())
}
pub fn state(&self) -> OnionServiceState {
self.0
.service
.peek(|s| s.as_ref().map(|s| s.status().state().into()))
.unwrap_or(OnionServiceState::Bootstrapping)
}
pub fn info(&self) -> OnionServiceInfo {
OnionServiceInfo {
state: self.state(),
bindings: self.0.bindings.peek(|b| {
b.iter()
.filter_map(|(port, b)| {
b.iter()
.find(|(_, rc)| rc.strong_count() > 0)
.map(|(addr, _)| (*port, *addr))
})
.collect()
}),
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +0,0 @@
#[cfg(feature = "arti")]
mod arti;
#[cfg(not(feature = "arti"))]
mod ctor;
#[cfg(feature = "arti")]
pub use arti::{OnionAddress, OnionStore, TorController, TorSecretKey, tor_api};
#[cfg(not(feature = "arti"))]
pub use ctor::{OnionAddress, OnionStore, TorController, TorSecretKey, tor_api};

View File

@@ -8,7 +8,7 @@ use ts_rs::TS;
use crate::GatewayId; use crate::GatewayId;
use crate::context::{CliContext, RpcContext}; use crate::context::{CliContext, RpcContext};
use crate::db::model::public::{NetworkInterfaceInfo, NetworkInterfaceType}; use crate::db::model::public::{GatewayType, NetworkInterfaceInfo, NetworkInterfaceType};
use crate::net::host::all_hosts; use crate::net::host::all_hosts;
use crate::prelude::*; use crate::prelude::*;
use crate::util::Invoke; use crate::util::Invoke;
@@ -32,14 +32,19 @@ pub fn tunnel_api<C: Context>() -> ParentHandler<C> {
} }
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)] #[ts(export)]
pub struct AddTunnelParams { pub struct AddTunnelParams {
#[arg(help = "help.arg.tunnel-name")] #[arg(help = "help.arg.tunnel-name")]
name: InternedString, name: InternedString,
#[arg(help = "help.arg.wireguard-config")] #[arg(help = "help.arg.wireguard-config")]
config: String, config: String,
#[arg(help = "help.arg.is-public")] #[arg(help = "help.arg.gateway-type")]
public: bool, #[serde(default, rename = "type")]
gateway_type: Option<GatewayType>,
#[arg(help = "help.arg.set-as-default-outbound")]
#[serde(default)]
set_as_default_outbound: bool,
} }
fn sanitize_config(config: &str) -> String { fn sanitize_config(config: &str) -> String {
@@ -64,7 +69,8 @@ pub async fn add_tunnel(
AddTunnelParams { AddTunnelParams {
name, name,
config, config,
public, gateway_type,
set_as_default_outbound,
}: AddTunnelParams, }: AddTunnelParams,
) -> Result<GatewayId, Error> { ) -> Result<GatewayId, Error> {
let ifaces = ctx.net_controller.net_iface.watcher.subscribe(); let ifaces = ctx.net_controller.net_iface.watcher.subscribe();
@@ -76,9 +82,10 @@ pub async fn add_tunnel(
iface.clone(), iface.clone(),
NetworkInterfaceInfo { NetworkInterfaceInfo {
name: Some(name), name: Some(name),
public: Some(public), public: None,
secure: None, secure: None,
ip_info: None, ip_info: None,
gateway_type,
}, },
); );
return true; return true;
@@ -120,6 +127,19 @@ pub async fn add_tunnel(
sub.recv().await; sub.recv().await;
if set_as_default_outbound {
ctx.db
.mutate(|db| {
db.as_public_mut()
.as_server_info_mut()
.as_network_mut()
.as_default_outbound_mut()
.ser(&Some(iface.clone()))
})
.await
.result?;
}
Ok(iface) Ok(iface)
} }
@@ -175,8 +195,12 @@ pub async fn remove_tunnel(
let host = host?; let host = host?;
host.as_bindings_mut().mutate(|b| { host.as_bindings_mut().mutate(|b| {
Ok(b.values_mut().for_each(|v| { Ok(b.values_mut().for_each(|v| {
v.net.private_disabled.remove(&id); v.addresses
v.net.public_enabled.remove(&id); .private_disabled
.retain(|h| h.gateway.id != id);
v.addresses
.public_enabled
.retain(|h| h.gateway.id != id);
})) }))
})?; })?;
} }

View File

@@ -1,19 +1,19 @@
use std::any::Any; use std::any::Any;
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::fmt; use std::fmt;
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr, SocketAddrV6};
use std::sync::{Arc, Weak}; use std::sync::{Arc, Weak};
use std::task::{Poll, ready}; use std::task::{Poll, ready};
use std::time::Duration;
use async_acme::acme::ACME_TLS_ALPN_NAME; use async_acme::acme::ACME_TLS_ALPN_NAME;
use color_eyre::eyre::eyre; use color_eyre::eyre::eyre;
use futures::FutureExt; use futures::FutureExt;
use futures::future::BoxFuture; use futures::future::BoxFuture;
use imbl::OrdMap;
use imbl_value::{InOMap, InternedString}; use imbl_value::{InOMap, InternedString};
use rpc_toolkit::{Context, HandlerArgs, HandlerExt, ParentHandler, from_fn}; use rpc_toolkit::{Context, HandlerArgs, HandlerExt, ParentHandler, from_fn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::net::TcpStream; use tokio::net::{TcpListener, TcpStream};
use tokio_rustls::TlsConnector; use tokio_rustls::TlsConnector;
use tokio_rustls::rustls::crypto::CryptoProvider; use tokio_rustls::rustls::crypto::CryptoProvider;
use tokio_rustls::rustls::pki_types::ServerName; use tokio_rustls::rustls::pki_types::ServerName;
@@ -23,28 +23,28 @@ use tracing::instrument;
use ts_rs::TS; use ts_rs::TS;
use visit_rs::Visit; use visit_rs::Visit;
use crate::ResultExt;
use crate::context::{CliContext, RpcContext}; use crate::context::{CliContext, RpcContext};
use crate::db::model::Database; use crate::db::model::Database;
use crate::db::model::public::AcmeSettings; use crate::db::model::public::{AcmeSettings, NetworkInterfaceInfo};
use crate::db::{DbAccessByKey, DbAccessMut}; use crate::db::{DbAccessByKey, DbAccessMut};
use crate::net::acme::{ use crate::net::acme::{
AcmeCertStore, AcmeProvider, AcmeTlsAlpnCache, AcmeTlsHandler, GetAcmeProvider, AcmeCertStore, AcmeProvider, AcmeTlsAlpnCache, AcmeTlsHandler, GetAcmeProvider,
}; };
use crate::net::gateway::{ use crate::net::gateway::{
AnyFilter, BindTcp, DynInterfaceFilter, GatewayInfo, InterfaceFilter, GatewayInfo, NetworkInterfaceController, NetworkInterfaceListenerAcceptMetadata,
NetworkInterfaceController, NetworkInterfaceListener,
}; };
use crate::net::ssl::{CertStore, RootCaTlsHandler}; use crate::net::ssl::{CertStore, RootCaTlsHandler};
use crate::net::tls::{ use crate::net::tls::{
ChainedHandler, TlsHandlerWrapper, TlsListener, TlsMetadata, WrapTlsHandler, ChainedHandler, TlsHandlerWrapper, TlsListener, TlsMetadata, WrapTlsHandler,
}; };
use crate::net::utils::ipv6_is_link_local;
use crate::net::web_server::{Accept, AcceptStream, ExtractVisitor, TcpMetadata, extract}; use crate::net::web_server::{Accept, AcceptStream, ExtractVisitor, TcpMetadata, extract};
use crate::prelude::*; use crate::prelude::*;
use crate::util::collections::EqSet; use crate::util::collections::EqSet;
use crate::util::future::{NonDetachingJoinHandle, WeakFuture}; use crate::util::future::{NonDetachingJoinHandle, WeakFuture};
use crate::util::serde::{HandlerExtSerde, MaybeUtf8String, display_serializable}; use crate::util::serde::{HandlerExtSerde, MaybeUtf8String, display_serializable};
use crate::util::sync::{SyncMutex, Watch}; use crate::util::sync::{SyncMutex, Watch};
use crate::{GatewayId, ResultExt};
pub fn vhost_api<C: Context>() -> ParentHandler<C> { pub fn vhost_api<C: Context>() -> ParentHandler<C> {
ParentHandler::new().subcommand( ParentHandler::new().subcommand(
@@ -93,7 +93,7 @@ pub struct VHostController {
interfaces: Arc<NetworkInterfaceController>, interfaces: Arc<NetworkInterfaceController>,
crypto_provider: Arc<CryptoProvider>, crypto_provider: Arc<CryptoProvider>,
acme_cache: AcmeTlsAlpnCache, acme_cache: AcmeTlsAlpnCache,
servers: SyncMutex<BTreeMap<u16, VHostServer<NetworkInterfaceListener>>>, servers: SyncMutex<BTreeMap<u16, VHostServer<VHostBindListener>>>,
} }
impl VHostController { impl VHostController {
pub fn new( pub fn new(
@@ -114,14 +114,22 @@ impl VHostController {
&self, &self,
hostname: Option<InternedString>, hostname: Option<InternedString>,
external: u16, external: u16,
target: DynVHostTarget<NetworkInterfaceListener>, target: DynVHostTarget<VHostBindListener>,
) -> Result<Arc<()>, Error> { ) -> Result<Arc<()>, Error> {
self.servers.mutate(|writable| { self.servers.mutate(|writable| {
let server = if let Some(server) = writable.remove(&external) { let server = if let Some(server) = writable.remove(&external) {
server server
} else { } else {
let bind_reqs = Watch::new(VHostBindRequirements::default());
let listener = VHostBindListener {
ip_info: self.interfaces.watcher.subscribe(),
port: external,
bind_reqs: bind_reqs.clone_unseen(),
listeners: BTreeMap::new(),
};
VHostServer::new( VHostServer::new(
self.interfaces.watcher.bind(BindTcp, external)?, listener,
bind_reqs,
self.db.clone(), self.db.clone(),
self.crypto_provider.clone(), self.crypto_provider.clone(),
self.acme_cache.clone(), self.acme_cache.clone(),
@@ -173,6 +181,143 @@ impl VHostController {
} }
} }
/// Union of all ProxyTargets' bind requirements for a VHostServer.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct VHostBindRequirements {
pub public_gateways: BTreeSet<GatewayId>,
pub private_ips: BTreeSet<IpAddr>,
}
fn compute_bind_reqs<A: Accept + 'static>(mapping: &Mapping<A>) -> VHostBindRequirements {
let mut reqs = VHostBindRequirements::default();
for (_, targets) in mapping {
for (target, rc) in targets {
if rc.strong_count() > 0 {
let (pub_gw, priv_ip) = target.0.bind_requirements();
reqs.public_gateways.extend(pub_gw);
reqs.private_ips.extend(priv_ip);
}
}
}
reqs
}
/// Listener that manages its own TCP listeners with IP-level precision.
/// Binds ALL IPs of public gateways and ONLY matching private IPs.
pub struct VHostBindListener {
ip_info: Watch<OrdMap<GatewayId, NetworkInterfaceInfo>>,
port: u16,
bind_reqs: Watch<VHostBindRequirements>,
listeners: BTreeMap<SocketAddr, (TcpListener, GatewayInfo)>,
}
fn update_vhost_listeners(
listeners: &mut BTreeMap<SocketAddr, (TcpListener, GatewayInfo)>,
port: u16,
ip_info: &OrdMap<GatewayId, NetworkInterfaceInfo>,
reqs: &VHostBindRequirements,
) -> Result<(), Error> {
let mut keep = BTreeSet::<SocketAddr>::new();
for (gw_id, info) in ip_info {
if let Some(ip_info) = &info.ip_info {
for ipnet in &ip_info.subnets {
let ip = ipnet.addr();
let should_bind =
reqs.public_gateways.contains(gw_id) || reqs.private_ips.contains(&ip);
if should_bind {
let addr = match ip {
IpAddr::V6(ip6) => SocketAddrV6::new(
ip6,
port,
0,
if ipv6_is_link_local(ip6) {
ip_info.scope_id
} else {
0
},
)
.into(),
ip => SocketAddr::new(ip, port),
};
keep.insert(addr);
if let Some((_, existing_info)) = listeners.get_mut(&addr) {
*existing_info = GatewayInfo {
id: gw_id.clone(),
info: info.clone(),
};
} else {
let tcp = TcpListener::from_std(
mio::net::TcpListener::bind(addr)
.with_kind(ErrorKind::Network)?
.into(),
)
.with_kind(ErrorKind::Network)?;
listeners.insert(
addr,
(
tcp,
GatewayInfo {
id: gw_id.clone(),
info: info.clone(),
},
),
);
}
}
}
}
}
listeners.retain(|key, _| keep.contains(key));
Ok(())
}
impl Accept for VHostBindListener {
type Metadata = NetworkInterfaceListenerAcceptMetadata;
fn poll_accept(
&mut self,
cx: &mut std::task::Context<'_>,
) -> Poll<Result<(Self::Metadata, AcceptStream), Error>> {
// Update listeners when ip_info or bind_reqs change
while self.ip_info.poll_changed(cx).is_ready()
|| self.bind_reqs.poll_changed(cx).is_ready()
{
let reqs = self.bind_reqs.read_and_mark_seen();
let listeners = &mut self.listeners;
let port = self.port;
self.ip_info.peek_and_mark_seen(|ip_info| {
update_vhost_listeners(listeners, port, ip_info, &reqs)
})?;
}
// Poll each listener for incoming connections
for (&addr, (listener, gw_info)) in &self.listeners {
match listener.poll_accept(cx) {
Poll::Ready(Ok((stream, peer_addr))) => {
if let Err(e) = socket2::SockRef::from(&stream).set_keepalive(true) {
tracing::error!("Failed to set tcp keepalive: {e}");
tracing::debug!("{e:?}");
}
return Poll::Ready(Ok((
NetworkInterfaceListenerAcceptMetadata {
inner: TcpMetadata {
local_addr: addr,
peer_addr,
},
info: gw_info.clone(),
},
Box::pin(stream),
)));
}
Poll::Ready(Err(e)) => {
tracing::trace!("VHostBindListener accept error on {addr}: {e}");
}
Poll::Pending => {}
}
}
Poll::Pending
}
}
pub trait VHostTarget<A: Accept>: std::fmt::Debug + Eq { pub trait VHostTarget<A: Accept>: std::fmt::Debug + Eq {
type PreprocessRes: Send + 'static; type PreprocessRes: Send + 'static;
#[allow(unused_variables)] #[allow(unused_variables)]
@@ -182,6 +327,10 @@ pub trait VHostTarget<A: Accept>: std::fmt::Debug + Eq {
fn acme(&self) -> Option<&AcmeProvider> { fn acme(&self) -> Option<&AcmeProvider> {
None None
} }
/// Returns (public_gateways, private_ips) this target needs the listener to bind on.
fn bind_requirements(&self) -> (BTreeSet<GatewayId>, BTreeSet<IpAddr>) {
(BTreeSet::new(), BTreeSet::new())
}
fn preprocess<'a>( fn preprocess<'a>(
&'a self, &'a self,
prev: ServerConfig, prev: ServerConfig,
@@ -200,6 +349,7 @@ pub trait VHostTarget<A: Accept>: std::fmt::Debug + Eq {
pub trait DynVHostTargetT<A: Accept>: std::fmt::Debug + Any { pub trait DynVHostTargetT<A: Accept>: std::fmt::Debug + Any {
fn filter(&self, metadata: &<A as Accept>::Metadata) -> bool; fn filter(&self, metadata: &<A as Accept>::Metadata) -> bool;
fn acme(&self) -> Option<&AcmeProvider>; fn acme(&self) -> Option<&AcmeProvider>;
fn bind_requirements(&self) -> (BTreeSet<GatewayId>, BTreeSet<IpAddr>);
fn preprocess<'a>( fn preprocess<'a>(
&'a self, &'a self,
prev: ServerConfig, prev: ServerConfig,
@@ -224,6 +374,9 @@ impl<A: Accept, T: VHostTarget<A> + 'static> DynVHostTargetT<A> for T {
fn acme(&self) -> Option<&AcmeProvider> { fn acme(&self) -> Option<&AcmeProvider> {
VHostTarget::acme(self) VHostTarget::acme(self)
} }
fn bind_requirements(&self) -> (BTreeSet<GatewayId>, BTreeSet<IpAddr>) {
VHostTarget::bind_requirements(self)
}
fn preprocess<'a>( fn preprocess<'a>(
&'a self, &'a self,
prev: ServerConfig, prev: ServerConfig,
@@ -301,7 +454,8 @@ impl<A: Accept + 'static> Preprocessed<A> {
#[derive(Clone)] #[derive(Clone)]
pub struct ProxyTarget { pub struct ProxyTarget {
pub filter: DynInterfaceFilter, pub public: BTreeSet<GatewayId>,
pub private: BTreeSet<IpAddr>,
pub acme: Option<AcmeProvider>, pub acme: Option<AcmeProvider>,
pub addr: SocketAddr, pub addr: SocketAddr,
pub add_x_forwarded_headers: bool, pub add_x_forwarded_headers: bool,
@@ -309,7 +463,8 @@ pub struct ProxyTarget {
} }
impl PartialEq for ProxyTarget { impl PartialEq for ProxyTarget {
fn eq(&self, other: &Self) -> bool { fn eq(&self, other: &Self) -> bool {
self.filter == other.filter self.public == other.public
&& self.private == other.private
&& self.acme == other.acme && self.acme == other.acme
&& self.addr == other.addr && self.addr == other.addr
&& self.connect_ssl.as_ref().map(Arc::as_ptr) && self.connect_ssl.as_ref().map(Arc::as_ptr)
@@ -320,7 +475,8 @@ impl Eq for ProxyTarget {}
impl fmt::Debug for ProxyTarget { impl fmt::Debug for ProxyTarget {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProxyTarget") f.debug_struct("ProxyTarget")
.field("filter", &self.filter) .field("public", &self.public)
.field("private", &self.private)
.field("acme", &self.acme) .field("acme", &self.acme)
.field("addr", &self.addr) .field("addr", &self.addr)
.field("add_x_forwarded_headers", &self.add_x_forwarded_headers) .field("add_x_forwarded_headers", &self.add_x_forwarded_headers)
@@ -340,16 +496,37 @@ where
{ {
type PreprocessRes = AcceptStream; type PreprocessRes = AcceptStream;
fn filter(&self, metadata: &<A as Accept>::Metadata) -> bool { fn filter(&self, metadata: &<A as Accept>::Metadata) -> bool {
let info = extract::<GatewayInfo, _>(metadata); let gw = extract::<GatewayInfo, _>(metadata);
if info.is_none() { let tcp = extract::<TcpMetadata, _>(metadata);
tracing::warn!("No GatewayInfo on metadata"); let (Some(gw), Some(tcp)) = (gw, tcp) else {
return false;
};
let Some(ip_info) = &gw.info.ip_info else {
return false;
};
let src = tcp.peer_addr.ip();
// Public if: source is a gateway/router IP (NAT'd internet),
// or source is outside all known subnets (direct internet)
let is_public = ip_info.lan_ip.contains(&src)
|| !ip_info.subnets.iter().any(|s| s.contains(&src));
if is_public {
self.public.contains(&gw.id)
} else {
// Private: accept if connection arrived on an interface with a matching IP
ip_info
.subnets
.iter()
.any(|s| self.private.contains(&s.addr()))
} }
info.as_ref()
.map_or(true, |i| self.filter.filter(&i.id, &i.info))
} }
fn acme(&self) -> Option<&AcmeProvider> { fn acme(&self) -> Option<&AcmeProvider> {
self.acme.as_ref() self.acme.as_ref()
} }
fn bind_requirements(&self) -> (BTreeSet<GatewayId>, BTreeSet<IpAddr>) {
(self.public.clone(), self.private.clone())
}
async fn preprocess<'a>( async fn preprocess<'a>(
&'a self, &'a self,
mut prev: ServerConfig, mut prev: ServerConfig,
@@ -634,28 +811,15 @@ where
struct VHostServer<A: Accept + 'static> { struct VHostServer<A: Accept + 'static> {
mapping: Watch<Mapping<A>>, mapping: Watch<Mapping<A>>,
bind_reqs: Watch<VHostBindRequirements>,
_thread: NonDetachingJoinHandle<()>, _thread: NonDetachingJoinHandle<()>,
} }
impl<'a> From<&'a BTreeMap<Option<InternedString>, BTreeMap<ProxyTarget, Weak<()>>>> for AnyFilter {
fn from(value: &'a BTreeMap<Option<InternedString>, BTreeMap<ProxyTarget, Weak<()>>>) -> Self {
Self(
value
.iter()
.flat_map(|(_, v)| {
v.iter()
.filter(|(_, r)| r.strong_count() > 0)
.map(|(t, _)| t.filter.clone())
})
.collect(),
)
}
}
impl<A: Accept> VHostServer<A> { impl<A: Accept> VHostServer<A> {
#[instrument(skip_all)] #[instrument(skip_all)]
fn new<M: HasModel>( fn new<M: HasModel>(
listener: A, listener: A,
bind_reqs: Watch<VHostBindRequirements>,
db: TypedPatchDb<M>, db: TypedPatchDb<M>,
crypto_provider: Arc<CryptoProvider>, crypto_provider: Arc<CryptoProvider>,
acme_cache: AcmeTlsAlpnCache, acme_cache: AcmeTlsAlpnCache,
@@ -679,6 +843,7 @@ impl<A: Accept> VHostServer<A> {
let mapping = Watch::new(BTreeMap::new()); let mapping = Watch::new(BTreeMap::new());
Self { Self {
mapping: mapping.clone(), mapping: mapping.clone(),
bind_reqs,
_thread: tokio::spawn(async move { _thread: tokio::spawn(async move {
let mut listener = VHostListener(TlsListener::new( let mut listener = VHostListener(TlsListener::new(
listener, listener,
@@ -729,6 +894,9 @@ impl<A: Accept> VHostServer<A> {
targets.insert(target, Arc::downgrade(&rc)); targets.insert(target, Arc::downgrade(&rc));
writable.insert(hostname, targets); writable.insert(hostname, targets);
res = Ok(rc); res = Ok(rc);
if changed {
self.update_bind_reqs(writable);
}
changed changed
}); });
if self.mapping.watcher_count() > 1 { if self.mapping.watcher_count() > 1 {
@@ -752,9 +920,23 @@ impl<A: Accept> VHostServer<A> {
if !targets.is_empty() { if !targets.is_empty() {
writable.insert(hostname, targets); writable.insert(hostname, targets);
} }
if pre != post {
self.update_bind_reqs(writable);
}
pre == post pre == post
}); });
} }
fn update_bind_reqs(&self, mapping: &Mapping<A>) {
let new_reqs = compute_bind_reqs(mapping);
self.bind_reqs.send_if_modified(|reqs| {
if *reqs != new_reqs {
*reqs = new_reqs;
true
} else {
false
}
});
}
fn is_empty(&self) -> bool { fn is_empty(&self) -> bool {
self.mapping.peek(|m| m.is_empty()) self.mapping.peek(|m| m.is_empty())
} }

View File

@@ -366,28 +366,6 @@ where
pub struct WebServerAcceptorSetter<A: Accept> { pub struct WebServerAcceptorSetter<A: Accept> {
acceptor: Watch<A>, acceptor: Watch<A>,
} }
impl<A, B> WebServerAcceptorSetter<Option<Either<A, B>>>
where
A: Accept,
B: Accept<Metadata = A::Metadata>,
{
pub fn try_upgrade<F: FnOnce(A) -> Result<B, Error>>(&self, f: F) -> Result<(), Error> {
let mut res = Ok(());
self.acceptor.send_modify(|a| {
*a = match a.take() {
Some(Either::Left(a)) => match f(a) {
Ok(b) => Some(Either::Right(b)),
Err(e) => {
res = Err(e);
None
}
},
x => x,
}
});
res
}
}
impl<A: Accept> Deref for WebServerAcceptorSetter<A> { impl<A: Accept> Deref for WebServerAcceptorSetter<A> {
type Target = Watch<A>; type Target = Watch<A>;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {

View File

@@ -55,20 +55,18 @@ pub async fn get_ssl_certificate(
.map(|(_, m)| m.as_hosts().as_entries()) .map(|(_, m)| m.as_hosts().as_entries())
.flatten_ok() .flatten_ok()
.map_ok(|(_, m)| { .map_ok(|(_, m)| {
Ok(m.as_onions() Ok(m.as_public_domains()
.de()? .keys()?
.iter() .into_iter()
.map(InternedString::from_display)
.chain(m.as_public_domains().keys()?)
.chain(m.as_private_domains().de()?) .chain(m.as_private_domains().de()?)
.chain( .chain(
m.as_hostname_info() m.as_bindings()
.de()? .de()?
.values() .values()
.flatten() .flat_map(|b| b.addresses.possible.iter().cloned())
.map(|h| h.to_san_hostname()), .map(|h| h.to_san_hostname()),
) )
.collect::<Vec<_>>()) .collect::<Vec<InternedString>>())
}) })
.map(|a| a.and_then(|a| a)) .map(|a| a.and_then(|a| a))
.flatten_ok() .flatten_ok()
@@ -181,20 +179,18 @@ pub async fn get_ssl_key(
.map(|m| m.as_hosts().as_entries()) .map(|m| m.as_hosts().as_entries())
.flatten_ok() .flatten_ok()
.map_ok(|(_, m)| { .map_ok(|(_, m)| {
Ok(m.as_onions() Ok(m.as_public_domains()
.de()? .keys()?
.iter() .into_iter()
.map(InternedString::from_display)
.chain(m.as_public_domains().keys()?)
.chain(m.as_private_domains().de()?) .chain(m.as_private_domains().de()?)
.chain( .chain(
m.as_hostname_info() m.as_bindings()
.de()? .de()?
.values() .values()
.flatten() .flat_map(|b| b.addresses.possible.iter().cloned())
.map(|h| h.to_san_hostname()), .map(|h| h.to_san_hostname()),
) )
.collect::<Vec<_>>()) .collect::<Vec<InternedString>>())
}) })
.map(|a| a.and_then(|a| a)) .map(|a| a.and_then(|a| a))
.flatten_ok() .flatten_ok()

View File

@@ -259,6 +259,7 @@ impl ServiceMap {
service_interfaces: Default::default(), service_interfaces: Default::default(),
hosts: Default::default(), hosts: Default::default(),
store_exposed_dependents: Default::default(), store_exposed_dependents: Default::default(),
outbound_gateway: None,
}, },
)?; )?;
}; };

View File

@@ -459,7 +459,7 @@ pub async fn add_forward(
}) })
.map(|s| s.prefix_len()) .map(|s| s.prefix_len())
.unwrap_or(32); .unwrap_or(32);
let rc = ctx.forward.add_forward(source, target, prefix).await?; let rc = ctx.forward.add_forward(source, target, prefix, None).await?;
ctx.active_forwards.mutate(|m| { ctx.active_forwards.mutate(|m| {
m.insert(source, rc); m.insert(source, rc);
}); });

View File

@@ -199,7 +199,7 @@ impl TunnelContext {
}) })
.map(|s| s.prefix_len()) .map(|s| s.prefix_len())
.unwrap_or(32); .unwrap_or(32);
active_forwards.insert(from, forward.add_forward(from, to, prefix).await?); active_forwards.insert(from, forward.add_forward(from, to, prefix, None).await?);
} }
Ok(Self(Arc::new(TunnelContextSeed { Ok(Self(Arc::new(TunnelContextSeed {

View File

@@ -59,8 +59,9 @@ mod v0_4_0_alpha_16;
mod v0_4_0_alpha_17; mod v0_4_0_alpha_17;
mod v0_4_0_alpha_18; mod v0_4_0_alpha_18;
mod v0_4_0_alpha_19; mod v0_4_0_alpha_19;
mod v0_4_0_alpha_20;
pub type Current = v0_4_0_alpha_19::Version; // VERSION_BUMP pub type Current = v0_4_0_alpha_20::Version; // VERSION_BUMP
impl Current { impl Current {
#[instrument(skip(self, db))] #[instrument(skip(self, db))]
@@ -181,7 +182,8 @@ enum Version {
V0_4_0_alpha_16(Wrapper<v0_4_0_alpha_16::Version>), V0_4_0_alpha_16(Wrapper<v0_4_0_alpha_16::Version>),
V0_4_0_alpha_17(Wrapper<v0_4_0_alpha_17::Version>), V0_4_0_alpha_17(Wrapper<v0_4_0_alpha_17::Version>),
V0_4_0_alpha_18(Wrapper<v0_4_0_alpha_18::Version>), V0_4_0_alpha_18(Wrapper<v0_4_0_alpha_18::Version>),
V0_4_0_alpha_19(Wrapper<v0_4_0_alpha_19::Version>), // VERSION_BUMP V0_4_0_alpha_19(Wrapper<v0_4_0_alpha_19::Version>),
V0_4_0_alpha_20(Wrapper<v0_4_0_alpha_20::Version>), // VERSION_BUMP
Other(exver::Version), Other(exver::Version),
} }
@@ -243,7 +245,8 @@ impl Version {
Self::V0_4_0_alpha_16(v) => DynVersion(Box::new(v.0)), Self::V0_4_0_alpha_16(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_alpha_17(v) => DynVersion(Box::new(v.0)), Self::V0_4_0_alpha_17(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_alpha_18(v) => DynVersion(Box::new(v.0)), Self::V0_4_0_alpha_18(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_alpha_19(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP Self::V0_4_0_alpha_19(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_alpha_20(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP
Self::Other(v) => { Self::Other(v) => {
return Err(Error::new( return Err(Error::new(
eyre!("unknown version {v}"), eyre!("unknown version {v}"),
@@ -297,7 +300,8 @@ impl Version {
Version::V0_4_0_alpha_16(Wrapper(x)) => x.semver(), Version::V0_4_0_alpha_16(Wrapper(x)) => x.semver(),
Version::V0_4_0_alpha_17(Wrapper(x)) => x.semver(), Version::V0_4_0_alpha_17(Wrapper(x)) => x.semver(),
Version::V0_4_0_alpha_18(Wrapper(x)) => x.semver(), Version::V0_4_0_alpha_18(Wrapper(x)) => x.semver(),
Version::V0_4_0_alpha_19(Wrapper(x)) => x.semver(), // VERSION_BUMP Version::V0_4_0_alpha_19(Wrapper(x)) => x.semver(),
Version::V0_4_0_alpha_20(Wrapper(x)) => x.semver(), // VERSION_BUMP
Version::Other(x) => x.clone(), Version::Other(x) => x.clone(),
} }
} }

View File

@@ -10,13 +10,13 @@ A server is not a toy. It is a critical component of the computing paradigm, and
Start9 is paving new ground with StartOS, trying to create what most developers and IT professionals thought impossible; namely, an OS and user experience that affords a normal person the same independent control over their data and communications as an experienced Linux sysadmin. Start9 is paving new ground with StartOS, trying to create what most developers and IT professionals thought impossible; namely, an OS and user experience that affords a normal person the same independent control over their data and communications as an experienced Linux sysadmin.
The difficulty of our endeavor requires making mistakes; and our integrity and dedication to excellence require that we correct them. This means a willingness to discard bad ideas and broken parts, and if absolutely necessary, to tear it all down and start over. That is exactly what we did with StartOS v0.2.0 in 2020. It is what we did with StartOS v0.3.0 in 2022. And we are doing it now with StartOS v0.4.0 in 2025. The difficulty of our endeavor requires making mistakes; and our integrity and dedication to excellence require that we correct them. This means a willingness to discard bad ideas and broken parts, and if absolutely necessary, to tear it all down and start over. That is exactly what we did with StartOS v0.2.0 in 2020. It is what we did with StartOS v0.3.0 in 2022. And we are doing it now with StartOS v0.4.0 in 2026.
v0.4.0 is a complete rewrite of StartOS, almost nothing survived. After nearly six years of building StartOS, we believe that we have finally arrived at the correct architecture and foundation that will allow us to deliver on the promise of sovereign computing. v0.4.0 is a complete rewrite of StartOS, almost nothing survived. After nearly six years of building StartOS, we believe that we have finally arrived at the correct architecture and foundation that will allow us to deliver on the promise of sovereign computing.
## Changelog ## Changelog
### Improved User interface ### New User interface
We re-wrote the StartOS UI to be more performant, more intuitive, and better looking on both mobile and desktop. Enjoy. We re-wrote the StartOS UI to be more performant, more intuitive, and better looking on both mobile and desktop. Enjoy.
@@ -28,6 +28,10 @@ StartOS v0.4.0 supports multiple languages and also makes it easy to add more la
Neither Docker nor Podman offer the reliability and flexibility needed for StartOS. Instead, v0.4.0 uses a nested container paradigm based on LXC for the outer container and Linux namespaces for sub containers. This architecture naturally supports multi container setups. Neither Docker nor Podman offer the reliability and flexibility needed for StartOS. Instead, v0.4.0 uses a nested container paradigm based on LXC for the outer container and Linux namespaces for sub containers. This architecture naturally supports multi container setups.
### Hardware Acceleration
Services can take advantage of (and require) the presence of certain hardware modules, such as Nvidia GPUs, for transcoding or inference purposes. For example, StartOS and Ollama can run natively on The Nvidia DGX Spark and take full advantage of the hardware/firmware stack to perform local inference against open source models.
### New S9PK archive format ### New S9PK archive format
The S9PK archive format has been overhauled to allow for signature verification of partial downloads, and allow direct mounting of container images without unpacking the s9pk. The S9PK archive format has been overhauled to allow for signature verification of partial downloads, and allow direct mounting of container images without unpacking the s9pk.
@@ -80,13 +84,13 @@ The new start-fs fuse module unifies file system expectations for various platfo
StartOS now uses Extended Versioning (Exver), which consists of three parts: (1) a Semver-compliant upstream version, (2) a Semver-compliant wrapper version, and (3) an optional "flavor" prefix. Flavors can be thought of as alternative implementations of services, where a user would only want one or the other installed, and data can feasibly be migrating between the two. Another common characteristic of flavors is that they satisfy the same API requirement of dependents, though this is not strictly necessary. A valid Exver looks something like this: `#knots:29.0:1.0-beta.1`. This would translate to "the first beta release of StartOS wrapper version 1.0 of Bitcoin Knots version 29.0". StartOS now uses Extended Versioning (Exver), which consists of three parts: (1) a Semver-compliant upstream version, (2) a Semver-compliant wrapper version, and (3) an optional "flavor" prefix. Flavors can be thought of as alternative implementations of services, where a user would only want one or the other installed, and data can feasibly be migrating between the two. Another common characteristic of flavors is that they satisfy the same API requirement of dependents, though this is not strictly necessary. A valid Exver looks something like this: `#knots:29.0:1.0-beta.1`. This would translate to "the first beta release of StartOS wrapper version 1.0 of Bitcoin Knots version 29.0".
### ACME ### Let's Encrypt
StartOS now supports using ACME protocol to automatically obtain SSL/TLS certificates from widely trusted certificate authorities, such as Let's Encrypt, for your public domains. This means people visiting your public websites and APIs will not need to download and trust your server's Root CA. StartOS now supports Let's Encrypt to automatically obtain SSL/TLS certificates for public domains. This means people visiting your public websites and APIs will not need to download and trust your server's Root CA.
### Gateways ### Gateways
Gateways connect your server to the Internet. They process outbound traffic, and under certain conditions, they also permit inbound traffic. For example, your router is a gateway. It is now possible add gateways to StartOS, such as StartTunnel, in order to more granularly control how your installed services are exposed to the Internet. Gateways connect your server to the Internet, facilitating inbound and outbound traffic. Your router is a gateway. It is now possible to add Wireguard VPN gateways to your server to control how devices outside the LAN connect to your server and how your server connects out to the Internet.
### Static DNS Servers ### Static DNS Servers

View File

@@ -1,4 +1,4 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::BTreeMap;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::path::Path; use std::path::Path;
@@ -23,17 +23,14 @@ use crate::disk::mount::filesystem::cifs::Cifs;
use crate::disk::mount::util::unmount; use crate::disk::mount::util::unmount;
use crate::hostname::Hostname; use crate::hostname::Hostname;
use crate::net::forward::AvailablePorts; use crate::net::forward::AvailablePorts;
use crate::net::host::Host;
use crate::net::keys::KeyStore; use crate::net::keys::KeyStore;
use crate::net::tor::{OnionAddress, TorSecretKey};
use crate::notifications::Notifications; use crate::notifications::Notifications;
use crate::prelude::*; use crate::prelude::*;
use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile; use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile;
use crate::ssh::{SshKeys, SshPubKey}; use crate::ssh::{SshKeys, SshPubKey};
use crate::util::Invoke; use crate::util::Invoke;
use crate::util::crypto::ed25519_expand_key;
use crate::util::serde::Pem; use crate::util::serde::Pem;
use crate::{DATA_DIR, HostId, Id, PACKAGE_DATA, PackageId, ReplayId}; use crate::{DATA_DIR, PACKAGE_DATA, PackageId, ReplayId};
lazy_static::lazy_static! { lazy_static::lazy_static! {
static ref V0_3_6_alpha_0: exver::Version = exver::Version::new( static ref V0_3_6_alpha_0: exver::Version = exver::Version::new(
@@ -146,12 +143,7 @@ pub struct Version;
impl VersionT for Version { impl VersionT for Version {
type Previous = v0_3_5_2::Version; type Previous = v0_3_5_2::Version;
type PreUpRes = ( type PreUpRes = (AccountInfo, SshKeys, CifsTargets);
AccountInfo,
SshKeys,
CifsTargets,
BTreeMap<PackageId, BTreeMap<HostId, TorSecretKey>>,
);
fn semver(self) -> exver::Version { fn semver(self) -> exver::Version {
V0_3_6_alpha_0.clone() V0_3_6_alpha_0.clone()
} }
@@ -166,20 +158,18 @@ impl VersionT for Version {
let cifs = previous_cifs(&pg).await?; let cifs = previous_cifs(&pg).await?;
let tor_keys = previous_tor_keys(&pg).await?;
Command::new("systemctl") Command::new("systemctl")
.arg("stop") .arg("stop")
.arg("postgresql@*.service") .arg("postgresql@*.service")
.invoke(crate::ErrorKind::Database) .invoke(crate::ErrorKind::Database)
.await?; .await?;
Ok((account, ssh_keys, cifs, tor_keys)) Ok((account, ssh_keys, cifs))
} }
fn up( fn up(
self, self,
db: &mut Value, db: &mut Value,
(account, ssh_keys, cifs, tor_keys): Self::PreUpRes, (account, ssh_keys, cifs): Self::PreUpRes,
) -> Result<Value, Error> { ) -> Result<Value, Error> {
let prev_package_data = db["package-data"].clone(); let prev_package_data = db["package-data"].clone();
@@ -242,11 +232,7 @@ impl VersionT for Version {
"ui": db["ui"], "ui": db["ui"],
}); });
let mut keystore = KeyStore::new(&account)?; let keystore = KeyStore::new(&account)?;
for key in tor_keys.values().flat_map(|v| v.values()) {
assert!(key.is_valid());
keystore.onion.insert(key.clone());
}
let private = { let private = {
let mut value = json!({}); let mut value = json!({});
@@ -350,20 +336,6 @@ impl VersionT for Version {
false false
}; };
let onions = input[&*id]["installed"]["interface-addresses"]
.as_object()
.into_iter()
.flatten()
.filter_map(|(id, addrs)| {
addrs["tor-address"].as_str().map(|addr| {
Ok((
HostId::from(Id::try_from(id.clone())?),
addr.parse::<OnionAddress>()?,
))
})
})
.collect::<Result<BTreeMap<_, _>, Error>>()?;
if let Err(e) = async { if let Err(e) = async {
let package_s9pk = tokio::fs::File::open(path).await?; let package_s9pk = tokio::fs::File::open(path).await?;
let file = MultiCursorFile::open(&package_s9pk).await?; let file = MultiCursorFile::open(&package_s9pk).await?;
@@ -381,11 +353,8 @@ impl VersionT for Version {
.await? .await?
.await?; .await?;
let to_sync = ctx ctx.db
.db
.mutate(|db| { .mutate(|db| {
let mut to_sync = BTreeSet::new();
let package = db let package = db
.as_public_mut() .as_public_mut()
.as_package_data_mut() .as_package_data_mut()
@@ -396,29 +365,11 @@ impl VersionT for Version {
.as_tasks_mut() .as_tasks_mut()
.remove(&ReplayId::from("needs-config"))?; .remove(&ReplayId::from("needs-config"))?;
} }
for (id, onion) in onions { Ok(())
package
.as_hosts_mut()
.upsert(&id, || Ok(Host::new()))?
.as_onions_mut()
.mutate(|o| {
o.clear();
o.insert(onion);
Ok(())
})?;
to_sync.insert(id);
}
Ok(to_sync)
}) })
.await .await
.result?; .result?;
if let Some(service) = &*ctx.services.get(&id).await {
for host_id in to_sync {
service.sync_host(host_id.clone()).await?;
}
}
Ok::<_, Error>(()) Ok::<_, Error>(())
} }
.await .await
@@ -481,33 +432,6 @@ async fn previous_account_info(pg: &sqlx::Pool<sqlx::Postgres>) -> Result<Accoun
password: account_query password: account_query
.try_get("password") .try_get("password")
.with_ctx(|_| (ErrorKind::Database, "password"))?, .with_ctx(|_| (ErrorKind::Database, "password"))?,
tor_keys: vec![TorSecretKey::from_bytes(
if let Some(bytes) = account_query
.try_get::<Option<Vec<u8>>, _>("tor_key")
.with_ctx(|_| (ErrorKind::Database, "tor_key"))?
{
<[u8; 64]>::try_from(bytes).map_err(|e| {
Error::new(
eyre!("expected vec of len 64, got len {}", e.len()),
ErrorKind::ParseDbField,
)
})?
} else {
ed25519_expand_key(
&<[u8; 32]>::try_from(
account_query
.try_get::<Vec<u8>, _>("network_key")
.with_kind(ErrorKind::Database)?,
)
.map_err(|e| {
Error::new(
eyre!("expected vec of len 32, got len {}", e.len()),
ErrorKind::ParseDbField,
)
})?,
)
},
)?],
server_id: account_query server_id: account_query
.try_get("server_id") .try_get("server_id")
.with_ctx(|_| (ErrorKind::Database, "server_id"))?, .with_ctx(|_| (ErrorKind::Database, "server_id"))?,
@@ -579,68 +503,3 @@ async fn previous_ssh_keys(pg: &sqlx::Pool<sqlx::Postgres>) -> Result<SshKeys, E
Ok(ssh_keys) Ok(ssh_keys)
} }
#[tracing::instrument(skip_all)]
async fn previous_tor_keys(
pg: &sqlx::Pool<sqlx::Postgres>,
) -> Result<BTreeMap<PackageId, BTreeMap<HostId, TorSecretKey>>, Error> {
let mut res = BTreeMap::<PackageId, BTreeMap<HostId, TorSecretKey>>::new();
let net_key_query = sqlx::query(r#"SELECT * FROM network_keys"#)
.fetch_all(pg)
.await
.with_kind(ErrorKind::Database)?;
for row in net_key_query {
let package_id: PackageId = row
.try_get::<String, _>("package")
.with_ctx(|_| (ErrorKind::Database, "network_keys::package"))?
.parse()?;
let interface_id: HostId = row
.try_get::<String, _>("interface")
.with_ctx(|_| (ErrorKind::Database, "network_keys::interface"))?
.parse()?;
let key = TorSecretKey::from_bytes(ed25519_expand_key(
&<[u8; 32]>::try_from(
row.try_get::<Vec<u8>, _>("key")
.with_ctx(|_| (ErrorKind::Database, "network_keys::key"))?,
)
.map_err(|e| {
Error::new(
eyre!("expected vec of len 32, got len {}", e.len()),
ErrorKind::ParseDbField,
)
})?,
))?;
res.entry(package_id).or_default().insert(interface_id, key);
}
let tor_key_query = sqlx::query(r#"SELECT * FROM tor"#)
.fetch_all(pg)
.await
.with_kind(ErrorKind::Database)?;
for row in tor_key_query {
let package_id: PackageId = row
.try_get::<String, _>("package")
.with_ctx(|_| (ErrorKind::Database, "tor::package"))?
.parse()?;
let interface_id: HostId = row
.try_get::<String, _>("interface")
.with_ctx(|_| (ErrorKind::Database, "tor::interface"))?
.parse()?;
let key = TorSecretKey::from_bytes(
<[u8; 64]>::try_from(
row.try_get::<Vec<u8>, _>("key")
.with_ctx(|_| (ErrorKind::Database, "tor::key"))?,
)
.map_err(|e| {
Error::new(
eyre!("expected vec of len 64, got len {}", e.len()),
ErrorKind::ParseDbField,
)
})?,
)?;
res.entry(package_id).or_default().insert(interface_id, key);
}
Ok(res)
}

View File

@@ -8,7 +8,6 @@ use super::v0_3_5::V0_3_0_COMPAT;
use super::{VersionT, v0_3_6_alpha_9}; use super::{VersionT, v0_3_6_alpha_9};
use crate::GatewayId; use crate::GatewayId;
use crate::net::host::address::PublicDomainConfig; use crate::net::host::address::PublicDomainConfig;
use crate::net::tor::OnionAddress;
use crate::prelude::*; use crate::prelude::*;
lazy_static::lazy_static! { lazy_static::lazy_static! {
@@ -22,7 +21,7 @@ lazy_static::lazy_static! {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[serde(tag = "kind")] #[serde(tag = "kind")]
enum HostAddress { enum HostAddress {
Onion { address: OnionAddress }, Onion { address: String },
Domain { address: InternedString }, Domain { address: InternedString },
} }

View File

@@ -1,11 +1,7 @@
use std::collections::BTreeSet;
use exver::{PreReleaseSegment, VersionRange}; use exver::{PreReleaseSegment, VersionRange};
use imbl_value::InternedString;
use super::v0_3_5::V0_3_0_COMPAT; use super::v0_3_5::V0_3_0_COMPAT;
use super::{VersionT, v0_4_0_alpha_11}; use super::{VersionT, v0_4_0_alpha_11};
use crate::net::tor::TorSecretKey;
use crate::prelude::*; use crate::prelude::*;
lazy_static::lazy_static! { lazy_static::lazy_static! {
@@ -33,48 +29,6 @@ impl VersionT for Version {
} }
#[instrument(skip_all)] #[instrument(skip_all)]
fn up(self, db: &mut Value, _: Self::PreUpRes) -> Result<Value, Error> { fn up(self, db: &mut Value, _: Self::PreUpRes) -> Result<Value, Error> {
let mut err = None;
let onion_store = db["private"]["keyStore"]["onion"]
.as_object_mut()
.or_not_found("private.keyStore.onion")?;
onion_store.retain(|o, v| match from_value::<TorSecretKey>(v.clone()) {
Ok(k) => k.is_valid() && &InternedString::from_display(&k.onion_address()) == o,
Err(e) => {
err = Some(e);
true
}
});
if let Some(e) = err {
return Err(e);
}
let allowed_addresses = onion_store.keys().cloned().collect::<BTreeSet<_>>();
let fix_host = |host: &mut Value| {
Ok::<_, Error>(
host["onions"]
.as_array_mut()
.or_not_found("host.onions")?
.retain(|addr| {
addr.as_str()
.map(|s| allowed_addresses.contains(s))
.unwrap_or(false)
}),
)
};
for (_, pde) in db["public"]["packageData"]
.as_object_mut()
.or_not_found("public.packageData")?
.iter_mut()
{
for (_, host) in pde["hosts"]
.as_object_mut()
.or_not_found("public.packageData[].hosts")?
.iter_mut()
{
fix_host(host)?;
}
}
fix_host(&mut db["public"]["serverInfo"]["network"]["host"])?;
if db["private"]["keyStore"]["localCerts"].is_null() { if db["private"]["keyStore"]["localCerts"].is_null() {
db["private"]["keyStore"]["localCerts"] = db["private"]["keyStore"]["localCerts"] =
db["private"]["keyStore"]["local_certs"].clone(); db["private"]["keyStore"]["local_certs"].clone();

View File

@@ -0,0 +1,192 @@
use exver::{PreReleaseSegment, VersionRange};
use super::v0_3_5::V0_3_0_COMPAT;
use super::{VersionT, v0_4_0_alpha_19};
use crate::prelude::*;
lazy_static::lazy_static! {
static ref V0_4_0_alpha_20: exver::Version = exver::Version::new(
[0, 4, 0],
[PreReleaseSegment::String("alpha".into()), 20.into()]
);
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Version;
impl VersionT for Version {
type Previous = v0_4_0_alpha_19::Version;
type PreUpRes = ();
async fn pre_up(self) -> Result<Self::PreUpRes, Error> {
Ok(())
}
fn semver(self) -> exver::Version {
V0_4_0_alpha_20.clone()
}
fn compat(self) -> &'static VersionRange {
&V0_3_0_COMPAT
}
#[instrument(skip_all)]
fn up(self, db: &mut Value, _: Self::PreUpRes) -> Result<Value, Error> {
// Remove onions and tor-related fields from server host
if let Some(host) = db
.get_mut("public")
.and_then(|p| p.get_mut("serverInfo"))
.and_then(|s| s.get_mut("network"))
.and_then(|n| n.get_mut("host"))
.and_then(|h| h.as_object_mut())
{
host.remove("onions");
}
// Remove onions from all package hosts
if let Some(packages) = db
.get_mut("public")
.and_then(|p| p.get_mut("packageData"))
.and_then(|p| p.as_object_mut())
{
for (_, package) in packages.iter_mut() {
if let Some(hosts) = package.get_mut("hosts").and_then(|h| h.as_object_mut()) {
for (_, host) in hosts.iter_mut() {
if let Some(host_obj) = host.as_object_mut() {
host_obj.remove("onions");
}
}
}
}
}
// Remove onion store from private keyStore
if let Some(key_store) = db
.get_mut("private")
.and_then(|p| p.get_mut("keyStore"))
.and_then(|k| k.as_object_mut())
{
key_store.remove("onion");
}
// Migrate server host: remove hostnameInfo, add addresses to bindings, clean net
migrate_host(
db.get_mut("public")
.and_then(|p| p.get_mut("serverInfo"))
.and_then(|s| s.get_mut("network"))
.and_then(|n| n.get_mut("host")),
);
// Migrate all package hosts
if let Some(packages) = db
.get_mut("public")
.and_then(|p| p.get_mut("packageData"))
.and_then(|p| p.as_object_mut())
{
for (_, package) in packages.iter_mut() {
if let Some(hosts) = package.get_mut("hosts").and_then(|h| h.as_object_mut()) {
for (_, host) in hosts.iter_mut() {
migrate_host(Some(host));
}
}
}
}
// Migrate availablePorts from IdPool format to BTreeMap<u16, bool>
// Rebuild from actual assigned ports in all bindings
migrate_available_ports(db);
Ok(Value::Null)
}
fn down(self, _db: &mut Value) -> Result<(), Error> {
Ok(())
}
}
fn collect_ports_from_host(host: Option<&Value>, ports: &mut Value) {
let Some(bindings) = host
.and_then(|h| h.get("bindings"))
.and_then(|b| b.as_object())
else {
return;
};
for (_, binding) in bindings.iter() {
if let Some(net) = binding.get("net") {
if let Some(port) = net.get("assignedPort").and_then(|p| p.as_u64()) {
if let Some(obj) = ports.as_object_mut() {
obj.insert(port.to_string().into(), Value::from(false));
}
}
if let Some(port) = net.get("assignedSslPort").and_then(|p| p.as_u64()) {
if let Some(obj) = ports.as_object_mut() {
obj.insert(port.to_string().into(), Value::from(true));
}
}
}
}
}
fn migrate_available_ports(db: &mut Value) {
let mut new_ports: Value = serde_json::json!({}).into();
// Collect from server host
let server_host = db
.get("public")
.and_then(|p| p.get("serverInfo"))
.and_then(|s| s.get("network"))
.and_then(|n| n.get("host"))
.cloned();
collect_ports_from_host(server_host.as_ref(), &mut new_ports);
// Collect from all package hosts
if let Some(packages) = db
.get("public")
.and_then(|p| p.get("packageData"))
.and_then(|p| p.as_object())
{
for (_, package) in packages.iter() {
if let Some(hosts) = package.get("hosts").and_then(|h| h.as_object()) {
for (_, host) in hosts.iter() {
collect_ports_from_host(Some(host), &mut new_ports);
}
}
}
}
// Replace private.availablePorts
if let Some(private) = db.get_mut("private").and_then(|p| p.as_object_mut()) {
private.insert("availablePorts".into(), new_ports);
}
}
fn migrate_host(host: Option<&mut Value>) {
let Some(host) = host.and_then(|h| h.as_object_mut()) else {
return;
};
// Remove hostnameInfo from host
host.remove("hostnameInfo");
// For each binding: add "addresses" field, remove gateway-level fields from "net"
if let Some(bindings) = host.get_mut("bindings").and_then(|b| b.as_object_mut()) {
for (_, binding) in bindings.iter_mut() {
if let Some(binding_obj) = binding.as_object_mut() {
// Add addresses if not present
if !binding_obj.contains_key("addresses") {
binding_obj.insert(
"addresses".into(),
serde_json::json!({
"privateDisabled": [],
"publicEnabled": [],
"possible": []
})
.into(),
);
}
// Remove gateway-level privateDisabled/publicEnabled from net
if let Some(net) = binding_obj.get_mut("net").and_then(|n| n.as_object_mut()) {
net.remove("privateDisabled");
net.remove("publicEnabled");
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { BindOptions } from './BindOptions' import type { BindOptions } from './BindOptions'
import type { DerivedAddressInfo } from './DerivedAddressInfo'
import type { NetInfo } from './NetInfo' import type { NetInfo } from './NetInfo'
export type BindInfo = { enabled: boolean; options: BindOptions; net: NetInfo } export type BindInfo = {
enabled: boolean
options: BindOptions
net: NetInfo
addresses: DerivedAddressInfo
}

View File

@@ -1,8 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { GatewayId } from './GatewayId'
export type BindingGatewaySetEnabledParams = { export type BindingSetAddressEnabledParams = {
internalPort: number internalPort: number
gateway: GatewayId address: string
enabled: boolean | null enabled: boolean | null
} }

View File

@@ -1,7 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { BindInfo } from './BindInfo'
export type OnionHostname = { export type Bindings = { [key: number]: BindInfo }
value: string
port: number | null
sslPort: number | null
}

View File

@@ -0,0 +1,17 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { HostnameInfo } from './HostnameInfo'
export type DerivedAddressInfo = {
/**
* User-controlled: private-gateway addresses the user has disabled
*/
privateDisabled: Array<HostnameInfo>
/**
* User-controlled: public-gateway addresses the user has enabled
*/
publicEnabled: Array<HostnameInfo>
/**
* COMPUTED: NetServiceData::update — all possible addresses for this binding
*/
possible: Array<HostnameInfo>
}

View File

@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ErrorData = { details: string; debug: string } export type ErrorData = { details: string; debug: string; info: unknown }

View File

@@ -1,15 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { BindInfo } from './BindInfo' import type { Bindings } from './Bindings'
import type { HostnameInfo } from './HostnameInfo'
import type { PublicDomainConfig } from './PublicDomainConfig' import type { PublicDomainConfig } from './PublicDomainConfig'
export type Host = { export type Host = {
bindings: { [key: number]: BindInfo } bindings: Bindings
onions: string[]
publicDomains: { [key: string]: PublicDomainConfig } publicDomains: { [key: string]: PublicDomainConfig }
privateDomains: Array<string> privateDomains: Array<string>
/**
* COMPUTED: NetService::update
*/
hostnameInfo: { [key: number]: Array<HostnameInfo> }
} }

View File

@@ -1,8 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { GatewayInfo } from './GatewayInfo' import type { GatewayInfo } from './GatewayInfo'
import type { IpHostname } from './IpHostname' import type { IpHostname } from './IpHostname'
import type { OnionHostname } from './OnionHostname'
export type HostnameInfo = export type HostnameInfo = {
| { kind: 'ip'; gateway: GatewayInfo; public: boolean; hostname: IpHostname } gateway: GatewayInfo
| { kind: 'onion'; hostname: OnionHostname } public: boolean
hostname: IpHostname
}

View File

@@ -1,9 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { GatewayId } from './GatewayId'
export type NetInfo = { export type NetInfo = {
privateDisabled: Array<GatewayId>
publicEnabled: Array<GatewayId>
assignedPort: number | null assignedPort: number | null
assignedSslPort: number | null assignedSslPort: number | null
} }

View File

@@ -36,7 +36,8 @@ export { BackupTargetFS } from './BackupTargetFS'
export { Base64 } from './Base64' export { Base64 } from './Base64'
export { BindId } from './BindId' export { BindId } from './BindId'
export { BindInfo } from './BindInfo' export { BindInfo } from './BindInfo'
export { BindingGatewaySetEnabledParams } from './BindingGatewaySetEnabledParams' export { BindingSetAddressEnabledParams } from './BindingSetAddressEnabledParams'
export { Bindings } from './Bindings'
export { BindOptions } from './BindOptions' export { BindOptions } from './BindOptions'
export { BindParams } from './BindParams' export { BindParams } from './BindParams'
export { Blake3Commitment } from './Blake3Commitment' export { Blake3Commitment } from './Blake3Commitment'
@@ -64,6 +65,7 @@ export { Dependencies } from './Dependencies'
export { DependencyMetadata } from './DependencyMetadata' export { DependencyMetadata } from './DependencyMetadata'
export { DependencyRequirement } from './DependencyRequirement' export { DependencyRequirement } from './DependencyRequirement'
export { DepInfo } from './DepInfo' export { DepInfo } from './DepInfo'
export { DerivedAddressInfo } from './DerivedAddressInfo'
export { Description } from './Description' export { Description } from './Description'
export { DesiredStatus } from './DesiredStatus' export { DesiredStatus } from './DesiredStatus'
export { DestroySubcontainerFsParams } from './DestroySubcontainerFsParams' export { DestroySubcontainerFsParams } from './DestroySubcontainerFsParams'
@@ -149,7 +151,6 @@ export { NetInfo } from './NetInfo'
export { NetworkInfo } from './NetworkInfo' export { NetworkInfo } from './NetworkInfo'
export { NetworkInterfaceInfo } from './NetworkInterfaceInfo' export { NetworkInterfaceInfo } from './NetworkInterfaceInfo'
export { NetworkInterfaceType } from './NetworkInterfaceType' export { NetworkInterfaceType } from './NetworkInterfaceType'
export { OnionHostname } from './OnionHostname'
export { OsIndex } from './OsIndex' export { OsIndex } from './OsIndex'
export { OsVersionInfoMap } from './OsVersionInfoMap' export { OsVersionInfoMap } from './OsVersionInfoMap'
export { OsVersionInfo } from './OsVersionInfo' export { OsVersionInfo } from './OsVersionInfo'

View File

@@ -1,6 +1,12 @@
import { PackageId, ServiceInterfaceId, ServiceInterfaceType } from '../types' import { PackageId, ServiceInterfaceId, ServiceInterfaceType } from '../types'
import { knownProtocols } from '../interfaces/Host' import { knownProtocols } from '../interfaces/Host'
import { AddressInfo, Host, Hostname, HostnameInfo } from '../types' import {
AddressInfo,
DerivedAddressInfo,
Host,
Hostname,
HostnameInfo,
} from '../types'
import { Effects } from '../Effects' import { Effects } from '../Effects'
import { DropGenerator, DropPromise } from './Drop' import { DropGenerator, DropPromise } from './Drop'
import { IpAddress, IPV6_LINK_LOCAL } from './ip' import { IpAddress, IPV6_LINK_LOCAL } from './ip'
@@ -20,7 +26,6 @@ export const getHostname = (url: string): Hostname | null => {
} }
type FilterKinds = type FilterKinds =
| 'onion'
| 'mdns' | 'mdns'
| 'domain' | 'domain'
| 'ip' | 'ip'
@@ -42,27 +47,25 @@ type VisibilityFilter<V extends 'public' | 'private'> = V extends 'public'
| (HostnameInfo & { public: false }) | (HostnameInfo & { public: false })
| VisibilityFilter<Exclude<V, 'private'>> | VisibilityFilter<Exclude<V, 'private'>>
: never : never
type KindFilter<K extends FilterKinds> = K extends 'onion' type KindFilter<K extends FilterKinds> = K extends 'mdns'
? (HostnameInfo & { kind: 'onion' }) | KindFilter<Exclude<K, 'onion'>> ?
: K extends 'mdns' | (HostnameInfo & { hostname: { kind: 'local' } })
| KindFilter<Exclude<K, 'mdns'>>
: K extends 'domain'
? ?
| (HostnameInfo & { kind: 'ip'; hostname: { kind: 'local' } }) | (HostnameInfo & { hostname: { kind: 'domain' } })
| KindFilter<Exclude<K, 'mdns'>> | KindFilter<Exclude<K, 'domain'>>
: K extends 'domain' : K extends 'ipv4'
? ?
| (HostnameInfo & { kind: 'ip'; hostname: { kind: 'domain' } }) | (HostnameInfo & { hostname: { kind: 'ipv4' } })
| KindFilter<Exclude<K, 'domain'>> | KindFilter<Exclude<K, 'ipv4'>>
: K extends 'ipv4' : K extends 'ipv6'
? ?
| (HostnameInfo & { kind: 'ip'; hostname: { kind: 'ipv4' } }) | (HostnameInfo & { hostname: { kind: 'ipv6' } })
| KindFilter<Exclude<K, 'ipv4'>> | KindFilter<Exclude<K, 'ipv6'>>
: K extends 'ipv6' : K extends 'ip'
? ? KindFilter<Exclude<K, 'ip'> | 'ipv4' | 'ipv6'>
| (HostnameInfo & { kind: 'ip'; hostname: { kind: 'ipv6' } }) : never
| KindFilter<Exclude<K, 'ipv6'>>
: K extends 'ip'
? KindFilter<Exclude<K, 'ip'> | 'ipv4' | 'ipv6'>
: never
type FilterReturnTy<F extends Filter> = F extends { type FilterReturnTy<F extends Filter> = F extends {
visibility: infer V extends 'public' | 'private' visibility: infer V extends 'public' | 'private'
@@ -90,10 +93,6 @@ const nonLocalFilter = {
const publicFilter = { const publicFilter = {
visibility: 'public', visibility: 'public',
} as const } as const
const onionFilter = {
kind: 'onion',
} as const
type Formats = 'hostname-info' | 'urlstring' | 'url' type Formats = 'hostname-info' | 'urlstring' | 'url'
type FormatReturnTy< type FormatReturnTy<
F extends Filter, F extends Filter,
@@ -124,7 +123,6 @@ export type Filled<F extends Filter = {}> = {
nonLocal: Filled<typeof nonLocalFilter & Filter> nonLocal: Filled<typeof nonLocalFilter & Filter>
public: Filled<typeof publicFilter & Filter> public: Filled<typeof publicFilter & Filter>
onion: Filled<typeof onionFilter & Filter>
} }
export type FilledAddressInfo = AddressInfo & Filled export type FilledAddressInfo = AddressInfo & Filled
export type ServiceInterfaceFilled = { export type ServiceInterfaceFilled = {
@@ -162,18 +160,14 @@ export const addressHostToUrl = (
scheme in knownProtocols && scheme in knownProtocols &&
port === knownProtocols[scheme as keyof typeof knownProtocols].defaultPort port === knownProtocols[scheme as keyof typeof knownProtocols].defaultPort
let hostname let hostname
if (host.kind === 'onion') { if (host.hostname.kind === 'domain') {
hostname = host.hostname.value
} else if (host.hostname.kind === 'ipv6') {
hostname = IPV6_LINK_LOCAL.contains(host.hostname.value)
? `[${host.hostname.value}%${host.hostname.scopeId}]`
: `[${host.hostname.value}]`
} else {
hostname = host.hostname.value hostname = host.hostname.value
} else if (host.kind === 'ip') {
if (host.hostname.kind === 'domain') {
hostname = host.hostname.value
} else if (host.hostname.kind === 'ipv6') {
hostname = IPV6_LINK_LOCAL.contains(host.hostname.value)
? `[${host.hostname.value}%${host.hostname.scopeId}]`
: `[${host.hostname.value}]`
} else {
hostname = host.hostname.value
}
} }
return `${scheme ? `${scheme}://` : ''}${ return `${scheme ? `${scheme}://` : ''}${
username ? `${username}@` : '' username ? `${username}@` : ''
@@ -201,13 +195,9 @@ function filterRec(
hostnames = hostnames.filter((h) => invert !== pred(h)) hostnames = hostnames.filter((h) => invert !== pred(h))
} }
if (filter.visibility === 'public') if (filter.visibility === 'public')
hostnames = hostnames.filter( hostnames = hostnames.filter((h) => invert !== h.public)
(h) => invert !== (h.kind === 'onion' || h.public),
)
if (filter.visibility === 'private') if (filter.visibility === 'private')
hostnames = hostnames.filter( hostnames = hostnames.filter((h) => invert !== !h.public)
(h) => invert !== (h.kind !== 'onion' && !h.public),
)
if (filter.kind) { if (filter.kind) {
const kind = new Set( const kind = new Set(
Array.isArray(filter.kind) ? filter.kind : [filter.kind], Array.isArray(filter.kind) ? filter.kind : [filter.kind],
@@ -219,19 +209,13 @@ function filterRec(
hostnames = hostnames.filter( hostnames = hostnames.filter(
(h) => (h) =>
invert !== invert !==
((kind.has('onion') && h.kind === 'onion') || ((kind.has('mdns') && h.hostname.kind === 'local') ||
(kind.has('mdns') && (kind.has('domain') && h.hostname.kind === 'domain') ||
h.kind === 'ip' && (kind.has('ipv4') && h.hostname.kind === 'ipv4') ||
h.hostname.kind === 'local') || (kind.has('ipv6') && h.hostname.kind === 'ipv6') ||
(kind.has('domain') &&
h.kind === 'ip' &&
h.hostname.kind === 'domain') ||
(kind.has('ipv4') && h.kind === 'ip' && h.hostname.kind === 'ipv4') ||
(kind.has('ipv6') && h.kind === 'ip' && h.hostname.kind === 'ipv6') ||
(kind.has('localhost') && (kind.has('localhost') &&
['localhost', '127.0.0.1', '::1'].includes(h.hostname.value)) || ['localhost', '127.0.0.1', '::1'].includes(h.hostname.value)) ||
(kind.has('link-local') && (kind.has('link-local') &&
h.kind === 'ip' &&
h.hostname.kind === 'ipv6' && h.hostname.kind === 'ipv6' &&
IPV6_LINK_LOCAL.contains(IpAddress.parse(h.hostname.value)))), IPV6_LINK_LOCAL.contains(IpAddress.parse(h.hostname.value)))),
) )
@@ -242,6 +226,14 @@ function filterRec(
return hostnames return hostnames
} }
function enabledAddresses(addr: DerivedAddressInfo): HostnameInfo[] {
return addr.possible.filter((h) =>
h.public
? addr.publicEnabled.some((e) => deepEqual(e, h))
: !addr.privateDisabled.some((d) => deepEqual(d, h)),
)
}
export const filledAddress = ( export const filledAddress = (
host: Host, host: Host,
addressInfo: AddressInfo, addressInfo: AddressInfo,
@@ -251,7 +243,8 @@ export const filledAddress = (
const u = toUrls(h) const u = toUrls(h)
return [u.url, u.sslUrl].filter((u) => u !== null) return [u.url, u.sslUrl].filter((u) => u !== null)
} }
const hostnames = host.hostnameInfo[addressInfo.internalPort] ?? [] const binding = host.bindings[addressInfo.internalPort]
const hostnames = binding ? enabledAddresses(binding.addresses) : []
function filledAddressFromHostnames<F extends Filter>( function filledAddressFromHostnames<F extends Filter>(
hostnames: HostnameInfo[], hostnames: HostnameInfo[],
@@ -266,11 +259,6 @@ export const filledAddress = (
filterRec(hostnames, publicFilter, false), filterRec(hostnames, publicFilter, false),
), ),
) )
const getOnion = once(() =>
filledAddressFromHostnames<typeof onionFilter & F>(
filterRec(hostnames, onionFilter, false),
),
)
return { return {
...addressInfo, ...addressInfo,
hostnames, hostnames,
@@ -294,9 +282,6 @@ export const filledAddress = (
get public(): Filled<typeof publicFilter & F> { get public(): Filled<typeof publicFilter & F> {
return getPublic() return getPublic()
}, },
get onion(): Filled<typeof onionFilter & F> {
return getOnion()
},
} }
} }

View File

@@ -21,11 +21,6 @@ export const localHostname: Pattern = {
description: 'Must be a valid ".local" hostname', description: 'Must be a valid ".local" hostname',
} }
export const torHostname: Pattern = {
regex: regexes.torHostname.matches(),
description: 'Must be a valid Tor (".onion") hostname',
}
export const url: Pattern = { export const url: Pattern = {
regex: regexes.url.matches(), regex: regexes.url.matches(),
description: 'Must be a valid URL', description: 'Must be a valid URL',
@@ -36,11 +31,6 @@ export const localUrl: Pattern = {
description: 'Must be a valid ".local" URL', description: 'Must be a valid ".local" URL',
} }
export const torUrl: Pattern = {
regex: regexes.torUrl.matches(),
description: 'Must be a valid Tor (".onion") URL',
}
export const ascii: Pattern = { export const ascii: Pattern = {
regex: regexes.ascii.matches(), regex: regexes.ascii.matches(),
description: description:

View File

@@ -39,10 +39,6 @@ export const localHostname = new ComposableRegex(
/[-a-zA-Z0-9@:%._\+~#=]{1,256}\.local/, /[-a-zA-Z0-9@:%._\+~#=]{1,256}\.local/,
) )
export const torHostname = new ComposableRegex(
/[-a-zA-Z0-9@:%._\+~#=]{1,256}\.onion/,
)
// https://ihateregex.io/expr/url/ // https://ihateregex.io/expr/url/
export const url = new ComposableRegex( export const url = new ComposableRegex(
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/, /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/,
@@ -52,10 +48,6 @@ export const localUrl = new ComposableRegex(
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.local\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/, /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.local\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/,
) )
export const torUrl = new ComposableRegex(
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.onion\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/,
)
// https://ihateregex.io/expr/ascii/ // https://ihateregex.io/expr/ascii/
export const ascii = new ComposableRegex(/[ -~]*/) export const ascii = new ComposableRegex(/[ -~]*/)

View File

@@ -67,7 +67,7 @@ import {
import { getOwnServiceInterfaces } from '../../base/lib/util/getServiceInterfaces' import { getOwnServiceInterfaces } from '../../base/lib/util/getServiceInterfaces'
import { Volumes, createVolumes } from './util/Volume' import { Volumes, createVolumes } from './util/Volume'
export const OSVersion = testTypeVersion('0.4.0-alpha.19') export const OSVersion = testTypeVersion('0.4.0-alpha.20')
// prettier-ignore // prettier-ignore
type AnyNeverCond<T extends any[], Then, Else> = type AnyNeverCond<T extends any[], Then, Else> =

View File

@@ -1,9 +1,9 @@
import * as fs from "fs" import * as fs from 'fs'
// https://stackoverflow.com/questions/2970525/converting-any-string-into-camel-case // https://stackoverflow.com/questions/2970525/converting-any-string-into-camel-case
export function camelCase(value: string) { export function camelCase(value: string) {
return value return value
.replace(/([\(\)\[\]])/g, "") .replace(/([\(\)\[\]])/g, '')
.replace(/^([A-Z])|[\s-_](\w)/g, function (match, p1, p2, offset) { .replace(/^([A-Z])|[\s-_](\w)/g, function (match, p1, p2, offset) {
if (p2) return p2.toUpperCase() if (p2) return p2.toUpperCase()
return p1.toLowerCase() return p1.toLowerCase()
@@ -23,12 +23,12 @@ export async function oldSpecToBuilder(
} }
function isString(x: unknown): x is string { function isString(x: unknown): x is string {
return typeof x === "string" return typeof x === 'string'
} }
export default async function makeFileContentFromOld( export default async function makeFileContentFromOld(
inputData: Promise<any> | any, inputData: Promise<any> | any,
{ StartSdk = "start-sdk", nested = true } = {}, { StartSdk = 'start-sdk', nested = true } = {},
) { ) {
const outputLines: string[] = [] const outputLines: string[] = []
outputLines.push(` outputLines.push(`
@@ -37,16 +37,16 @@ const {InputSpec, List, Value, Variants} = sdk
`) `)
const data = await inputData const data = await inputData
const namedConsts = new Set(["InputSpec", "Value", "List"]) const namedConsts = new Set(['InputSpec', 'Value', 'List'])
const inputSpecName = newConst("inputSpecSpec", convertInputSpec(data)) const inputSpecName = newConst('inputSpecSpec', convertInputSpec(data))
outputLines.push(`export type InputSpecSpec = typeof ${inputSpecName}._TYPE;`) outputLines.push(`export type InputSpecSpec = typeof ${inputSpecName}._TYPE;`)
return outputLines.join("\n") return outputLines.join('\n')
function newConst(key: string, data: string, type?: string) { function newConst(key: string, data: string, type?: string) {
const variableName = getNextConstName(camelCase(key)) const variableName = getNextConstName(camelCase(key))
outputLines.push( outputLines.push(
`export const ${variableName}${!type ? "" : `: ${type}`} = ${data};`, `export const ${variableName}${!type ? '' : `: ${type}`} = ${data};`,
) )
return variableName return variableName
} }
@@ -55,7 +55,7 @@ const {InputSpec, List, Value, Variants} = sdk
return newConst(key, data) return newConst(key, data)
} }
function convertInputSpecInner(data: any) { function convertInputSpecInner(data: any) {
let answer = "{" let answer = '{'
for (const [key, value] of Object.entries(data)) { for (const [key, value] of Object.entries(data)) {
const variableName = maybeNewConst(key, convertValueSpec(value)) const variableName = maybeNewConst(key, convertValueSpec(value))
@@ -69,7 +69,7 @@ const {InputSpec, List, Value, Variants} = sdk
} }
function convertValueSpec(value: any): string { function convertValueSpec(value: any): string {
switch (value.type) { switch (value.type) {
case "string": { case 'string': {
if (value.textarea) { if (value.textarea) {
return `${rangeToTodoComment( return `${rangeToTodoComment(
value?.range, value?.range,
@@ -99,12 +99,12 @@ const {InputSpec, List, Value, Variants} = sdk
warning: value.warning || null, warning: value.warning || null,
masked: value.masked || false, masked: value.masked || false,
placeholder: value.placeholder || null, placeholder: value.placeholder || null,
inputmode: "text", inputmode: 'text',
patterns: value.pattern patterns: value.pattern
? [ ? [
{ {
regex: value.pattern, regex: value.pattern,
description: value["pattern-description"], description: value['pattern-description'],
}, },
] ]
: [], : [],
@@ -115,7 +115,7 @@ const {InputSpec, List, Value, Variants} = sdk
2, 2,
)})` )})`
} }
case "number": { case 'number': {
return `${rangeToTodoComment( return `${rangeToTodoComment(
value?.range, value?.range,
)}Value.number(${JSON.stringify( )}Value.number(${JSON.stringify(
@@ -136,7 +136,7 @@ const {InputSpec, List, Value, Variants} = sdk
2, 2,
)})` )})`
} }
case "boolean": { case 'boolean': {
return `Value.toggle(${JSON.stringify( return `Value.toggle(${JSON.stringify(
{ {
name: value.name || null, name: value.name || null,
@@ -148,15 +148,15 @@ const {InputSpec, List, Value, Variants} = sdk
2, 2,
)})` )})`
} }
case "enum": { case 'enum': {
const allValueNames = new Set([ const allValueNames = new Set([
...(value?.["values"] || []), ...(value?.['values'] || []),
...Object.keys(value?.["value-names"] || {}), ...Object.keys(value?.['value-names'] || {}),
]) ])
const values = Object.fromEntries( const values = Object.fromEntries(
Array.from(allValueNames) Array.from(allValueNames)
.filter(isString) .filter(isString)
.map((key) => [key, value?.spec?.["value-names"]?.[key] || key]), .map((key) => [key, value?.spec?.['value-names']?.[key] || key]),
) )
return `Value.select(${JSON.stringify( return `Value.select(${JSON.stringify(
{ {
@@ -170,9 +170,9 @@ const {InputSpec, List, Value, Variants} = sdk
2, 2,
)} as const)` )} as const)`
} }
case "object": { case 'object': {
const specName = maybeNewConst( const specName = maybeNewConst(
value.name + "_spec", value.name + '_spec',
convertInputSpec(value.spec), convertInputSpec(value.spec),
) )
return `Value.object({ return `Value.object({
@@ -180,10 +180,10 @@ const {InputSpec, List, Value, Variants} = sdk
description: ${JSON.stringify(value.description || null)}, description: ${JSON.stringify(value.description || null)},
}, ${specName})` }, ${specName})`
} }
case "union": { case 'union': {
const variants = maybeNewConst( const variants = maybeNewConst(
value.name + "_variants", value.name + '_variants',
convertVariants(value.variants, value.tag["variant-names"] || {}), convertVariants(value.variants, value.tag['variant-names'] || {}),
) )
return `Value.union({ return `Value.union({
@@ -194,18 +194,18 @@ const {InputSpec, List, Value, Variants} = sdk
variants: ${variants}, variants: ${variants},
})` })`
} }
case "list": { case 'list': {
if (value.subtype === "enum") { if (value.subtype === 'enum') {
const allValueNames = new Set([ const allValueNames = new Set([
...(value?.spec?.["values"] || []), ...(value?.spec?.['values'] || []),
...Object.keys(value?.spec?.["value-names"] || {}), ...Object.keys(value?.spec?.['value-names'] || {}),
]) ])
const values = Object.fromEntries( const values = Object.fromEntries(
Array.from(allValueNames) Array.from(allValueNames)
.filter(isString) .filter(isString)
.map((key: string) => [ .map((key: string) => [
key, key,
value?.spec?.["value-names"]?.[key] ?? key, value?.spec?.['value-names']?.[key] ?? key,
]), ]),
) )
return `Value.multiselect(${JSON.stringify( return `Value.multiselect(${JSON.stringify(
@@ -222,10 +222,10 @@ const {InputSpec, List, Value, Variants} = sdk
2, 2,
)})` )})`
} }
const list = maybeNewConst(value.name + "_list", convertList(value)) const list = maybeNewConst(value.name + '_list', convertList(value))
return `Value.list(${list})` return `Value.list(${list})`
} }
case "pointer": { case 'pointer': {
return `/* TODO deal with point removed point "${value.name}" */null as any` return `/* TODO deal with point removed point "${value.name}" */null as any`
} }
} }
@@ -234,7 +234,7 @@ const {InputSpec, List, Value, Variants} = sdk
function convertList(value: any) { function convertList(value: any) {
switch (value.subtype) { switch (value.subtype) {
case "string": { case 'string': {
return `${rangeToTodoComment(value?.range)}List.text(${JSON.stringify( return `${rangeToTodoComment(value?.range)}List.text(${JSON.stringify(
{ {
name: value.name || null, name: value.name || null,
@@ -253,7 +253,7 @@ const {InputSpec, List, Value, Variants} = sdk
? [ ? [
{ {
regex: value.spec.pattern, regex: value.spec.pattern,
description: value?.spec?.["pattern-description"], description: value?.spec?.['pattern-description'],
}, },
] ]
: [], : [],
@@ -281,12 +281,12 @@ const {InputSpec, List, Value, Variants} = sdk
// placeholder: value?.spec?.placeholder || null, // placeholder: value?.spec?.placeholder || null,
// })})` // })})`
// } // }
case "enum": { case 'enum': {
return "/* error!! list.enum */" return '/* error!! list.enum */'
} }
case "object": { case 'object': {
const specName = maybeNewConst( const specName = maybeNewConst(
value.name + "_spec", value.name + '_spec',
convertInputSpec(value.spec.spec), convertInputSpec(value.spec.spec),
) )
return `${rangeToTodoComment(value?.range)}List.obj({ return `${rangeToTodoComment(value?.range)}List.obj({
@@ -297,20 +297,20 @@ const {InputSpec, List, Value, Variants} = sdk
description: ${JSON.stringify(value.description || null)}, description: ${JSON.stringify(value.description || null)},
}, { }, {
spec: ${specName}, spec: ${specName},
displayAs: ${JSON.stringify(value?.spec?.["display-as"] || null)}, displayAs: ${JSON.stringify(value?.spec?.['display-as'] || null)},
uniqueBy: ${JSON.stringify(value?.spec?.["unique-by"] || null)}, uniqueBy: ${JSON.stringify(value?.spec?.['unique-by'] || null)},
})` })`
} }
case "union": { case 'union': {
const variants = maybeNewConst( const variants = maybeNewConst(
value.name + "_variants", value.name + '_variants',
convertVariants( convertVariants(
value.spec.variants, value.spec.variants,
value.spec["variant-names"] || {}, value.spec['variant-names'] || {},
), ),
) )
const unionValueName = maybeNewConst( const unionValueName = maybeNewConst(
value.name + "_union", value.name + '_union',
`${rangeToTodoComment(value?.range)} `${rangeToTodoComment(value?.range)}
Value.union({ Value.union({
name: ${JSON.stringify(value?.spec?.tag?.name || null)}, name: ${JSON.stringify(value?.spec?.tag?.name || null)},
@@ -324,7 +324,7 @@ const {InputSpec, List, Value, Variants} = sdk
`, `,
) )
const listInputSpec = maybeNewConst( const listInputSpec = maybeNewConst(
value.name + "_list_inputSpec", value.name + '_list_inputSpec',
` `
InputSpec.of({ InputSpec.of({
"union": ${unionValueName} "union": ${unionValueName}
@@ -340,8 +340,8 @@ const {InputSpec, List, Value, Variants} = sdk
warning: ${JSON.stringify(value.warning || null)}, warning: ${JSON.stringify(value.warning || null)},
}, { }, {
spec: ${listInputSpec}, spec: ${listInputSpec},
displayAs: ${JSON.stringify(value?.spec?.["display-as"] || null)}, displayAs: ${JSON.stringify(value?.spec?.['display-as'] || null)},
uniqueBy: ${JSON.stringify(value?.spec?.["unique-by"] || null)}, uniqueBy: ${JSON.stringify(value?.spec?.['unique-by'] || null)},
})` })`
} }
} }
@@ -352,7 +352,7 @@ const {InputSpec, List, Value, Variants} = sdk
variants: Record<string, unknown>, variants: Record<string, unknown>,
variantNames: Record<string, string>, variantNames: Record<string, string>,
): string { ): string {
let answer = "Variants.of({" let answer = 'Variants.of({'
for (const [key, value] of Object.entries(variants)) { for (const [key, value] of Object.entries(variants)) {
const variantSpec = maybeNewConst(key, convertInputSpec(value)) const variantSpec = maybeNewConst(key, convertInputSpec(value))
answer += `"${key}": {name: "${ answer += `"${key}": {name: "${
@@ -373,7 +373,7 @@ const {InputSpec, List, Value, Variants} = sdk
} }
function rangeToTodoComment(range: string | undefined) { function rangeToTodoComment(range: string | undefined) {
if (!range) return "" if (!range) return ''
return `/* TODO: Convert range for this value (${range})*/` return `/* TODO: Convert range for this value (${range})*/`
} }

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "startos-ui", "name": "startos-ui",
"version": "0.4.0-alpha.19", "version": "0.4.0-alpha.20",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "startos-ui", "name": "startos-ui",
"version": "0.4.0-alpha.19", "version": "0.4.0-alpha.20",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@angular/animations": "^20.3.0", "@angular/animations": "^20.3.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "startos-ui", "name": "startos-ui",
"version": "0.4.0-alpha.19", "version": "0.4.0-alpha.20",
"author": "Start9 Labs, Inc", "author": "Start9 Labs, Inc",
"homepage": "https://start9.com/", "homepage": "https://start9.com/",
"license": "MIT", "license": "MIT",

View File

@@ -679,4 +679,16 @@ export default {
714: 'Installation abgeschlossen!', 714: 'Installation abgeschlossen!',
715: 'StartOS wurde erfolgreich installiert.', 715: 'StartOS wurde erfolgreich installiert.',
716: 'Weiter zur Einrichtung', 716: 'Weiter zur Einrichtung',
717: '',
718: '',
719: '',
720: '',
721: '',
722: '',
723: '',
724: '',
725: '',
726: '',
727: '',
728: '',
} satisfies i18n } satisfies i18n

View File

@@ -679,4 +679,16 @@ export const ENGLISH: Record<string, number> = {
'Installation Complete!': 714, 'Installation Complete!': 714,
'StartOS has been installed successfully.': 715, 'StartOS has been installed successfully.': 715,
'Continue to Setup': 716, 'Continue to Setup': 716,
'Set Outbound Gateway': 717,
'Current': 718,
'System default)': 719,
'Outbound Gateway': 720,
'Select the gateway for outbound traffic': 721,
'The type of gateway': 722,
'Outbound Only': 723,
'Set as default outbound': 724,
'Route all outbound traffic through this gateway': 725,
'Wireguard Config File': 726,
'Inbound/Outbound': 727,
'StartTunnel (Inbound/Outbound)': 728,
} }

View File

@@ -679,4 +679,16 @@ export default {
714: '¡Instalación completada!', 714: '¡Instalación completada!',
715: 'StartOS se ha instalado correctamente.', 715: 'StartOS se ha instalado correctamente.',
716: 'Continuar con la configuración', 716: 'Continuar con la configuración',
717: '',
718: '',
719: '',
720: '',
721: '',
722: '',
723: '',
724: '',
725: '',
726: '',
727: '',
728: '',
} satisfies i18n } satisfies i18n

View File

@@ -679,4 +679,16 @@ export default {
714: 'Installation terminée !', 714: 'Installation terminée !',
715: 'StartOS a été installé avec succès.', 715: 'StartOS a été installé avec succès.',
716: 'Continuer vers la configuration', 716: 'Continuer vers la configuration',
717: '',
718: '',
719: '',
720: '',
721: '',
722: '',
723: '',
724: '',
725: '',
726: '',
727: '',
728: '',
} satisfies i18n } satisfies i18n

View File

@@ -679,4 +679,16 @@ export default {
714: 'Instalacja zakończona!', 714: 'Instalacja zakończona!',
715: 'StartOS został pomyślnie zainstalowany.', 715: 'StartOS został pomyślnie zainstalowany.',
716: 'Przejdź do konfiguracji', 716: 'Przejdź do konfiguracji',
717: '',
718: '',
719: '',
720: '',
721: '',
722: '',
723: '',
724: '',
725: '',
726: '',
727: '',
728: '',
} satisfies i18n } satisfies i18n

View File

@@ -1,5 +1,4 @@
export type AccessType = export type AccessType =
| 'tor'
| 'mdns' | 'mdns'
| 'localhost' | 'localhost'
| 'ipv4' | 'ipv4'

View File

@@ -45,8 +45,8 @@ export const mockTunnelData: TunnelData = {
gateways: { gateways: {
eth0: { eth0: {
name: null, name: null,
public: null,
secure: null, secure: null,
type: null,
ipInfo: { ipInfo: {
name: 'Wired Connection 1', name: 'Wired Connection 1',
scopeId: 1, scopeId: 1,

View File

@@ -83,32 +83,8 @@ export class InterfaceGatewaysComponent {
readonly gateways = input.required<InterfaceGateway[] | undefined>() readonly gateways = input.required<InterfaceGateway[] | undefined>()
async onToggle(gateway: InterfaceGateway) { async onToggle(_gateway: InterfaceGateway) {
const addressInfo = this.interface.value()!.addressInfo // TODO: Replace with per-address toggle UI (Section 6 frontend overhaul).
const pkgId = this.interface.packageId() // Gateway-level toggle replaced by set-address-enabled RPC.
const loader = this.loader.open().subscribe()
try {
if (pkgId) {
await this.api.pkgBindingToggleGateway({
gateway: gateway.id,
enabled: !gateway.enabled,
internalPort: addressInfo.internalPort,
host: addressInfo.hostId,
package: pkgId,
})
} else {
await this.api.serverBindingToggleGateway({
gateway: gateway.id,
enabled: !gateway.enabled,
internalPort: 80,
})
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
} }
} }

View File

@@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { tuiButtonOptionsProvider } from '@taiga-ui/core' import { tuiButtonOptionsProvider } from '@taiga-ui/core'
import { MappedServiceInterface } from './interface.service' import { MappedServiceInterface } from './interface.service'
import { InterfaceGatewaysComponent } from './gateways.component' import { InterfaceGatewaysComponent } from './gateways.component'
import { InterfaceTorDomainsComponent } from './tor-domains.component'
import { PublicDomainsComponent } from './public-domains/pd.component' import { PublicDomainsComponent } from './public-domains/pd.component'
import { InterfacePrivateDomainsComponent } from './private-domains.component' import { InterfacePrivateDomainsComponent } from './private-domains.component'
import { InterfaceAddressesComponent } from './addresses/addresses.component' import { InterfaceAddressesComponent } from './addresses/addresses.component'
@@ -16,7 +15,6 @@ import { InterfaceAddressesComponent } from './addresses/addresses.component'
[publicDomains]="value()?.publicDomains" [publicDomains]="value()?.publicDomains"
[addSsl]="value()?.addSsl || false" [addSsl]="value()?.addSsl || false"
></section> ></section>
<section [torDomains]="value()?.torDomains"></section>
<section [privateDomains]="value()?.privateDomains"></section> <section [privateDomains]="value()?.privateDomains"></section>
</div> </div>
<hr [style.width.rem]="10" /> <hr [style.width.rem]="10" />
@@ -52,7 +50,6 @@ import { InterfaceAddressesComponent } from './addresses/addresses.component'
providers: [tuiButtonOptionsProvider({ size: 'xs' })], providers: [tuiButtonOptionsProvider({ size: 'xs' })],
imports: [ imports: [
InterfaceGatewaysComponent, InterfaceGatewaysComponent,
InterfaceTorDomainsComponent,
PublicDomainsComponent, PublicDomainsComponent,
InterfacePrivateDomainsComponent, InterfacePrivateDomainsComponent,
InterfaceAddressesComponent, InterfaceAddressesComponent,

View File

@@ -27,17 +27,9 @@ function cmpWithRankedPredicates<T extends AddressWithInfo>(
return 0 return 0
} }
type TorAddress = AddressWithInfo & { info: { kind: 'onion' } } type LanAddress = AddressWithInfo & { info: { public: false } }
function filterTor(a: AddressWithInfo): a is TorAddress {
return a.info.kind === 'onion'
}
function cmpTor(a: TorAddress, b: TorAddress): -1 | 0 | 1 {
return cmpWithRankedPredicates(a, b, [x => !x.showSsl])
}
type LanAddress = AddressWithInfo & { info: { kind: 'ip'; public: false } }
function filterLan(a: AddressWithInfo): a is LanAddress { function filterLan(a: AddressWithInfo): a is LanAddress {
return a.info.kind === 'ip' && !a.info.public return !a.info.public
} }
function cmpLan(host: T.Host, a: LanAddress, b: LanAddress): -1 | 0 | 1 { function cmpLan(host: T.Host, a: LanAddress, b: LanAddress): -1 | 0 | 1 {
return cmpWithRankedPredicates(a, b, [ return cmpWithRankedPredicates(a, b, [
@@ -53,15 +45,12 @@ function cmpLan(host: T.Host, a: LanAddress, b: LanAddress): -1 | 0 | 1 {
type VpnAddress = AddressWithInfo & { type VpnAddress = AddressWithInfo & {
info: { info: {
kind: 'ip'
public: false public: false
hostname: { kind: 'ipv4' | 'ipv6' | 'domain' } hostname: { kind: 'ipv4' | 'ipv6' | 'domain' }
} }
} }
function filterVpn(a: AddressWithInfo): a is VpnAddress { function filterVpn(a: AddressWithInfo): a is VpnAddress {
return ( return !a.info.public && a.info.hostname.kind !== 'local'
a.info.kind === 'ip' && !a.info.public && a.info.hostname.kind !== 'local'
)
} }
function cmpVpn(host: T.Host, a: VpnAddress, b: VpnAddress): -1 | 0 | 1 { function cmpVpn(host: T.Host, a: VpnAddress, b: VpnAddress): -1 | 0 | 1 {
return cmpWithRankedPredicates(a, b, [ return cmpWithRankedPredicates(a, b, [
@@ -76,13 +65,12 @@ function cmpVpn(host: T.Host, a: VpnAddress, b: VpnAddress): -1 | 0 | 1 {
type ClearnetAddress = AddressWithInfo & { type ClearnetAddress = AddressWithInfo & {
info: { info: {
kind: 'ip'
public: true public: true
hostname: { kind: 'ipv4' | 'ipv6' | 'domain' } hostname: { kind: 'ipv4' | 'ipv6' | 'domain' }
} }
} }
function filterClearnet(a: AddressWithInfo): a is ClearnetAddress { function filterClearnet(a: AddressWithInfo): a is ClearnetAddress {
return a.info.kind === 'ip' && a.info.public return a.info.public
} }
function cmpClearnet( function cmpClearnet(
host: T.Host, host: T.Host,
@@ -142,10 +130,7 @@ export class InterfaceService {
h, h,
) )
const info = h const info = h
const gateway = const gateway = gateways.find(g => h.gateway.id === g.id)
h.kind === 'ip'
? gateways.find(g => h.gateway.id === g.id)
: undefined
const res = [] const res = []
if (url) { if (url) {
res.push({ res.push({
@@ -171,7 +156,6 @@ export class InterfaceService {
}, },
) )
const torAddrs = allAddressesWithInfo.filter(filterTor).sort(cmpTor)
const lanAddrs = allAddressesWithInfo const lanAddrs = allAddressesWithInfo
.filter(filterLan) .filter(filterLan)
.sort((a, b) => cmpLan(host, a, b)) .sort((a, b) => cmpLan(host, a, b))
@@ -188,7 +172,6 @@ export class InterfaceService {
clearnetAddrs[0], clearnetAddrs[0],
lanAddrs[0], lanAddrs[0],
vpnAddrs[0], vpnAddrs[0],
torAddrs[0],
] ]
.filter(a => !!a) .filter(a => !!a)
.reduce((acc, x) => { .reduce((acc, x) => {
@@ -214,9 +197,8 @@ export class InterfaceService {
kind: 'domain', kind: 'domain',
visibility: 'public', visibility: 'public',
}) })
const tor = addresses.filter({ kind: 'onion' })
const wanIp = addresses.filter({ kind: 'ipv4', visibility: 'public' }) const wanIp = addresses.filter({ kind: 'ipv4', visibility: 'public' })
const bestPublic = [publicDomains, tor, wanIp].flatMap(h => const bestPublic = [publicDomains, wanIp].flatMap(h =>
h.format('urlstring'), h.format('urlstring'),
)[0] )[0]
const privateDomains = addresses.filter({ const privateDomains = addresses.filter({
@@ -254,9 +236,6 @@ export class InterfaceService {
.format('urlstring')[0] .format('urlstring')[0]
onLan = true onLan = true
break break
case 'tor':
matching = tor.format('urlstring')[0]
break
case 'mdns': case 'mdns':
matching = mdns.format('urlstring')[0] matching = mdns.format('urlstring')[0]
onLan = true onLan = true
@@ -273,19 +252,23 @@ export class InterfaceService {
serviceInterface: T.ServiceInterface, serviceInterface: T.ServiceInterface,
host: T.Host, host: T.Host,
): T.HostnameInfo[] { ): T.HostnameInfo[] {
let hostnameInfo = const binding =
host.hostnameInfo[serviceInterface.addressInfo.internalPort] host.bindings[serviceInterface.addressInfo.internalPort]
return ( if (!binding) return []
hostnameInfo?.filter( const addr = binding.addresses
h => const enabled = addr.possible.filter(h =>
this.config.accessType === 'localhost' || h.public
!( ? addr.publicEnabled.some(e => utils.deepEqual(e, h))
h.kind === 'ip' && : !addr.privateDisabled.some(d => utils.deepEqual(d, h)),
((h.hostname.kind === 'ipv6' && )
utils.IPV6_LINK_LOCAL.contains(h.hostname.value)) || return enabled.filter(
h.gateway.id === 'lo') h =>
), this.config.accessType === 'localhost' ||
) || [] !(
(h.hostname.kind === 'ipv6' &&
utils.IPV6_LINK_LOCAL.contains(h.hostname.value)) ||
h.gateway.id === 'lo'
),
) )
} }
@@ -302,31 +285,7 @@ export class InterfaceService {
"Requires trusting your server's Root CA", "Requires trusting your server's Root CA",
) )
// ** Tor ** {
if (info.kind === 'onion') {
access = null
gatewayName = null
type = 'Tor'
bullets = [
this.i18n.transform('Connections can be slow or unreliable at times'),
this.i18n.transform(
'Public if you share the address publicly, otherwise private',
),
this.i18n.transform('Requires using a Tor-enabled device or browser'),
]
// Tor (SSL)
if (showSsl) {
bullets = [rootCaRequired, ...bullets]
// Tor (NON-SSL)
} else {
bullets.unshift(
this.i18n.transform(
'Ideal for anonymous, censorship-resistant hosting and remote access',
),
)
}
// ** Not Tor **
} else {
const port = info.hostname.sslPort || info.hostname.port const port = info.hostname.sslPort || info.hostname.port
gatewayName = info.gateway.name gatewayName = info.gateway.name
@@ -479,7 +438,6 @@ export class InterfaceService {
export type MappedServiceInterface = T.ServiceInterface & { export type MappedServiceInterface = T.ServiceInterface & {
gateways: InterfaceGateway[] gateways: InterfaceGateway[]
torDomains: string[]
publicDomains: PublicDomain[] publicDomains: PublicDomain[]
privateDomains: string[] privateDomains: string[]
addresses: { addresses: {

View File

@@ -1,193 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import {
DialogService,
DocsLinkDirective,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB, utils } from '@start9labs/start-sdk'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiSkeleton } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { filter } from 'rxjs'
import {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { InterfaceComponent } from './interface.component'
type OnionForm = {
key: string
}
@Component({
selector: 'section[torDomains]',
template: `
<header>
{{ 'Tor Domains' | i18n }}
<a
tuiIconButton
docsLink
path="/user-manual/connecting-remotely/tor.html"
appearance="icon"
iconStart="@tui.external-link"
>
{{ 'Documentation' | i18n }}
</a>
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="add()"
[disabled]="!torDomains()"
>
{{ 'Add' | i18n }}
</button>
</header>
@for (domain of torDomains(); track domain) {
<div tuiCell="s">
<span tuiTitle>{{ domain }}</span>
<button
tuiIconButton
iconStart="@tui.trash"
appearance="action-destructive"
(click)="remove(domain)"
>
{{ 'Delete' | i18n }}
</button>
</div>
} @empty {
@if (torDomains()) {
<app-placeholder icon="@tui.target">
{{ 'No Tor domains' | i18n }}
</app-placeholder>
} @else {
@for (_ of [0, 1]; track $index) {
<label tuiCell="s">
<span tuiTitle [tuiSkeleton]="true">{{ 'Loading' | i18n }}</span>
</label>
}
}
}
`,
styles: `
:host {
grid-column: span 6;
overflow-wrap: break-word;
}
`,
host: { class: 'g-card' },
imports: [
TuiCell,
TuiTitle,
TuiButton,
PlaceholderComponent,
i18nPipe,
DocsLinkDirective,
TuiSkeleton,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceTorDomainsComponent {
private readonly dialog = inject(DialogService)
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly interface = inject(InterfaceComponent)
private readonly i18n = inject(i18nPipe)
readonly torDomains = input.required<readonly string[] | undefined>()
async remove(onion: string) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Removing').subscribe()
const params = { onion }
try {
if (this.interface.packageId()) {
await this.api.pkgRemoveOnion({
...params,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
await this.api.serverRemoveOnion(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
})
}
async add() {
this.formDialog.open<FormContext<OnionForm>>(FormComponent, {
label: 'New Tor domain',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
key: ISB.Value.text({
name: this.i18n.transform('Private Key (optional)')!,
description: this.i18n.transform(
'Optionally provide a base64-encoded ed25519 private key for generating the Tor V3 (.onion) domain. If not provided, a random key will be generated.',
),
required: false,
default: null,
patterns: [utils.Patterns.base64],
}),
}),
),
buttons: [
{
text: this.i18n.transform('Save')!,
handler: async value => this.save(value.key),
},
],
},
})
}
private async save(key?: string): Promise<boolean> {
const loader = this.loader.open('Saving').subscribe()
try {
const onion = key
? await this.api.addTorKey({ key })
: await this.api.generateTorKey({})
if (this.interface.packageId()) {
await this.api.pkgAddOnion({
onion,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
await this.api.serverAddOnion({ onion })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -13,10 +13,6 @@ export const ROUTES: Routes = [
path: 'os', path: 'os',
loadComponent: () => import('./routes/os.component'), loadComponent: () => import('./routes/os.component'),
}, },
{
path: 'tor',
loadComponent: () => import('./routes/tor.component'),
},
] ]
export default ROUTES export default ROUTES

View File

@@ -79,12 +79,6 @@ export default class SystemLogsComponent {
subtitle: 'Raw, unfiltered operating system logs', subtitle: 'Raw, unfiltered operating system logs',
icon: '@tui.square-dashed-bottom-code', icon: '@tui.square-dashed-bottom-code',
}, },
{
link: 'tor',
title: 'Tor Logs',
subtitle: 'Diagnostics for the Tor daemon on this server',
icon: '@tui.target',
},
{ {
link: 'kernel', link: 'kernel',
title: 'Kernel Logs', title: 'Kernel Logs',

View File

@@ -1,32 +0,0 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
import { LogsHeaderComponent } from 'src/app/routes/portal/routes/logs/components/header.component'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
template: `
<logs-header [title]="'Tor Logs' | i18n">
{{ 'Diagnostics for the Tor daemon on this server' | i18n }}
</logs-header>
<logs context="tor" [followLogs]="follow" [fetchLogs]="fetch" />
`,
styles: `
:host {
padding: 1rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LogsComponent, LogsHeaderComponent, i18nPipe],
host: { class: 'g-page' },
})
export default class SystemTorComponent {
private readonly api = inject(ApiService)
protected readonly follow = (params: RR.FollowServerLogsReq) =>
this.api.followTorLogs(params)
protected readonly fetch = (params: RR.GetServerLogsReq) =>
this.api.getTorLogs(params)
}

View File

@@ -15,7 +15,6 @@ import { TuiBadge } from '@taiga-ui/kit'
`, `,
styles: ` styles: `
:host { :host {
clip-path: inset(0 round 0.75rem);
cursor: pointer; cursor: pointer;
&:hover { &:hover {

View File

@@ -6,12 +6,18 @@ import {
inject, inject,
} from '@angular/core' } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop' import { toSignal } from '@angular/core/rxjs-interop'
import { getPkgId, i18nPipe } from '@start9labs/shared' import {
import { T } from '@start9labs/start-sdk' ErrorService,
getPkgId,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB, T } from '@start9labs/start-sdk'
import { TuiCell } from '@taiga-ui/layout' import { TuiCell } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs' import { firstValueFrom, map } from 'rxjs'
import { ActionService } from 'src/app/services/action.service' import { ActionService } from 'src/app/services/action.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model' import { DataModel } from 'src/app/services/patch-db/data-model'
import { StandardActionsService } from 'src/app/services/standard-actions.service' import { StandardActionsService } from 'src/app/services/standard-actions.service'
import { getManifest } from 'src/app/utils/get-package-data' import { getManifest } from 'src/app/utils/get-package-data'
@@ -20,6 +26,9 @@ import {
PrimaryStatus, PrimaryStatus,
renderPkgStatus, renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service' } from 'src/app/services/pkg-status-rendering.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
const INACTIVE: PrimaryStatus[] = [ const INACTIVE: PrimaryStatus[] = [
'installing', 'installing',
@@ -65,6 +74,12 @@ const ALLOWED_STATUSES: Record<T.AllowedStatuses, Set<string>> = {
<section class="g-card"> <section class="g-card">
<header>StartOS</header> <header>StartOS</header>
<button
tuiCell
[action]="outboundGatewayAction()"
[inactive]="inactive"
(click)="openOutboundGatewayModal()"
></button>
<button <button
tuiCell tuiCell
[action]="rebuild" [action]="rebuild"
@@ -95,66 +110,78 @@ const ALLOWED_STATUSES: Record<T.AllowedStatuses, Set<string>> = {
export default class ServiceActionsRoute { export default class ServiceActionsRoute {
private readonly actions = inject(ActionService) private readonly actions = inject(ActionService)
private readonly i18n = inject(i18nPipe) private readonly i18n = inject(i18nPipe)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly formDialog = inject(FormDialogService)
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
ungrouped: 'General' | 'Other' = 'General' ungrouped: 'General' | 'Other' = 'General'
readonly service = inject(StandardActionsService) readonly service = inject(StandardActionsService)
readonly package = toSignal( readonly package = toSignal(
inject<PatchDB<DataModel>>(PatchDB) this.patch.watch$('packageData', getPkgId()).pipe(
.watch$('packageData', getPkgId()) map(pkg => {
.pipe( const specialGroup = Object.values(pkg.actions).some(a => !!a.group)
map(pkg => { ? 'Other'
const specialGroup = Object.values(pkg.actions).some(a => !!a.group) : 'General'
? 'Other' const status = renderPkgStatus(pkg).primary
: 'General' return {
const status = renderPkgStatus(pkg).primary status,
return { icon: pkg.icon,
status, manifest: getManifest(pkg),
icon: pkg.icon, outboundGateway: pkg.outboundGateway,
manifest: getManifest(pkg), actions: Object.entries(pkg.actions)
actions: Object.entries(pkg.actions) .filter(([_, action]) => action.visibility !== 'hidden')
.filter(([_, action]) => action.visibility !== 'hidden') .map(([id, action]) => ({
.map(([id, action]) => ({ ...action,
...action, id,
id, group: action.group || specialGroup,
group: action.group || specialGroup, visibility: ALLOWED_STATUSES[action.allowedStatuses].has(status)
visibility: ALLOWED_STATUSES[action.allowedStatuses].has( ? action.visibility
status, : ({
) disabled: `${this.i18n.transform('Action can only be executed when service is')} ${this.i18n.transform(action.allowedStatuses === 'only-running' ? 'Running' : 'Stopped')?.toLowerCase()}`,
? action.visibility } as T.ActionVisibility),
: ({ }))
disabled: `${this.i18n.transform('Action can only be executed when service is')} ${this.i18n.transform(action.allowedStatuses === 'only-running' ? 'Running' : 'Stopped')?.toLowerCase()}`, .sort((a, b) => {
} as T.ActionVisibility), if (a.group === specialGroup && b.group !== specialGroup) return 1
})) if (b.group === specialGroup && a.group !== specialGroup)
.sort((a, b) => { return -1
if (a.group === specialGroup && b.group !== specialGroup)
return 1
if (b.group === specialGroup && a.group !== specialGroup)
return -1
const groupCompare = a.group.localeCompare(b.group) // sort groups lexicographically const groupCompare = a.group.localeCompare(b.group) // sort groups lexicographically
if (groupCompare !== 0) return groupCompare if (groupCompare !== 0) return groupCompare
return a.id.localeCompare(b.id) // sort actions within groups lexicographically return a.id.localeCompare(b.id) // sort actions within groups lexicographically
}) })
.reduce< .reduce<
Record< Record<
string, string,
Array<T.ActionMetadata & { id: string; group: string }> Array<T.ActionMetadata & { id: string; group: string }>
> >
>((acc, action) => { >((acc, action) => {
const key = action.group const key = action.group
if (!acc[key]) { if (!acc[key]) {
acc[key] = [] acc[key] = []
} }
acc[key].push(action) acc[key].push(action)
return acc return acc
}, {}), }, {}),
} }
}), }),
), ),
) )
readonly outboundGatewayAction = computed(() => {
const pkg = this.package()
const gateway = pkg?.outboundGateway
return {
name: this.i18n.transform('Set Outbound Gateway')!,
description: gateway
? `${this.i18n.transform('Current')}: ${gateway}`
: `${this.i18n.transform('Current')}: ${this.i18n.transform('System')}`,
}
})
readonly rebuild = { readonly rebuild = {
name: this.i18n.transform('Rebuild Service')!, name: this.i18n.transform('Rebuild Service')!,
description: this.i18n.transform( description: this.i18n.transform(
@@ -181,6 +208,71 @@ export default class ServiceActionsRoute {
}) })
} }
async openOutboundGatewayModal() {
const pkg = this.package()
if (!pkg) return
const gateways = await firstValueFrom(
this.patch.watch$('serverInfo', 'network', 'gateways'),
)
const SYSTEM_KEY = 'system'
const options: Record<string, string> = {
[SYSTEM_KEY]: this.i18n.transform('System default')!,
}
Object.entries(gateways)
.filter(
([_, g]) =>
!!g.ipInfo &&
g.ipInfo.deviceType !== 'bridge' &&
g.ipInfo.deviceType !== 'loopback',
)
.forEach(([id, g]) => {
options[id] = g.name ?? g.ipInfo?.name ?? id
})
const spec = ISB.InputSpec.of({
gateway: ISB.Value.select({
name: this.i18n.transform('Outbound Gateway'),
description: this.i18n.transform(
'Select the gateway for outbound traffic',
),
default: pkg.outboundGateway ?? SYSTEM_KEY,
values: options,
}),
})
this.formDialog.open(FormComponent, {
label: 'Set Outbound Gateway',
data: {
spec: await configBuilderToSpec(spec),
buttons: [
{
text: this.i18n.transform('Save'),
handler: async (input: typeof spec._TYPE) => {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.setServiceOutbound({
packageId: pkg.manifest.id,
gateway: input.gateway === SYSTEM_KEY ? null : input.gateway,
})
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
},
},
],
},
})
}
protected readonly isInactive = computed( protected readonly isInactive = computed(
(pkg = this.package()) => !pkg || INACTIVE.includes(pkg.status), (pkg = this.package()) => !pkg || INACTIVE.includes(pkg.status),
) )

View File

@@ -135,11 +135,10 @@ export default class ServiceInterfaceRoute {
gateways.map(g => ({ gateways.map(g => ({
enabled: enabled:
(g.public (g.public
? binding?.net.publicEnabled.includes(g.id) ? binding?.addresses.publicEnabled.some(a => a.gateway.id === g.id)
: !binding?.net.privateDisabled.includes(g.id)) ?? false, : !binding?.addresses.privateDisabled.some(a => a.gateway.id === g.id)) ?? false,
...g, ...g,
})) || [], })) || [],
torDomains: host.onions,
publicDomains: getPublicDomains(host.publicDomains, gateways), publicDomains: getPublicDomains(host.publicDomains, gateways),
privateDomains: host.privateDomains, privateDomains: host.privateDomains,
addSsl: !!binding?.options.addSsl, addSsl: !!binding?.options.addSsl,

View File

@@ -13,7 +13,6 @@ import {
TuiFiles, TuiFiles,
tuiInputFilesOptionsProvider, tuiInputFilesOptionsProvider,
} from '@taiga-ui/kit' } from '@taiga-ui/kit'
import { ConfigService } from 'src/app/services/config.service'
import { TitleDirective } from 'src/app/services/title.service' import { TitleDirective } from 'src/app/services/title.service'
import { SideloadPackageComponent } from './package.component' import { SideloadPackageComponent } from './package.component'
@@ -55,11 +54,6 @@ import { MarketplacePkgSideload, validateS9pk } from './sideload.utils'
<div> <div>
<tui-avatar appearance="secondary" src="@tui.upload" /> <tui-avatar appearance="secondary" src="@tui.upload" />
<p>{{ 'Upload .s9pk package file' | i18n }}</p> <p>{{ 'Upload .s9pk package file' | i18n }}</p>
@if (isTor) {
<p class="g-warning">
{{ 'Warning: package upload will be slow over Tor.' | i18n }}
</p>
}
<button tuiButton>{{ 'Select' | i18n }}</button> <button tuiButton>{{ 'Select' | i18n }}</button>
</div> </div>
} }
@@ -92,8 +86,6 @@ import { MarketplacePkgSideload, validateS9pk } from './sideload.utils'
], ],
}) })
export default class SideloadComponent { export default class SideloadComponent {
readonly isTor = inject(ConfigService).accessType === 'tor'
file: File | null = null file: File | null = null
readonly package = signal<MarketplacePkgSideload | null>(null) readonly package = signal<MarketplacePkgSideload | null>(null)
readonly error = signal<i18nKey | null>(null) readonly error = signal<i18nKey | null>(null)

View File

@@ -15,6 +15,7 @@ import { GatewaysTableComponent } from './table.component'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { TitleDirective } from 'src/app/services/title.service' import { TitleDirective } from 'src/app/services/title.service'
import { ISB } from '@start9labs/start-sdk' import { ISB } from '@start9labs/start-sdk'
import { RR } from 'src/app/services/api/api.types'
@Component({ @Component({
template: ` template: `
@@ -51,11 +52,6 @@ import { ISB } from '@start9labs/start-sdk'
<gateways-table /> <gateways-table />
</section> </section>
`, `,
styles: `
:host {
max-width: 64rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
CommonModule, CommonModule,
@@ -85,8 +81,19 @@ export default class GatewaysComponent {
default: null, default: null,
placeholder: 'StartTunnel 1', placeholder: 'StartTunnel 1',
}), }),
type: ISB.Value.select({
name: this.i18n.transform('Type'),
description: this.i18n.transform('The type of gateway'),
default: 'inbound-outbound',
values: {
'inbound-outbound': this.i18n.transform(
'StartTunnel (Inbound/Outbound)',
),
'outbound-only': this.i18n.transform('Outbound Only'),
},
}),
config: ISB.Value.union({ config: ISB.Value.union({
name: this.i18n.transform('StartTunnel Config File'), name: this.i18n.transform('Wireguard Config File'),
default: 'paste', default: 'paste',
variants: ISB.Variants.of({ variants: ISB.Variants.of({
paste: { paste: {
@@ -113,10 +120,17 @@ export default class GatewaysComponent {
}, },
}), }),
}), }),
setAsDefaultOutbound: ISB.Value.toggle({
name: this.i18n.transform('Set as default outbound'),
description: this.i18n.transform(
'Route all outbound traffic through this gateway',
),
default: false,
}),
}) })
this.formDialog.open(FormComponent, { this.formDialog.open(FormComponent, {
label: 'Add StartTunnel Gateway', label: 'Add Wireguard Gateway',
data: { data: {
spec: await configBuilderToSpec(spec), spec: await configBuilderToSpec(spec),
buttons: [ buttons: [
@@ -132,7 +146,8 @@ export default class GatewaysComponent {
input.config.selection === 'paste' input.config.selection === 'paste'
? input.config.value.file ? input.config.value.file
: await (input.config.value.file as any as File).text(), : await (input.config.value.file as any as File).text(),
public: false, type: input.type as RR.GatewayType,
setAsDefaultOutbound: input.setAsDefaultOutbound,
}) })
return true return true
} catch (e: any) { } catch (e: any) {

View File

@@ -15,6 +15,7 @@ import {
TuiButton, TuiButton,
TuiDataList, TuiDataList,
TuiDropdown, TuiDropdown,
TuiIcon,
TuiOptGroup, TuiOptGroup,
TuiTextfield, TuiTextfield,
} from '@taiga-ui/core' } from '@taiga-ui/core'
@@ -24,32 +25,55 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service' import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { GatewayPlus } from 'src/app/services/gateway.service' import { GatewayPlus } from 'src/app/services/gateway.service'
import { TuiBadge } from '@taiga-ui/kit'
@Component({ @Component({
selector: 'tr[gateway]', selector: 'tr[gateway]',
template: ` template: `
@if (gateway(); as gateway) { @if (gateway(); as gateway) {
<td class="name"> <td>
{{ gateway.name }} {{ gateway.name }}
</td> @if (gateway.isDefaultOutbound) {
<td class="type"> <span tuiBadge tuiStatus appearance="positive">Default outbound</span>
@if (gateway.ipInfo.deviceType; as type) { }
{{ type }} ({{ </td>
gateway.public ? ('public' | i18n) : ('private' | i18n) <td>
}}) @switch (gateway.ipInfo.deviceType) {
} @else { @case ('ethernet') {
- <tui-icon icon="@tui.cable" />
{{ 'Ethernet' | i18n }}
}
@case ('wireless') {
<tui-icon icon="@tui.wifi" />
{{ 'WiFi' | i18n }}
}
@case ('wireguard') {
<tui-icon icon="@tui.shield" />
{{ 'WireGuard' | i18n }}
}
@default {
{{ gateway.ipInfo.deviceType }}
}
}
</td>
<td>
@if (gateway.type === 'outbound-only') {
<tui-icon icon="@tui.arrow-up-right" />
{{ 'Outbound Only' | i18n }}
} @else {
<tui-icon icon="@tui.arrow-left-right" />
{{ 'Inbound/Outbound' | i18n }}
} }
</td> </td>
<td class="lan">{{ gateway.lanIpv4.join(', ') }}</td>
<td <td
class="wan" class="wan"
[style.color]=" [style.color]="
gateway.ipInfo.wanIp ? 'var(--tui-text-warning)' : undefined gateway.ipInfo.wanIp ? undefined : 'var(--tui-text-warning)'
" "
> >
{{ gateway.ipInfo.wanIp || ('Error' | i18n) }} {{ gateway.ipInfo.wanIp || ('Error' | i18n) }}
</td> </td>
<td class="lan">{{ gateway.lanIpv4.join(', ') || '-' }}</td>
<td> <td>
<button <button
tuiIconButton tuiIconButton
@@ -67,6 +91,18 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
{{ 'Rename' | i18n }} {{ 'Rename' | i18n }}
</button> </button>
</tui-opt-group> </tui-opt-group>
@if (!gateway.isDefaultOutbound) {
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.arrow-up-right"
(click)="setDefaultOutbound()"
>
{{ 'Set as Default Outbound' | i18n }}
</button>
</tui-opt-group>
}
@if (gateway.ipInfo.deviceType === 'wireguard') { @if (gateway.ipInfo.deviceType === 'wireguard') {
<tui-opt-group> <tui-opt-group>
<button <button
@@ -87,19 +123,11 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
`, `,
styles: ` styles: `
td:last-child { td:last-child {
grid-area: 1 / 3 / 5; grid-area: 1 / 3 / 7;
align-self: center; align-self: center;
text-align: right; text-align: right;
} }
.name {
width: 14rem;
}
.type {
width: 14rem;
}
:host-context(tui-root._mobile) { :host-context(tui-root._mobile) {
grid-template-columns: min-content 1fr min-content; grid-template-columns: min-content 1fr min-content;
@@ -107,11 +135,15 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
grid-column: span 2; grid-column: span 2;
} }
.type { .connection {
grid-column: span 2; grid-column: span 2;
order: -1; order: -1;
} }
.type {
grid-column: span 2;
}
.lan, .lan,
.wan { .wan {
grid-column: span 2; grid-column: span 2;
@@ -132,9 +164,11 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
TuiButton, TuiButton,
TuiDropdown, TuiDropdown,
TuiDataList, TuiDataList,
TuiIcon,
TuiOptGroup, TuiOptGroup,
TuiTextfield, TuiTextfield,
i18nPipe, i18nPipe,
TuiBadge,
], ],
}) })
export class GatewaysItemComponent { export class GatewaysItemComponent {
@@ -166,6 +200,18 @@ export class GatewaysItemComponent {
}) })
} }
async setDefaultOutbound() {
const loader = this.loader.open().subscribe()
try {
await this.api.setDefaultOutbound({ gateway: this.gateway().id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async rename() { async rename() {
const { id, name } = this.gateway() const { id, name } = this.gateway()
const renameSpec = ISB.InputSpec.of({ const renameSpec = ISB.InputSpec.of({

View File

@@ -8,12 +8,21 @@ import { GatewayService } from 'src/app/services/gateway.service'
@Component({ @Component({
selector: 'gateways-table', selector: 'gateways-table',
template: ` template: `
<table [appTable]="['Name', 'Type', $any('LAN IP'), $any('WAN IP'), null]"> <table
[appTable]="[
'Name',
'Connection',
'Type',
$any('WAN IP'),
$any('LAN IP'),
null,
]"
>
@for (gateway of gatewayService.gateways(); track $index) { @for (gateway of gatewayService.gateways(); track $index) {
<tr [gateway]="gateway"></tr> <tr [gateway]="gateway"></tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="5"> <td colspan="7">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div> <div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td> </td>
</tr> </tr>

View File

@@ -47,13 +47,11 @@ import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { filter } from 'rxjs' import { filter } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
import { OSService } from 'src/app/services/os.service' import { OSService } from 'src/app/services/os.service'
import { DataModel } from 'src/app/services/patch-db/data-model' import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service' import { TitleDirective } from 'src/app/services/title.service'
import { SnekDirective } from './snek.directive' import { SnekDirective } from './snek.directive'
import { UPDATE } from './update.component' import { UPDATE } from './update.component'
import { SystemWipeComponent } from './wipe.component'
import { KeyboardSelectComponent } from './keyboard-select.component' import { KeyboardSelectComponent } from './keyboard-select.component'
@Component({ @Component({
@@ -66,7 +64,7 @@ import { KeyboardSelectComponent } from './keyboard-select.component'
</ng-container> </ng-container>
@if (server(); as server) { @if (server(); as server) {
<div tuiCell tuiAppearance="outline-grayscale"> <div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.zap" /> <tui-icon icon="@tui.zap" (click)="count = count + 1" />
<span tuiTitle> <span tuiTitle>
<strong>{{ 'Software Update' | i18n }}</strong> <strong>{{ 'Software Update' | i18n }}</strong>
<span tuiSubtitle [style.flex-wrap]="'wrap'"> <span tuiSubtitle [style.flex-wrap]="'wrap'">
@@ -178,18 +176,6 @@ import { KeyboardSelectComponent } from './keyboard-select.component'
</button> </button>
} }
</div> </div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.rotate-cw" (click)="count = count + 1" />
<span tuiTitle>
<strong>{{ 'Restart Tor' | i18n }}</strong>
<span tuiSubtitle>
{{ 'Restart the Tor daemon on your server' | i18n }}
</span>
</span>
<button tuiButton appearance="glass" (click)="onTorRestart()">
{{ 'Restart' | i18n }}
</button>
</div>
@if (count > 4) { @if (count > 4) {
<div tuiCell tuiAppearance="outline-grayscale" tuiAnimated> <div tuiCell tuiAppearance="outline-grayscale" tuiAnimated>
<tui-icon icon="@tui.briefcase-medical" /> <tui-icon icon="@tui.briefcase-medical" />
@@ -283,12 +269,10 @@ export default class SystemGeneralComponent {
private readonly errorService = inject(ErrorService) private readonly errorService = inject(ErrorService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB) private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly isTor = inject(ConfigService).accessType === 'tor'
private readonly dialog = inject(DialogService) private readonly dialog = inject(DialogService)
private readonly i18n = inject(i18nPipe) private readonly i18n = inject(i18nPipe)
private readonly injector = inject(INJECTOR) private readonly injector = inject(INJECTOR)
wipe = false
count = 0 count = 0
readonly server = toSignal(this.patch.watch$('serverInfo')) readonly server = toSignal(this.patch.watch$('serverInfo'))
@@ -392,24 +376,6 @@ export default class SystemGeneralComponent {
}) })
} }
onTorRestart() {
this.wipe = false
this.dialog
.openConfirm({
label: this.isTor ? 'Warning' : 'Confirm',
data: {
content: new PolymorpheusComponent(
SystemWipeComponent,
this.injector,
),
yes: 'Restart',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.resetTor(this.wipe))
}
async onRepair() { async onRepair() {
this.dialog this.dialog
.openConfirm({ .openConfirm({
@@ -532,19 +498,6 @@ export default class SystemGeneralComponent {
.subscribe(() => this.restart()) .subscribe(() => this.restart())
} }
private async resetTor(wipeState: boolean) {
const loader = this.loader.open().subscribe()
try {
await this.api.resetTor({ wipeState, reason: 'User triggered' })
this.dialog.openAlert('Tor restart in progress').subscribe()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private update() { private update() {
this.dialogs this.dialogs
.open(UPDATE, { .open(UPDATE, {

View File

@@ -1,36 +0,0 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { TuiLabel } from '@taiga-ui/core'
import { TuiCheckbox } from '@taiga-ui/kit'
import { ConfigService } from 'src/app/services/config.service'
import SystemGeneralComponent from './general.component'
import { i18nPipe } from '@start9labs/shared'
@Component({
template: `
@if (isTor) {
<p>
{{
'You are currently connected over Tor. If you restart the Tor daemon, you will lose connectivity until it comes back online.'
| i18n
}}
</p>
}
<p>
{{
'Optionally wipe state to forcibly acquire new guard nodes. It is recommended to try without wiping state first.'
| i18n
}}
</p>
<label tuiLabel>
<input type="checkbox" tuiCheckbox [(ngModel)]="component.wipe" />
{{ 'Wipe state' | i18n }}
</label>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiLabel, FormsModule, TuiCheckbox, i18nPipe],
})
export class SystemWipeComponent {
readonly isTor = inject(ConfigService).accessType === 'tor'
readonly component = inject(SystemGeneralComponent)
}

View File

@@ -96,11 +96,10 @@ export default class StartOsUiComponent {
gateways: gateways.map(g => ({ gateways: gateways.map(g => ({
enabled: enabled:
(g.public (g.public
? binding?.net.publicEnabled.includes(g.id) ? binding?.addresses.publicEnabled.some(a => a.gateway.id === g.id)
: !binding?.net.privateDisabled.includes(g.id)) ?? false, : !binding?.addresses.privateDisabled.some(a => a.gateway.id === g.id)) ?? false,
...g, ...g,
})), })),
torDomains: network.host.onions,
publicDomains: getPublicDomains(network.host.publicDomains, gateways), publicDomains: getPublicDomains(network.host.publicDomains, gateways),
privateDomains: network.host.privateDomains, privateDomains: network.host.privateDomains,
addSsl: true, addSsl: true,

View File

@@ -2126,8 +2126,74 @@ export namespace Mock {
net: { net: {
assignedPort: 80, assignedPort: 80,
assignedSslPort: 443, assignedSslPort: 443,
publicEnabled: [], },
addresses: {
privateDisabled: [], privateDisabled: [],
publicEnabled: [],
possible: [
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 1234,
},
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 1234,
},
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv4',
value: '192.168.10.11',
port: null,
sslPort: 1234,
},
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.2',
port: null,
sslPort: 1234,
},
},
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'ipv6',
value: '[fe80:cd00:0000:0cde:1257:0000:211e:72cd]',
scopeId: 2,
port: null,
sslPort: 1234,
},
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv6',
value: '[fe80:cd00:0000:0cde:1257:0000:211e:1234]',
scopeId: 3,
port: null,
sslPort: 1234,
},
},
],
}, },
options: { options: {
addSsl: null, addSsl: null,
@@ -2138,87 +2204,6 @@ export namespace Mock {
}, },
publicDomains: {}, publicDomains: {},
privateDomains: [], privateDomains: [],
onions: [],
hostnameInfo: {
80: [
{
kind: 'ip',
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv4',
value: '192.168.10.11',
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.2',
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'ipv6',
value: '[fe80:cd00:0000:0cde:1257:0000:211e:72cd]',
scopeId: 2,
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv6',
value: '[fe80:cd00:0000:0cde:1257:0000:211e:1234]',
scopeId: 3,
port: null,
sslPort: 1234,
},
},
{
kind: 'onion',
hostname: {
value: 'bitcoin-p2p.onion',
port: 80,
sslPort: 443,
},
},
],
},
}, },
bcdefgh: { bcdefgh: {
bindings: { bindings: {
@@ -2227,8 +2212,11 @@ export namespace Mock {
net: { net: {
assignedPort: 8332, assignedPort: 8332,
assignedSslPort: null, assignedSslPort: null,
publicEnabled: [], },
addresses: {
privateDisabled: [], privateDisabled: [],
publicEnabled: [],
possible: [],
}, },
options: { options: {
addSsl: null, addSsl: null,
@@ -2239,10 +2227,6 @@ export namespace Mock {
}, },
publicDomains: {}, publicDomains: {},
privateDomains: [], privateDomains: [],
onions: [],
hostnameInfo: {
8332: [],
},
}, },
cdefghi: { cdefghi: {
bindings: { bindings: {
@@ -2251,8 +2235,11 @@ export namespace Mock {
net: { net: {
assignedPort: 8333, assignedPort: 8333,
assignedSslPort: null, assignedSslPort: null,
publicEnabled: [], },
addresses: {
privateDisabled: [], privateDisabled: [],
publicEnabled: [],
possible: [],
}, },
options: { options: {
addSsl: null, addSsl: null,
@@ -2263,13 +2250,10 @@ export namespace Mock {
}, },
publicDomains: {}, publicDomains: {},
privateDomains: [], privateDomains: [],
onions: [],
hostnameInfo: {
8333: [],
},
}, },
}, },
storeExposedDependents: [], storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/', registry: 'https://registry.start9.com/',
developerKey: 'developer-key', developerKey: 'developer-key',
tasks: { tasks: {
@@ -2338,6 +2322,7 @@ export namespace Mock {
}, },
hosts: {}, hosts: {},
storeExposedDependents: [], storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/', registry: 'https://registry.start9.com/',
developerKey: 'developer-key', developerKey: 'developer-key',
tasks: {}, tasks: {},
@@ -2444,6 +2429,7 @@ export namespace Mock {
}, },
hosts: {}, hosts: {},
storeExposedDependents: [], storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/', registry: 'https://registry.start9.com/',
developerKey: 'developer-key', developerKey: 'developer-key',
tasks: { tasks: {

View File

@@ -79,14 +79,14 @@ export namespace RR {
uptime: number // seconds uptime: number // seconds
} }
export type GetServerLogsReq = FetchLogsReq // server.logs & server.kernel-logs & net.tor.logs export type GetServerLogsReq = FetchLogsReq // server.logs & server.kernel-logs
export type GetServerLogsRes = FetchLogsRes export type GetServerLogsRes = FetchLogsRes
export type FollowServerLogsReq = { export type FollowServerLogsReq = {
limit?: number // (optional) default is 50. Ignored if cursor provided limit?: number // (optional) default is 50. Ignored if cursor provided
boot?: number | string | null // (optional) number is offset (0: current, -1 prev, +1 first), string is a specific boot id, null is all. Default is undefined boot?: number | string | null // (optional) number is offset (0: current, -1 prev, +1 first), string is a specific boot id, null is all. Default is undefined
cursor?: string // the last known log. Websocket will return all logs since this log cursor?: string // the last known log. Websocket will return all logs since this log
} // server.logs.follow & server.kernel-logs.follow & net.tor.follow-logs } // server.logs.follow & server.kernel-logs.follow
export type FollowServerLogsRes = { export type FollowServerLogsRes = {
startCursor: string startCursor: string
guid: string guid: string
@@ -120,12 +120,6 @@ export namespace RR {
} // net.dns.query } // net.dns.query
export type QueryDnsRes = string | null export type QueryDnsRes = string | null
export type ResetTorReq = {
wipeState: boolean
reason: string
} // net.tor.reset
export type ResetTorRes = null
export type SetKeyboardReq = FullKeyboard // server.set-keyboard export type SetKeyboardReq = FullKeyboard // server.set-keyboard
export type SetKeyboardRes = null export type SetKeyboardRes = null
@@ -258,10 +252,13 @@ export namespace RR {
// network // network
export type GatewayType = 'inbound-outbound' | 'outbound-only'
export type AddTunnelReq = { export type AddTunnelReq = {
name: string name: string
config: string // file contents config: string // file contents
public: boolean type: GatewayType
setAsDefaultOutbound?: boolean
} // net.tunnel.add } // net.tunnel.add
export type AddTunnelRes = { export type AddTunnelRes = {
id: string id: string
@@ -276,6 +273,17 @@ export namespace RR {
export type RemoveTunnelReq = { id: string } // net.tunnel.remove export type RemoveTunnelReq = { id: string } // net.tunnel.remove
export type RemoveTunnelRes = null export type RemoveTunnelRes = null
// Set default outbound gateway
export type SetDefaultOutboundReq = { gateway: string | null } // net.gateway.set-default-outbound
export type SetDefaultOutboundRes = null
// Set service outbound gateway
export type SetServiceOutboundReq = {
packageId: string
gateway: string | null
} // package.set-outbound-gateway
export type SetServiceOutboundRes = null
export type InitAcmeReq = { export type InitAcmeReq = {
provider: string provider: string
contact: string[] contact: string[]
@@ -287,29 +295,13 @@ export namespace RR {
} }
export type RemoveAcmeRes = null export type RemoveAcmeRes = null
export type AddTorKeyReq = { export type ServerBindingSetAddressEnabledReq = {
// net.tor.key.add // server.host.binding.set-address-enabled
key: string
}
export type GenerateTorKeyReq = {} // net.tor.key.generate
export type AddTorKeyRes = string // onion address *with* .onion suffix
export type ServerBindingToggleGatewayReq = {
// server.host.binding.set-gateway-enabled
gateway: T.GatewayId
internalPort: 80 internalPort: 80
enabled: boolean address: string // JSON-serialized HostnameInfo
enabled: boolean | null // null = reset to default
} }
export type ServerBindingToggleGatewayRes = null export type ServerBindingSetAddressEnabledRes = null
export type ServerAddOnionReq = {
// server.host.address.onion.add
onion: string // address *with* .onion suffix
}
export type AddOnionRes = null
export type ServerRemoveOnionReq = ServerAddOnionReq // server.host.address.onion.remove
export type RemoveOnionRes = null
export type OsUiAddPublicDomainReq = { export type OsUiAddPublicDomainReq = {
// server.host.address.domain.public.add // server.host.address.domain.public.add
@@ -337,23 +329,16 @@ export namespace RR {
} }
export type OsUiRemovePrivateDomainRes = null export type OsUiRemovePrivateDomainRes = null
export type PkgBindingToggleGatewayReq = Omit< export type PkgBindingSetAddressEnabledReq = Omit<
ServerBindingToggleGatewayReq, ServerBindingSetAddressEnabledReq,
'internalPort' 'internalPort'
> & { > & {
// package.host.binding.set-gateway-enabled // package.host.binding.set-address-enabled
internalPort: number internalPort: number
package: T.PackageId // string package: T.PackageId // string
host: T.HostId // string host: T.HostId // string
} }
export type PkgBindingToggleGatewayRes = null export type PkgBindingSetAddressEnabledRes = null
export type PkgAddOnionReq = ServerAddOnionReq & {
// package.host.address.onion.add
package: T.PackageId // string
host: T.HostId // string
}
export type PkgRemoveOnionReq = PkgAddOnionReq // package.host.address.onion.remove
export type PkgAddPublicDomainReq = OsUiAddPublicDomainReq & { export type PkgAddPublicDomainReq = OsUiAddPublicDomainReq & {
// package.host.address.domain.public.add // package.host.address.domain.public.add

View File

@@ -81,8 +81,6 @@ export abstract class ApiService {
params: RR.GetServerLogsReq, params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> ): Promise<RR.GetServerLogsRes>
abstract getTorLogs(params: RR.GetServerLogsReq): Promise<RR.GetServerLogsRes>
abstract getKernelLogs( abstract getKernelLogs(
params: RR.GetServerLogsReq, params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> ): Promise<RR.GetServerLogsRes>
@@ -91,10 +89,6 @@ export abstract class ApiService {
params: RR.FollowServerLogsReq, params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> ): Promise<RR.FollowServerLogsRes>
abstract followTorLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes>
abstract followKernelLogs( abstract followKernelLogs(
params: RR.FollowServerLogsReq, params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> ): Promise<RR.FollowServerLogsRes>
@@ -125,8 +119,6 @@ export abstract class ApiService {
abstract queryDns(params: RR.QueryDnsReq): Promise<RR.QueryDnsRes> abstract queryDns(params: RR.QueryDnsReq): Promise<RR.QueryDnsRes>
abstract resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes>
// smtp // smtp
abstract setSmtp(params: RR.SetSMTPReq): Promise<RR.SetSMTPRes> abstract setSmtp(params: RR.SetSMTPReq): Promise<RR.SetSMTPRes>
@@ -183,6 +175,14 @@ export abstract class ApiService {
abstract removeTunnel(params: RR.RemoveTunnelReq): Promise<RR.RemoveTunnelRes> abstract removeTunnel(params: RR.RemoveTunnelReq): Promise<RR.RemoveTunnelRes>
abstract setDefaultOutbound(
params: RR.SetDefaultOutboundReq,
): Promise<RR.SetDefaultOutboundRes>
abstract setServiceOutbound(
params: RR.SetServiceOutboundReq,
): Promise<RR.SetServiceOutboundRes>
// ** domains ** // ** domains **
// wifi // wifi
@@ -344,21 +344,9 @@ export abstract class ApiService {
abstract removeAcme(params: RR.RemoveAcmeReq): Promise<RR.RemoveAcmeRes> abstract removeAcme(params: RR.RemoveAcmeReq): Promise<RR.RemoveAcmeRes>
abstract addTorKey(params: RR.AddTorKeyReq): Promise<RR.AddTorKeyRes> abstract serverBindingSetAddressEnabled(
params: RR.ServerBindingSetAddressEnabledReq,
abstract generateTorKey( ): Promise<RR.ServerBindingSetAddressEnabledRes>
params: RR.GenerateTorKeyReq,
): Promise<RR.AddTorKeyRes>
abstract serverBindingToggleGateway(
params: RR.ServerBindingToggleGatewayReq,
): Promise<RR.ServerBindingToggleGatewayRes>
abstract serverAddOnion(params: RR.ServerAddOnionReq): Promise<RR.AddOnionRes>
abstract serverRemoveOnion(
params: RR.ServerRemoveOnionReq,
): Promise<RR.RemoveOnionRes>
abstract osUiAddPublicDomain( abstract osUiAddPublicDomain(
params: RR.OsUiAddPublicDomainReq, params: RR.OsUiAddPublicDomainReq,
@@ -376,15 +364,9 @@ export abstract class ApiService {
params: RR.OsUiRemovePrivateDomainReq, params: RR.OsUiRemovePrivateDomainReq,
): Promise<RR.OsUiRemovePrivateDomainRes> ): Promise<RR.OsUiRemovePrivateDomainRes>
abstract pkgBindingToggleGateway( abstract pkgBindingSetAddressEnabled(
params: RR.PkgBindingToggleGatewayReq, params: RR.PkgBindingSetAddressEnabledReq,
): Promise<RR.PkgBindingToggleGatewayRes> ): Promise<RR.PkgBindingSetAddressEnabledRes>
abstract pkgAddOnion(params: RR.PkgAddOnionReq): Promise<RR.AddOnionRes>
abstract pkgRemoveOnion(
params: RR.PkgRemoveOnionReq,
): Promise<RR.RemoveOnionRes>
abstract pkgAddPublicDomain( abstract pkgAddPublicDomain(
params: RR.PkgAddPublicDomainReq, params: RR.PkgAddPublicDomainReq,

View File

@@ -195,10 +195,6 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'server.logs', params }) return this.rpcRequest({ method: 'server.logs', params })
} }
async getTorLogs(params: RR.GetServerLogsReq): Promise<RR.GetServerLogsRes> {
return this.rpcRequest({ method: 'net.tor.logs', params })
}
async getKernelLogs( async getKernelLogs(
params: RR.GetServerLogsReq, params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> { ): Promise<RR.GetServerLogsRes> {
@@ -211,12 +207,6 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'server.logs.follow', params }) return this.rpcRequest({ method: 'server.logs.follow', params })
} }
async followTorLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
return this.rpcRequest({ method: 'net.tor.logs.follow', params })
}
async followKernelLogs( async followKernelLogs(
params: RR.FollowServerLogsReq, params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> { ): Promise<RR.FollowServerLogsRes> {
@@ -278,10 +268,6 @@ export class LiveApiService extends ApiService {
}) })
} }
async resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes> {
return this.rpcRequest({ method: 'net.tor.reset', params })
}
// marketplace URLs // marketplace URLs
async checkOSUpdate( async checkOSUpdate(
@@ -369,6 +355,18 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'net.tunnel.remove', params }) return this.rpcRequest({ method: 'net.tunnel.remove', params })
} }
async setDefaultOutbound(
params: RR.SetDefaultOutboundReq,
): Promise<RR.SetDefaultOutboundRes> {
return this.rpcRequest({ method: 'net.gateway.set-default-outbound', params })
}
async setServiceOutbound(
params: RR.SetServiceOutboundReq,
): Promise<RR.SetServiceOutboundRes> {
return this.rpcRequest({ method: 'package.set-outbound-gateway', params })
}
// wifi // wifi
async enableWifi(params: RR.EnabledWifiReq): Promise<RR.EnabledWifiRes> { async enableWifi(params: RR.EnabledWifiReq): Promise<RR.EnabledWifiRes> {
@@ -621,41 +619,11 @@ export class LiveApiService extends ApiService {
}) })
} }
async addTorKey(params: RR.AddTorKeyReq): Promise<RR.AddTorKeyRes> { async serverBindingSetAddressEnabled(
params: RR.ServerBindingSetAddressEnabledReq,
): Promise<RR.ServerBindingSetAddressEnabledRes> {
return this.rpcRequest({ return this.rpcRequest({
method: 'net.tor.key.add', method: 'server.host.binding.set-address-enabled',
params,
})
}
async generateTorKey(params: RR.GenerateTorKeyReq): Promise<RR.AddTorKeyRes> {
return this.rpcRequest({
method: 'net.tor.key.generate',
params,
})
}
async serverBindingToggleGateway(
params: RR.ServerBindingToggleGatewayReq,
): Promise<RR.ServerBindingToggleGatewayRes> {
return this.rpcRequest({
method: 'server.host.binding.set-gateway-enabled',
params,
})
}
async serverAddOnion(params: RR.ServerAddOnionReq): Promise<RR.AddOnionRes> {
return this.rpcRequest({
method: 'server.host.address.onion.add',
params,
})
}
async serverRemoveOnion(
params: RR.ServerRemoveOnionReq,
): Promise<RR.RemoveOnionRes> {
return this.rpcRequest({
method: 'server.host.address.onion.remove',
params, params,
}) })
} }
@@ -696,27 +664,11 @@ export class LiveApiService extends ApiService {
}) })
} }
async pkgBindingToggleGateway( async pkgBindingSetAddressEnabled(
params: RR.PkgBindingToggleGatewayReq, params: RR.PkgBindingSetAddressEnabledReq,
): Promise<RR.PkgBindingToggleGatewayRes> { ): Promise<RR.PkgBindingSetAddressEnabledRes> {
return this.rpcRequest({ return this.rpcRequest({
method: 'package.host.binding.set-gateway-enabled', method: 'package.host.binding.set-address-enabled',
params,
})
}
async pkgAddOnion(params: RR.PkgAddOnionReq): Promise<RR.AddOnionRes> {
return this.rpcRequest({
method: 'package.host.address.onion.add',
params,
})
}
async pkgRemoveOnion(
params: RR.PkgRemoveOnionReq,
): Promise<RR.RemoveOnionRes> {
return this.rpcRequest({
method: 'package.host.address.onion.remove',
params, params,
}) })
} }

View File

@@ -281,17 +281,6 @@ export class MockApiService extends ApiService {
} }
} }
async getTorLogs(params: RR.GetServerLogsReq): Promise<RR.GetServerLogsRes> {
await pauseFor(2000)
const entries = this.randomLogs(params.limit)
return {
entries,
startCursor: 'start-cursor',
endCursor: 'end-cursor',
}
}
async getKernelLogs( async getKernelLogs(
params: RR.GetServerLogsReq, params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> { ): Promise<RR.GetServerLogsRes> {
@@ -315,16 +304,6 @@ export class MockApiService extends ApiService {
} }
} }
async followTorLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
await pauseFor(2000)
return {
startCursor: 'start-cursor',
guid: 'logs-guid',
}
}
async followKernelLogs( async followKernelLogs(
params: RR.FollowServerLogsReq, params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> { ): Promise<RR.FollowServerLogsRes> {
@@ -504,11 +483,6 @@ export class MockApiService extends ApiService {
return null return null
} }
async resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes> {
await pauseFor(2000)
return null
}
// marketplace URLs // marketplace URLs
async checkOSUpdate( async checkOSUpdate(
@@ -592,13 +566,12 @@ export class MockApiService extends ApiService {
const id = `wg${this.proxyId++}` const id = `wg${this.proxyId++}`
const patch: AddOperation<T.NetworkInterfaceInfo>[] = [ const patch: AddOperation<any>[] = [
{ {
op: PatchOp.ADD, op: PatchOp.ADD,
path: `/serverInfo/network/gateways/${id}`, path: `/serverInfo/network/gateways/${id}`,
value: { value: {
name: params.name, name: params.name,
public: params.public,
secure: false, secure: false,
ipInfo: { ipInfo: {
name: id, name: id,
@@ -610,9 +583,19 @@ export class MockApiService extends ApiService {
lanIp: ['192.168.1.10'], lanIp: ['192.168.1.10'],
dnsServers: [], dnsServers: [],
}, },
type: params.type,
}, },
}, },
] ]
if (params.setAsDefaultOutbound) {
;(patch as any[]).push({
op: PatchOp.REPLACE,
path: '/serverInfo/network/defaultOutbound',
value: id,
})
}
this.mockRevision(patch) this.mockRevision(patch)
return { id } return { id }
@@ -646,6 +629,38 @@ export class MockApiService extends ApiService {
return null return null
} }
async setDefaultOutbound(
params: RR.SetDefaultOutboundReq,
): Promise<RR.SetDefaultOutboundRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/network/defaultOutbound',
value: params.gateway,
},
]
this.mockRevision(patch)
return null
}
async setServiceOutbound(
params: RR.SetServiceOutboundReq,
): Promise<RR.SetServiceOutboundRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: `/packageData/${params.packageId}/outboundGateway`,
value: params.gateway,
},
]
this.mockRevision(patch)
return null
}
// wifi // wifi
async enableWifi(params: RR.EnabledWifiReq): Promise<RR.EnabledWifiRes> { async enableWifi(params: RR.EnabledWifiReq): Promise<RR.EnabledWifiRes> {
@@ -1374,77 +1389,12 @@ export class MockApiService extends ApiService {
return null return null
} }
async addTorKey(params: RR.AddTorKeyReq): Promise<RR.AddTorKeyRes> { async serverBindingSetAddressEnabled(
await pauseFor(2000) params: RR.ServerBindingSetAddressEnabledReq,
return 'vanityabcdefghijklmnop.onion' ): Promise<RR.ServerBindingSetAddressEnabledRes> {
}
async generateTorKey(params: RR.GenerateTorKeyReq): Promise<RR.AddTorKeyRes> {
await pauseFor(2000)
return 'abcdefghijklmnopqrstuv.onion'
}
async serverBindingToggleGateway(
params: RR.ServerBindingToggleGatewayReq,
): Promise<RR.ServerBindingToggleGatewayRes> {
await pauseFor(2000) await pauseFor(2000)
const patch = [ // Mock: no-op since address enable/disable modifies DerivedAddressInfo sets
{
op: PatchOp.REPLACE,
path: `/serverInfo/network/host/bindings/${params.internalPort}/net/publicEnabled`,
value: params.enabled ? [params.gateway] : [],
},
]
this.mockRevision(patch)
return null
}
async serverAddOnion(params: RR.ServerAddOnionReq): Promise<RR.AddOnionRes> {
await pauseFor(2000)
const patch: Operation<any>[] = [
{
op: PatchOp.ADD,
path: `/serverInfo/host/onions/0`,
value: params.onion,
},
{
op: PatchOp.ADD,
path: `/serverInfo/host/hostnameInfo/80/0`,
value: {
kind: 'onion',
hostname: {
port: 80,
sslPort: 443,
value: params.onion,
},
},
},
]
this.mockRevision(patch)
return null
}
async serverRemoveOnion(
params: RR.ServerRemoveOnionReq,
): Promise<RR.RemoveOnionRes> {
await pauseFor(2000)
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/serverInfo/host/onions/0`,
},
{
op: PatchOp.REMOVE,
path: `/serverInfo/host/hostnameInfo/80/-1`,
},
]
this.mockRevision(patch)
return null return null
} }
@@ -1463,10 +1413,9 @@ export class MockApiService extends ApiService {
}, },
{ {
op: PatchOp.ADD, op: PatchOp.ADD,
path: `/serverInfo/host/hostnameInfo/80/0`, path: `/serverInfo/network/host/bindings/80/addresses/possible/0`,
value: { value: {
kind: 'ip', gateway: { id: 'eth0', name: 'Ethernet', public: false },
gatewayId: 'eth0',
public: true, public: true,
hostname: { hostname: {
kind: 'domain', kind: 'domain',
@@ -1495,7 +1444,7 @@ export class MockApiService extends ApiService {
}, },
{ {
op: PatchOp.REMOVE, op: PatchOp.REMOVE,
path: `/serverInfo/host/hostnameInfo/80/0`, path: `/serverInfo/network/host/bindings/80/addresses/possible/0`,
}, },
] ]
this.mockRevision(patch) this.mockRevision(patch)
@@ -1516,10 +1465,9 @@ export class MockApiService extends ApiService {
}, },
{ {
op: PatchOp.ADD, op: PatchOp.ADD,
path: `/serverInfo/host/hostnameInfo/80/0`, path: `/serverInfo/network/host/bindings/80/addresses/possible/0`,
value: { value: {
kind: 'ip', gateway: { id: 'eth0', name: 'Ethernet', public: false },
gatewayId: 'eth0',
public: false, public: false,
hostname: { hostname: {
kind: 'domain', kind: 'domain',
@@ -1549,7 +1497,7 @@ export class MockApiService extends ApiService {
}, },
{ {
op: PatchOp.REMOVE, op: PatchOp.REMOVE,
path: `/serverInfo/host/hostnameInfo/80/0`, path: `/serverInfo/network/host/bindings/80/addresses/possible/0`,
}, },
] ]
this.mockRevision(patch) this.mockRevision(patch)
@@ -1557,67 +1505,12 @@ export class MockApiService extends ApiService {
return null return null
} }
async pkgBindingToggleGateway( async pkgBindingSetAddressEnabled(
params: RR.PkgBindingToggleGatewayReq, params: RR.PkgBindingSetAddressEnabledReq,
): Promise<RR.PkgBindingToggleGatewayRes> { ): Promise<RR.PkgBindingSetAddressEnabledRes> {
await pauseFor(2000) await pauseFor(2000)
const patch = [ // Mock: no-op since address enable/disable modifies DerivedAddressInfo sets
{
op: PatchOp.REPLACE,
path: `/packageData/${params.package}/hosts/${params.host}/bindings/${params.internalPort}/net/privateDisabled`,
value: params.enabled ? [] : [params.gateway],
},
]
this.mockRevision(patch)
return null
}
async pkgAddOnion(params: RR.PkgAddOnionReq): Promise<RR.AddOnionRes> {
await pauseFor(2000)
const patch: Operation<any>[] = [
{
op: PatchOp.ADD,
path: `/packageData/${params.package}/hosts/${params.host}/onions/0`,
value: params.onion,
},
{
op: PatchOp.ADD,
path: `/packageData/${params.package}/hosts/${params.host}/hostnameInfo/80/0`,
value: {
kind: 'onion',
hostname: {
port: 80,
sslPort: 443,
value: params.onion,
},
},
},
]
this.mockRevision(patch)
return null
}
async pkgRemoveOnion(
params: RR.PkgRemoveOnionReq,
): Promise<RR.RemoveOnionRes> {
await pauseFor(2000)
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/packageData/${params.package}/hosts/${params.host}/onions/0`,
},
{
op: PatchOp.REMOVE,
path: `/packageData/${params.package}/hosts/${params.host}/hostnameInfo/80/0`,
},
]
this.mockRevision(patch)
return null return null
} }
@@ -1636,10 +1529,9 @@ export class MockApiService extends ApiService {
}, },
{ {
op: PatchOp.ADD, op: PatchOp.ADD,
path: `/packageData/${params.package}/hosts/${params.host}/hostnameInfo/80/0`, path: `/packageData/${params.package}/hosts/${params.host}/bindings/80/addresses/possible/0`,
value: { value: {
kind: 'ip', gateway: { id: 'eth0', name: 'Ethernet', public: false },
gatewayId: 'eth0',
public: true, public: true,
hostname: { hostname: {
kind: 'domain', kind: 'domain',
@@ -1668,7 +1560,7 @@ export class MockApiService extends ApiService {
}, },
{ {
op: PatchOp.REMOVE, op: PatchOp.REMOVE,
path: `/packageData/${params.package}/hosts/${params.host}/hostnameInfo/80/0`, path: `/packageData/${params.package}/hosts/${params.host}/bindings/80/addresses/possible/0`,
}, },
] ]
this.mockRevision(patch) this.mockRevision(patch)
@@ -1689,10 +1581,9 @@ export class MockApiService extends ApiService {
}, },
{ {
op: PatchOp.ADD, op: PatchOp.ADD,
path: `/packageData/${params.package}/hosts/${params.host}/hostnameInfo/80/0`, path: `/packageData/${params.package}/hosts/${params.host}/bindings/80/addresses/possible/0`,
value: { value: {
kind: 'ip', gateway: { id: 'eth0', name: 'Ethernet', public: false },
gatewayId: 'eth0',
public: false, public: false,
hostname: { hostname: {
kind: 'domain', kind: 'domain',
@@ -1722,7 +1613,7 @@ export class MockApiService extends ApiService {
}, },
{ {
op: PatchOp.REMOVE, op: PatchOp.REMOVE,
path: `/packageData/${params.package}/hosts/${params.host}/hostnameInfo/80/0`, path: `/packageData/${params.package}/hosts/${params.host}/bindings/80/addresses/possible/0`,
}, },
] ]
this.mockRevision(patch) this.mockRevision(patch)

View File

@@ -38,8 +38,74 @@ export const mockPatchData: DataModel = {
net: { net: {
assignedPort: null, assignedPort: null,
assignedSslPort: 443, assignedSslPort: 443,
publicEnabled: [], },
addresses: {
privateDisabled: [], privateDisabled: [],
publicEnabled: [],
possible: [
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 443,
},
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 443,
},
},
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.1',
port: null,
sslPort: 443,
},
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.2',
port: null,
sslPort: 443,
},
},
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'ipv6',
value: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd',
scopeId: 2,
port: null,
sslPort: 443,
},
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv6',
value: 'fe80::cd00:0000:0cde:1257:0000:211e:1234',
scopeId: 3,
port: null,
sslPort: 443,
},
},
],
}, },
options: { options: {
preferredExternalPort: 80, preferredExternalPort: 80,
@@ -54,93 +120,12 @@ export const mockPatchData: DataModel = {
}, },
publicDomains: {}, publicDomains: {},
privateDomains: [], privateDomains: [],
onions: ['myveryownspecialtoraddress'],
hostnameInfo: {
80: [
{
kind: 'ip',
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 443,
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 443,
},
},
{
kind: 'ip',
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.1',
port: null,
sslPort: 443,
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.2',
port: null,
sslPort: 443,
},
},
{
kind: 'ip',
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'ipv6',
value: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd',
scopeId: 2,
port: null,
sslPort: 443,
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv6',
value: 'fe80::cd00:0000:0cde:1257:0000:211e:1234',
scopeId: 3,
port: null,
sslPort: 443,
},
},
{
kind: 'onion',
hostname: {
value: 'myveryownspecialtoraddress.onion',
port: 80,
sslPort: 443,
},
},
],
},
}, },
gateways: { gateways: {
eth0: { eth0: {
name: null, name: null,
public: null,
secure: null, secure: null,
type: null,
ipInfo: { ipInfo: {
name: 'Wired Connection 1', name: 'Wired Connection 1',
scopeId: 1, scopeId: 1,
@@ -154,8 +139,8 @@ export const mockPatchData: DataModel = {
}, },
wlan0: { wlan0: {
name: null, name: null,
public: null,
secure: null, secure: null,
type: null,
ipInfo: { ipInfo: {
name: 'Wireless Connection 1', name: 'Wireless Connection 1',
scopeId: 2, scopeId: 2,
@@ -172,8 +157,8 @@ export const mockPatchData: DataModel = {
}, },
wireguard1: { wireguard1: {
name: 'StartTunnel', name: 'StartTunnel',
public: null,
secure: null, secure: null,
type: 'inbound-outbound',
ipInfo: { ipInfo: {
name: 'wireguard1', name: 'wireguard1',
scopeId: 2, scopeId: 2,
@@ -188,7 +173,23 @@ export const mockPatchData: DataModel = {
dnsServers: ['1.1.1.1'], dnsServers: ['1.1.1.1'],
}, },
}, },
wireguard2: {
name: 'Mullvad VPN',
secure: null,
type: 'outbound-only',
ipInfo: {
name: 'wireguard2',
scopeId: 4,
deviceType: 'wireguard',
subnets: [],
wanIp: '198.51.100.77',
ntpServers: [],
lanIp: [],
dnsServers: ['10.64.0.1'],
},
},
}, },
defaultOutbound: 'eth0',
dns: { dns: {
dhcpServers: ['1.1.1.1', '8.8.8.8'], dhcpServers: ['1.1.1.1', '8.8.8.8'],
staticServers: null, staticServers: null,
@@ -335,6 +336,7 @@ export const mockPatchData: DataModel = {
}, },
hosts: {}, hosts: {},
storeExposedDependents: [], storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/', registry: 'https://registry.start9.com/',
developerKey: 'developer-key', developerKey: 'developer-key',
tasks: { tasks: {
@@ -512,8 +514,74 @@ export const mockPatchData: DataModel = {
net: { net: {
assignedPort: 80, assignedPort: 80,
assignedSslPort: 443, assignedSslPort: 443,
publicEnabled: [], },
addresses: {
privateDisabled: [], privateDisabled: [],
publicEnabled: [],
possible: [
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 1234,
},
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 1234,
},
},
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.1',
port: null,
sslPort: 1234,
},
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.2',
port: null,
sslPort: 1234,
},
},
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'ipv6',
value: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd',
scopeId: 2,
port: null,
sslPort: 1234,
},
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv6',
value: 'fe80::cd00:0000:0cde:1257:0000:211e:1234',
scopeId: 3,
port: null,
sslPort: 1234,
},
},
],
}, },
options: { options: {
addSsl: null, addSsl: null,
@@ -524,87 +592,6 @@ export const mockPatchData: DataModel = {
}, },
publicDomains: {}, publicDomains: {},
privateDomains: [], privateDomains: [],
onions: [],
hostnameInfo: {
80: [
{
kind: 'ip',
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.1',
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.2',
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'ipv6',
value: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd',
scopeId: 2,
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv6',
value: 'fe80::cd00:0000:0cde:1257:0000:211e:1234',
scopeId: 3,
port: null,
sslPort: 1234,
},
},
{
kind: 'onion',
hostname: {
value: 'bitcoin-p2p.onion',
port: 80,
sslPort: 443,
},
},
],
},
}, },
bcdefgh: { bcdefgh: {
bindings: { bindings: {
@@ -613,8 +600,11 @@ export const mockPatchData: DataModel = {
net: { net: {
assignedPort: 8332, assignedPort: 8332,
assignedSslPort: null, assignedSslPort: null,
publicEnabled: [], },
addresses: {
privateDisabled: [], privateDisabled: [],
publicEnabled: [],
possible: [],
}, },
options: { options: {
addSsl: null, addSsl: null,
@@ -625,10 +615,6 @@ export const mockPatchData: DataModel = {
}, },
publicDomains: {}, publicDomains: {},
privateDomains: [], privateDomains: [],
onions: [],
hostnameInfo: {
8332: [],
},
}, },
cdefghi: { cdefghi: {
bindings: { bindings: {
@@ -637,8 +623,11 @@ export const mockPatchData: DataModel = {
net: { net: {
assignedPort: 8333, assignedPort: 8333,
assignedSslPort: null, assignedSslPort: null,
publicEnabled: [], },
addresses: {
privateDisabled: [], privateDisabled: [],
publicEnabled: [],
possible: [],
}, },
options: { options: {
addSsl: null, addSsl: null,
@@ -649,13 +638,10 @@ export const mockPatchData: DataModel = {
}, },
publicDomains: {}, publicDomains: {},
privateDomains: [], privateDomains: [],
onions: [],
hostnameInfo: {
8333: [],
},
}, },
}, },
storeExposedDependents: [], storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/', registry: 'https://registry.start9.com/',
developerKey: 'developer-key', developerKey: 'developer-key',
tasks: { tasks: {

View File

@@ -32,7 +32,6 @@ export class ConfigService {
private getAccessType = utils.once(() => { private getAccessType = utils.once(() => {
if (useMocks) return mocks.maskAs if (useMocks) return mocks.maskAs
if (this.hostname === 'localhost') return 'localhost' if (this.hostname === 'localhost') return 'localhost'
if (this.hostname.endsWith('.onion')) return 'tor'
if (this.hostname.endsWith('.local')) return 'mdns' if (this.hostname.endsWith('.local')) return 'mdns'
let ip = null let ip = null
try { try {
@@ -51,7 +50,7 @@ export class ConfigService {
} }
isLanHttp(): boolean { isLanHttp(): boolean {
return !this.isHttps() && !['localhost', 'tor'].includes(this.accessType) return !this.isHttps() && this.accessType !== 'localhost'
} }
isHttps(): boolean { isHttps(): boolean {

View File

@@ -1,7 +1,7 @@
import { inject, Injectable } from '@angular/core' import { inject, Injectable } from '@angular/core'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { T, utils } from '@start9labs/start-sdk' import { T, utils } from '@start9labs/start-sdk'
import { map } from 'rxjs/operators' import { map } from 'rxjs'
import { DataModel } from './patch-db/data-model' import { DataModel } from './patch-db/data-model'
import { toSignal } from '@angular/core/rxjs-interop' import { toSignal } from '@angular/core/rxjs-interop'
@@ -12,39 +12,47 @@ export type GatewayPlus = T.NetworkInterfaceInfo & {
subnets: utils.IpNet[] subnets: utils.IpNet[]
lanIpv4: string[] lanIpv4: string[]
wanIp?: utils.IpAddress wanIp?: utils.IpAddress
public: boolean isDefaultOutbound: boolean
} }
@Injectable() @Injectable()
export class GatewayService { export class GatewayService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly network$ = this.patch.watch$('serverInfo', 'network')
readonly defaultOutbound = toSignal(
this.network$.pipe(map(n => n.defaultOutbound)),
)
readonly gateways = toSignal( readonly gateways = toSignal(
inject<PatchDB<DataModel>>(PatchDB) this.network$.pipe(
.watch$('serverInfo', 'network', 'gateways') map(network => {
.pipe( const gateways = network.gateways
map(gateways => const defaultOutbound = network.defaultOutbound
Object.entries(gateways) return Object.entries(gateways)
.filter(([_, val]) => !!val?.ipInfo) .filter(([_, val]) => !!val?.ipInfo)
.filter( .filter(
([_, val]) => ([_, val]) =>
val?.ipInfo?.deviceType !== 'bridge' && val?.ipInfo?.deviceType !== 'bridge' &&
val?.ipInfo?.deviceType !== 'loopback', val?.ipInfo?.deviceType !== 'loopback',
) )
.map(([id, val]) => { .map(([id, val]) => {
const subnets = const subnets =
val.ipInfo?.subnets.map(s => utils.IpNet.parse(s)) ?? [] val.ipInfo?.subnets.map(s => utils.IpNet.parse(s)) ?? []
const name = val.name ?? val.ipInfo!.name const name = val.name ?? val.ipInfo!.name
return { return {
...val, ...val,
id, id,
name, name,
subnets, subnets,
lanIpv4: subnets.filter(s => s.isIpv4()).map(s => s.address), lanIpv4: subnets.filter(s => s.isIpv4()).map(s => s.address),
public: val.public ?? subnets.some(s => s.isPublic()), wanIp:
wanIp: val.ipInfo?.wanIp && utils.IpAddress.parse(val.ipInfo?.wanIp),
val.ipInfo?.wanIp && utils.IpAddress.parse(val.ipInfo?.wanIp), isDefaultOutbound: id === defaultOutbound,
} as GatewayPlus } as GatewayPlus
}), })
), }),
), ),
) )
} }