mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 20:43:41 +00:00
Compare commits
17 Commits
chore/sdk-
...
feature/ou
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea130079ab | ||
|
|
2a54625f43 | ||
|
|
4e638fb58e | ||
|
|
73274ef6e0 | ||
|
|
e1915bf497 | ||
|
|
8204074bdf | ||
|
|
2ee403e7de | ||
|
|
cc5f316514 | ||
|
|
1974dfd66f | ||
|
|
2e03a95e47 | ||
|
|
b6262c8e13 | ||
|
|
ba740a9ee2 | ||
|
|
8f809dab21 | ||
|
|
c0b2cbe1c8 | ||
|
|
f2142f0bb3 | ||
|
|
86ca23c093 | ||
|
|
463b6ca4ef |
6
.claude/settings.json
Normal file
6
.claude/settings.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"attribution": {
|
||||
"commit": "",
|
||||
"pr": ""
|
||||
}
|
||||
}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -19,4 +19,6 @@ secrets.db
|
||||
/compiled.tar
|
||||
/compiled-*.tar
|
||||
/build/lib/firmware
|
||||
tmp
|
||||
tmp
|
||||
web/.i18n-checked
|
||||
agents/USER.md
|
||||
|
||||
164
CLAUDE.md
Normal file
164
CLAUDE.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
StartOS is an open-source Linux distribution for running personal servers. It manages discovery, installation, network configuration, backups, and health monitoring of self-hosted services.
|
||||
|
||||
**Tech Stack:**
|
||||
- Backend: Rust (async/Tokio, Axum web framework)
|
||||
- Frontend: Angular 20 + TypeScript + TaigaUI
|
||||
- Container runtime: Node.js/TypeScript with LXC
|
||||
- Database/State: Patch-DB (git submodule) - storage layer with reactive frontend sync
|
||||
- API: JSON-RPC via rpc-toolkit (see `agents/rpc-toolkit.md`)
|
||||
- Auth: Password + session cookie, public/private key signatures, local authcookie (see `core/src/middleware/auth/`)
|
||||
|
||||
## Build & Development
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for:
|
||||
- Environment setup and requirements
|
||||
- Build commands and make targets
|
||||
- Testing and formatting commands
|
||||
- Environment variables
|
||||
|
||||
**Quick reference:**
|
||||
```bash
|
||||
. ./devmode.sh # Enable dev mode
|
||||
make update-startbox REMOTE=start9@<ip> # Fastest iteration (binary + UI)
|
||||
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
|
||||
|
||||
### Core (`/core`)
|
||||
The Rust backend daemon. Main binaries:
|
||||
- `startbox` - Main daemon (runs as `startd`)
|
||||
- `start-cli` - CLI interface
|
||||
- `start-container` - Runs inside LXC containers; communicates with host and manages subcontainers
|
||||
- `registrybox` - Registry daemon
|
||||
- `tunnelbox` - VPN/tunnel daemon
|
||||
|
||||
**Key modules:**
|
||||
- `src/context/` - Context types (RpcContext, CliContext, InitContext, DiagnosticContext)
|
||||
- `src/service/` - Service lifecycle management with actor pattern (`service_actor.rs`)
|
||||
- `src/db/model/` - Patch-DB models (`public.rs` synced to frontend, `private.rs` backend-only)
|
||||
- `src/net/` - Networking (DNS, ACME, WiFi, Tor via Arti, WireGuard)
|
||||
- `src/s9pk/` - S9PK package format (merkle archive)
|
||||
- `src/registry/` - Package registry management
|
||||
|
||||
**RPC Pattern:** See `agents/rpc-toolkit.md`
|
||||
|
||||
### Web (`/web`)
|
||||
Angular projects sharing common code:
|
||||
- `projects/ui/` - Main admin interface
|
||||
- `projects/setup-wizard/` - Initial setup
|
||||
- `projects/start-tunnel/` - VPN management UI
|
||||
- `projects/shared/` - Common library (API clients, components)
|
||||
- `projects/marketplace/` - Service discovery
|
||||
|
||||
**Development:**
|
||||
```bash
|
||||
cd web
|
||||
npm ci
|
||||
npm run start:ui # Dev server with mocks
|
||||
npm run build:ui # Production build
|
||||
npm run check # Type check all projects
|
||||
```
|
||||
|
||||
### Container Runtime (`/container-runtime`)
|
||||
Node.js runtime that manages service containers via RPC. See `RPCSpec.md` for protocol.
|
||||
|
||||
**Container Architecture:**
|
||||
```
|
||||
LXC Container (uniform base for all services)
|
||||
└── systemd
|
||||
└── container-runtime.service
|
||||
└── Loads /usr/lib/startos/package/index.js (from s9pk javascript.squashfs)
|
||||
└── Package JS launches subcontainers (from images in s9pk)
|
||||
```
|
||||
|
||||
The container runtime communicates with the host via JSON-RPC over Unix socket. Package JavaScript must export functions conforming to the `ABI` type defined in `sdk/base/lib/types.ts`.
|
||||
|
||||
**`/media/startos/` directory (mounted by host into container):**
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `volumes/<name>/` | Package data volumes (id-mapped, persistent) |
|
||||
| `assets/` | Read-only assets from s9pk `assets.squashfs` |
|
||||
| `images/<name>/` | Container images (squashfs, used for subcontainers) |
|
||||
| `images/<name>.env` | Environment variables for image |
|
||||
| `images/<name>.json` | Image metadata |
|
||||
| `backup/` | Backup mount point (mounted during backup operations) |
|
||||
| `rpc/service.sock` | RPC socket (container runtime listens here) |
|
||||
| `rpc/host.sock` | Host RPC socket (for effects callbacks to host) |
|
||||
|
||||
**S9PK Structure:** See `agents/s9pk-structure.md`
|
||||
|
||||
### SDK (`/sdk`)
|
||||
TypeScript SDK for packaging services (`@start9labs/start-sdk`).
|
||||
|
||||
- `base/` - Core types, ABI definitions, effects interface (`@start9labs/start-sdk-base`)
|
||||
- `package/` - Full SDK for package developers, re-exports base
|
||||
|
||||
### Patch-DB (`/patch-db`)
|
||||
Git submodule providing diff-based state synchronization. Changes to `db/model/public.rs` automatically sync to the frontend.
|
||||
|
||||
**Key patterns:**
|
||||
- `db.peek().await` - Get a read-only snapshot of the database state
|
||||
- `db.mutate(|db| { ... }).await` - Apply mutations atomically, returns `MutateResult`
|
||||
- `#[derive(HasModel)]` - Derive macro for types stored in the database, generates typed accessors
|
||||
|
||||
**Generated accessor types** (from `HasModel` derive):
|
||||
- `as_field()` - Immutable reference: `&Model<T>`
|
||||
- `as_field_mut()` - Mutable reference: `&mut Model<T>`
|
||||
- `into_field()` - Owned value: `Model<T>`
|
||||
|
||||
**`Model<T>` APIs** (from `db/prelude.rs`):
|
||||
- `.de()` - Deserialize to `T`
|
||||
- `.ser(&value)` - Serialize from `T`
|
||||
- `.mutate(|v| ...)` - Deserialize, mutate, reserialize
|
||||
- For maps: `.keys()`, `.as_idx(&key)`, `.as_idx_mut(&key)`, `.insert()`, `.remove()`, `.contains_key()`
|
||||
|
||||
## Supplementary Documentation
|
||||
|
||||
The `agents/` directory contains detailed documentation for AI assistants:
|
||||
|
||||
- `TODO.md` - Pending tasks for AI agents (check this first, remove items when completed)
|
||||
- `USER.md` - Current user identifier (gitignored, see below)
|
||||
- `rpc-toolkit.md` - JSON-RPC patterns and handler configuration
|
||||
- `core-rust-patterns.md` - Common utilities and patterns for Rust code in `/core` (guard pattern, mount guards, etc.)
|
||||
- `s9pk-structure.md` - S9PK package format structure
|
||||
- `i18n-patterns.md` - Internationalization key conventions and usage in `/core`
|
||||
|
||||
### Session Startup
|
||||
|
||||
On startup:
|
||||
|
||||
1. **Check for `agents/USER.md`** - If it doesn't exist, prompt the user for their name/identifier and create it. This file is gitignored since it varies per developer.
|
||||
|
||||
2. **Check `agents/TODO.md` for relevant tasks** - Show TODOs that either:
|
||||
- Have no `@username` tag (relevant to everyone)
|
||||
- Are tagged with the current user's identifier
|
||||
|
||||
Skip TODOs tagged with a different user.
|
||||
|
||||
3. **Ask "What would you like to do today?"** - Offer options for each relevant TODO item, plus "Something else" for other requests.
|
||||
259
CONTRIBUTING.md
259
CONTRIBUTING.md
@@ -11,123 +11,190 @@ This guide is for contributing to the StartOS. If you are interested in packagin
|
||||
|
||||
```bash
|
||||
/
|
||||
├── assets/
|
||||
├── container-runtime/
|
||||
├── core/
|
||||
├── build/
|
||||
├── debian/
|
||||
├── web/
|
||||
├── image-recipe/
|
||||
├── patch-db
|
||||
└── sdk/
|
||||
├── assets/ # Screenshots for README
|
||||
├── build/ # Auxiliary files and scripts for deployed images
|
||||
├── container-runtime/ # Node.js program managing package containers
|
||||
├── core/ # Rust backend: API, daemon (startd), CLI (start-cli)
|
||||
├── debian/ # Debian package maintainer scripts
|
||||
├── image-recipe/ # Scripts for building StartOS images
|
||||
├── patch-db/ # (submodule) Diff-based data store for frontend sync
|
||||
├── sdk/ # TypeScript SDK for building StartOS packages
|
||||
└── web/ # Web UIs (Angular)
|
||||
```
|
||||
|
||||
#### assets
|
||||
|
||||
screenshots for the StartOS README
|
||||
|
||||
#### container-runtime
|
||||
|
||||
A NodeJS program that dynamically loads maintainer scripts and communicates with the OS to manage packages
|
||||
|
||||
#### core
|
||||
|
||||
An API, daemon (startd), and CLI (start-cli) that together provide the core functionality of StartOS.
|
||||
|
||||
#### build
|
||||
|
||||
Auxiliary files and scripts to include in deployed StartOS images
|
||||
|
||||
#### debian
|
||||
|
||||
Maintainer scripts for the StartOS Debian package
|
||||
|
||||
#### web
|
||||
|
||||
Web UIs served under various conditions and used to interact with StartOS APIs.
|
||||
|
||||
#### image-recipe
|
||||
|
||||
Scripts for building StartOS images
|
||||
|
||||
#### patch-db (submodule)
|
||||
|
||||
A diff based data store used to synchronize data between the web interfaces and server.
|
||||
|
||||
#### sdk
|
||||
|
||||
A typescript sdk for building start-os packages
|
||||
See component READMEs for details:
|
||||
- [`core`](core/README.md)
|
||||
- [`web`](web/README.md)
|
||||
- [`build`](build/README.md)
|
||||
- [`patch-db`](https://github.com/Start9Labs/patch-db)
|
||||
|
||||
## Environment Setup
|
||||
|
||||
#### Clone the StartOS repository
|
||||
|
||||
```sh
|
||||
git clone https://github.com/Start9Labs/start-os.git --recurse-submodules
|
||||
cd start-os
|
||||
```
|
||||
|
||||
#### Continue to your project of interest for additional instructions:
|
||||
### Development Mode
|
||||
|
||||
- [`core`](core/README.md)
|
||||
- [`web-interfaces`](web-interfaces/README.md)
|
||||
- [`build`](build/README.md)
|
||||
- [`patch-db`](https://github.com/Start9Labs/patch-db)
|
||||
For faster iteration during development:
|
||||
|
||||
```sh
|
||||
. ./devmode.sh
|
||||
```
|
||||
|
||||
This sets `ENVIRONMENT=dev` and `GIT_BRANCH_AS_HASH=1` to prevent rebuilds on every commit.
|
||||
|
||||
## Building
|
||||
|
||||
This project uses [GNU Make](https://www.gnu.org/software/make/) to build its components. To build any specific component, simply run `make <TARGET>` replacing `<TARGET>` with the name of the target you'd like to build
|
||||
All builds can be performed on any operating system that can run Docker.
|
||||
|
||||
This project uses [GNU Make](https://www.gnu.org/software/make/) to build its components.
|
||||
|
||||
### Requirements
|
||||
|
||||
- [GNU Make](https://www.gnu.org/software/make/)
|
||||
- [Docker](https://docs.docker.com/get-docker/)
|
||||
- [Docker](https://docs.docker.com/get-docker/) or [Podman](https://podman.io/)
|
||||
- [NodeJS v20.16.0](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
|
||||
- [sed](https://www.gnu.org/software/sed/)
|
||||
- [grep](https://www.gnu.org/software/grep/)
|
||||
- [awk](https://www.gnu.org/software/gawk/)
|
||||
- [Rust](https://rustup.rs/) (nightly for formatting)
|
||||
- [sed](https://www.gnu.org/software/sed/), [grep](https://www.gnu.org/software/grep/), [awk](https://www.gnu.org/software/gawk/)
|
||||
- [jq](https://jqlang.github.io/jq/)
|
||||
- [gzip](https://www.gnu.org/software/gzip/)
|
||||
- [brotli](https://github.com/google/brotli)
|
||||
- [gzip](https://www.gnu.org/software/gzip/), [brotli](https://github.com/google/brotli)
|
||||
|
||||
### Environment variables
|
||||
### Environment Variables
|
||||
|
||||
- `PLATFORM`: which platform you would like to build for. Must be one of `x86_64`, `x86_64-nonfree`, `aarch64`, `aarch64-nonfree`, `raspberrypi`
|
||||
- NOTE: `nonfree` images are for including `nonfree` firmware packages in the built ISO
|
||||
- `ENVIRONMENT`: a hyphen separated set of feature flags to enable
|
||||
- `dev`: enables password ssh (INSECURE!) and does not compress frontends
|
||||
- `unstable`: enables assertions that will cause errors on unexpected inconsistencies that are undesirable in production use either for performance or reliability reasons
|
||||
- `docker`: use `docker` instead of `podman`
|
||||
- `GIT_BRANCH_AS_HASH`: set to `1` to use the current git branch name as the git hash so that the project does not need to be rebuilt on each commit
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `PLATFORM` | Target platform: `x86_64`, `x86_64-nonfree`, `aarch64`, `aarch64-nonfree`, `riscv64`, `raspberrypi` |
|
||||
| `ENVIRONMENT` | Hyphen-separated feature flags (see below) |
|
||||
| `PROFILE` | Build profile: `release` (default) or `dev` |
|
||||
| `GIT_BRANCH_AS_HASH` | Set to `1` to use git branch name as version hash (avoids rebuilds) |
|
||||
|
||||
### Useful Make Targets
|
||||
**ENVIRONMENT flags:**
|
||||
- `dev` - Enables password SSH before setup, skips frontend compression
|
||||
- `unstable` - Enables assertions and debugging with performance penalty
|
||||
- `console` - Enables tokio-console for async debugging
|
||||
|
||||
**Platform notes:**
|
||||
- `-nonfree` variants include proprietary firmware and drivers
|
||||
- `raspberrypi` includes non-free components by necessity
|
||||
- Platform is remembered between builds if not specified
|
||||
|
||||
### Make Targets
|
||||
|
||||
#### Building
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| `iso` | Create full `.iso` image (not for raspberrypi) |
|
||||
| `img` | Create full `.img` image (raspberrypi only) |
|
||||
| `deb` | Build Debian package |
|
||||
| `all` | Build all Rust binaries |
|
||||
| `uis` | Build all web UIs |
|
||||
| `ui` | Build main UI only |
|
||||
| `ts-bindings` | Generate TypeScript bindings from Rust types |
|
||||
|
||||
#### Deploying to Device
|
||||
|
||||
For devices on the same network:
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| `update-startbox REMOTE=start9@<ip>` | Deploy binary + UI only (fastest) |
|
||||
| `update-deb REMOTE=start9@<ip>` | Deploy full Debian package |
|
||||
| `update REMOTE=start9@<ip>` | OTA-style update |
|
||||
| `reflash REMOTE=start9@<ip>` | Reflash as if using live ISO |
|
||||
| `update-overlay REMOTE=start9@<ip>` | Deploy to in-memory overlay (reverts on reboot) |
|
||||
|
||||
For devices on different networks (uses [magic-wormhole](https://github.com/magic-wormhole/magic-wormhole)):
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| `wormhole` | Send startbox binary |
|
||||
| `wormhole-deb` | Send Debian package |
|
||||
| `wormhole-squashfs` | Send squashfs image |
|
||||
|
||||
#### Other
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| `format` | Run code formatting (Rust nightly required) |
|
||||
| `test` | Run all automated tests |
|
||||
| `test-core` | Run Rust tests |
|
||||
| `test-sdk` | Run SDK tests |
|
||||
| `test-container-runtime` | Run container runtime tests |
|
||||
| `clean` | Delete all compiled artifacts |
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
make test # All tests
|
||||
make test-core # Rust tests (via ./core/run-tests.sh)
|
||||
make test-sdk # SDK tests
|
||||
make test-container-runtime # Container runtime tests
|
||||
|
||||
# Run specific Rust test
|
||||
cd core && cargo test <test_name> --features=test
|
||||
```
|
||||
|
||||
## Code Formatting
|
||||
|
||||
```bash
|
||||
# Rust (requires nightly)
|
||||
make format
|
||||
|
||||
# TypeScript/HTML/SCSS (web)
|
||||
cd web && npm run format
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Formatting
|
||||
|
||||
Run the formatters before committing. Configuration is handled by `rustfmt.toml` (Rust) and prettier configs (TypeScript).
|
||||
|
||||
### Documentation & Comments
|
||||
|
||||
**Rust:**
|
||||
- Add doc comments (`///`) to public APIs, structs, and non-obvious functions
|
||||
- Use `//` comments sparingly for complex logic that isn't self-evident
|
||||
- Prefer self-documenting code (clear naming, small functions) over comments
|
||||
|
||||
**TypeScript:**
|
||||
- Document exported functions and complex types with JSDoc
|
||||
- Keep comments focused on "why" rather than "what"
|
||||
|
||||
**General:**
|
||||
- Don't add comments that just restate the code
|
||||
- Update or remove comments when code changes
|
||||
- TODOs should include context: `// TODO(username): reason`
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
**Types:**
|
||||
- `feat` - New feature
|
||||
- `fix` - Bug fix
|
||||
- `docs` - Documentation only
|
||||
- `style` - Formatting, no code change
|
||||
- `refactor` - Code change that neither fixes a bug nor adds a feature
|
||||
- `test` - Adding or updating tests
|
||||
- `chore` - Build process, dependencies, etc.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
feat(web): add dark mode toggle
|
||||
fix(core): resolve race condition in service startup
|
||||
docs: update CONTRIBUTING.md with style guidelines
|
||||
refactor(sdk): simplify package validation logic
|
||||
```
|
||||
|
||||
- `iso`: Create a full `.iso` image
|
||||
- Only possible from Debian
|
||||
- Not available for `PLATFORM=raspberrypi`
|
||||
- Additional Requirements:
|
||||
- [debspawn](https://github.com/lkhq/debspawn)
|
||||
- `img`: Create a full `.img` image
|
||||
- Only possible from Debian
|
||||
- Only available for `PLATFORM=raspberrypi`
|
||||
- Additional Requirements:
|
||||
- [debspawn](https://github.com/lkhq/debspawn)
|
||||
- `format`: Run automatic code formatting for the project
|
||||
- Additional Requirements:
|
||||
- [rust](https://rustup.rs/)
|
||||
- `test`: Run automated tests for the project
|
||||
- Additional Requirements:
|
||||
- [rust](https://rustup.rs/)
|
||||
- `update`: Deploy the current working project to a device over ssh as if through an over-the-air update
|
||||
- Requires an argument `REMOTE` which is the ssh address of the device, i.e. `start9@192.168.122.2`
|
||||
- `reflash`: Deploy the current working project to a device over ssh as if using a live `iso` image to reflash it
|
||||
- Requires an argument `REMOTE` which is the ssh address of the device, i.e. `start9@192.168.122.2`
|
||||
- `update-overlay`: Deploy the current working project to a device over ssh to the in-memory overlay without restarting it
|
||||
- WARNING: changes will be reverted after the device is rebooted
|
||||
- WARNING: changes to `init` will not take effect as the device is already initialized
|
||||
- Requires an argument `REMOTE` which is the ssh address of the device, i.e. `start9@192.168.122.2`
|
||||
- `wormhole`: Deploy the `startbox` to a device using [magic-wormhole](https://github.com/magic-wormhole/magic-wormhole)
|
||||
- When the build it complete will emit a command to paste into the shell of the device to upgrade it
|
||||
- Additional Requirements:
|
||||
- [magic-wormhole](https://github.com/magic-wormhole/magic-wormhole)
|
||||
- `clean`: Delete all compiled artifacts
|
||||
|
||||
228
agents/TODO.md
Normal file
228
agents/TODO.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# AI Agent TODOs
|
||||
|
||||
Pending tasks for AI agents. Remove items when completed.
|
||||
|
||||
## Unreviewed CLAUDE.md Sections
|
||||
|
||||
- [ ] 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.
|
||||
249
agents/core-rust-patterns.md
Normal file
249
agents/core-rust-patterns.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# Utilities & Patterns
|
||||
|
||||
This document covers common utilities and patterns used throughout the StartOS codebase.
|
||||
|
||||
## Util Module (`core/src/util/`)
|
||||
|
||||
The `util` module contains reusable utilities. Key submodules:
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `actor/` | Actor pattern implementation for concurrent state management |
|
||||
| `collections/` | Custom collection types |
|
||||
| `crypto.rs` | Cryptographic utilities (encryption, hashing) |
|
||||
| `future.rs` | Future/async utilities |
|
||||
| `io.rs` | File I/O helpers (create_file, canonicalize, etc.) |
|
||||
| `iter.rs` | Iterator extensions |
|
||||
| `net.rs` | Network utilities |
|
||||
| `rpc.rs` | RPC helpers |
|
||||
| `rpc_client.rs` | RPC client utilities |
|
||||
| `serde.rs` | Serialization helpers (Base64, display/fromstr, etc.) |
|
||||
| `sync.rs` | Synchronization primitives (SyncMutex, etc.) |
|
||||
|
||||
## Command Invocation (`Invoke` trait)
|
||||
|
||||
The `Invoke` trait provides a clean way to run external commands with error handling:
|
||||
|
||||
```rust
|
||||
use crate::util::Invoke;
|
||||
|
||||
// Simple invocation
|
||||
tokio::process::Command::new("ls")
|
||||
.arg("-la")
|
||||
.invoke(ErrorKind::Filesystem)
|
||||
.await?;
|
||||
|
||||
// With timeout
|
||||
tokio::process::Command::new("slow-command")
|
||||
.timeout(Some(Duration::from_secs(30)))
|
||||
.invoke(ErrorKind::Timeout)
|
||||
.await?;
|
||||
|
||||
// With input
|
||||
let mut input = Cursor::new(b"input data");
|
||||
tokio::process::Command::new("cat")
|
||||
.input(Some(&mut input))
|
||||
.invoke(ErrorKind::Filesystem)
|
||||
.await?;
|
||||
|
||||
// Piped commands
|
||||
tokio::process::Command::new("cat")
|
||||
.arg("file.txt")
|
||||
.pipe(&mut tokio::process::Command::new("grep").arg("pattern"))
|
||||
.invoke(ErrorKind::Filesystem)
|
||||
.await?;
|
||||
```
|
||||
|
||||
## Guard Pattern
|
||||
|
||||
Guards ensure cleanup happens when they go out of scope.
|
||||
|
||||
### `GeneralGuard` / `GeneralBoxedGuard`
|
||||
|
||||
For arbitrary cleanup actions:
|
||||
|
||||
```rust
|
||||
use crate::util::GeneralGuard;
|
||||
|
||||
let guard = GeneralGuard::new(|| {
|
||||
println!("Cleanup runs on drop");
|
||||
});
|
||||
|
||||
// Do work...
|
||||
|
||||
// Explicit drop with action
|
||||
guard.drop();
|
||||
|
||||
// Or skip the action
|
||||
// guard.drop_without_action();
|
||||
```
|
||||
|
||||
### `FileLock`
|
||||
|
||||
File-based locking with automatic unlock:
|
||||
|
||||
```rust
|
||||
use crate::util::FileLock;
|
||||
|
||||
let lock = FileLock::new("/path/to/lockfile", true).await?; // blocking=true
|
||||
// Lock held until dropped or explicitly unlocked
|
||||
lock.unlock().await?;
|
||||
```
|
||||
|
||||
## Mount Guard Pattern (`core/src/disk/mount/guard.rs`)
|
||||
|
||||
RAII guards for filesystem mounts. Ensures filesystems are unmounted when guards are dropped.
|
||||
|
||||
### `MountGuard`
|
||||
|
||||
Basic mount guard:
|
||||
|
||||
```rust
|
||||
use crate::disk::mount::guard::MountGuard;
|
||||
use crate::disk::mount::filesystem::{MountType, ReadOnly};
|
||||
|
||||
let guard = MountGuard::mount(&filesystem, "/mnt/target", ReadOnly).await?;
|
||||
|
||||
// Use the mounted filesystem at guard.path()
|
||||
do_something(guard.path()).await?;
|
||||
|
||||
// Explicit unmount (or auto-unmounts on drop)
|
||||
guard.unmount(false).await?; // false = don't delete mountpoint
|
||||
```
|
||||
|
||||
### `TmpMountGuard`
|
||||
|
||||
Reference-counted temporary mount (mounts to `/media/startos/tmp/`):
|
||||
|
||||
```rust
|
||||
use crate::disk::mount::guard::TmpMountGuard;
|
||||
use crate::disk::mount::filesystem::ReadOnly;
|
||||
|
||||
// Multiple clones share the same mount
|
||||
let guard1 = TmpMountGuard::mount(&filesystem, ReadOnly).await?;
|
||||
let guard2 = guard1.clone();
|
||||
|
||||
// Mount stays alive while any guard exists
|
||||
// Auto-unmounts when last guard is dropped
|
||||
```
|
||||
|
||||
### `GenericMountGuard` trait
|
||||
|
||||
All mount guards implement this trait:
|
||||
|
||||
```rust
|
||||
pub trait GenericMountGuard: std::fmt::Debug + Send + Sync + 'static {
|
||||
fn path(&self) -> &Path;
|
||||
fn unmount(self) -> impl Future<Output = Result<(), Error>> + Send;
|
||||
}
|
||||
```
|
||||
|
||||
### `SubPath`
|
||||
|
||||
Wraps a mount guard to point to a subdirectory:
|
||||
|
||||
```rust
|
||||
use crate::disk::mount::guard::SubPath;
|
||||
|
||||
let mount = TmpMountGuard::mount(&filesystem, ReadOnly).await?;
|
||||
let subdir = SubPath::new(mount, "data/subdir");
|
||||
|
||||
// subdir.path() returns the full path including subdirectory
|
||||
```
|
||||
|
||||
## FileSystem Implementations (`core/src/disk/mount/filesystem/`)
|
||||
|
||||
Various filesystem types that can be mounted:
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `bind.rs` | Bind mounts |
|
||||
| `block_dev.rs` | Block device mounts |
|
||||
| `cifs.rs` | CIFS/SMB network shares |
|
||||
| `ecryptfs.rs` | Encrypted filesystem |
|
||||
| `efivarfs.rs` | EFI variables |
|
||||
| `httpdirfs.rs` | HTTP directory as filesystem |
|
||||
| `idmapped.rs` | ID-mapped mounts |
|
||||
| `label.rs` | Mount by label |
|
||||
| `loop_dev.rs` | Loop device mounts |
|
||||
| `overlayfs.rs` | Overlay filesystem |
|
||||
|
||||
## Other Useful Utilities
|
||||
|
||||
### `Apply` / `ApplyRef` traits
|
||||
|
||||
Fluent method chaining:
|
||||
|
||||
```rust
|
||||
use crate::util::Apply;
|
||||
|
||||
let result = some_value
|
||||
.apply(|v| transform(v))
|
||||
.apply(|v| another_transform(v));
|
||||
```
|
||||
|
||||
### `Container<T>`
|
||||
|
||||
Async-safe optional container:
|
||||
|
||||
```rust
|
||||
use crate::util::Container;
|
||||
|
||||
let container = Container::new(None);
|
||||
container.set(value).await;
|
||||
let taken = container.take().await;
|
||||
```
|
||||
|
||||
### `HashWriter<H, W>`
|
||||
|
||||
Write data while computing hash:
|
||||
|
||||
```rust
|
||||
use crate::util::HashWriter;
|
||||
use sha2::Sha256;
|
||||
|
||||
let writer = HashWriter::new(Sha256::new(), file);
|
||||
// Write data...
|
||||
let (hasher, file) = writer.finish();
|
||||
let hash = hasher.finalize();
|
||||
```
|
||||
|
||||
### `Never` type
|
||||
|
||||
Uninhabited type for impossible cases:
|
||||
|
||||
```rust
|
||||
use crate::util::Never;
|
||||
|
||||
fn impossible() -> Never {
|
||||
// This function can never return
|
||||
}
|
||||
|
||||
let never: Never = impossible();
|
||||
never.absurd::<String>() // Can convert to any type
|
||||
```
|
||||
|
||||
### `MaybeOwned<'a, T>`
|
||||
|
||||
Either borrowed or owned data:
|
||||
|
||||
```rust
|
||||
use crate::util::MaybeOwned;
|
||||
|
||||
fn accept_either(data: MaybeOwned<'_, String>) {
|
||||
// Use &*data to access the value
|
||||
}
|
||||
|
||||
accept_either(MaybeOwned::from(&existing_string));
|
||||
accept_either(MaybeOwned::from(owned_string));
|
||||
```
|
||||
|
||||
### `new_guid()`
|
||||
|
||||
Generate a random GUID:
|
||||
|
||||
```rust
|
||||
use crate::util::new_guid;
|
||||
|
||||
let guid = new_guid(); // Returns InternedString
|
||||
```
|
||||
301
agents/exver.md
Normal file
301
agents/exver.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# exver — Extended Versioning
|
||||
|
||||
Extended semver supporting **downstream versioning** (wrapper updates independent of upstream) and **flavors** (package fork variants).
|
||||
|
||||
Two implementations exist:
|
||||
- **Rust crate** (`exver`) — used in `core/`. Source: https://github.com/Start9Labs/exver-rs
|
||||
- **TypeScript** (`sdk/base/lib/exver/index.ts`) — used in `sdk/` and `web/`
|
||||
|
||||
Both parse the same string format and agree on `satisfies` semantics.
|
||||
|
||||
## Version Format
|
||||
|
||||
An **ExtendedVersion** string looks like:
|
||||
|
||||
```
|
||||
[#flavor:]upstream:downstream
|
||||
```
|
||||
|
||||
- **upstream** — the original package version (semver-style: `1.2.3`, `1.2.3-beta.1`)
|
||||
- **downstream** — the StartOS wrapper version (incremented independently)
|
||||
- **flavor** — optional lowercase ASCII prefix for fork variants
|
||||
|
||||
Examples:
|
||||
- `1.2.3:0` — upstream 1.2.3, first downstream release
|
||||
- `1.2.3:2` — upstream 1.2.3, third downstream release
|
||||
- `#bitcoin:21.0:1` — bitcoin flavor, upstream 21.0, downstream 1
|
||||
- `1.0.0-rc.1:0` — upstream with prerelease tag
|
||||
|
||||
## Core Types
|
||||
|
||||
### `Version`
|
||||
|
||||
A semver-style version with arbitrary digit segments and optional prerelease.
|
||||
|
||||
**Rust:**
|
||||
```rust
|
||||
use exver::Version;
|
||||
|
||||
let v = Version::new([1, 2, 3], []); // 1.2.3
|
||||
let v = Version::new([1, 0], ["beta".into()]); // 1.0-beta
|
||||
let v: Version = "1.2.3".parse().unwrap();
|
||||
|
||||
v.number() // &[1, 2, 3]
|
||||
v.prerelease() // &[]
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
const v = new Version([1, 2, 3], [])
|
||||
const v = Version.parse("1.2.3")
|
||||
|
||||
v.number // number[]
|
||||
v.prerelease // (string | number)[]
|
||||
v.compare(other) // 'greater' | 'equal' | 'less'
|
||||
v.compareForSort(other) // -1 | 0 | 1
|
||||
```
|
||||
|
||||
Default: `0`
|
||||
|
||||
### `ExtendedVersion`
|
||||
|
||||
The primary version type. Wraps upstream + downstream `Version` plus an optional flavor.
|
||||
|
||||
**Rust:**
|
||||
```rust
|
||||
use exver::ExtendedVersion;
|
||||
|
||||
let ev = ExtendedVersion::new(
|
||||
Version::new([1, 2, 3], []),
|
||||
Version::default(), // downstream = 0
|
||||
);
|
||||
let ev: ExtendedVersion = "1.2.3:0".parse().unwrap();
|
||||
|
||||
ev.flavor() // Option<&str>
|
||||
ev.upstream() // &Version
|
||||
ev.downstream() // &Version
|
||||
|
||||
// Builder methods (consuming):
|
||||
ev.with_flavor("bitcoin")
|
||||
ev.without_flavor()
|
||||
ev.map_upstream(|v| ...)
|
||||
ev.map_downstream(|v| ...)
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
const ev = new ExtendedVersion(null, upstream, downstream)
|
||||
const ev = ExtendedVersion.parse("1.2.3:0")
|
||||
const ev = ExtendedVersion.parseEmver("1.2.3.4") // emver compat
|
||||
|
||||
ev.flavor // string | null
|
||||
ev.upstream // Version
|
||||
ev.downstream // Version
|
||||
|
||||
ev.compare(other) // 'greater' | 'equal' | 'less' | null
|
||||
ev.equals(other) // boolean
|
||||
ev.greaterThan(other) // boolean
|
||||
ev.lessThan(other) // boolean
|
||||
ev.incrementMajor() // new ExtendedVersion
|
||||
ev.incrementMinor() // new ExtendedVersion
|
||||
```
|
||||
|
||||
**Ordering:** Versions with different flavors are **not comparable** (`PartialOrd`/`compare` returns `None`/`null`).
|
||||
|
||||
Default: `0:0`
|
||||
|
||||
### `VersionString` (Rust only, StartOS wrapper)
|
||||
|
||||
Defined in `core/src/util/version.rs`. Caches the original string representation alongside the parsed `ExtendedVersion`. Used as the key type in registry version maps.
|
||||
|
||||
```rust
|
||||
use crate::util::VersionString;
|
||||
|
||||
let vs: VersionString = "1.2.3:0".parse().unwrap();
|
||||
let vs = VersionString::from(extended_version);
|
||||
|
||||
// Deref to ExtendedVersion:
|
||||
vs.satisfies(&range);
|
||||
vs.upstream();
|
||||
|
||||
// String access:
|
||||
vs.as_str(); // &str
|
||||
AsRef::<str>::as_ref(&vs);
|
||||
```
|
||||
|
||||
`Ord` is implemented with a total ordering — versions with different flavors are ordered by flavor name (unflavored sorts last).
|
||||
|
||||
### `VersionRange`
|
||||
|
||||
A predicate over `ExtendedVersion`. Supports comparison operators, boolean logic, and flavor constraints.
|
||||
|
||||
**Rust:**
|
||||
```rust
|
||||
use exver::VersionRange;
|
||||
|
||||
// Constructors:
|
||||
VersionRange::any() // matches everything
|
||||
VersionRange::none() // matches nothing
|
||||
VersionRange::exactly(ev) // = ev
|
||||
VersionRange::anchor(GTE, ev) // >= ev
|
||||
VersionRange::caret(ev) // ^ev (compatible changes)
|
||||
VersionRange::tilde(ev) // ~ev (patch-level changes)
|
||||
|
||||
// Combinators (smart — eagerly simplify):
|
||||
VersionRange::and(a, b) // a && b
|
||||
VersionRange::or(a, b) // a || b
|
||||
VersionRange::not(a) // !a
|
||||
|
||||
// Parsing:
|
||||
let r: VersionRange = ">=1.0.0:0".parse().unwrap();
|
||||
let r: VersionRange = "^1.2.3:0".parse().unwrap();
|
||||
let r: VersionRange = ">=1.0.0 <2.0.0".parse().unwrap(); // implicit AND
|
||||
let r: VersionRange = ">=1.0.0 || >=2.0.0".parse().unwrap();
|
||||
let r: VersionRange = "#bitcoin".parse().unwrap(); // flavor match
|
||||
let r: VersionRange = "*".parse().unwrap(); // any
|
||||
|
||||
// Monoid wrappers for folding:
|
||||
AnyRange // fold with or, empty = None
|
||||
AllRange // fold with and, empty = Any
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
// Constructors:
|
||||
VersionRange.any()
|
||||
VersionRange.none()
|
||||
VersionRange.anchor('=', ev)
|
||||
VersionRange.anchor('>=', ev)
|
||||
VersionRange.anchor('^', ev) // ^ and ~ are first-class operators
|
||||
VersionRange.anchor('~', ev)
|
||||
VersionRange.flavor(null) // match unflavored versions
|
||||
VersionRange.flavor("bitcoin") // match #bitcoin versions
|
||||
|
||||
// Combinators — static (smart, variadic):
|
||||
VersionRange.and(a, b, c, ...)
|
||||
VersionRange.or(a, b, c, ...)
|
||||
|
||||
// Combinators — instance (not smart, just wrap):
|
||||
range.and(other)
|
||||
range.or(other)
|
||||
range.not()
|
||||
|
||||
// Parsing:
|
||||
VersionRange.parse(">=1.0.0:0")
|
||||
VersionRange.parseEmver(">=1.2.3.4") // emver compat
|
||||
|
||||
// Analysis (TS only):
|
||||
range.normalize() // canonical form (see below)
|
||||
range.satisfiable() // boolean
|
||||
range.intersects(other) // boolean
|
||||
```
|
||||
|
||||
**Checking satisfaction:**
|
||||
|
||||
```rust
|
||||
// Rust:
|
||||
version.satisfies(&range) // bool
|
||||
```
|
||||
```typescript
|
||||
// TypeScript:
|
||||
version.satisfies(range) // boolean
|
||||
range.satisfiedBy(version) // boolean (convenience)
|
||||
```
|
||||
|
||||
Also available on `Version` (wraps in `ExtendedVersion` with downstream=0).
|
||||
|
||||
When no operator is specified in a range string, `^` (caret) is the default.
|
||||
|
||||
## Operators
|
||||
|
||||
| Syntax | Rust | TS | Meaning |
|
||||
|--------|------|----|---------|
|
||||
| `=` | `EQ` | `'='` | Equal |
|
||||
| `!=` | `NEQ` | `'!='` | Not equal |
|
||||
| `>` | `GT` | `'>'` | Greater than |
|
||||
| `>=` | `GTE` | `'>='` | Greater than or equal |
|
||||
| `<` | `LT` | `'<'` | Less than |
|
||||
| `<=` | `LTE` | `'<='` | Less than or equal |
|
||||
| `^` | expanded to `And(GTE, LT)` | `'^'` | Compatible (first non-zero digit unchanged) |
|
||||
| `~` | expanded to `And(GTE, LT)` | `'~'` | Patch-level (minor unchanged) |
|
||||
|
||||
## Flavor Rules
|
||||
|
||||
- Versions with **different flavors** never satisfy comparison operators (except `!=`, which returns true)
|
||||
- `VersionRange::Flavor(Some("bitcoin"))` matches only `#bitcoin:*` versions
|
||||
- `VersionRange::Flavor(None)` matches only unflavored versions
|
||||
- Flavor constraints compose with `and`/`or`/`not` like any other range
|
||||
|
||||
## Reduction and Normalization
|
||||
|
||||
### Rust: `reduce()` (shallow)
|
||||
|
||||
`VersionRange::reduce(self) -> Self` re-applies smart constructor rules to one level of the AST. Useful for simplifying a node that was constructed directly (e.g. deserialized) rather than through the smart constructors.
|
||||
|
||||
**Smart constructor rules applied by `and`, `or`, `not`, and `reduce`:**
|
||||
|
||||
`and`:
|
||||
- `and(Any, b) → b`, `and(a, Any) → a`
|
||||
- `and(None, _) → None`, `and(_, None) → None`
|
||||
|
||||
`or`:
|
||||
- `or(Any, _) → Any`, `or(_, Any) → Any`
|
||||
- `or(None, b) → b`, `or(a, None) → a`
|
||||
|
||||
`not`:
|
||||
- `not(=v) → !=v`, `not(!=v) → =v`
|
||||
- `not(and(a, b)) → or(not(a), not(b))` (De Morgan)
|
||||
- `not(or(a, b)) → and(not(a), not(b))` (De Morgan)
|
||||
- `not(not(a)) → a`
|
||||
- `not(Any) → None`, `not(None) → Any`
|
||||
|
||||
### TypeScript: `normalize()` (deep, canonical)
|
||||
|
||||
`VersionRange.normalize(): VersionRange` in `sdk/base/lib/exver/index.ts` performs full normalization by converting the range AST into a canonical form. This is a deep operation that produces a semantically equivalent but simplified range.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. **`tables()`** — Converts the VersionRange AST into truth tables (`VersionRangeTable`). Each table is a number line split at version boundary points, with boolean values for each segment indicating whether versions in that segment satisfy the range. Separate tables are maintained per flavor (and for flavor negations).
|
||||
|
||||
2. **`VersionRangeTable.zip(a, b, func)`** — Merges two tables by walking their boundary points in sorted order and applying a boolean function (`&&` or `||`) to combine segment values. Adjacent segments with the same boolean value are collapsed automatically.
|
||||
|
||||
3. **`VersionRangeTable.and/or/not`** — Table-level boolean operations. `and` computes the cross-product of flavor tables (since `#a && #b` for different flavors is unsatisfiable). `not` inverts all segment values.
|
||||
|
||||
4. **`VersionRangeTable.collapse()`** — Checks if a table is uniformly true or false across all flavors and segments. Returns `true`, `false`, or `null` (mixed).
|
||||
|
||||
5. **`VersionRangeTable.minterms()`** — Converts truth tables back into a VersionRange AST in [sum-of-products](https://en.wikipedia.org/wiki/Canonical_normal_form#Minterms) canonical form. Each `true` segment becomes a product term (conjunction of boundary constraints), and all terms are joined with `or`. Adjacent boundary points collapse into `=` anchors.
|
||||
|
||||
**Example:** `normalize` can simplify:
|
||||
- `>=1.0.0:0 && <=1.0.0:0` → `=1.0.0:0`
|
||||
- `>=2.0.0:0 || >=1.0.0:0` → `>=1.0.0:0`
|
||||
- `!(!>=1.0.0:0)` → `>=1.0.0:0`
|
||||
|
||||
**Also exposes:**
|
||||
- `satisfiable(): boolean` — returns `true` if there exists any version satisfying the range (checks if `collapse(tables())` is not `false`)
|
||||
- `intersects(other): boolean` — returns `true` if `and(this, other)` is satisfiable
|
||||
|
||||
## API Differences Between Rust and TypeScript
|
||||
|
||||
| | Rust | TypeScript |
|
||||
|-|------|------------|
|
||||
| **`^` / `~`** | Expanded at construction to `And(GTE, LT)` | First-class operator on `Anchor` |
|
||||
| **`not()`** | Static, eagerly simplifies (De Morgan, double negation) | Instance method, just wraps |
|
||||
| **`and()`/`or()`** | Binary static | Both binary instance and variadic static |
|
||||
| **Normalization** | `reduce()` — shallow, one AST level | `normalize()` — deep canonical form via truth tables |
|
||||
| **Satisfiability** | Not available | `satisfiable()` and `intersects(other)` |
|
||||
| **ExtendedVersion helpers** | `with_flavor()`, `without_flavor()`, `map_upstream()`, `map_downstream()` | `incrementMajor()`, `incrementMinor()`, `greaterThan()`, `lessThan()`, `equals()`, etc. |
|
||||
| **Monoid wrappers** | `AnyRange` (fold with `or`) and `AllRange` (fold with `and`) | Not present — use variadic static methods |
|
||||
| **`VersionString`** | Wrapper caching parsed + string form | Not present |
|
||||
| **Emver compat** | `From<emver::Version>` for `ExtendedVersion` | `ExtendedVersion.parseEmver()`, `VersionRange.parseEmver()` |
|
||||
|
||||
## Serde
|
||||
|
||||
All types serialize/deserialize as strings (requires `serde` feature, enabled in StartOS):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.2.3:0",
|
||||
"targetVersion": ">=1.0.0:0 <2.0.0:0",
|
||||
"sourceVersion": "^0.3.0:0"
|
||||
}
|
||||
```
|
||||
100
agents/i18n-patterns.md
Normal file
100
agents/i18n-patterns.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# i18n Patterns in `core/`
|
||||
|
||||
## Library & Setup
|
||||
|
||||
**Crate:** [`rust-i18n`](https://crates.io/crates/rust-i18n) v3.1.5 (`core/Cargo.toml`)
|
||||
|
||||
**Initialization** (`core/src/lib.rs:3`):
|
||||
```rust
|
||||
rust_i18n::i18n!("locales", fallback = ["en_US"]);
|
||||
```
|
||||
This macro scans `core/locales/` at compile time and embeds all translations as constants.
|
||||
|
||||
**Prelude re-export** (`core/src/prelude.rs:4`):
|
||||
```rust
|
||||
pub use rust_i18n::t;
|
||||
```
|
||||
Most modules import `t!` via the prelude.
|
||||
|
||||
## Translation File
|
||||
|
||||
**Location:** `core/locales/i18n.yaml`
|
||||
**Format:** YAML v2 (~755 keys)
|
||||
|
||||
**Supported languages:** `en_US`, `de_DE`, `es_ES`, `fr_FR`, `pl_PL`
|
||||
|
||||
**Entry structure:**
|
||||
```yaml
|
||||
namespace.sub.key-name:
|
||||
en_US: "English text with %{param}"
|
||||
de_DE: "German text with %{param}"
|
||||
# ...
|
||||
```
|
||||
|
||||
## Using `t!()`
|
||||
|
||||
```rust
|
||||
// Simple key
|
||||
t!("error.unknown")
|
||||
|
||||
// With parameter interpolation (%{name} in YAML)
|
||||
t!("bins.deprecated.renamed", old = old_name, new = new_name)
|
||||
```
|
||||
|
||||
## Key Naming Conventions
|
||||
|
||||
Keys use **dot-separated hierarchical namespaces** with **kebab-case** for multi-word segments:
|
||||
|
||||
```
|
||||
<module>.<submodule>.<descriptive-name>
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `error.incorrect-password` — error kind label
|
||||
- `bins.start-init.updating-firmware` — startup phase message
|
||||
- `backup.bulk.complete-title` — backup notification title
|
||||
- `help.arg.acme-contact` — CLI help text for an argument
|
||||
- `context.diagnostic.starting-diagnostic-ui` — diagnostic context status
|
||||
|
||||
### Top-Level Namespaces
|
||||
|
||||
| Namespace | Purpose |
|
||||
|-----------|---------|
|
||||
| `error.*` | `ErrorKind` display strings (see `src/error.rs`) |
|
||||
| `bins.*` | CLI binary messages (deprecated, start-init, startd, etc.) |
|
||||
| `init.*` | Initialization phase labels |
|
||||
| `setup.*` | First-run setup messages |
|
||||
| `context.*` | Context startup messages (diagnostic, setup, CLI) |
|
||||
| `service.*` | Service lifecycle messages |
|
||||
| `backup.*` | Backup/restore operation messages |
|
||||
| `registry.*` | Package registry messages |
|
||||
| `net.*` | Network-related messages |
|
||||
| `middleware.*` | Request middleware messages (auth, etc.) |
|
||||
| `disk.*` | Disk operation messages |
|
||||
| `lxc.*` | Container management messages |
|
||||
| `system.*` | System monitoring/metrics messages |
|
||||
| `notifications.*` | User-facing notification messages |
|
||||
| `update.*` | OS update messages |
|
||||
| `util.*` | Utility messages (TUI, RPC) |
|
||||
| `ssh.*` | SSH operation messages |
|
||||
| `shutdown.*` | Shutdown-related messages |
|
||||
| `logs.*` | Log-related messages |
|
||||
| `auth.*` | Authentication messages |
|
||||
| `help.*` | CLI help text (`help.arg.<arg-name>`) |
|
||||
| `about.*` | CLI command descriptions |
|
||||
|
||||
## Locale Selection
|
||||
|
||||
`core/src/bins/mod.rs:15-36` — `set_locale_from_env()`:
|
||||
|
||||
1. Reads `LANG` environment variable
|
||||
2. Strips `.UTF-8` suffix
|
||||
3. Exact-matches against available locales, falls back to language-prefix match (e.g. `en_GB` matches `en_US`)
|
||||
|
||||
## Adding New Keys
|
||||
|
||||
1. Add the key to `core/locales/i18n.yaml` with all 5 language translations
|
||||
2. Use the `t!("your.key.name")` macro in Rust code
|
||||
3. Follow existing namespace conventions — match the module path where the key is used
|
||||
4. Use kebab-case for multi-word segments
|
||||
5. Translations are validated at compile time
|
||||
226
agents/rpc-toolkit.md
Normal file
226
agents/rpc-toolkit.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# rpc-toolkit
|
||||
|
||||
StartOS uses [rpc-toolkit](https://github.com/Start9Labs/rpc-toolkit) for its JSON-RPC API. This document covers the patterns used in this codebase.
|
||||
|
||||
## Overview
|
||||
|
||||
The API is JSON-RPC (not REST). All endpoints are RPC methods organized in a hierarchical command structure.
|
||||
|
||||
## Handler Functions
|
||||
|
||||
There are four types of handler functions, chosen based on the function's characteristics:
|
||||
|
||||
### `from_fn_async` - Async handlers
|
||||
For standard async functions. Most handlers use this.
|
||||
|
||||
```rust
|
||||
pub async fn my_handler(ctx: RpcContext, params: MyParams) -> Result<MyResponse, Error> {
|
||||
// Can use .await
|
||||
}
|
||||
|
||||
from_fn_async(my_handler)
|
||||
```
|
||||
|
||||
### `from_fn_async_local` - Non-thread-safe async handlers
|
||||
For async functions that are not `Send` (cannot be safely moved between threads). Use when working with non-thread-safe types.
|
||||
|
||||
```rust
|
||||
pub async fn cli_download(ctx: CliContext, params: Params) -> Result<(), Error> {
|
||||
// Non-Send async operations
|
||||
}
|
||||
|
||||
from_fn_async_local(cli_download)
|
||||
```
|
||||
|
||||
### `from_fn_blocking` - Sync blocking handlers
|
||||
For synchronous functions that perform blocking I/O or long computations.
|
||||
|
||||
```rust
|
||||
pub fn query_dns(ctx: RpcContext, params: DnsParams) -> Result<DnsResponse, Error> {
|
||||
// Blocking operations (file I/O, DNS lookup, etc.)
|
||||
}
|
||||
|
||||
from_fn_blocking(query_dns)
|
||||
```
|
||||
|
||||
### `from_fn` - Sync non-blocking handlers
|
||||
For pure functions or quick synchronous operations with no I/O.
|
||||
|
||||
```rust
|
||||
pub fn echo(ctx: RpcContext, params: EchoParams) -> Result<String, Error> {
|
||||
Ok(params.message)
|
||||
}
|
||||
|
||||
from_fn(echo)
|
||||
```
|
||||
|
||||
## ParentHandler
|
||||
|
||||
Groups related RPC methods into a hierarchy:
|
||||
|
||||
```rust
|
||||
use rpc_toolkit::{Context, HandlerExt, ParentHandler, from_fn_async};
|
||||
|
||||
pub fn my_api<C: Context>() -> ParentHandler<C> {
|
||||
ParentHandler::new()
|
||||
.subcommand("list", from_fn_async(list_handler).with_call_remote::<CliContext>())
|
||||
.subcommand("create", from_fn_async(create_handler).with_call_remote::<CliContext>())
|
||||
}
|
||||
```
|
||||
|
||||
## Handler Extensions
|
||||
|
||||
Chain methods to configure handler behavior.
|
||||
|
||||
**Ordering rules:**
|
||||
1. `with_about()` must come AFTER other CLI modifiers (`no_display()`, `with_custom_display_fn()`, etc.)
|
||||
2. `with_call_remote()` must be the LAST adapter in the chain
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `.with_metadata("key", Value)` | Attach metadata for middleware |
|
||||
| `.no_cli()` | RPC-only, not available via CLI |
|
||||
| `.no_display()` | No CLI output |
|
||||
| `.with_display_serializable()` | Default JSON/YAML output for CLI |
|
||||
| `.with_custom_display_fn(\|_, res\| ...)` | Custom CLI output formatting |
|
||||
| `.with_about("about.description")` | Add help text (i18n key) - **after CLI modifiers** |
|
||||
| `.with_call_remote::<CliContext>()` | Enable CLI to call remotely - **must be last** |
|
||||
|
||||
### Correct ordering example:
|
||||
```rust
|
||||
from_fn_async(my_handler)
|
||||
.with_metadata("sync_db", Value::Bool(true)) // metadata early
|
||||
.no_display() // CLI modifier
|
||||
.with_about("about.my-handler") // after CLI modifiers
|
||||
.with_call_remote::<CliContext>() // always last
|
||||
```
|
||||
|
||||
## Metadata by Middleware
|
||||
|
||||
Metadata tags are processed by different middleware. Group them logically:
|
||||
|
||||
### Auth Middleware (`middleware/auth/mod.rs`)
|
||||
|
||||
| Metadata | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `authenticated` | `true` | Whether endpoint requires authentication. Set to `false` for public endpoints. |
|
||||
|
||||
### Session Auth Middleware (`middleware/auth/session.rs`)
|
||||
|
||||
| Metadata | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `login` | `false` | Special handling for login endpoints (rate limiting, cookie setting) |
|
||||
| `get_session` | `false` | Inject session ID into params as `__Auth_session` |
|
||||
|
||||
### Signature Auth Middleware (`middleware/auth/signature.rs`)
|
||||
|
||||
| Metadata | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `get_signer` | `false` | Inject signer public key into params as `__Auth_signer` |
|
||||
|
||||
### Registry Auth (extends Signature Auth)
|
||||
|
||||
| Metadata | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `admin` | `false` | Require admin privileges (signer must be in admin list) |
|
||||
| `get_device_info` | `false` | Inject device info header for hardware filtering |
|
||||
|
||||
### Database Middleware (`middleware/db.rs`)
|
||||
|
||||
| Metadata | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `sync_db` | `false` | Sync database after mutation, add `X-Patch-Sequence` header |
|
||||
|
||||
## Context Types
|
||||
|
||||
Different contexts for different execution environments:
|
||||
|
||||
- `RpcContext` - Web/RPC requests with full service access
|
||||
- `CliContext` - CLI operations, calls remote RPC
|
||||
- `InitContext` - During system initialization
|
||||
- `DiagnosticContext` - Diagnostic/recovery mode
|
||||
- `RegistryContext` - Registry daemon context
|
||||
- `EffectContext` - Service effects context (container-to-host calls)
|
||||
|
||||
## Parameter Structs
|
||||
|
||||
Parameters use derive macros for JSON-RPC, CLI parsing, and TypeScript generation:
|
||||
|
||||
```rust
|
||||
#[derive(Deserialize, Serialize, Parser, TS)]
|
||||
#[serde(rename_all = "camelCase")] // JSON-RPC uses camelCase
|
||||
#[command(rename_all = "kebab-case")] // CLI uses kebab-case
|
||||
#[ts(export)] // Generate TypeScript types
|
||||
pub struct MyParams {
|
||||
pub package_id: PackageId,
|
||||
}
|
||||
```
|
||||
|
||||
### Middleware Injection
|
||||
|
||||
Auth middleware can inject values into params using special field names:
|
||||
|
||||
```rust
|
||||
#[derive(Deserialize, Serialize, Parser, TS)]
|
||||
pub struct MyParams {
|
||||
#[ts(skip)]
|
||||
#[serde(rename = "__Auth_session")] // Injected by session auth
|
||||
session: InternedString,
|
||||
|
||||
#[ts(skip)]
|
||||
#[serde(rename = "__Auth_signer")] // Injected by signature auth
|
||||
signer: AnyVerifyingKey,
|
||||
|
||||
#[ts(skip)]
|
||||
#[serde(rename = "__Auth_userAgent")] // Injected during login
|
||||
user_agent: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Adding a New RPC Endpoint
|
||||
|
||||
1. Define params struct with `Deserialize, Serialize, Parser, TS`
|
||||
2. Choose handler type based on sync/async and thread-safety
|
||||
3. Write handler function taking `(Context, Params) -> Result<Response, Error>`
|
||||
4. Add to parent handler with appropriate extensions (display modifiers before `with_about`)
|
||||
5. TypeScript types auto-generated via `make ts-bindings`
|
||||
|
||||
### Public (Unauthenticated) Endpoint
|
||||
|
||||
```rust
|
||||
from_fn_async(get_info)
|
||||
.with_metadata("authenticated", Value::Bool(false))
|
||||
.with_display_serializable()
|
||||
.with_about("about.get-info")
|
||||
.with_call_remote::<CliContext>() // last
|
||||
```
|
||||
|
||||
### Mutating Endpoint with DB Sync
|
||||
|
||||
```rust
|
||||
from_fn_async(update_config)
|
||||
.with_metadata("sync_db", Value::Bool(true))
|
||||
.no_display()
|
||||
.with_about("about.update-config")
|
||||
.with_call_remote::<CliContext>() // last
|
||||
```
|
||||
|
||||
### Session-Aware Endpoint
|
||||
|
||||
```rust
|
||||
from_fn_async(logout)
|
||||
.with_metadata("get_session", Value::Bool(true))
|
||||
.no_display()
|
||||
.with_about("about.logout")
|
||||
.with_call_remote::<CliContext>() // last
|
||||
```
|
||||
|
||||
## File Locations
|
||||
|
||||
- Handler definitions: Throughout `core/src/` modules
|
||||
- Main API tree: `core/src/lib.rs` (`main_api()`, `server()`, `package()`)
|
||||
- Auth middleware: `core/src/middleware/auth/`
|
||||
- DB middleware: `core/src/middleware/db.rs`
|
||||
- Context types: `core/src/context/`
|
||||
122
agents/s9pk-structure.md
Normal file
122
agents/s9pk-structure.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# S9PK Package Format
|
||||
|
||||
S9PK is the package format for StartOS services. Version 2 uses a merkle archive structure for efficient downloading and cryptographic verification.
|
||||
|
||||
## File Format
|
||||
|
||||
S9PK files begin with a 3-byte header: `0x3b 0x3b 0x02` (magic bytes + version 2).
|
||||
|
||||
The archive is cryptographically signed using Ed25519 with prehashed content (SHA-512 over blake3 merkle root hash).
|
||||
|
||||
## Archive Structure
|
||||
|
||||
```
|
||||
/
|
||||
├── manifest.json # Package metadata (required)
|
||||
├── icon.<ext> # Package icon - any image/* format (required)
|
||||
├── LICENSE.md # License text (required)
|
||||
├── dependencies/ # Dependency metadata (optional)
|
||||
│ └── <package-id>/
|
||||
│ ├── metadata.json # DependencyMetadata
|
||||
│ └── icon.<ext> # Dependency icon
|
||||
├── javascript.squashfs # Package JavaScript code (required)
|
||||
├── assets.squashfs # Static assets (optional, legacy: assets/ directory)
|
||||
└── images/ # Container images by architecture
|
||||
└── <arch>/ # e.g., x86_64, aarch64, riscv64
|
||||
├── <image-id>.squashfs # Container filesystem
|
||||
├── <image-id>.json # Image metadata
|
||||
└── <image-id>.env # Environment variables
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### manifest.json
|
||||
|
||||
The package manifest contains all metadata:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | string | Package identifier (e.g., `bitcoind`) |
|
||||
| `title` | string | Display name |
|
||||
| `version` | string | Extended version string |
|
||||
| `satisfies` | string[] | Version ranges this version satisfies |
|
||||
| `releaseNotes` | string/object | Release notes (localized) |
|
||||
| `canMigrateTo` | string | Version range for forward migration |
|
||||
| `canMigrateFrom` | string | Version range for backward migration |
|
||||
| `license` | string | License type |
|
||||
| `wrapperRepo` | string | StartOS wrapper repository URL |
|
||||
| `upstreamRepo` | string | Upstream project URL |
|
||||
| `supportSite` | string | Support site URL |
|
||||
| `marketingSite` | string | Marketing site URL |
|
||||
| `donationUrl` | string? | Optional donation URL |
|
||||
| `docsUrl` | string? | Optional documentation URL |
|
||||
| `description` | object | Short and long descriptions (localized) |
|
||||
| `images` | object | Image configurations by image ID |
|
||||
| `volumes` | string[] | Volume IDs for persistent data |
|
||||
| `alerts` | object | User alerts for lifecycle events |
|
||||
| `dependencies` | object | Package dependencies |
|
||||
| `hardwareRequirements` | object | Hardware requirements (arch, RAM, devices) |
|
||||
| `hardwareAcceleration` | boolean | Whether package uses hardware acceleration |
|
||||
| `gitHash` | string? | Git commit hash |
|
||||
| `osVersion` | string | Minimum StartOS version |
|
||||
| `sdkVersion` | string? | SDK version used to build |
|
||||
|
||||
### javascript.squashfs
|
||||
|
||||
Contains the package JavaScript that implements the `ABI` interface from `@start9labs/start-sdk-base`. This code runs in the container runtime and manages the package lifecycle.
|
||||
|
||||
The squashfs is mounted at `/usr/lib/startos/package/` and the runtime loads `index.js`.
|
||||
|
||||
### images/
|
||||
|
||||
Container images organized by architecture:
|
||||
|
||||
- **`<image-id>.squashfs`** - Container root filesystem
|
||||
- **`<image-id>.json`** - Image metadata (entrypoint, user, workdir, etc.)
|
||||
- **`<image-id>.env`** - Environment variables for the container
|
||||
|
||||
Images are built from Docker/Podman and converted to squashfs. The `ImageConfig` in manifest specifies:
|
||||
- `arch` - Supported architectures
|
||||
- `emulateMissingAs` - Fallback architecture for emulation
|
||||
- `nvidiaContainer` - Whether to enable NVIDIA container support
|
||||
|
||||
### assets.squashfs
|
||||
|
||||
Static assets accessible to the package, mounted read-only at `/media/startos/assets/` in the container.
|
||||
|
||||
### dependencies/
|
||||
|
||||
Metadata for dependencies displayed in the UI:
|
||||
- `metadata.json` - Just title for now
|
||||
- `icon.<ext>` - Icon for the dependency
|
||||
|
||||
## Merkle Archive
|
||||
|
||||
The S9PK uses a merkle tree structure where each file and directory has a blake3 hash. This enables:
|
||||
|
||||
1. **Partial downloads** - Download and verify individual files
|
||||
2. **Integrity verification** - Verify any subset of the archive
|
||||
3. **Efficient updates** - Only download changed portions
|
||||
4. **DOS protection** - Size limits enforced before downloading content
|
||||
|
||||
Files are sorted by priority for streaming (manifest first, then icon, license, dependencies, javascript, assets, images).
|
||||
|
||||
## Building S9PK
|
||||
|
||||
Use `start-cli s9pk pack` to build packages:
|
||||
|
||||
```bash
|
||||
start-cli s9pk pack <manifest-path> -o <output.s9pk>
|
||||
```
|
||||
|
||||
Images can be sourced from:
|
||||
- Docker/Podman build (`--docker-build`)
|
||||
- Existing Docker tag (`--docker-tag`)
|
||||
- Pre-built squashfs files
|
||||
|
||||
## Related Code
|
||||
|
||||
- `core/src/s9pk/v2/mod.rs` - S9pk struct and serialization
|
||||
- `core/src/s9pk/v2/manifest.rs` - Manifest types
|
||||
- `core/src/s9pk/v2/pack.rs` - Packing logic
|
||||
- `core/src/s9pk/merkle_archive/` - Merkle archive implementation
|
||||
@@ -111,6 +111,6 @@ if [ "$CHROOT_RES" -eq 0 ]; then
|
||||
reboot
|
||||
fi
|
||||
|
||||
umount -R /media/startos/next
|
||||
umount /media/startos/next
|
||||
umount /media/startos/upper
|
||||
rm -rf /media/startos/upper /media/startos/next
|
||||
@@ -5,7 +5,7 @@ if [ -z "$sip" ] || [ -z "$dip" ] || [ -z "$dprefix" ] || [ -z "$sport" ] || [ -
|
||||
exit 1
|
||||
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
|
||||
if ! iptables -C $kind -j "${NAME}_${kind}" 2> /dev/null; then
|
||||
@@ -36,8 +36,22 @@ if [ "$UNDO" = 1 ]; then
|
||||
fi
|
||||
|
||||
# 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"
|
||||
iptables -t nat -A ${NAME}_PREROUTING -d "$sip" -p udp --dport "$sport" -j DNAT --to-destination "$dip:$dport"
|
||||
# When src_subnet is set, only forward traffic from that subnet (private forwards)
|
||||
# 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)
|
||||
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 udp --dport $dport -m state --state NEW -j ACCEPT
|
||||
|
||||
exit $err
|
||||
exit $err
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
# Container RPC SERVER Specification
|
||||
# Container RPC Server Specification
|
||||
|
||||
The container runtime exposes a JSON-RPC server over a Unix socket at `/media/startos/rpc/service.sock`.
|
||||
|
||||
## Methods
|
||||
|
||||
### init
|
||||
|
||||
initialize runtime (mount `/proc`, `/sys`, `/dev`, and `/run` to each image in `/media/images`)
|
||||
Initialize the runtime and system.
|
||||
|
||||
called after os has mounted js and images to the container
|
||||
#### params
|
||||
|
||||
#### args
|
||||
|
||||
`[]`
|
||||
```ts
|
||||
{
|
||||
id: string,
|
||||
kind: "install" | "update" | "restore" | null,
|
||||
}
|
||||
```
|
||||
|
||||
#### response
|
||||
|
||||
@@ -18,11 +23,16 @@ called after os has mounted js and images to the container
|
||||
|
||||
### exit
|
||||
|
||||
shutdown runtime
|
||||
Shutdown runtime and optionally run exit hooks for a target version.
|
||||
|
||||
#### args
|
||||
#### params
|
||||
|
||||
`[]`
|
||||
```ts
|
||||
{
|
||||
id: string,
|
||||
target: string | null, // ExtendedVersion or VersionRange
|
||||
}
|
||||
```
|
||||
|
||||
#### response
|
||||
|
||||
@@ -30,11 +40,11 @@ shutdown runtime
|
||||
|
||||
### start
|
||||
|
||||
run main method if not already running
|
||||
Run main method if not already running.
|
||||
|
||||
#### args
|
||||
#### params
|
||||
|
||||
`[]`
|
||||
None
|
||||
|
||||
#### response
|
||||
|
||||
@@ -42,11 +52,11 @@ run main method if not already running
|
||||
|
||||
### stop
|
||||
|
||||
stop main method by sending SIGTERM to child processes, and SIGKILL after timeout
|
||||
Stop main method by sending SIGTERM to child processes, and SIGKILL after timeout.
|
||||
|
||||
#### args
|
||||
#### params
|
||||
|
||||
`{ timeout: millis }`
|
||||
None
|
||||
|
||||
#### response
|
||||
|
||||
@@ -54,15 +64,16 @@ stop main method by sending SIGTERM to child processes, and SIGKILL after timeou
|
||||
|
||||
### execute
|
||||
|
||||
run a specific package procedure
|
||||
Run a specific package procedure.
|
||||
|
||||
#### args
|
||||
#### params
|
||||
|
||||
```ts
|
||||
{
|
||||
procedure: JsonPath,
|
||||
input: any,
|
||||
timeout: millis,
|
||||
id: string, // event ID
|
||||
procedure: string, // JSON path (e.g., "/backup/create", "/actions/{name}/run")
|
||||
input: any,
|
||||
timeout: number | null,
|
||||
}
|
||||
```
|
||||
|
||||
@@ -72,18 +83,64 @@ run a specific package procedure
|
||||
|
||||
### sandbox
|
||||
|
||||
run a specific package procedure in sandbox mode
|
||||
Run a specific package procedure in sandbox mode. Same interface as `execute`.
|
||||
|
||||
#### args
|
||||
UNIMPLEMENTED: this feature is planned but does not exist
|
||||
|
||||
#### params
|
||||
|
||||
```ts
|
||||
{
|
||||
procedure: JsonPath,
|
||||
input: any,
|
||||
timeout: millis,
|
||||
id: string,
|
||||
procedure: string,
|
||||
input: any,
|
||||
timeout: number | null,
|
||||
}
|
||||
```
|
||||
|
||||
#### response
|
||||
|
||||
`any`
|
||||
|
||||
### callback
|
||||
|
||||
Handle a callback from an effect.
|
||||
|
||||
#### params
|
||||
|
||||
```ts
|
||||
{
|
||||
id: number,
|
||||
args: any[],
|
||||
}
|
||||
```
|
||||
|
||||
#### response
|
||||
|
||||
`null` (no response sent)
|
||||
|
||||
### eval
|
||||
|
||||
Evaluate a script in the runtime context. Used for debugging.
|
||||
|
||||
#### params
|
||||
|
||||
```ts
|
||||
{
|
||||
script: string,
|
||||
}
|
||||
```
|
||||
|
||||
#### response
|
||||
|
||||
`any`
|
||||
|
||||
## Procedures
|
||||
|
||||
The `execute` and `sandbox` methods route to procedures based on the `procedure` path:
|
||||
|
||||
| Procedure | Description |
|
||||
| -------------------------- | ---------------------------- |
|
||||
| `/backup/create` | Create a backup |
|
||||
| `/actions/{name}/getInput` | Get input spec for an action |
|
||||
| `/actions/{name}/run` | Run an action with input |
|
||||
|
||||
@@ -82,18 +82,15 @@ export class DockerProcedureContainer extends Drop {
|
||||
}),
|
||||
)
|
||||
} else if (volumeMount.type === "certificate") {
|
||||
const hostInfo = await effects.getHostInfo({
|
||||
hostId: volumeMount["interface-id"],
|
||||
})
|
||||
const hostnames = [
|
||||
`${packageId}.embassy`,
|
||||
...new Set(
|
||||
Object.values(
|
||||
(
|
||||
await effects.getHostInfo({
|
||||
hostId: volumeMount["interface-id"],
|
||||
})
|
||||
)?.hostnameInfo || {},
|
||||
)
|
||||
.flatMap((h) => h)
|
||||
.flatMap((h) => (h.kind === "onion" ? [h.hostname.value] : [])),
|
||||
Object.values(hostInfo?.bindings || {})
|
||||
.flatMap((b) => b.addresses.possible)
|
||||
.map((h) => h.hostname.value),
|
||||
).values(),
|
||||
]
|
||||
const certChain = await effects.getSslCertificate({
|
||||
|
||||
@@ -1244,12 +1244,8 @@ async function updateConfig(
|
||||
? ""
|
||||
: catchFn(
|
||||
() =>
|
||||
(specValue.target === "lan-address"
|
||||
? filled.addressInfo!.filter({ kind: "mdns" }) ||
|
||||
filled.addressInfo!.onion
|
||||
: filled.addressInfo!.onion ||
|
||||
filled.addressInfo!.filter({ kind: "mdns" })
|
||||
).hostnames[0].hostname.value,
|
||||
filled.addressInfo!.filter({ kind: "mdns" })!.hostnames[0]
|
||||
.hostname.value,
|
||||
) || ""
|
||||
mutConfigValue[key] = url
|
||||
}
|
||||
|
||||
2724
core/Cargo.lock
generated
2724
core/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ license = "MIT"
|
||||
name = "start-os"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/Start9Labs/start-os"
|
||||
version = "0.4.0-alpha.19" # VERSION_BUMP
|
||||
version = "0.4.0-alpha.20" # VERSION_BUMP
|
||||
|
||||
[lib]
|
||||
name = "startos"
|
||||
@@ -42,17 +42,6 @@ name = "tunnelbox"
|
||||
path = "src/main/tunnelbox.rs"
|
||||
|
||||
[features]
|
||||
arti = [
|
||||
"arti-client",
|
||||
"safelog",
|
||||
"tor-cell",
|
||||
"tor-hscrypto",
|
||||
"tor-hsservice",
|
||||
"tor-keymgr",
|
||||
"tor-llcrypto",
|
||||
"tor-proto",
|
||||
"tor-rtcompat",
|
||||
]
|
||||
beta = []
|
||||
console = ["console-subscriber", "tokio/tracing"]
|
||||
default = []
|
||||
@@ -62,16 +51,6 @@ unstable = ["backtrace-on-stack-overflow"]
|
||||
|
||||
[dependencies]
|
||||
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 = [
|
||||
"use_rustls",
|
||||
"use_tokio",
|
||||
@@ -100,7 +79,6 @@ console-subscriber = { version = "0.5.0", optional = true }
|
||||
const_format = "0.2.34"
|
||||
cookie = "0.18.0"
|
||||
cookie_store = "0.22.0"
|
||||
curve25519-dalek = "4.1.3"
|
||||
der = { version = "0.7.9", features = ["derive", "pem"] }
|
||||
digest = "0.10.7"
|
||||
divrem = "1.0.0"
|
||||
@@ -216,7 +194,6 @@ rpassword = "7.2.0"
|
||||
rust-argon2 = "3.0.0"
|
||||
rust-i18n = "3.1.5"
|
||||
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"] }
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
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-tungstenite = { version = "0.26.2", features = ["native-tls", "url"] }
|
||||
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"
|
||||
tracing = "0.1.39"
|
||||
tracing-error = "0.2.0"
|
||||
|
||||
@@ -8,7 +8,6 @@ use openssl::x509::X509;
|
||||
use crate::db::model::DatabaseModel;
|
||||
use crate::hostname::{Hostname, generate_hostname, generate_id};
|
||||
use crate::net::ssl::{gen_nistp256, make_root_cert};
|
||||
use crate::net::tor::TorSecretKey;
|
||||
use crate::prelude::*;
|
||||
use crate::util::serde::Pem;
|
||||
|
||||
@@ -26,7 +25,6 @@ pub struct AccountInfo {
|
||||
pub server_id: String,
|
||||
pub hostname: Hostname,
|
||||
pub password: String,
|
||||
pub tor_keys: Vec<TorSecretKey>,
|
||||
pub root_ca_key: PKey<Private>,
|
||||
pub root_ca_cert: X509,
|
||||
pub ssh_key: ssh_key::PrivateKey,
|
||||
@@ -36,7 +34,6 @@ impl AccountInfo {
|
||||
pub fn new(password: &str, start_time: SystemTime) -> Result<Self, Error> {
|
||||
let server_id = generate_id();
|
||||
let hostname = generate_hostname();
|
||||
let tor_key = vec![TorSecretKey::generate()];
|
||||
let root_ca_key = gen_nistp256()?;
|
||||
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(
|
||||
@@ -48,7 +45,6 @@ impl AccountInfo {
|
||||
server_id,
|
||||
hostname,
|
||||
password: hash_password(password)?,
|
||||
tor_keys: tor_key,
|
||||
root_ca_key,
|
||||
root_ca_cert,
|
||||
ssh_key,
|
||||
@@ -61,17 +57,6 @@ impl AccountInfo {
|
||||
let hostname = Hostname(db.as_public().as_server_info().as_hostname().de()?);
|
||||
let password = db.as_private().as_password().de()?;
|
||||
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 root_ca_key = cert_store.as_root_key().de()?.0;
|
||||
let root_ca_cert = cert_store.as_root_cert().de()?.0;
|
||||
@@ -82,7 +67,6 @@ impl AccountInfo {
|
||||
server_id,
|
||||
hostname,
|
||||
password,
|
||||
tor_keys,
|
||||
root_ca_key,
|
||||
root_ca_cert,
|
||||
ssh_key,
|
||||
@@ -97,17 +81,6 @@ impl AccountInfo {
|
||||
server_info
|
||||
.as_pubkey_mut()
|
||||
.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)?;
|
||||
db.as_private_mut().as_password_mut().ser(&self.password)?;
|
||||
db.as_private_mut()
|
||||
@@ -117,9 +90,6 @@ impl AccountInfo {
|
||||
.as_developer_key_mut()
|
||||
.ser(Pem::new_ref(&self.developer_key))?;
|
||||
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();
|
||||
if cert_store.as_root_cert().de()?.0 != self.root_ca_cert {
|
||||
cert_store
|
||||
@@ -148,11 +118,5 @@ impl AccountInfo {
|
||||
self.hostname.no_dot_host_name(),
|
||||
self.hostname.local_domain_name(),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(
|
||||
self.tor_keys
|
||||
.iter()
|
||||
.map(|k| InternedString::from_display(&k.onion_address())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ use ssh_key::private::Ed25519Keypair;
|
||||
|
||||
use crate::account::AccountInfo;
|
||||
use crate::hostname::{Hostname, generate_hostname, generate_id};
|
||||
use crate::net::tor::TorSecretKey;
|
||||
use crate::prelude::*;
|
||||
use crate::util::crypto::ed25519_expand_key;
|
||||
use crate::util::serde::{Base32, Base64, Pem};
|
||||
|
||||
pub struct OsBackup {
|
||||
@@ -85,10 +83,6 @@ impl OsBackupV0 {
|
||||
&mut ssh_key::rand_core::OsRng::default(),
|
||||
ssh_key::Algorithm::Ed25519,
|
||||
)?,
|
||||
tor_keys: TorSecretKey::from_bytes(self.tor_key.0)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.collect(),
|
||||
developer_key: ed25519_dalek::SigningKey::generate(
|
||||
&mut ssh_key::rand_core::OsRng::default(),
|
||||
),
|
||||
@@ -119,10 +113,6 @@ impl OsBackupV1 {
|
||||
root_ca_key: self.root_ca_key.0,
|
||||
root_ca_cert: self.root_ca_cert.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),
|
||||
},
|
||||
ui: self.ui,
|
||||
@@ -140,7 +130,6 @@ struct OsBackupV2 {
|
||||
root_ca_key: Pem<PKey<Private>>, // PEM Encoded OpenSSL Key
|
||||
root_ca_cert: Pem<X509>, // PEM Encoded OpenSSL X509 Certificate
|
||||
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
|
||||
ui: Value, // JSON Value
|
||||
}
|
||||
@@ -154,7 +143,6 @@ impl OsBackupV2 {
|
||||
root_ca_key: self.root_ca_key.0,
|
||||
root_ca_cert: self.root_ca_cert.0,
|
||||
ssh_key: self.ssh_key.0,
|
||||
tor_keys: self.tor_keys,
|
||||
developer_key: self.compat_s9pk_key.0,
|
||||
},
|
||||
ui: self.ui,
|
||||
@@ -167,7 +155,6 @@ impl OsBackupV2 {
|
||||
root_ca_key: Pem(backup.account.root_ca_key.clone()),
|
||||
root_ca_cert: Pem(backup.account.root_ca_cert.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()),
|
||||
ui: backup.ui.clone(),
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::disk::fsck::RepairStrategy;
|
||||
use crate::disk::main::DEFAULT_PASSWORD;
|
||||
use crate::firmware::{check_for_firmware_update, update_firmware};
|
||||
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::prelude::*;
|
||||
use crate::progress::FullProgressTracker;
|
||||
@@ -19,7 +19,7 @@ use crate::{DATA_DIR, PLATFORM};
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn setup_or_init(
|
||||
server: &mut WebServer<UpgradableListener>,
|
||||
server: &mut WebServer<WildcardListener>,
|
||||
config: &ServerConfig,
|
||||
) -> Result<Result<(RpcContext, FullProgressTracker), Shutdown>, Error> {
|
||||
if let Some(firmware) = check_for_firmware_update()
|
||||
@@ -204,7 +204,7 @@ async fn setup_or_init(
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn main(
|
||||
server: &mut WebServer<UpgradableListener>,
|
||||
server: &mut WebServer<WildcardListener>,
|
||||
config: &ServerConfig,
|
||||
) -> Result<Result<(RpcContext, FullProgressTracker), Shutdown>, Error> {
|
||||
if &*PLATFORM == "raspberrypi" && tokio::fs::metadata(STANDBY_MODE_PATH).await.is_ok() {
|
||||
|
||||
@@ -12,7 +12,7 @@ use tracing::instrument;
|
||||
use crate::context::config::ServerConfig;
|
||||
use crate::context::rpc::InitRpcContextPhases;
|
||||
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::web_server::{Acceptor, WebServer};
|
||||
use crate::prelude::*;
|
||||
@@ -23,7 +23,7 @@ use crate::util::logger::LOGGER;
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn inner_main(
|
||||
server: &mut WebServer<UpgradableListener>,
|
||||
server: &mut WebServer<WildcardListener>,
|
||||
config: &ServerConfig,
|
||||
) -> Result<Option<Shutdown>, Error> {
|
||||
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"));
|
||||
let res = rt.block_on(async {
|
||||
let mut server = WebServer::new(
|
||||
Acceptor::bind_upgradable(SelfContainedNetworkInterfaceListener::bind(BindTcp, 80)),
|
||||
Acceptor::new(WildcardListener::new(80)?),
|
||||
refresher(),
|
||||
);
|
||||
match inner_main(&mut server, &config).await {
|
||||
|
||||
@@ -13,7 +13,7 @@ use visit_rs::Visit;
|
||||
|
||||
use crate::context::CliContext;
|
||||
use crate::context::config::ClientConfig;
|
||||
use crate::net::gateway::{Bind, BindTcp};
|
||||
use tokio::net::TcpListener;
|
||||
use crate::net::tls::TlsListener;
|
||||
use crate::net::web_server::{Accept, Acceptor, MetadataVisitor, WebServer};
|
||||
use crate::prelude::*;
|
||||
@@ -57,7 +57,12 @@ async fn inner_main(config: &TunnelConfig) -> Result<(), Error> {
|
||||
if !a.contains_key(&key) {
|
||||
match (|| {
|
||||
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 {
|
||||
db: https_db.clone(),
|
||||
crypto_provider: Arc::new(tokio_rustls::rustls::crypto::ring::default_provider()),
|
||||
|
||||
@@ -34,7 +34,7 @@ use crate::disk::mount::guard::MountGuard;
|
||||
use crate::init::{InitResult, check_time_is_synchronized};
|
||||
use crate::install::PKG_ARCHIVE_DIR;
|
||||
use crate::lxc::LxcManager;
|
||||
use crate::net::gateway::UpgradableListener;
|
||||
use crate::net::gateway::WildcardListener;
|
||||
use crate::net::net_controller::{NetController, NetService};
|
||||
use crate::net::socks::DEFAULT_SOCKS_LISTEN;
|
||||
use crate::net::utils::{find_eth_iface, find_wifi_iface};
|
||||
@@ -132,7 +132,7 @@ pub struct RpcContext(Arc<RpcContextSeed>);
|
||||
impl RpcContext {
|
||||
#[instrument(skip_all)]
|
||||
pub async fn init(
|
||||
webserver: &WebServerAcceptorSetter<UpgradableListener>,
|
||||
webserver: &WebServerAcceptorSetter<WildcardListener>,
|
||||
config: &ServerConfig,
|
||||
disk_guid: InternedString,
|
||||
init_result: Option<InitResult>,
|
||||
@@ -167,7 +167,7 @@ impl RpcContext {
|
||||
} else {
|
||||
let net_ctrl =
|
||||
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?;
|
||||
(net_ctrl, os_net_service)
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::context::RpcContext;
|
||||
use crate::context::config::ServerConfig;
|
||||
use crate::disk::mount::guard::{MountGuard, TmpMountGuard};
|
||||
use crate::hostname::Hostname;
|
||||
use crate::net::gateway::UpgradableListener;
|
||||
use crate::net::gateway::WildcardListener;
|
||||
use crate::net::web_server::{WebServer, WebServerAcceptorSetter};
|
||||
use crate::prelude::*;
|
||||
use crate::progress::FullProgressTracker;
|
||||
@@ -51,7 +51,7 @@ pub struct SetupResult {
|
||||
}
|
||||
|
||||
pub struct SetupContextSeed {
|
||||
pub webserver: WebServerAcceptorSetter<UpgradableListener>,
|
||||
pub webserver: WebServerAcceptorSetter<WildcardListener>,
|
||||
pub config: SyncMutex<ServerConfig>,
|
||||
pub disable_encryption: bool,
|
||||
pub progress: FullProgressTracker,
|
||||
@@ -70,7 +70,7 @@ pub struct SetupContext(Arc<SetupContextSeed>);
|
||||
impl SetupContext {
|
||||
#[instrument(skip_all)]
|
||||
pub fn init(
|
||||
webserver: &WebServer<UpgradableListener>,
|
||||
webserver: &WebServer<WildcardListener>,
|
||||
config: ServerConfig,
|
||||
) -> Result<Self, Error> {
|
||||
let (shutdown, _) = tokio::sync::broadcast::channel(1);
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::s9pk::manifest::{LocaleString, Manifest};
|
||||
use crate::status::StatusInfo;
|
||||
use crate::util::DataUrl;
|
||||
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)]
|
||||
#[ts(export)]
|
||||
@@ -381,6 +381,9 @@ pub struct PackageDataEntry {
|
||||
pub hosts: Hosts,
|
||||
#[ts(type = "string[]")]
|
||||
pub store_exposed_dependents: Vec<JsonPointer>,
|
||||
#[serde(default)]
|
||||
#[ts(type = "string | null")]
|
||||
pub outbound_gateway: Option<GatewayId>,
|
||||
}
|
||||
impl AsRef<PackageDataEntry> for PackageDataEntry {
|
||||
fn as_ref(&self) -> &PackageDataEntry {
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::db::model::Database;
|
||||
use crate::db::model::package::AllPackageData;
|
||||
use crate::net::acme::AcmeProvider;
|
||||
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::vhost::AlpnInfo;
|
||||
use crate::prelude::*;
|
||||
@@ -63,36 +63,35 @@ impl Public {
|
||||
post_init_migration_todos: BTreeMap::new(),
|
||||
network: NetworkInfo {
|
||||
host: Host {
|
||||
bindings: [(
|
||||
80,
|
||||
BindInfo {
|
||||
enabled: false,
|
||||
options: BindOptions {
|
||||
preferred_external_port: 80,
|
||||
add_ssl: Some(AddSslOptions {
|
||||
preferred_external_port: 443,
|
||||
add_x_forwarded_headers: false,
|
||||
alpn: Some(AlpnInfo::Specified(vec![
|
||||
MaybeUtf8String("h2".into()),
|
||||
MaybeUtf8String("http/1.1".into()),
|
||||
])),
|
||||
}),
|
||||
secure: None,
|
||||
bindings: Bindings(
|
||||
[(
|
||||
80,
|
||||
BindInfo {
|
||||
enabled: false,
|
||||
options: BindOptions {
|
||||
preferred_external_port: 80,
|
||||
add_ssl: Some(AddSslOptions {
|
||||
preferred_external_port: 443,
|
||||
add_x_forwarded_headers: false,
|
||||
alpn: Some(AlpnInfo::Specified(vec![
|
||||
MaybeUtf8String("h2".into()),
|
||||
MaybeUtf8String("http/1.1".into()),
|
||||
])),
|
||||
}),
|
||||
secure: None,
|
||||
},
|
||||
net: NetInfo {
|
||||
assigned_port: None,
|
||||
assigned_ssl_port: Some(443),
|
||||
},
|
||||
addresses: DerivedAddressInfo::default(),
|
||||
},
|
||||
net: NetInfo {
|
||||
assigned_port: None,
|
||||
assigned_ssl_port: Some(443),
|
||||
private_disabled: OrdSet::new(),
|
||||
public_enabled: OrdSet::new(),
|
||||
},
|
||||
},
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
onions: account.tor_keys.iter().map(|k| k.onion_address()).collect(),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
public_domains: BTreeMap::new(),
|
||||
private_domains: BTreeSet::new(),
|
||||
hostname_info: BTreeMap::new(),
|
||||
},
|
||||
wifi: WifiInfo {
|
||||
enabled: true,
|
||||
@@ -117,6 +116,7 @@ impl Public {
|
||||
acme
|
||||
},
|
||||
dns: Default::default(),
|
||||
default_outbound: None,
|
||||
},
|
||||
status_info: ServerStatus {
|
||||
backup_progress: None,
|
||||
@@ -220,6 +220,9 @@ pub struct NetworkInfo {
|
||||
pub acme: BTreeMap<AcmeProvider, AcmeSettings>,
|
||||
#[serde(default)]
|
||||
pub dns: DnsSettings,
|
||||
#[serde(default)]
|
||||
#[ts(type = "string | null")]
|
||||
pub default_outbound: Option<GatewayId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
|
||||
@@ -239,39 +242,42 @@ pub struct DnsSettings {
|
||||
#[ts(export)]
|
||||
pub struct NetworkInterfaceInfo {
|
||||
pub name: Option<InternedString>,
|
||||
#[ts(skip)]
|
||||
pub public: Option<bool>,
|
||||
pub secure: Option<bool>,
|
||||
pub ip_info: Option<Arc<IpInfo>>,
|
||||
#[serde(default, rename = "type")]
|
||||
pub gateway_type: Option<GatewayType>,
|
||||
}
|
||||
impl NetworkInterfaceInfo {
|
||||
pub fn public(&self) -> bool {
|
||||
self.public.unwrap_or_else(|| {
|
||||
!self.ip_info.as_ref().map_or(true, |ip_info| {
|
||||
let ip4s = ip_info
|
||||
.subnets
|
||||
.iter()
|
||||
.filter_map(|ipnet| {
|
||||
if let IpAddr::V4(ip4) = ipnet.addr() {
|
||||
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)
|
||||
let ip4s = ip_info
|
||||
.subnets
|
||||
.iter()
|
||||
.filter_map(|ipnet| {
|
||||
if let IpAddr::V4(ip4) = ipnet.addr() {
|
||||
Some(ip4)
|
||||
} 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 {
|
||||
@@ -310,6 +316,15 @@ pub enum NetworkInterfaceType {
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[model = "Model<Self>"]
|
||||
|
||||
@@ -42,11 +42,11 @@ pub enum ErrorKind {
|
||||
ParseUrl = 19,
|
||||
DiskNotAvailable = 20,
|
||||
BlockDevice = 21,
|
||||
InvalidOnionAddress = 22,
|
||||
// InvalidOnionAddress = 22,
|
||||
Pack = 23,
|
||||
ValidateS9pk = 24,
|
||||
DiskCorrupted = 25, // Remove
|
||||
Tor = 26,
|
||||
// Tor = 26,
|
||||
ConfigGen = 27,
|
||||
ParseNumber = 28,
|
||||
Database = 29,
|
||||
@@ -126,11 +126,11 @@ impl ErrorKind {
|
||||
ParseUrl => t!("error.parse-url"),
|
||||
DiskNotAvailable => t!("error.disk-not-available"),
|
||||
BlockDevice => t!("error.block-device"),
|
||||
InvalidOnionAddress => t!("error.invalid-onion-address"),
|
||||
// InvalidOnionAddress => t!("error.invalid-onion-address"),
|
||||
Pack => t!("error.pack"),
|
||||
ValidateS9pk => t!("error.validate-s9pk"),
|
||||
DiskCorrupted => t!("error.disk-corrupted"), // Remove
|
||||
Tor => t!("error.tor"),
|
||||
// Tor => t!("error.tor"),
|
||||
ConfigGen => t!("error.config-gen"),
|
||||
ParseNumber => t!("error.parse-number"),
|
||||
Database => t!("error.database"),
|
||||
@@ -370,17 +370,6 @@ impl From<reqwest::Error> for Error {
|
||||
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 {
|
||||
fn from(e: zbus::Error) -> Self {
|
||||
Error::new(e, ErrorKind::DBus)
|
||||
@@ -508,8 +497,10 @@ impl From<Error> for RpcError {
|
||||
}
|
||||
impl From<RpcError> for Error {
|
||||
fn from(e: RpcError) -> Self {
|
||||
let data = ErrorData::from(&e);
|
||||
let info = data.info.clone();
|
||||
Error::new(
|
||||
ErrorData::from(&e),
|
||||
data,
|
||||
if let Ok(kind) = e.code.try_into() {
|
||||
kind
|
||||
} else if e.code == METHOD_NOT_FOUND_ERROR.code {
|
||||
@@ -523,6 +514,7 @@ impl From<RpcError> for Error {
|
||||
ErrorKind::Unknown
|
||||
},
|
||||
)
|
||||
.with_info(info)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::db::model::public::ServerStatus;
|
||||
use crate::developer::OS_DEVELOPER_KEY_PATH;
|
||||
use crate::hostname::Hostname;
|
||||
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::socks::DEFAULT_SOCKS_LISTEN;
|
||||
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)]
|
||||
pub async fn init(
|
||||
webserver: &WebServerAcceptorSetter<UpgradableListener>,
|
||||
webserver: &WebServerAcceptorSetter<WildcardListener>,
|
||||
cfg: &ServerConfig,
|
||||
InitPhases {
|
||||
preinit,
|
||||
@@ -218,7 +218,7 @@ pub async fn init(
|
||||
)
|
||||
.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?;
|
||||
start_net.complete();
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ pub async fn install(
|
||||
json!({
|
||||
"id": id,
|
||||
"targetVersion": VersionRange::exactly(version.deref().clone()),
|
||||
"otherVersions": "none",
|
||||
}),
|
||||
RegistryUrlParams {
|
||||
registry: registry.clone(),
|
||||
@@ -484,7 +485,7 @@ pub async fn cli_install(
|
||||
let mut packages: GetPackageResponse = from_value(
|
||||
ctx.call_remote::<RegistryContext>(
|
||||
"package.get",
|
||||
json!({ "id": &id, "targetVersion": version, "sourceVersion": source_version }),
|
||||
json!({ "id": &id, "targetVersion": version, "sourceVersion": source_version, "otherVersions": "none" }),
|
||||
)
|
||||
.await?,
|
||||
)?;
|
||||
|
||||
@@ -4,8 +4,8 @@ use std::sync::{Arc, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::channel::oneshot;
|
||||
use id_pool::IdPool;
|
||||
use iddqd::{IdOrdItem, IdOrdMap};
|
||||
use rand::Rng;
|
||||
use imbl::OrdMap;
|
||||
use rpc_toolkit::{Context, HandlerArgs, HandlerExt, ParentHandler, from_fn_async};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -15,7 +15,6 @@ use tokio::sync::mpsc;
|
||||
use crate::GatewayId;
|
||||
use crate::context::{CliContext, RpcContext};
|
||||
use crate::db::model::public::NetworkInterfaceInfo;
|
||||
use crate::net::gateway::{DynInterfaceFilter, InterfaceFilter};
|
||||
use crate::prelude::*;
|
||||
use crate::util::Invoke;
|
||||
use crate::util::future::NonDetachingJoinHandle;
|
||||
@@ -23,25 +22,76 @@ use crate::util::serde::{HandlerExtSerde, display_serializable};
|
||||
use crate::util::sync::Watch;
|
||||
|
||||
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)]
|
||||
pub struct AvailablePorts(IdPool);
|
||||
pub struct AvailablePorts(BTreeMap<u16, bool>);
|
||||
impl AvailablePorts {
|
||||
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> {
|
||||
self.0.request_id().ok_or_else(|| {
|
||||
Error::new(
|
||||
eyre!("{}", t!("net.forward.no-dynamic-ports-available")),
|
||||
ErrorKind::Network,
|
||||
)
|
||||
})
|
||||
pub fn alloc(&mut self, ssl: bool) -> Result<u16, Error> {
|
||||
let mut rng = rand::rng();
|
||||
for _ in 0..1000 {
|
||||
let port = rng.random_range(EPHEMERAL_PORT_START..u16::MAX);
|
||||
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>) {
|
||||
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();
|
||||
table.add_row(row![bc => "FROM", "TO", "FILTER"]);
|
||||
table.add_row(row![bc => "FROM", "TO", "REQS"]);
|
||||
|
||||
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)?;
|
||||
@@ -79,6 +129,7 @@ struct ForwardMapping {
|
||||
source: SocketAddrV4,
|
||||
target: SocketAddrV4,
|
||||
target_prefix: u8,
|
||||
src_filter: Option<SourceFilter>,
|
||||
rc: Weak<()>,
|
||||
}
|
||||
|
||||
@@ -93,9 +144,10 @@ impl PortForwardState {
|
||||
source: SocketAddrV4,
|
||||
target: SocketAddrV4,
|
||||
target_prefix: u8,
|
||||
src_filter: Option<SourceFilter>,
|
||||
) -> Result<Arc<()>, Error> {
|
||||
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() {
|
||||
return Ok(existing_rc);
|
||||
} else {
|
||||
@@ -104,21 +156,28 @@ impl PortForwardState {
|
||||
return Ok(rc);
|
||||
}
|
||||
} 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) {
|
||||
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(());
|
||||
forward(source, target, target_prefix).await?;
|
||||
forward(source, target, target_prefix, src_filter.as_ref()).await?;
|
||||
self.mappings.insert(
|
||||
source,
|
||||
ForwardMapping {
|
||||
source,
|
||||
target,
|
||||
target_prefix,
|
||||
src_filter,
|
||||
rc: Arc::downgrade(&rc),
|
||||
},
|
||||
);
|
||||
@@ -136,7 +195,13 @@ impl PortForwardState {
|
||||
|
||||
for source in to_remove {
|
||||
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(())
|
||||
@@ -157,9 +222,14 @@ impl Drop for PortForwardState {
|
||||
let mappings = std::mem::take(&mut self.mappings);
|
||||
tokio::spawn(async move {
|
||||
for (_, mapping) in mappings {
|
||||
unforward(mapping.source, mapping.target, mapping.target_prefix)
|
||||
.await
|
||||
.log_err();
|
||||
unforward(
|
||||
mapping.source,
|
||||
mapping.target,
|
||||
mapping.target_prefix,
|
||||
mapping.src_filter.as_ref(),
|
||||
)
|
||||
.await
|
||||
.log_err();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -171,6 +241,7 @@ enum PortForwardCommand {
|
||||
source: SocketAddrV4,
|
||||
target: SocketAddrV4,
|
||||
target_prefix: u8,
|
||||
src_filter: Option<SourceFilter>,
|
||||
respond: oneshot::Sender<Result<Arc<()>, Error>>,
|
||||
},
|
||||
Gc {
|
||||
@@ -257,9 +328,12 @@ impl PortForwardController {
|
||||
source,
|
||||
target,
|
||||
target_prefix,
|
||||
src_filter,
|
||||
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();
|
||||
}
|
||||
PortForwardCommand::Gc { respond } => {
|
||||
@@ -284,6 +358,7 @@ impl PortForwardController {
|
||||
source: SocketAddrV4,
|
||||
target: SocketAddrV4,
|
||||
target_prefix: u8,
|
||||
src_filter: Option<SourceFilter>,
|
||||
) -> Result<Arc<()>, Error> {
|
||||
let (send, recv) = oneshot::channel();
|
||||
self.req
|
||||
@@ -291,6 +366,7 @@ impl PortForwardController {
|
||||
source,
|
||||
target,
|
||||
target_prefix,
|
||||
src_filter,
|
||||
respond: send,
|
||||
})
|
||||
.map_err(err_has_exited)?;
|
||||
@@ -321,14 +397,14 @@ struct InterfaceForwardRequest {
|
||||
external: u16,
|
||||
target: SocketAddrV4,
|
||||
target_prefix: u8,
|
||||
filter: DynInterfaceFilter,
|
||||
reqs: ForwardRequirements,
|
||||
rc: Arc<()>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct InterfaceForwardEntry {
|
||||
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
|
||||
forwards: BTreeMap<SocketAddrV4, Arc<()>>,
|
||||
}
|
||||
@@ -346,7 +422,7 @@ impl InterfaceForwardEntry {
|
||||
fn new(external: u16) -> Self {
|
||||
Self {
|
||||
external,
|
||||
filter: BTreeMap::new(),
|
||||
targets: BTreeMap::new(),
|
||||
forwards: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -358,28 +434,50 @@ impl InterfaceForwardEntry {
|
||||
) -> Result<(), Error> {
|
||||
let mut keep = BTreeSet::<SocketAddrV4>::new();
|
||||
|
||||
for (iface, info) in ip_info.iter() {
|
||||
if let Some((target, target_prefix)) = self
|
||||
.filter
|
||||
.iter()
|
||||
.filter(|(_, (_, _, rc))| rc.strong_count() > 0)
|
||||
.find(|(filter, _)| filter.filter(iface, info))
|
||||
.map(|(_, (target, target_prefix, _))| (*target, *target_prefix))
|
||||
{
|
||||
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
|
||||
for (gw_id, info) in ip_info.iter() {
|
||||
if let Some(ip_info) = &info.ip_info {
|
||||
for subnet in ip_info.subnets.iter() {
|
||||
if let IpAddr::V4(ip) = subnet.addr() {
|
||||
let addr = SocketAddrV4::new(ip, self.external);
|
||||
if keep.contains(&addr) {
|
||||
continue;
|
||||
}
|
||||
}) {
|
||||
keep.insert(addr);
|
||||
if !self.forwards.contains_key(&addr) {
|
||||
let rc = port_forward
|
||||
.add_forward(addr, target, target_prefix)
|
||||
|
||||
for (reqs, (target, target_prefix, rc)) in self.targets.iter() {
|
||||
if rc.strong_count() == 0 {
|
||||
continue;
|
||||
}
|
||||
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?;
|
||||
self.forwards.insert(addr, rc);
|
||||
self.forwards.insert(addr, fwd_rc);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -398,7 +496,7 @@ impl InterfaceForwardEntry {
|
||||
external,
|
||||
target,
|
||||
target_prefix,
|
||||
filter,
|
||||
reqs,
|
||||
mut rc,
|
||||
}: InterfaceForwardRequest,
|
||||
ip_info: &OrdMap<GatewayId, NetworkInterfaceInfo>,
|
||||
@@ -412,8 +510,8 @@ impl InterfaceForwardEntry {
|
||||
}
|
||||
|
||||
let entry = self
|
||||
.filter
|
||||
.entry(filter)
|
||||
.targets
|
||||
.entry(reqs)
|
||||
.or_insert_with(|| (target, target_prefix, Arc::downgrade(&rc)));
|
||||
if entry.0 != target {
|
||||
entry.0 = target;
|
||||
@@ -436,7 +534,7 @@ impl InterfaceForwardEntry {
|
||||
ip_info: &OrdMap<GatewayId, NetworkInterfaceInfo>,
|
||||
port_forward: &PortForwardController,
|
||||
) -> 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
|
||||
}
|
||||
@@ -495,7 +593,7 @@ pub struct ForwardTable(pub BTreeMap<u16, ForwardTarget>);
|
||||
pub struct ForwardTarget {
|
||||
pub target: SocketAddrV4,
|
||||
pub target_prefix: u8,
|
||||
pub filter: String,
|
||||
pub reqs: String,
|
||||
}
|
||||
|
||||
impl From<&InterfaceForwardState> for ForwardTable {
|
||||
@@ -506,16 +604,16 @@ impl From<&InterfaceForwardState> for ForwardTable {
|
||||
.iter()
|
||||
.flat_map(|entry| {
|
||||
entry
|
||||
.filter
|
||||
.targets
|
||||
.iter()
|
||||
.filter(|(_, (_, _, rc))| rc.strong_count() > 0)
|
||||
.map(|(filter, (target, target_prefix, _))| {
|
||||
.map(|(reqs, (target, target_prefix, _))| {
|
||||
(
|
||||
entry.external,
|
||||
ForwardTarget {
|
||||
target: *target,
|
||||
target_prefix: *target_prefix,
|
||||
filter: format!("{:#?}", filter),
|
||||
reqs: format!("{reqs}"),
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -534,16 +632,6 @@ enum InterfaceForwardCommand {
|
||||
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 {
|
||||
req: mpsc::UnboundedSender<InterfaceForwardCommand>,
|
||||
_thread: NonDetachingJoinHandle<()>,
|
||||
@@ -593,7 +681,7 @@ impl InterfacePortForwardController {
|
||||
pub async fn add(
|
||||
&self,
|
||||
external: u16,
|
||||
filter: DynInterfaceFilter,
|
||||
reqs: ForwardRequirements,
|
||||
target: SocketAddrV4,
|
||||
target_prefix: u8,
|
||||
) -> Result<Arc<()>, Error> {
|
||||
@@ -605,7 +693,7 @@ impl InterfacePortForwardController {
|
||||
external,
|
||||
target,
|
||||
target_prefix,
|
||||
filter,
|
||||
reqs,
|
||||
rc,
|
||||
},
|
||||
send,
|
||||
@@ -637,15 +725,21 @@ async fn forward(
|
||||
source: SocketAddrV4,
|
||||
target: SocketAddrV4,
|
||||
target_prefix: u8,
|
||||
src_filter: Option<&SourceFilter>,
|
||||
) -> Result<(), Error> {
|
||||
Command::new("/usr/lib/startos/scripts/forward-port")
|
||||
.env("sip", source.ip().to_string())
|
||||
let mut cmd = Command::new("/usr/lib/startos/scripts/forward-port");
|
||||
cmd.env("sip", source.ip().to_string())
|
||||
.env("dip", target.ip().to_string())
|
||||
.env("dprefix", target_prefix.to_string())
|
||||
.env("sport", source.port().to_string())
|
||||
.env("dport", target.port().to_string())
|
||||
.invoke(ErrorKind::Network)
|
||||
.await?;
|
||||
.env("dport", target.port().to_string());
|
||||
if let Some(filter) = src_filter {
|
||||
cmd.env("src_subnet", &filter.subnet);
|
||||
if !filter.excluded.is_empty() {
|
||||
cmd.env("excluded_src", &filter.excluded);
|
||||
}
|
||||
}
|
||||
cmd.invoke(ErrorKind::Network).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -653,15 +747,21 @@ async fn unforward(
|
||||
source: SocketAddrV4,
|
||||
target: SocketAddrV4,
|
||||
target_prefix: u8,
|
||||
src_filter: Option<&SourceFilter>,
|
||||
) -> Result<(), Error> {
|
||||
Command::new("/usr/lib/startos/scripts/forward-port")
|
||||
.env("UNDO", "1")
|
||||
let mut cmd = Command::new("/usr/lib/startos/scripts/forward-port");
|
||||
cmd.env("UNDO", "1")
|
||||
.env("sip", source.ip().to_string())
|
||||
.env("dip", target.ip().to_string())
|
||||
.env("dprefix", target_prefix.to_string())
|
||||
.env("sport", source.port().to_string())
|
||||
.env("dport", target.port().to_string())
|
||||
.invoke(ErrorKind::Network)
|
||||
.await?;
|
||||
.env("dport", target.port().to_string());
|
||||
if let Some(filter) = src_filter {
|
||||
cmd.env("src_subnet", &filter.subnet);
|
||||
if !filter.excluded.is_empty() {
|
||||
cmd.env("excluded_src", &filter.excluded);
|
||||
}
|
||||
}
|
||||
cmd.invoke(ErrorKind::Network).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
use std::any::Any;
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV6};
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::task::{Poll, ready};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::task::Poll;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
use futures::future::Either;
|
||||
use futures::{FutureExt, Stream, StreamExt, TryStreamExt};
|
||||
use imbl::{OrdMap, OrdSet};
|
||||
use imbl_value::InternedString;
|
||||
@@ -36,15 +33,14 @@ use crate::db::model::Database;
|
||||
use crate::db::model::public::{IpInfo, NetworkInterfaceInfo, NetworkInterfaceType};
|
||||
use crate::net::forward::START9_BRIDGE_IFACE;
|
||||
use crate::net::gateway::device::DeviceProxy;
|
||||
use crate::net::utils::ipv6_is_link_local;
|
||||
use crate::net::web_server::{Accept, AcceptStream, Acceptor, MetadataVisitor};
|
||||
use crate::net::web_server::{Accept, AcceptStream, MetadataVisitor, TcpMetadata};
|
||||
use crate::prelude::*;
|
||||
use crate::util::Invoke;
|
||||
use crate::util::collections::OrdMapIterMut;
|
||||
use crate::util::future::{NonDetachingJoinHandle, Until};
|
||||
use crate::util::io::open_file;
|
||||
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> {
|
||||
ParentHandler::new()
|
||||
@@ -758,13 +754,14 @@ async fn watch_ip(
|
||||
|
||||
write_to.send_if_modified(
|
||||
|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)
|
||||
.map_or((None, None, None, None), |i| {
|
||||
.map_or((None, None, None, None, None), |i| {
|
||||
(
|
||||
i.name.clone(),
|
||||
i.public,
|
||||
i.secure,
|
||||
i.gateway_type,
|
||||
i.ip_info
|
||||
.as_ref()
|
||||
.and_then(|i| i.wan_ip),
|
||||
@@ -779,6 +776,7 @@ async fn watch_ip(
|
||||
public,
|
||||
secure,
|
||||
ip_info: Some(ip_info.clone()),
|
||||
gateway_type,
|
||||
},
|
||||
)
|
||||
.filter(|old| &old.ip_info == &Some(ip_info))
|
||||
@@ -838,7 +836,6 @@ pub struct NetworkInterfaceWatcher {
|
||||
activated: Watch<BTreeMap<GatewayId, bool>>,
|
||||
ip_info: Watch<OrdMap<GatewayId, NetworkInterfaceInfo>>,
|
||||
_watcher: NonDetachingJoinHandle<()>,
|
||||
listeners: SyncMutex<BTreeMap<u16, Weak<()>>>,
|
||||
}
|
||||
impl NetworkInterfaceWatcher {
|
||||
pub fn new(
|
||||
@@ -858,7 +855,6 @@ impl NetworkInterfaceWatcher {
|
||||
watcher(ip_info, activated).await
|
||||
})
|
||||
.into(),
|
||||
listeners: SyncMutex::new(BTreeMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -885,51 +881,6 @@ impl NetworkInterfaceWatcher {
|
||||
pub fn ip_info(&self) -> OrdMap<GatewayId, NetworkInterfaceInfo> {
|
||||
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 {
|
||||
@@ -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(
|
||||
ip_info: &OrdMap<GatewayId, NetworkInterfaceInfo>,
|
||||
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)]
|
||||
pub struct GatewayInfo {
|
||||
pub id: GatewayId,
|
||||
@@ -1509,212 +1209,88 @@ impl<V: MetadataVisitor> Visit<V> for GatewayInfo {
|
||||
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> {
|
||||
pub ip_info: Watch<OrdMap<GatewayId, NetworkInterfaceInfo>>,
|
||||
listeners: ListenerMap<B>,
|
||||
_arc: Arc<()>,
|
||||
}
|
||||
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,
|
||||
/// Metadata for connections accepted by WildcardListener or VHostBindListener.
|
||||
#[derive(Clone, Debug, VisitFields)]
|
||||
pub struct NetworkInterfaceListenerAcceptMetadata {
|
||||
pub inner: TcpMetadata,
|
||||
pub info: GatewayInfo,
|
||||
}
|
||||
impl<B: Bind> fmt::Debug for NetworkInterfaceListenerAcceptMetadata<B> {
|
||||
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,
|
||||
{
|
||||
impl<V: MetadataVisitor> Visit<V> for NetworkInterfaceListenerAcceptMetadata {
|
||||
fn visit(&self, visitor: &mut V) -> V::Result {
|
||||
self.visit_fields(visitor).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: Bind> Accept for NetworkInterfaceListener<B> {
|
||||
type Metadata = NetworkInterfaceListenerAcceptMetadata<B>;
|
||||
/// A simple TCP listener on 0.0.0.0:port that looks up GatewayInfo from the
|
||||
/// 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(
|
||||
&mut self,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> Poll<Result<(Self::Metadata, AcceptStream), Error>> {
|
||||
NetworkInterfaceListener::poll_accept(self, cx, &true).map(|res| {
|
||||
res.map(|(info, inner, stream)| {
|
||||
(
|
||||
NetworkInterfaceListenerAcceptMetadata { inner, info },
|
||||
stream,
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SelfContainedNetworkInterfaceListener<B: Bind = BindTcp> {
|
||||
_watch_thread: NonDetachingJoinHandle<()>,
|
||||
listener: NetworkInterfaceListener<B>,
|
||||
}
|
||||
impl<B: Bind> SelfContainedNetworkInterfaceListener<B> {
|
||||
pub fn bind(bind: B, port: u16) -> Self {
|
||||
let ip_info = Watch::new(OrdMap::new());
|
||||
let _watch_thread =
|
||||
tokio::spawn(watcher(ip_info.clone(), Watch::new(BTreeMap::new()))).into();
|
||||
Self {
|
||||
_watch_thread,
|
||||
listener: NetworkInterfaceListener::new(ip_info, bind, port),
|
||||
if let Poll::Ready((stream, peer_addr)) = TcpListener::poll_accept(&self.listener, cx)? {
|
||||
if let Err(e) = socket2::SockRef::from(&stream).set_keepalive(true) {
|
||||
tracing::error!("Failed to set tcp keepalive: {e}");
|
||||
tracing::debug!("{e:?}");
|
||||
}
|
||||
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(),
|
||||
info: info.clone(),
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| GatewayInfo {
|
||||
id: InternedString::from_static("").into(),
|
||||
info: NetworkInterfaceInfo::default(),
|
||||
});
|
||||
return Poll::Ready(Ok((
|
||||
NetworkInterfaceListenerAcceptMetadata {
|
||||
inner: TcpMetadata {
|
||||
local_addr,
|
||||
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(),
|
||||
})),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
@@ -12,23 +12,15 @@ use crate::context::{CliContext, RpcContext};
|
||||
use crate::db::model::DatabaseModel;
|
||||
use crate::net::acme::AcmeProvider;
|
||||
use crate::net::host::{HostApiKind, all_hosts};
|
||||
use crate::net::tor::OnionAddress;
|
||||
use crate::prelude::*;
|
||||
use crate::util::serde::{HandlerExtSerde, display_serializable};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
#[serde(rename_all_fields = "camelCase")]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum HostAddress {
|
||||
Onion {
|
||||
address: OnionAddress,
|
||||
},
|
||||
Domain {
|
||||
address: InternedString,
|
||||
public: Option<PublicDomainConfig>,
|
||||
private: bool,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HostAddress {
|
||||
pub address: InternedString,
|
||||
pub public: Option<PublicDomainConfig>,
|
||||
pub private: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
@@ -38,18 +30,7 @@ pub struct PublicDomainConfig {
|
||||
}
|
||||
|
||||
fn handle_duplicates(db: &mut DatabaseModel) -> Result<(), Error> {
|
||||
let mut onions = BTreeSet::<OnionAddress>::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| {
|
||||
if domains.contains(&domain) {
|
||||
return Err(Error::new(
|
||||
@@ -68,9 +49,6 @@ fn handle_duplicates(db: &mut DatabaseModel) -> Result<(), Error> {
|
||||
not_in_use.push(host);
|
||||
continue;
|
||||
}
|
||||
for onion in host.as_onions().de()? {
|
||||
check_onion(&mut onions, onion)?;
|
||||
}
|
||||
let public = host.as_public_domains().keys()?;
|
||||
for domain in &public {
|
||||
check_domain(&mut domains, domain.clone())?;
|
||||
@@ -82,16 +60,11 @@ fn handle_duplicates(db: &mut DatabaseModel) -> Result<(), Error> {
|
||||
}
|
||||
}
|
||||
for host in not_in_use {
|
||||
host.as_onions_mut()
|
||||
.mutate(|o| Ok(o.retain(|o| !onions.contains(o))))?;
|
||||
host.as_public_domains_mut()
|
||||
.mutate(|d| Ok(d.retain(|d, _| !domains.contains(d))))?;
|
||||
host.as_private_domains_mut()
|
||||
.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()?;
|
||||
for domain in &public {
|
||||
check_domain(&mut domains, domain.clone())?;
|
||||
@@ -159,29 +132,6 @@ pub fn address_api<C: Context, Kind: HostApiKind>()
|
||||
)
|
||||
.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(
|
||||
"list",
|
||||
from_fn_async(list_addresses::<Kind>)
|
||||
@@ -197,32 +147,18 @@ pub fn address_api<C: Context, Kind: HostApiKind>()
|
||||
|
||||
let mut table = Table::new();
|
||||
table.add_row(row![bc => "ADDRESS", "PUBLIC", "ACME PROVIDER"]);
|
||||
for address in &res {
|
||||
match address {
|
||||
HostAddress::Onion { address } => {
|
||||
table.add_row(row![address, true, "N/A"]);
|
||||
}
|
||||
HostAddress::Domain {
|
||||
address,
|
||||
public: Some(PublicDomainConfig { gateway, acme }),
|
||||
private,
|
||||
} => {
|
||||
table.add_row(row![
|
||||
address,
|
||||
&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"]);
|
||||
}
|
||||
for entry in &res {
|
||||
if let Some(PublicDomainConfig { gateway, acme }) = &entry.public {
|
||||
table.add_row(row![
|
||||
entry.address,
|
||||
&format!(
|
||||
"{} ({gateway})",
|
||||
if entry.private { "YES" } else { "ONLY" }
|
||||
),
|
||||
acme.as_ref().map(|a| a.0.as_str()).unwrap_or("NONE")
|
||||
]);
|
||||
} else {
|
||||
table.add_row(row![entry.address, &format!("NO"), "N/A"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,55 +287,6 @@ pub async fn remove_private_domain<Kind: HostApiKind>(
|
||||
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>(
|
||||
ctx: RpcContext,
|
||||
_: Empty,
|
||||
|
||||
@@ -3,21 +3,20 @@ use std::str::FromStr;
|
||||
|
||||
use clap::Parser;
|
||||
use clap::builder::ValueParserFactory;
|
||||
use imbl::OrdSet;
|
||||
use rpc_toolkit::{Context, Empty, HandlerArgs, HandlerExt, ParentHandler, from_fn_async};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::context::{CliContext, RpcContext};
|
||||
use crate::db::model::public::NetworkInterfaceInfo;
|
||||
use crate::db::prelude::Map;
|
||||
use crate::net::forward::AvailablePorts;
|
||||
use crate::net::gateway::InterfaceFilter;
|
||||
use crate::net::host::HostApiKind;
|
||||
use crate::net::service_interface::HostnameInfo;
|
||||
use crate::net::vhost::AlpnInfo;
|
||||
use crate::prelude::*;
|
||||
use crate::util::FromStrParser;
|
||||
use crate::util::serde::{HandlerExtSerde, display_serializable};
|
||||
use crate::{GatewayId, HostId};
|
||||
use crate::HostId;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS)]
|
||||
#[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")]
|
||||
#[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 enabled: bool,
|
||||
pub options: BindOptions,
|
||||
pub net: NetInfo,
|
||||
pub addresses: DerivedAddressInfo,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
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_ssl_port: Option<u16>,
|
||||
}
|
||||
@@ -71,25 +127,28 @@ impl BindInfo {
|
||||
pub fn new(available_ports: &mut AvailablePorts, options: BindOptions) -> Result<Self, Error> {
|
||||
let mut assigned_port = None;
|
||||
let mut assigned_ssl_port = None;
|
||||
if options.add_ssl.is_some() {
|
||||
assigned_ssl_port = Some(available_ports.alloc()?);
|
||||
if let Some(ssl) = &options.add_ssl {
|
||||
assigned_ssl_port = available_ports
|
||||
.try_alloc(ssl.preferred_external_port, true)
|
||||
.or_else(|| Some(available_ports.alloc(true).ok()?));
|
||||
}
|
||||
if options
|
||||
.secure
|
||||
.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 {
|
||||
enabled: true,
|
||||
options,
|
||||
net: NetInfo {
|
||||
private_disabled: OrdSet::new(),
|
||||
public_enabled: OrdSet::new(),
|
||||
assigned_port,
|
||||
assigned_ssl_port,
|
||||
},
|
||||
addresses: DerivedAddressInfo::default(),
|
||||
})
|
||||
}
|
||||
pub fn update(
|
||||
@@ -97,7 +156,11 @@ impl BindInfo {
|
||||
available_ports: &mut AvailablePorts,
|
||||
options: BindOptions,
|
||||
) -> Result<Self, Error> {
|
||||
let Self { net: mut lan, .. } = self;
|
||||
let Self {
|
||||
net: mut lan,
|
||||
addresses,
|
||||
..
|
||||
} = self;
|
||||
if options
|
||||
.secure
|
||||
.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() {
|
||||
Some(port)
|
||||
} else if let Some(port) =
|
||||
available_ports.try_alloc(options.preferred_external_port, false)
|
||||
{
|
||||
Some(port)
|
||||
} else {
|
||||
Some(available_ports.alloc()?)
|
||||
Some(available_ports.alloc(false)?)
|
||||
};
|
||||
} else {
|
||||
if let Some(port) = lan.assigned_port.take() {
|
||||
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() {
|
||||
Some(port)
|
||||
} else if let Some(port) = available_ports.try_alloc(ssl.preferred_external_port, true)
|
||||
{
|
||||
Some(port)
|
||||
} else {
|
||||
Some(available_ports.alloc()?)
|
||||
Some(available_ports.alloc(true)?)
|
||||
};
|
||||
} else {
|
||||
if let Some(port) = lan.assigned_ssl_port.take() {
|
||||
@@ -128,22 +198,17 @@ impl BindInfo {
|
||||
enabled: true,
|
||||
options,
|
||||
net: lan,
|
||||
addresses: DerivedAddressInfo {
|
||||
private_disabled: addresses.private_disabled,
|
||||
public_enabled: addresses.public_enabled,
|
||||
possible: BTreeSet::new(),
|
||||
},
|
||||
})
|
||||
}
|
||||
pub fn disable(&mut self) {
|
||||
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)]
|
||||
#[ts(export)]
|
||||
@@ -188,7 +253,7 @@ pub fn binding<C: Context, Kind: HostApiKind>()
|
||||
|
||||
let mut table = Table::new();
|
||||
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![
|
||||
internal,
|
||||
info.enabled,
|
||||
@@ -213,12 +278,12 @@ pub fn binding<C: Context, Kind: HostApiKind>()
|
||||
.with_call_remote::<CliContext>(),
|
||||
)
|
||||
.subcommand(
|
||||
"set-gateway-enabled",
|
||||
from_fn_async(set_gateway_enabled::<Kind>)
|
||||
"set-address-enabled",
|
||||
from_fn_async(set_address_enabled::<Kind>)
|
||||
.with_metadata("sync_db", Value::Bool(true))
|
||||
.with_inherited(Kind::inheritance)
|
||||
.no_display()
|
||||
.with_about("about.set-gateway-enabled-for-binding")
|
||||
.with_about("about.set-address-enabled-for-binding")
|
||||
.with_call_remote::<CliContext>(),
|
||||
)
|
||||
}
|
||||
@@ -227,7 +292,7 @@ pub async fn list_bindings<Kind: HostApiKind>(
|
||||
ctx: RpcContext,
|
||||
_: Empty,
|
||||
inheritance: Kind::Inheritance,
|
||||
) -> Result<BTreeMap<u16, BindInfo>, Error> {
|
||||
) -> Result<Bindings, Error> {
|
||||
Kind::host_for(&inheritance, &mut ctx.db.peek().await)?
|
||||
.as_bindings()
|
||||
.de()
|
||||
@@ -236,50 +301,44 @@ pub async fn list_bindings<Kind: HostApiKind>(
|
||||
#[derive(Deserialize, Serialize, Parser, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct BindingGatewaySetEnabledParams {
|
||||
pub struct BindingSetAddressEnabledParams {
|
||||
#[arg(help = "help.arg.internal-port")]
|
||||
internal_port: u16,
|
||||
#[arg(help = "help.arg.gateway-id")]
|
||||
gateway: GatewayId,
|
||||
#[arg(long, help = "help.arg.address")]
|
||||
address: String,
|
||||
#[arg(long, help = "help.arg.binding-enabled")]
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn set_gateway_enabled<Kind: HostApiKind>(
|
||||
pub async fn set_address_enabled<Kind: HostApiKind>(
|
||||
ctx: RpcContext,
|
||||
BindingGatewaySetEnabledParams {
|
||||
BindingSetAddressEnabledParams {
|
||||
internal_port,
|
||||
gateway,
|
||||
address,
|
||||
enabled,
|
||||
}: BindingGatewaySetEnabledParams,
|
||||
}: BindingSetAddressEnabledParams,
|
||||
inheritance: Kind::Inheritance,
|
||||
) -> Result<(), Error> {
|
||||
let enabled = enabled.unwrap_or(true);
|
||||
let gateway_public = ctx
|
||||
.net_controller
|
||||
.net_iface
|
||||
.watcher
|
||||
.ip_info()
|
||||
.get(&gateway)
|
||||
.or_not_found(&gateway)?
|
||||
.public();
|
||||
let address: HostnameInfo =
|
||||
serde_json::from_str(&address).with_kind(ErrorKind::Deserialization)?;
|
||||
ctx.db
|
||||
.mutate(|db| {
|
||||
Kind::host_for(&inheritance, db)?
|
||||
.as_bindings_mut()
|
||||
.mutate(|b| {
|
||||
let net = &mut b.get_mut(&internal_port).or_not_found(internal_port)?.net;
|
||||
if gateway_public {
|
||||
let bind = b.get_mut(&internal_port).or_not_found(internal_port)?;
|
||||
if address.public {
|
||||
if enabled {
|
||||
net.public_enabled.insert(gateway);
|
||||
bind.addresses.public_enabled.insert(address.clone());
|
||||
} else {
|
||||
net.public_enabled.remove(&gateway);
|
||||
bind.addresses.public_enabled.remove(&address);
|
||||
}
|
||||
} else {
|
||||
if enabled {
|
||||
net.private_disabled.remove(&gateway);
|
||||
bind.addresses.private_disabled.remove(&address);
|
||||
} else {
|
||||
net.private_disabled.insert(gateway);
|
||||
bind.addresses.private_disabled.insert(address.clone());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -13,9 +13,7 @@ use crate::context::RpcContext;
|
||||
use crate::db::model::DatabaseModel;
|
||||
use crate::net::forward::AvailablePorts;
|
||||
use crate::net::host::address::{HostAddress, PublicDomainConfig, address_api};
|
||||
use crate::net::host::binding::{BindInfo, BindOptions, binding};
|
||||
use crate::net::service_interface::HostnameInfo;
|
||||
use crate::net::tor::OnionAddress;
|
||||
use crate::net::host::binding::{BindInfo, BindOptions, Bindings, binding};
|
||||
use crate::prelude::*;
|
||||
use crate::{HostId, PackageId};
|
||||
|
||||
@@ -27,13 +25,9 @@ pub mod binding;
|
||||
#[model = "Model<Self>"]
|
||||
#[ts(export)]
|
||||
pub struct Host {
|
||||
pub bindings: BTreeMap<u16, BindInfo>,
|
||||
#[ts(type = "string[]")]
|
||||
pub onions: BTreeSet<OnionAddress>,
|
||||
pub bindings: Bindings,
|
||||
pub public_domains: BTreeMap<InternedString, PublicDomainConfig>,
|
||||
pub private_domains: BTreeSet<InternedString>,
|
||||
/// COMPUTED: NetService::update
|
||||
pub hostname_info: BTreeMap<u16, Vec<HostnameInfo>>, // internal port -> Hostnames
|
||||
}
|
||||
|
||||
impl AsRef<Host> for Host {
|
||||
@@ -46,24 +40,18 @@ impl Host {
|
||||
Self::default()
|
||||
}
|
||||
pub fn addresses<'a>(&'a self) -> impl Iterator<Item = HostAddress> + 'a {
|
||||
self.onions
|
||||
self.public_domains
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|address| HostAddress::Onion { address })
|
||||
.chain(
|
||||
self.public_domains
|
||||
.iter()
|
||||
.map(|(address, config)| HostAddress::Domain {
|
||||
address: address.clone(),
|
||||
public: Some(config.clone()),
|
||||
private: self.private_domains.contains(address),
|
||||
}),
|
||||
)
|
||||
.map(|(address, config)| HostAddress {
|
||||
address: address.clone(),
|
||||
public: Some(config.clone()),
|
||||
private: self.private_domains.contains(address),
|
||||
})
|
||||
.chain(
|
||||
self.private_domains
|
||||
.iter()
|
||||
.filter(|a| !self.public_domains.contains_key(*a))
|
||||
.map(|address| HostAddress::Domain {
|
||||
.map(|address| HostAddress {
|
||||
address: address.clone(),
|
||||
public: None,
|
||||
private: true,
|
||||
@@ -112,22 +100,7 @@ pub fn host_for<'a>(
|
||||
.as_hosts_mut(),
|
||||
)
|
||||
}
|
||||
let tor_key = if host_info(db, package_id)?.as_idx(host_id).is_none() {
|
||||
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)
|
||||
})
|
||||
host_info(db, package_id)?.upsert(host_id, || Ok(Host::new()))
|
||||
}
|
||||
|
||||
pub fn all_hosts(db: &mut DatabaseModel) -> impl Iterator<Item = Result<&mut Model<Host>, Error>> {
|
||||
|
||||
@@ -3,28 +3,21 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::account::AccountInfo;
|
||||
use crate::net::acme::AcmeCertStore;
|
||||
use crate::net::ssl::CertStore;
|
||||
use crate::net::tor::OnionStore;
|
||||
use crate::prelude::*;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, HasModel)]
|
||||
#[model = "Model<Self>"]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KeyStore {
|
||||
pub onion: OnionStore,
|
||||
pub local_certs: CertStore,
|
||||
#[serde(default)]
|
||||
pub acme: AcmeCertStore,
|
||||
}
|
||||
impl KeyStore {
|
||||
pub fn new(account: &AccountInfo) -> Result<Self, Error> {
|
||||
let mut res = Self {
|
||||
onion: OnionStore::new(),
|
||||
Ok(Self {
|
||||
local_certs: CertStore::new(account)?,
|
||||
acme: AcmeCertStore::new(),
|
||||
};
|
||||
for tor_key in account.tor_keys.iter().cloned() {
|
||||
res.onion.insert(tor_key);
|
||||
}
|
||||
Ok(res)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ pub mod socks;
|
||||
pub mod ssl;
|
||||
pub mod static_server;
|
||||
pub mod tls;
|
||||
pub mod tor;
|
||||
pub mod tunnel;
|
||||
pub mod utils;
|
||||
pub mod vhost;
|
||||
@@ -23,7 +22,6 @@ pub mod wifi;
|
||||
|
||||
pub fn net_api<C: Context>() -> ParentHandler<C> {
|
||||
ParentHandler::new()
|
||||
.subcommand("tor", tor::tor_api::<C>().with_about("about.tor-commands"))
|
||||
.subcommand(
|
||||
"acme",
|
||||
acme::acme_api::<C>().with_about("about.setup-acme-certificate"),
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use color_eyre::eyre::eyre;
|
||||
use imbl::{OrdMap, vector};
|
||||
use imbl::vector;
|
||||
use imbl_value::InternedString;
|
||||
use ipnet::IpNet;
|
||||
use tokio::sync::Mutex;
|
||||
@@ -16,17 +16,15 @@ use crate::db::model::public::NetworkInterfaceType;
|
||||
use crate::error::ErrorCollection;
|
||||
use crate::hostname::Hostname;
|
||||
use crate::net::dns::DnsController;
|
||||
use crate::net::forward::{InterfacePortForwardController, START9_BRIDGE_IFACE, add_iptables_rule};
|
||||
use crate::net::gateway::{
|
||||
AndFilter, DynInterfaceFilter, IdFilter, InterfaceFilter, NetworkInterfaceController, OrFilter,
|
||||
PublicFilter, SecureFilter, TypeFilter,
|
||||
use crate::net::forward::{
|
||||
ForwardRequirements, InterfacePortForwardController, START9_BRIDGE_IFACE, add_iptables_rule,
|
||||
};
|
||||
use crate::net::gateway::NetworkInterfaceController;
|
||||
use crate::net::host::address::HostAddress;
|
||||
use crate::net::host::binding::{AddSslOptions, BindId, BindOptions};
|
||||
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::tor::{OnionAddress, TorController, TorSecretKey};
|
||||
use crate::net::utils::ipv6_is_local;
|
||||
use crate::net::vhost::{AlpnInfo, DynVHostTarget, ProxyTarget, VHostController};
|
||||
use crate::prelude::*;
|
||||
@@ -36,7 +34,6 @@ use crate::{GatewayId, HOST_IP, HostId, OptionExt, PackageId};
|
||||
|
||||
pub struct NetController {
|
||||
pub(crate) db: TypedPatchDb<Database>,
|
||||
pub(super) tor: TorController,
|
||||
pub(super) vhost: VHostController,
|
||||
pub(super) tls_client_config: Arc<TlsClientConfig>,
|
||||
pub(crate) net_iface: Arc<NetworkInterfaceController>,
|
||||
@@ -54,8 +51,7 @@ impl NetController {
|
||||
socks_listen: SocketAddr,
|
||||
) -> Result<Self, Error> {
|
||||
let net_iface = Arc::new(NetworkInterfaceController::new(db.clone()));
|
||||
let tor = TorController::new()?;
|
||||
let socks = SocksController::new(socks_listen, tor.clone())?;
|
||||
let socks = SocksController::new(socks_listen)?;
|
||||
let crypto_provider = Arc::new(tokio_rustls::rustls::crypto::ring::default_provider());
|
||||
let tls_client_config = Arc::new(crate::net::tls::client_config(
|
||||
crypto_provider.clone(),
|
||||
@@ -87,7 +83,6 @@ impl NetController {
|
||||
.await?;
|
||||
Ok(Self {
|
||||
db: db.clone(),
|
||||
tor,
|
||||
vhost: VHostController::new(db.clone(), net_iface.clone(), crypto_provider),
|
||||
tls_client_config,
|
||||
dns: DnsController::init(db, &net_iface.watcher).await?,
|
||||
@@ -165,10 +160,9 @@ impl NetController {
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
struct HostBinds {
|
||||
forwards: BTreeMap<u16, (SocketAddrV4, DynInterfaceFilter, Arc<()>)>,
|
||||
forwards: BTreeMap<u16, (SocketAddrV4, ForwardRequirements, Arc<()>)>,
|
||||
vhosts: BTreeMap<(Option<InternedString>, u16), (ProxyTarget, Arc<()>)>,
|
||||
private_dns: BTreeMap<InternedString, Arc<()>>,
|
||||
tor: BTreeMap<OnionAddress, (OrdMap<u16, SocketAddr>, Vec<Arc<()>>)>,
|
||||
}
|
||||
|
||||
pub struct NetServiceData {
|
||||
@@ -207,7 +201,7 @@ impl NetServiceData {
|
||||
.as_entries_mut()?
|
||||
{
|
||||
host.as_bindings_mut().mutate(|b| {
|
||||
for (internal_port, info) in b {
|
||||
for (internal_port, info) in b.iter_mut() {
|
||||
if !except.contains(&BindId {
|
||||
id: host_id.clone(),
|
||||
internal_port: *internal_port,
|
||||
@@ -238,7 +232,7 @@ impl NetServiceData {
|
||||
.as_network_mut()
|
||||
.as_host_mut();
|
||||
host.as_bindings_mut().mutate(|b| {
|
||||
for (internal_port, info) in b {
|
||||
for (internal_port, info) in b.iter_mut() {
|
||||
if !except.contains(&BindId {
|
||||
id: HostId::default(),
|
||||
internal_port: *internal_port,
|
||||
@@ -256,425 +250,295 @@ impl NetServiceData {
|
||||
}
|
||||
}
|
||||
|
||||
async fn update(&mut self, ctrl: &NetController, id: HostId, host: Host) -> Result<(), Error> {
|
||||
let mut forwards: BTreeMap<u16, (SocketAddrV4, DynInterfaceFilter)> = BTreeMap::new();
|
||||
async fn update(
|
||||
&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 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 peek = ctrl.db.peek().await;
|
||||
|
||||
// LAN
|
||||
let server_info = peek.as_public().as_server_info();
|
||||
let net_ifaces = ctrl.net_iface.watcher.ip_info();
|
||||
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 {
|
||||
continue;
|
||||
}
|
||||
if bind.net.assigned_port.is_some() || bind.net.assigned_ssl_port.is_some() {
|
||||
let mut hostnames = BTreeSet::new();
|
||||
if let Some(ssl) = &bind.options.add_ssl {
|
||||
let external = bind
|
||||
.net
|
||||
.assigned_ssl_port
|
||||
.or_not_found("assigned ssl port")?;
|
||||
let addr = (self.ip, *port).into();
|
||||
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)
|
||||
}
|
||||
};
|
||||
for hostname in ctrl.server_hostnames.iter().cloned() {
|
||||
vhosts.insert(
|
||||
(hostname, external),
|
||||
ProxyTarget {
|
||||
filter: bind.net.clone().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()),
|
||||
},
|
||||
);
|
||||
}
|
||||
for address in host.addresses() {
|
||||
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))
|
||||
})
|
||||
if bind.net.assigned_port.is_none() && bind.net.assigned_ssl_port.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
bind.addresses.possible.clear();
|
||||
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(|(_, info)| info.ip_info.is_some())
|
||||
{
|
||||
let gateway = GatewayInfo {
|
||||
id: gateway_id.clone(),
|
||||
name: info
|
||||
.name
|
||||
.clone()
|
||||
.or_else(|| info.ip_info.as_ref().map(|i| i.name.clone()))
|
||||
.unwrap_or_else(|| gateway_id.clone().into()),
|
||||
public: info.public(),
|
||||
};
|
||||
let port = bind.net.assigned_port.filter(|_| {
|
||||
bind.options.secure.map_or(false, |s| {
|
||||
!(s.ssl && bind.options.add_ssl.is_some()) || info.secure()
|
||||
})
|
||||
});
|
||||
// .local addresses (private only, non-public, non-wireguard gateways)
|
||||
if !info.public()
|
||||
&& info.ip_info.as_ref().map_or(false, |i| {
|
||||
i.device_type != Some(NetworkInterfaceType::Wireguard)
|
||||
})
|
||||
.filter(|(id, info)| bind.net.filter(id, info))
|
||||
{
|
||||
let gateway = GatewayInfo {
|
||||
id: gateway_id.clone(),
|
||||
name: info
|
||||
.name
|
||||
.clone()
|
||||
.or_else(|| info.ip_info.as_ref().map(|i| i.name.clone()))
|
||||
.unwrap_or_else(|| gateway_id.clone().into()),
|
||||
public: info.public(),
|
||||
};
|
||||
let port = bind.net.assigned_port.filter(|_| {
|
||||
bind.options.secure.map_or(false, |s| {
|
||||
!(s.ssl && bind.options.add_ssl.is_some()) || info.secure()
|
||||
})
|
||||
bind.addresses.possible.insert(HostnameInfo {
|
||||
gateway: gateway.clone(),
|
||||
public: false,
|
||||
hostname: IpHostname::Local {
|
||||
value: InternedString::from_display(&{
|
||||
let hostname = &hostname;
|
||||
lazy_format!("{hostname}.local")
|
||||
}),
|
||||
port,
|
||||
ssl_port: bind.net.assigned_ssl_port,
|
||||
},
|
||||
});
|
||||
if !info.public()
|
||||
&& info.ip_info.as_ref().map_or(false, |i| {
|
||||
i.device_type != Some(NetworkInterfaceType::Wireguard)
|
||||
})
|
||||
{
|
||||
bind_hostname_info.push(HostnameInfo::Ip {
|
||||
}
|
||||
// Domain addresses
|
||||
for HostAddress {
|
||||
address,
|
||||
public,
|
||||
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(),
|
||||
public: false,
|
||||
hostname: IpHostname::Local {
|
||||
value: InternedString::from_display(&{
|
||||
let hostname = &hostname;
|
||||
lazy_format!("{hostname}.local")
|
||||
}),
|
||||
public,
|
||||
hostname: IpHostname::Domain {
|
||||
value: address.clone(),
|
||||
port: domain_port,
|
||||
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,
|
||||
ssl_port: bind.net.assigned_ssl_port,
|
||||
},
|
||||
});
|
||||
}
|
||||
for address in host.addresses() {
|
||||
if let HostAddress::Domain {
|
||||
address,
|
||||
public,
|
||||
private,
|
||||
} = 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 {
|
||||
for ipnet in &ip_info.subnets {
|
||||
match ipnet {
|
||||
IpNet::V4(net) => {
|
||||
if !public {
|
||||
bind.addresses.possible.insert(HostnameInfo {
|
||||
gateway: gateway.clone(),
|
||||
public,
|
||||
hostname: IpHostname::Domain {
|
||||
value: address.clone(),
|
||||
port: None,
|
||||
ssl_port: Some(443),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
bind_hostname_info.push(HostnameInfo::Ip {
|
||||
gateway: gateway.clone(),
|
||||
public,
|
||||
hostname: IpHostname::Domain {
|
||||
value: address.clone(),
|
||||
hostname: IpHostname::Ipv4 {
|
||||
value: net.addr(),
|
||||
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(),
|
||||
public: true,
|
||||
hostname: IpHostname::Ipv4 {
|
||||
value: wan_ip,
|
||||
port,
|
||||
ssl_port: bind.net.assigned_ssl_port,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: Build controller entries from enabled addresses ──
|
||||
for (port, bind) in host.bindings.iter() {
|
||||
if !bind.enabled {
|
||||
continue;
|
||||
}
|
||||
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 {
|
||||
bind_hostname_info.push(HostnameInfo::Ip {
|
||||
gateway: gateway.clone(),
|
||||
public,
|
||||
hostname: IpHostname::Ipv4 {
|
||||
value: net.addr(),
|
||||
port,
|
||||
ssl_port: bind.net.assigned_ssl_port,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Domain vhosts: group by (domain, ssl_port), merge public/private sets
|
||||
for addr_info in &enabled_addresses {
|
||||
if let IpHostname::Domain {
|
||||
value: domain,
|
||||
ssl_port: Some(domain_ssl_port),
|
||||
..
|
||||
} = &addr_info.hostname
|
||||
{
|
||||
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: Option<u16>,
|
||||
ssl: Option<u16>,
|
||||
}
|
||||
let mut tor_hostname_ports = BTreeMap::<u16, TorHostnamePorts>::new();
|
||||
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)
|
||||
// Non-SSL forwards
|
||||
if bind
|
||||
.options
|
||||
.secure
|
||||
.map_or(true, |s| !(s.ssl && bind.options.add_ssl.is_some()))
|
||||
{
|
||||
tor_binds.insert(
|
||||
ssl.preferred_external_port,
|
||||
SocketAddr::from(([127, 0, 0, 1], ssl_internal)),
|
||||
);
|
||||
tor_hostname_ports.insert(
|
||||
*internal,
|
||||
TorHostnamePorts {
|
||||
non_ssl: Some(info.options.preferred_external_port)
|
||||
.filter(|p| *p != ssl.preferred_external_port),
|
||||
ssl: Some(ssl.preferred_external_port),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
tor_hostname_ports.insert(
|
||||
*internal,
|
||||
TorHostnamePorts {
|
||||
non_ssl: Some(info.options.preferred_external_port),
|
||||
ssl: None,
|
||||
},
|
||||
let external = bind.net.assigned_port.or_not_found("assigned lan port")?;
|
||||
let fwd_public: BTreeSet<GatewayId> = enabled_addresses
|
||||
.iter()
|
||||
.filter(|a| a.public)
|
||||
.map(|a| a.gateway.id.clone())
|
||||
.collect();
|
||||
let fwd_private: BTreeSet<IpAddr> = enabled_addresses
|
||||
.iter()
|
||||
.filter(|a| !a.public)
|
||||
.filter_map(|a| {
|
||||
net_ifaces
|
||||
.get(&a.gateway.id)
|
||||
.and_then(|i| i.ip_info.as_ref())
|
||||
})
|
||||
.flat_map(|ip| ip.subnets.iter().map(|s| s.addr()))
|
||||
.collect();
|
||||
forwards.insert(
|
||||
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() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 3: Reconcile ──
|
||||
let all = binds
|
||||
.forwards
|
||||
.keys()
|
||||
@@ -683,8 +547,8 @@ impl NetServiceData {
|
||||
.collect::<BTreeSet<_>>();
|
||||
for external in all {
|
||||
let mut prev = binds.forwards.remove(&external);
|
||||
if let Some((internal, filter)) = forwards.remove(&external) {
|
||||
prev = prev.filter(|(i, f, _)| i == &internal && *f == filter);
|
||||
if let Some((internal, reqs)) = forwards.remove(&external) {
|
||||
prev = prev.filter(|(i, r, _)| i == &internal && *r == reqs);
|
||||
binds.forwards.insert(
|
||||
external,
|
||||
if let Some(prev) = prev {
|
||||
@@ -692,11 +556,11 @@ impl NetServiceData {
|
||||
} else {
|
||||
(
|
||||
internal,
|
||||
filter.clone(),
|
||||
reqs.clone(),
|
||||
ctrl.forward
|
||||
.add(
|
||||
external,
|
||||
filter,
|
||||
reqs,
|
||||
internal,
|
||||
net_ifaces
|
||||
.iter()
|
||||
@@ -763,40 +627,18 @@ impl NetServiceData {
|
||||
}
|
||||
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
|
||||
.db
|
||||
.mutate(|db| {
|
||||
host_for(db, self.id.as_ref(), &id)?
|
||||
.as_hostname_info_mut()
|
||||
.ser(&hostname_info)
|
||||
let bindings = host_for(db, self.id.as_ref(), &id)?.as_bindings_mut();
|
||||
for (port, bind) in host.bindings.0 {
|
||||
if let Some(b) = bindings.as_idx_mut(&port) {
|
||||
b.as_addresses_mut()
|
||||
.as_possible_mut()
|
||||
.ser(&bind.addresses.possible)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
res.result?;
|
||||
|
||||
@@ -6,31 +6,21 @@ use ts_rs::TS;
|
||||
|
||||
use crate::{GatewayId, HostId, ServiceInterfaceId};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename_all_fields = "camelCase")]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum HostnameInfo {
|
||||
Ip {
|
||||
gateway: GatewayInfo,
|
||||
public: bool,
|
||||
hostname: IpHostname,
|
||||
},
|
||||
Onion {
|
||||
hostname: OnionHostname,
|
||||
},
|
||||
pub struct HostnameInfo {
|
||||
pub gateway: GatewayInfo,
|
||||
pub public: bool,
|
||||
pub hostname: IpHostname,
|
||||
}
|
||||
impl HostnameInfo {
|
||||
pub fn to_san_hostname(&self) -> InternedString {
|
||||
match self {
|
||||
Self::Ip { hostname, .. } => hostname.to_san_hostname(),
|
||||
Self::Onion { hostname } => hostname.to_san_hostname(),
|
||||
}
|
||||
self.hostname.to_san_hostname()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GatewayInfo {
|
||||
@@ -39,22 +29,7 @@ pub struct GatewayInfo {
|
||||
pub public: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, 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)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename_all_fields = "camelCase")]
|
||||
|
||||
@@ -8,7 +8,6 @@ use socks5_impl::server::{AuthAdaptor, ClientConnection, Server};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
use crate::HOST_IP;
|
||||
use crate::net::tor::TorController;
|
||||
use crate::prelude::*;
|
||||
use crate::util::actor::background::BackgroundJobQueue;
|
||||
use crate::util::future::NonDetachingJoinHandle;
|
||||
@@ -22,7 +21,7 @@ pub struct SocksController {
|
||||
_thread: NonDetachingJoinHandle<()>,
|
||||
}
|
||||
impl SocksController {
|
||||
pub fn new(listen: SocketAddr, tor: TorController) -> Result<Self, Error> {
|
||||
pub fn new(listen: SocketAddr) -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
_thread: tokio::spawn(async move {
|
||||
let auth: AuthAdaptor<()> = Arc::new(NoAuth);
|
||||
@@ -45,7 +44,6 @@ impl SocksController {
|
||||
loop {
|
||||
match server.accept().await {
|
||||
Ok((stream, _)) => {
|
||||
let tor = tor.clone();
|
||||
bg.add_job(async move {
|
||||
if let Err(e) = async {
|
||||
match stream
|
||||
@@ -57,40 +55,6 @@ impl SocksController {
|
||||
.await
|
||||
.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) => {
|
||||
if let Ok(mut target) = match addr {
|
||||
Address::DomainAddress(domain, port) => {
|
||||
|
||||
@@ -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
@@ -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};
|
||||
@@ -8,7 +8,7 @@ use ts_rs::TS;
|
||||
|
||||
use crate::GatewayId;
|
||||
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::prelude::*;
|
||||
use crate::util::Invoke;
|
||||
@@ -32,14 +32,19 @@ pub fn tunnel_api<C: Context>() -> ParentHandler<C> {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct AddTunnelParams {
|
||||
#[arg(help = "help.arg.tunnel-name")]
|
||||
name: InternedString,
|
||||
#[arg(help = "help.arg.wireguard-config")]
|
||||
config: String,
|
||||
#[arg(help = "help.arg.is-public")]
|
||||
public: bool,
|
||||
#[arg(help = "help.arg.gateway-type")]
|
||||
#[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 {
|
||||
@@ -64,7 +69,8 @@ pub async fn add_tunnel(
|
||||
AddTunnelParams {
|
||||
name,
|
||||
config,
|
||||
public,
|
||||
gateway_type,
|
||||
set_as_default_outbound,
|
||||
}: AddTunnelParams,
|
||||
) -> Result<GatewayId, Error> {
|
||||
let ifaces = ctx.net_controller.net_iface.watcher.subscribe();
|
||||
@@ -76,9 +82,10 @@ pub async fn add_tunnel(
|
||||
iface.clone(),
|
||||
NetworkInterfaceInfo {
|
||||
name: Some(name),
|
||||
public: Some(public),
|
||||
public: None,
|
||||
secure: None,
|
||||
ip_info: None,
|
||||
gateway_type,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
@@ -120,6 +127,19 @@ pub async fn add_tunnel(
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -175,8 +195,12 @@ pub async fn remove_tunnel(
|
||||
let host = host?;
|
||||
host.as_bindings_mut().mutate(|b| {
|
||||
Ok(b.values_mut().for_each(|v| {
|
||||
v.net.private_disabled.remove(&id);
|
||||
v.net.public_enabled.remove(&id);
|
||||
v.addresses
|
||||
.private_disabled
|
||||
.retain(|h| h.gateway.id != id);
|
||||
v.addresses
|
||||
.public_enabled
|
||||
.retain(|h| h.gateway.id != id);
|
||||
}))
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
use std::any::Any;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::net::{IpAddr, SocketAddr, SocketAddrV6};
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::task::{Poll, ready};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_acme::acme::ACME_TLS_ALPN_NAME;
|
||||
use color_eyre::eyre::eyre;
|
||||
use futures::FutureExt;
|
||||
use futures::future::BoxFuture;
|
||||
use imbl::OrdMap;
|
||||
use imbl_value::{InOMap, InternedString};
|
||||
use rpc_toolkit::{Context, HandlerArgs, HandlerExt, ParentHandler, from_fn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio_rustls::TlsConnector;
|
||||
use tokio_rustls::rustls::crypto::CryptoProvider;
|
||||
use tokio_rustls::rustls::pki_types::ServerName;
|
||||
@@ -23,28 +23,28 @@ use tracing::instrument;
|
||||
use ts_rs::TS;
|
||||
use visit_rs::Visit;
|
||||
|
||||
use crate::ResultExt;
|
||||
use crate::context::{CliContext, RpcContext};
|
||||
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::net::acme::{
|
||||
AcmeCertStore, AcmeProvider, AcmeTlsAlpnCache, AcmeTlsHandler, GetAcmeProvider,
|
||||
};
|
||||
use crate::net::gateway::{
|
||||
AnyFilter, BindTcp, DynInterfaceFilter, GatewayInfo, InterfaceFilter,
|
||||
NetworkInterfaceController, NetworkInterfaceListener,
|
||||
GatewayInfo, NetworkInterfaceController, NetworkInterfaceListenerAcceptMetadata,
|
||||
};
|
||||
use crate::net::ssl::{CertStore, RootCaTlsHandler};
|
||||
use crate::net::tls::{
|
||||
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::prelude::*;
|
||||
use crate::util::collections::EqSet;
|
||||
use crate::util::future::{NonDetachingJoinHandle, WeakFuture};
|
||||
use crate::util::serde::{HandlerExtSerde, MaybeUtf8String, display_serializable};
|
||||
use crate::util::sync::{SyncMutex, Watch};
|
||||
use crate::{GatewayId, ResultExt};
|
||||
|
||||
pub fn vhost_api<C: Context>() -> ParentHandler<C> {
|
||||
ParentHandler::new().subcommand(
|
||||
@@ -93,7 +93,7 @@ pub struct VHostController {
|
||||
interfaces: Arc<NetworkInterfaceController>,
|
||||
crypto_provider: Arc<CryptoProvider>,
|
||||
acme_cache: AcmeTlsAlpnCache,
|
||||
servers: SyncMutex<BTreeMap<u16, VHostServer<NetworkInterfaceListener>>>,
|
||||
servers: SyncMutex<BTreeMap<u16, VHostServer<VHostBindListener>>>,
|
||||
}
|
||||
impl VHostController {
|
||||
pub fn new(
|
||||
@@ -114,14 +114,22 @@ impl VHostController {
|
||||
&self,
|
||||
hostname: Option<InternedString>,
|
||||
external: u16,
|
||||
target: DynVHostTarget<NetworkInterfaceListener>,
|
||||
target: DynVHostTarget<VHostBindListener>,
|
||||
) -> Result<Arc<()>, Error> {
|
||||
self.servers.mutate(|writable| {
|
||||
let server = if let Some(server) = writable.remove(&external) {
|
||||
server
|
||||
} 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(
|
||||
self.interfaces.watcher.bind(BindTcp, external)?,
|
||||
listener,
|
||||
bind_reqs,
|
||||
self.db.clone(),
|
||||
self.crypto_provider.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 {
|
||||
type PreprocessRes: Send + 'static;
|
||||
#[allow(unused_variables)]
|
||||
@@ -182,6 +327,10 @@ pub trait VHostTarget<A: Accept>: std::fmt::Debug + Eq {
|
||||
fn acme(&self) -> Option<&AcmeProvider> {
|
||||
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>(
|
||||
&'a self,
|
||||
prev: ServerConfig,
|
||||
@@ -200,6 +349,7 @@ pub trait VHostTarget<A: Accept>: std::fmt::Debug + Eq {
|
||||
pub trait DynVHostTargetT<A: Accept>: std::fmt::Debug + Any {
|
||||
fn filter(&self, metadata: &<A as Accept>::Metadata) -> bool;
|
||||
fn acme(&self) -> Option<&AcmeProvider>;
|
||||
fn bind_requirements(&self) -> (BTreeSet<GatewayId>, BTreeSet<IpAddr>);
|
||||
fn preprocess<'a>(
|
||||
&'a self,
|
||||
prev: ServerConfig,
|
||||
@@ -224,6 +374,9 @@ impl<A: Accept, T: VHostTarget<A> + 'static> DynVHostTargetT<A> for T {
|
||||
fn acme(&self) -> Option<&AcmeProvider> {
|
||||
VHostTarget::acme(self)
|
||||
}
|
||||
fn bind_requirements(&self) -> (BTreeSet<GatewayId>, BTreeSet<IpAddr>) {
|
||||
VHostTarget::bind_requirements(self)
|
||||
}
|
||||
fn preprocess<'a>(
|
||||
&'a self,
|
||||
prev: ServerConfig,
|
||||
@@ -301,7 +454,8 @@ impl<A: Accept + 'static> Preprocessed<A> {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyTarget {
|
||||
pub filter: DynInterfaceFilter,
|
||||
pub public: BTreeSet<GatewayId>,
|
||||
pub private: BTreeSet<IpAddr>,
|
||||
pub acme: Option<AcmeProvider>,
|
||||
pub addr: SocketAddr,
|
||||
pub add_x_forwarded_headers: bool,
|
||||
@@ -309,7 +463,8 @@ pub struct ProxyTarget {
|
||||
}
|
||||
impl PartialEq for ProxyTarget {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.filter == other.filter
|
||||
self.public == other.public
|
||||
&& self.private == other.private
|
||||
&& self.acme == other.acme
|
||||
&& self.addr == other.addr
|
||||
&& self.connect_ssl.as_ref().map(Arc::as_ptr)
|
||||
@@ -320,7 +475,8 @@ impl Eq for ProxyTarget {}
|
||||
impl fmt::Debug for ProxyTarget {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ProxyTarget")
|
||||
.field("filter", &self.filter)
|
||||
.field("public", &self.public)
|
||||
.field("private", &self.private)
|
||||
.field("acme", &self.acme)
|
||||
.field("addr", &self.addr)
|
||||
.field("add_x_forwarded_headers", &self.add_x_forwarded_headers)
|
||||
@@ -340,16 +496,37 @@ where
|
||||
{
|
||||
type PreprocessRes = AcceptStream;
|
||||
fn filter(&self, metadata: &<A as Accept>::Metadata) -> bool {
|
||||
let info = extract::<GatewayInfo, _>(metadata);
|
||||
if info.is_none() {
|
||||
tracing::warn!("No GatewayInfo on metadata");
|
||||
let gw = extract::<GatewayInfo, _>(metadata);
|
||||
let tcp = extract::<TcpMetadata, _>(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> {
|
||||
self.acme.as_ref()
|
||||
}
|
||||
fn bind_requirements(&self) -> (BTreeSet<GatewayId>, BTreeSet<IpAddr>) {
|
||||
(self.public.clone(), self.private.clone())
|
||||
}
|
||||
async fn preprocess<'a>(
|
||||
&'a self,
|
||||
mut prev: ServerConfig,
|
||||
@@ -634,28 +811,15 @@ where
|
||||
|
||||
struct VHostServer<A: Accept + 'static> {
|
||||
mapping: Watch<Mapping<A>>,
|
||||
bind_reqs: Watch<VHostBindRequirements>,
|
||||
_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> {
|
||||
#[instrument(skip_all)]
|
||||
fn new<M: HasModel>(
|
||||
listener: A,
|
||||
bind_reqs: Watch<VHostBindRequirements>,
|
||||
db: TypedPatchDb<M>,
|
||||
crypto_provider: Arc<CryptoProvider>,
|
||||
acme_cache: AcmeTlsAlpnCache,
|
||||
@@ -679,6 +843,7 @@ impl<A: Accept> VHostServer<A> {
|
||||
let mapping = Watch::new(BTreeMap::new());
|
||||
Self {
|
||||
mapping: mapping.clone(),
|
||||
bind_reqs,
|
||||
_thread: tokio::spawn(async move {
|
||||
let mut listener = VHostListener(TlsListener::new(
|
||||
listener,
|
||||
@@ -729,6 +894,9 @@ impl<A: Accept> VHostServer<A> {
|
||||
targets.insert(target, Arc::downgrade(&rc));
|
||||
writable.insert(hostname, targets);
|
||||
res = Ok(rc);
|
||||
if changed {
|
||||
self.update_bind_reqs(writable);
|
||||
}
|
||||
changed
|
||||
});
|
||||
if self.mapping.watcher_count() > 1 {
|
||||
@@ -752,9 +920,23 @@ impl<A: Accept> VHostServer<A> {
|
||||
if !targets.is_empty() {
|
||||
writable.insert(hostname, targets);
|
||||
}
|
||||
if pre != post {
|
||||
self.update_bind_reqs(writable);
|
||||
}
|
||||
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 {
|
||||
self.mapping.peek(|m| m.is_empty())
|
||||
}
|
||||
|
||||
@@ -366,28 +366,6 @@ where
|
||||
pub struct WebServerAcceptorSetter<A: Accept> {
|
||||
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> {
|
||||
type Target = Watch<A>;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::progress::{FullProgressTracker, ProgressUnits};
|
||||
use crate::registry::context::RegistryContext;
|
||||
use crate::registry::device_info::DeviceInfo;
|
||||
use crate::registry::package::index::{PackageIndex, PackageVersionInfo};
|
||||
use crate::s9pk::manifest::LocaleString;
|
||||
use crate::s9pk::merkle_archive::source::ArchiveSource;
|
||||
use crate::s9pk::v2::SIG_CONTEXT;
|
||||
use crate::util::VersionString;
|
||||
@@ -38,11 +39,11 @@ impl Default for PackageDetailLevel {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, TS)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct PackageInfoShort {
|
||||
pub release_notes: String,
|
||||
pub release_notes: LocaleString,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, TS, Parser, HasModel)]
|
||||
@@ -89,17 +90,20 @@ impl GetPackageResponse {
|
||||
|
||||
let lesser_versions: BTreeMap<_, _> = self
|
||||
.other_versions
|
||||
.as_ref()
|
||||
.clone()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(v, _)| ***v < *version)
|
||||
.filter(|(v, _)| **v < *version)
|
||||
.collect();
|
||||
|
||||
if !lesser_versions.is_empty() {
|
||||
table.add_row(row![bc => "OLDER VERSIONS"]);
|
||||
table.add_row(row![bc => "VERSION", "RELEASE NOTES"]);
|
||||
for (version, info) in lesser_versions {
|
||||
table.add_row(row![AsRef::<str>::as_ref(version), &info.release_notes]);
|
||||
table.add_row(row![
|
||||
AsRef::<str>::as_ref(&version),
|
||||
&info.release_notes.localized()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +151,7 @@ fn get_matching_models(
|
||||
id,
|
||||
source_version,
|
||||
device_info,
|
||||
target_version,
|
||||
..
|
||||
}: &GetPackageParams,
|
||||
) -> Result<Vec<(PackageId, ExtendedVersion, Model<PackageVersionInfo>)>, Error> {
|
||||
@@ -165,26 +170,29 @@ fn get_matching_models(
|
||||
.as_entries()?
|
||||
.into_iter()
|
||||
.map(|(v, info)| {
|
||||
let ev = ExtendedVersion::from(v);
|
||||
Ok::<_, Error>(
|
||||
if source_version.as_ref().map_or(Ok(true), |source_version| {
|
||||
Ok::<_, Error>(
|
||||
source_version.satisfies(
|
||||
&info
|
||||
.as_source_version()
|
||||
.de()?
|
||||
.unwrap_or(VersionRange::any()),
|
||||
),
|
||||
)
|
||||
})? {
|
||||
if target_version.as_ref().map_or(true, |tv| ev.satisfies(tv))
|
||||
&& source_version.as_ref().map_or(Ok(true), |source_version| {
|
||||
Ok::<_, Error>(
|
||||
source_version.satisfies(
|
||||
&info
|
||||
.as_source_version()
|
||||
.de()?
|
||||
.unwrap_or(VersionRange::any()),
|
||||
),
|
||||
)
|
||||
})?
|
||||
{
|
||||
let mut info = info.clone();
|
||||
if let Some(device_info) = &device_info {
|
||||
if info.for_device(device_info)? {
|
||||
Some((k.clone(), ExtendedVersion::from(v), info))
|
||||
Some((k.clone(), ev, info))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some((k.clone(), ExtendedVersion::from(v), info))
|
||||
Some((k.clone(), ev, info))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
@@ -207,12 +215,7 @@ pub async fn get_package(ctx: RegistryContext, params: GetPackageParams) -> Resu
|
||||
for (id, version, info) in get_matching_models(&peek.as_index().as_package(), ¶ms)? {
|
||||
let package_best = best.entry(id.clone()).or_default();
|
||||
let package_other = other.entry(id.clone()).or_default();
|
||||
if params
|
||||
.target_version
|
||||
.as_ref()
|
||||
.map_or(true, |v| version.satisfies(v))
|
||||
&& package_best.keys().all(|k| !(**k > version))
|
||||
{
|
||||
if package_best.keys().all(|k| !(**k > version)) {
|
||||
for worse_version in package_best
|
||||
.keys()
|
||||
.filter(|k| ***k < version)
|
||||
@@ -569,3 +572,42 @@ pub async fn cli_download(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_matching_info_short() {
|
||||
use crate::registry::package::index::PackageMetadata;
|
||||
use crate::s9pk::manifest::{Alerts, Description};
|
||||
use crate::util::DataUrl;
|
||||
|
||||
let lang_map = |s: &str| {
|
||||
LocaleString::LanguageMap([("en".into(), s.into())].into_iter().collect())
|
||||
};
|
||||
|
||||
let info = PackageVersionInfo {
|
||||
metadata: PackageMetadata {
|
||||
title: "Test Package".into(),
|
||||
icon: DataUrl::from_vec("image/png", vec![]),
|
||||
description: Description {
|
||||
short: lang_map("A short description"),
|
||||
long: lang_map("A longer description of the test package"),
|
||||
},
|
||||
release_notes: lang_map("Initial release"),
|
||||
git_hash: None,
|
||||
license: "MIT".into(),
|
||||
wrapper_repo: "https://github.com/example/wrapper".parse().unwrap(),
|
||||
upstream_repo: "https://github.com/example/upstream".parse().unwrap(),
|
||||
support_site: "https://example.com/support".parse().unwrap(),
|
||||
marketing_site: "https://example.com".parse().unwrap(),
|
||||
donation_url: None,
|
||||
docs_url: None,
|
||||
alerts: Alerts::default(),
|
||||
dependency_metadata: BTreeMap::new(),
|
||||
os_version: exver::Version::new([0, 3, 6], []),
|
||||
sdk_version: None,
|
||||
hardware_acceleration: false,
|
||||
},
|
||||
source_version: None,
|
||||
s9pks: Vec::new(),
|
||||
};
|
||||
from_value::<PackageInfoShort>(to_value(&info).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
@@ -55,20 +55,18 @@ pub async fn get_ssl_certificate(
|
||||
.map(|(_, m)| m.as_hosts().as_entries())
|
||||
.flatten_ok()
|
||||
.map_ok(|(_, m)| {
|
||||
Ok(m.as_onions()
|
||||
.de()?
|
||||
.iter()
|
||||
.map(InternedString::from_display)
|
||||
.chain(m.as_public_domains().keys()?)
|
||||
Ok(m.as_public_domains()
|
||||
.keys()?
|
||||
.into_iter()
|
||||
.chain(m.as_private_domains().de()?)
|
||||
.chain(
|
||||
m.as_hostname_info()
|
||||
m.as_bindings()
|
||||
.de()?
|
||||
.values()
|
||||
.flatten()
|
||||
.flat_map(|b| b.addresses.possible.iter().cloned())
|
||||
.map(|h| h.to_san_hostname()),
|
||||
)
|
||||
.collect::<Vec<_>>())
|
||||
.collect::<Vec<InternedString>>())
|
||||
})
|
||||
.map(|a| a.and_then(|a| a))
|
||||
.flatten_ok()
|
||||
@@ -181,20 +179,18 @@ pub async fn get_ssl_key(
|
||||
.map(|m| m.as_hosts().as_entries())
|
||||
.flatten_ok()
|
||||
.map_ok(|(_, m)| {
|
||||
Ok(m.as_onions()
|
||||
.de()?
|
||||
.iter()
|
||||
.map(InternedString::from_display)
|
||||
.chain(m.as_public_domains().keys()?)
|
||||
Ok(m.as_public_domains()
|
||||
.keys()?
|
||||
.into_iter()
|
||||
.chain(m.as_private_domains().de()?)
|
||||
.chain(
|
||||
m.as_hostname_info()
|
||||
m.as_bindings()
|
||||
.de()?
|
||||
.values()
|
||||
.flatten()
|
||||
.flat_map(|b| b.addresses.possible.iter().cloned())
|
||||
.map(|h| h.to_san_hostname()),
|
||||
)
|
||||
.collect::<Vec<_>>())
|
||||
.collect::<Vec<InternedString>>())
|
||||
})
|
||||
.map(|a| a.and_then(|a| a))
|
||||
.flatten_ok()
|
||||
|
||||
@@ -259,6 +259,7 @@ impl ServiceMap {
|
||||
service_interfaces: Default::default(),
|
||||
hosts: Default::default(),
|
||||
store_exposed_dependents: Default::default(),
|
||||
outbound_gateway: None,
|
||||
},
|
||||
)?;
|
||||
};
|
||||
|
||||
@@ -459,7 +459,7 @@ pub async fn add_forward(
|
||||
})
|
||||
.map(|s| s.prefix_len())
|
||||
.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| {
|
||||
m.insert(source, rc);
|
||||
});
|
||||
|
||||
@@ -199,7 +199,7 @@ impl TunnelContext {
|
||||
})
|
||||
.map(|s| s.prefix_len())
|
||||
.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 {
|
||||
|
||||
@@ -524,26 +524,26 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
|
||||
"To access your Web URL securely, trust your Root CA (displayed above) on your client device(s):\n",
|
||||
" - MacOS\n",
|
||||
" 1. Open the Terminal app\n",
|
||||
" 2. Paste the following command (**DO NOTt** click Return): pbcopy < ~/Desktop/ca.crt\n",
|
||||
" 2. Paste the following command (**DO NOT** click Return): pbcopy < ~/Desktop/ca.crt\n",
|
||||
" 3. Copy your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n",
|
||||
" 4. Back in Terminal, click Return. ca.crt is saved to your Desktop\n",
|
||||
" 5. Complete by trusting your Root CA: https://https://staging.docs.start9.com/device-guides/mac/ca.html\n",
|
||||
" 5. Complete by trusting your Root CA: https://staging.docs.start9.com/device-guides/mac/ca.html\n",
|
||||
" - Linux\n",
|
||||
" 1. Open gedit, nano, or any editor\n",
|
||||
" 2. Copy/paste your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n",
|
||||
" 3. Name the file ca.crt and save as plaintext\n",
|
||||
" 5. Complete by trusting your Root CA: https://https://staging.docs.start9.com/device-guides/linux/ca.html\n",
|
||||
" 5. Complete by trusting your Root CA: https://staging.docs.start9.com/device-guides/linux/ca.html\n",
|
||||
" - Windows\n",
|
||||
" 1. Open the Notepad app\n",
|
||||
" 2. Copy/paste your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n",
|
||||
" 3. Name the file ca.crt and save as plaintext\n",
|
||||
" 5. Complete by trusting your Root CA: https://https://staging.docs.start9.com/device-guides/windows/ca.html\n",
|
||||
" 5. Complete by trusting your Root CA: https://staging.docs.start9.com/device-guides/windows/ca.html\n",
|
||||
" - Android/Graphene\n",
|
||||
" 1. Send the ca.crt file (created above) to yourself\n",
|
||||
" 2. Complete by trusting your Root CA: https://https://staging.docs.start9.com/device-guides/android/ca.html\n",
|
||||
" 2. Complete by trusting your Root CA: https://staging.docs.start9.com/device-guides/android/ca.html\n",
|
||||
" - iOS\n",
|
||||
" 1. Send the ca.crt file (created above) to yourself\n",
|
||||
" 2. Complete by trusting your Root CA: https://https://staging.docs.start9.com/device-guides/ios/ca.html\n",
|
||||
" 2. Complete by trusting your Root CA: https://staging.docs.start9.com/device-guides/ios/ca.html\n",
|
||||
));
|
||||
|
||||
return Ok(());
|
||||
|
||||
@@ -59,8 +59,9 @@ mod v0_4_0_alpha_16;
|
||||
mod v0_4_0_alpha_17;
|
||||
mod v0_4_0_alpha_18;
|
||||
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 {
|
||||
#[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_17(Wrapper<v0_4_0_alpha_17::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),
|
||||
}
|
||||
|
||||
@@ -243,7 +245,8 @@ impl Version {
|
||||
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_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) => {
|
||||
return Err(Error::new(
|
||||
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_17(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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
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".
|
||||
|
||||
### 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 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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -23,17 +23,14 @@ use crate::disk::mount::filesystem::cifs::Cifs;
|
||||
use crate::disk::mount::util::unmount;
|
||||
use crate::hostname::Hostname;
|
||||
use crate::net::forward::AvailablePorts;
|
||||
use crate::net::host::Host;
|
||||
use crate::net::keys::KeyStore;
|
||||
use crate::net::tor::{OnionAddress, TorSecretKey};
|
||||
use crate::notifications::Notifications;
|
||||
use crate::prelude::*;
|
||||
use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile;
|
||||
use crate::ssh::{SshKeys, SshPubKey};
|
||||
use crate::util::Invoke;
|
||||
use crate::util::crypto::ed25519_expand_key;
|
||||
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! {
|
||||
static ref V0_3_6_alpha_0: exver::Version = exver::Version::new(
|
||||
@@ -146,12 +143,7 @@ pub struct Version;
|
||||
|
||||
impl VersionT for Version {
|
||||
type Previous = v0_3_5_2::Version;
|
||||
type PreUpRes = (
|
||||
AccountInfo,
|
||||
SshKeys,
|
||||
CifsTargets,
|
||||
BTreeMap<PackageId, BTreeMap<HostId, TorSecretKey>>,
|
||||
);
|
||||
type PreUpRes = (AccountInfo, SshKeys, CifsTargets);
|
||||
fn semver(self) -> exver::Version {
|
||||
V0_3_6_alpha_0.clone()
|
||||
}
|
||||
@@ -166,20 +158,18 @@ impl VersionT for Version {
|
||||
|
||||
let cifs = previous_cifs(&pg).await?;
|
||||
|
||||
let tor_keys = previous_tor_keys(&pg).await?;
|
||||
|
||||
Command::new("systemctl")
|
||||
.arg("stop")
|
||||
.arg("postgresql@*.service")
|
||||
.invoke(crate::ErrorKind::Database)
|
||||
.await?;
|
||||
|
||||
Ok((account, ssh_keys, cifs, tor_keys))
|
||||
Ok((account, ssh_keys, cifs))
|
||||
}
|
||||
fn up(
|
||||
self,
|
||||
db: &mut Value,
|
||||
(account, ssh_keys, cifs, tor_keys): Self::PreUpRes,
|
||||
(account, ssh_keys, cifs): Self::PreUpRes,
|
||||
) -> Result<Value, Error> {
|
||||
let prev_package_data = db["package-data"].clone();
|
||||
|
||||
@@ -242,11 +232,7 @@ impl VersionT for Version {
|
||||
"ui": db["ui"],
|
||||
});
|
||||
|
||||
let mut 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 keystore = KeyStore::new(&account)?;
|
||||
|
||||
let private = {
|
||||
let mut value = json!({});
|
||||
@@ -350,20 +336,6 @@ impl VersionT for Version {
|
||||
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 {
|
||||
let package_s9pk = tokio::fs::File::open(path).await?;
|
||||
let file = MultiCursorFile::open(&package_s9pk).await?;
|
||||
@@ -381,11 +353,8 @@ impl VersionT for Version {
|
||||
.await?
|
||||
.await?;
|
||||
|
||||
let to_sync = ctx
|
||||
.db
|
||||
ctx.db
|
||||
.mutate(|db| {
|
||||
let mut to_sync = BTreeSet::new();
|
||||
|
||||
let package = db
|
||||
.as_public_mut()
|
||||
.as_package_data_mut()
|
||||
@@ -396,29 +365,11 @@ impl VersionT for Version {
|
||||
.as_tasks_mut()
|
||||
.remove(&ReplayId::from("needs-config"))?;
|
||||
}
|
||||
for (id, onion) in onions {
|
||||
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)
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.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>(())
|
||||
}
|
||||
.await
|
||||
@@ -481,33 +432,6 @@ async fn previous_account_info(pg: &sqlx::Pool<sqlx::Postgres>) -> Result<Accoun
|
||||
password: account_query
|
||||
.try_get("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
|
||||
.try_get("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)
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ use super::v0_3_5::V0_3_0_COMPAT;
|
||||
use super::{VersionT, v0_3_6_alpha_9};
|
||||
use crate::GatewayId;
|
||||
use crate::net::host::address::PublicDomainConfig;
|
||||
use crate::net::tor::OnionAddress;
|
||||
use crate::prelude::*;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -22,7 +21,7 @@ lazy_static::lazy_static! {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(tag = "kind")]
|
||||
enum HostAddress {
|
||||
Onion { address: OnionAddress },
|
||||
Onion { address: String },
|
||||
Domain { address: InternedString },
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use exver::{PreReleaseSegment, VersionRange};
|
||||
use imbl_value::InternedString;
|
||||
|
||||
use super::v0_3_5::V0_3_0_COMPAT;
|
||||
use super::{VersionT, v0_4_0_alpha_11};
|
||||
use crate::net::tor::TorSecretKey;
|
||||
use crate::prelude::*;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -33,48 +29,6 @@ impl VersionT for Version {
|
||||
}
|
||||
#[instrument(skip_all)]
|
||||
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() {
|
||||
db["private"]["keyStore"]["localCerts"] =
|
||||
db["private"]["keyStore"]["local_certs"].clone();
|
||||
|
||||
192
core/src/version/v0_4_0_alpha_20.rs
Normal file
192
core/src/version/v0_4_0_alpha_20.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,569 +16,160 @@ import {
|
||||
MountParams,
|
||||
StatusInfo,
|
||||
Manifest,
|
||||
} from "./osBindings"
|
||||
} from './osBindings'
|
||||
import {
|
||||
PackageId,
|
||||
Dependencies,
|
||||
ServiceInterfaceId,
|
||||
SmtpValue,
|
||||
ActionResult,
|
||||
} from "./types"
|
||||
} from './types'
|
||||
|
||||
/** Used to reach out from the pure js runtime */
|
||||
|
||||
/**
|
||||
* The Effects interface is the primary mechanism for a StartOS service to interact
|
||||
* with the host operating system. All system operations—file I/O, networking,
|
||||
* health reporting, dependency management, and more—are performed through Effects.
|
||||
*
|
||||
* Effects are passed to all lifecycle functions (main, init, uninit, actions, etc.)
|
||||
* and provide a controlled, sandboxed API for service operations.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* export const main = sdk.setupMain(async ({ effects }) => {
|
||||
* // Use effects to interact with the system
|
||||
* const smtp = await effects.getSystemSmtp({})
|
||||
* await effects.setHealth({ name: 'server', result: { result: 'success' } })
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export type Effects = {
|
||||
/**
|
||||
* A unique identifier for the current event/request context.
|
||||
* Returns null when not executing within an event context.
|
||||
* Useful for correlating logs and tracking request flows.
|
||||
*/
|
||||
readonly eventId: string | null
|
||||
|
||||
/**
|
||||
* Creates a child Effects context with a namespaced identifier.
|
||||
* Child contexts inherit the parent's capabilities but have their own
|
||||
* event tracking namespace, useful for organizing complex operations.
|
||||
*
|
||||
* @param name - The name to append to the context namespace
|
||||
* @returns A new Effects instance scoped to the child context
|
||||
*/
|
||||
child: (name: string) => Effects
|
||||
|
||||
/**
|
||||
* Internal retry mechanism for `.const()` operations.
|
||||
* Called automatically when a const operation needs to be retried
|
||||
* due to dependency changes. Not typically used directly by service developers.
|
||||
*/
|
||||
constRetry?: () => void
|
||||
|
||||
/**
|
||||
* Indicates whether the Effects instance is currently within a valid execution context.
|
||||
* Returns false if the context has been destroyed or left.
|
||||
*/
|
||||
isInContext: boolean
|
||||
|
||||
/**
|
||||
* Registers a cleanup callback to be invoked when leaving the current context.
|
||||
* Use this to clean up resources, close connections, or perform other teardown
|
||||
* operations when the service or action completes.
|
||||
*
|
||||
* @param fn - Cleanup function to execute on context exit. Can return void, null, or undefined.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* effects.onLeaveContext(() => {
|
||||
* socket.close()
|
||||
* clearInterval(healthCheckInterval)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
onLeaveContext: (fn: () => void | null | undefined) => void
|
||||
|
||||
/**
|
||||
* Clears registered callbacks by their internal IDs.
|
||||
* Used for cleanup when callbacks are no longer needed.
|
||||
*
|
||||
* @param options - Either `{ only: number[] }` to clear specific callbacks,
|
||||
* or `{ except: number[] }` to clear all except the specified ones
|
||||
* @returns Promise resolving to null on completion
|
||||
*/
|
||||
clearCallbacks: (
|
||||
options: { only: number[] } | { except: number[] },
|
||||
) => Promise<null>
|
||||
|
||||
/**
|
||||
* Action-related methods for defining, invoking, and managing user-callable operations.
|
||||
* Actions appear in the StartOS UI and can be triggered by users or other services.
|
||||
*/
|
||||
// action
|
||||
action: {
|
||||
/**
|
||||
* Exports an action to make it available in the StartOS UI.
|
||||
* Call this during initialization to register actions that users can invoke.
|
||||
*
|
||||
* @param options.id - Unique identifier for the action
|
||||
* @param options.metadata - Action configuration including name, description, input spec, and visibility
|
||||
* @returns Promise resolving to null on success
|
||||
*/
|
||||
/** Define an action that can be invoked by a user or service */
|
||||
export(options: { id: ActionId; metadata: ActionMetadata }): Promise<null>
|
||||
|
||||
/**
|
||||
* Removes all exported actions except those specified.
|
||||
* Typically called during initialization before re-registering current actions.
|
||||
*
|
||||
* @param options.except - Array of action IDs to keep (not remove)
|
||||
* @returns Promise resolving to null on success
|
||||
*/
|
||||
/** Remove all exported actions */
|
||||
clear(options: { except: ActionId[] }): Promise<null>
|
||||
|
||||
/**
|
||||
* Retrieves the previously submitted input for an action.
|
||||
* Useful for pre-filling forms with the last-used values.
|
||||
*
|
||||
* @param options.packageId - Package ID (defaults to current package if omitted)
|
||||
* @param options.actionId - The action whose input to retrieve
|
||||
* @returns Promise resolving to the stored input, or null if none exists
|
||||
*/
|
||||
getInput(options: {
|
||||
packageId?: PackageId
|
||||
actionId: ActionId
|
||||
}): Promise<ActionInput | null>
|
||||
|
||||
/**
|
||||
* Programmatically invokes an action on this or another service.
|
||||
* Enables service-to-service communication and automation.
|
||||
*
|
||||
* @param options.packageId - Target package ID (defaults to current package if omitted)
|
||||
* @param options.actionId - The action to invoke
|
||||
* @param options.input - Input data matching the action's input specification
|
||||
* @returns Promise resolving to the action result, or null if the action doesn't exist
|
||||
*/
|
||||
run<Input extends Record<string, unknown>>(options: {
|
||||
packageId?: PackageId
|
||||
actionId: ActionId
|
||||
input?: Input
|
||||
}): Promise<ActionResult | null>
|
||||
|
||||
/**
|
||||
* Creates a task that appears in the StartOS UI task list.
|
||||
* Tasks are used for long-running operations or required setup steps
|
||||
* that need user attention (e.g., "Create admin user", "Configure backup").
|
||||
*
|
||||
* @param options - Task configuration including ID, name, description, and completion criteria
|
||||
* @returns Promise resolving to null on success
|
||||
*/
|
||||
createTask(options: CreateTaskParams): Promise<null>
|
||||
|
||||
/**
|
||||
* Removes tasks from the UI task list.
|
||||
*
|
||||
* @param options - Either `{ only: string[] }` to remove specific tasks,
|
||||
* or `{ except: string[] }` to remove all except specified tasks
|
||||
* @returns Promise resolving to null on success
|
||||
*/
|
||||
clearTasks(
|
||||
options: { only: string[] } | { except: string[] },
|
||||
): Promise<null>
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Control Methods - Manage service lifecycle
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Restarts this service's main function.
|
||||
* The current main process will be terminated and a new one started.
|
||||
* Use this after configuration changes that require a restart to take effect.
|
||||
*
|
||||
* @returns Promise resolving to null when the restart has been initiated
|
||||
*/
|
||||
// control
|
||||
/** restart this service's main function */
|
||||
restart(): Promise<null>
|
||||
|
||||
/**
|
||||
* Gracefully stops this service's main function.
|
||||
* The daemon will receive a termination signal and be given time to clean up.
|
||||
*
|
||||
* @returns Promise resolving to null when shutdown has been initiated
|
||||
*/
|
||||
/** stop this service's main function */
|
||||
shutdown(): Promise<null>
|
||||
|
||||
/**
|
||||
* Queries the current status of a service from the host OS.
|
||||
*
|
||||
* @param options.packageId - Package to query (defaults to current package if omitted)
|
||||
* @param options.callback - Optional callback invoked when status changes (for reactive updates)
|
||||
* @returns Promise resolving to the service's current status information
|
||||
*/
|
||||
/** ask the host os what the service's current status is */
|
||||
getStatus(options: {
|
||||
packageId?: PackageId
|
||||
callback?: () => void
|
||||
}): Promise<StatusInfo>
|
||||
|
||||
/**
|
||||
* @deprecated This method is deprecated and should not be used.
|
||||
* Health status is now managed through the health check system.
|
||||
*
|
||||
* Previously used to manually indicate the service's run state to the host OS.
|
||||
*/
|
||||
/** DEPRECATED: indicate to the host os what runstate the service is in */
|
||||
setMainStatus(options: SetMainStatus): Promise<null>
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Dependency Methods - Manage service dependencies and inter-service communication
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Declares the runtime dependencies this service requires.
|
||||
* Dependencies can be marked as "running" (must be actively running) or "exists" (must be installed).
|
||||
* Call this during initialization to ensure dependencies are satisfied before the service starts.
|
||||
*
|
||||
* @param options.dependencies - Array of dependency requirements with package IDs, version ranges, and dependency kind
|
||||
* @returns Promise resolving to null on success
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await effects.setDependencies({
|
||||
* dependencies: [
|
||||
* { packageId: 'bitcoind', versionRange: '>=25.0.0', kind: 'running' },
|
||||
* { packageId: 'lnd', versionRange: '>=0.16.0', kind: 'exists' }
|
||||
* ]
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
// dependency
|
||||
/** Set the dependencies of what the service needs, usually run during the inputSpec action as a best practice */
|
||||
setDependencies(options: { dependencies: Dependencies }): Promise<null>
|
||||
|
||||
/**
|
||||
* Retrieves the complete list of dependencies for this service.
|
||||
* Includes both statically declared dependencies from the manifest
|
||||
* and dynamically set dependencies from setDependencies().
|
||||
*
|
||||
* @returns Promise resolving to array of all dependency requirements
|
||||
*/
|
||||
/** Get the list of the dependencies, both the dynamic set by the effect of setDependencies and the end result any required in the manifest */
|
||||
getDependencies(): Promise<DependencyRequirement[]>
|
||||
|
||||
/**
|
||||
* Tests whether the specified or all dependencies are currently satisfied.
|
||||
* Use this to verify dependencies are met before performing operations that require them.
|
||||
*
|
||||
* @param options.packageIds - Specific packages to check (checks all dependencies if omitted)
|
||||
* @returns Promise resolving to array of check results indicating satisfaction status
|
||||
*/
|
||||
/** Test whether current dependency requirements are satisfied */
|
||||
checkDependencies(options: {
|
||||
packageIds?: PackageId[]
|
||||
}): Promise<CheckDependenciesResult[]>
|
||||
|
||||
/**
|
||||
* Mounts a volume from a dependency service into this service's filesystem.
|
||||
* Enables read-only or read-write access to another service's data.
|
||||
*
|
||||
* @param options - Mount configuration including dependency ID, volume ID, mountpoint, and access mode
|
||||
* @returns Promise resolving to the mount path
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Mount bitcoind's data directory for read access
|
||||
* const mountPath = await effects.mount({
|
||||
* dependencyId: 'bitcoind',
|
||||
* volumeId: 'main',
|
||||
* mountpoint: '/mnt/bitcoin',
|
||||
* readonly: true
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
/** mount a volume of a dependency */
|
||||
mount(options: MountParams): Promise<string>
|
||||
|
||||
/**
|
||||
* Returns the package IDs of all services currently installed on the system.
|
||||
* Useful for discovering available services for optional integrations.
|
||||
*
|
||||
* @returns Promise resolving to array of installed package IDs
|
||||
*/
|
||||
/** Returns a list of the ids of all installed packages */
|
||||
getInstalledPackages(): Promise<string[]>
|
||||
|
||||
/**
|
||||
* Retrieves the manifest of another installed service.
|
||||
* Use this to inspect another service's metadata, version, or capabilities.
|
||||
*
|
||||
* @param options.packageId - The package ID to retrieve the manifest for
|
||||
* @param options.callback - Optional callback invoked when the manifest changes (for reactive updates)
|
||||
* @returns Promise resolving to the service's manifest
|
||||
*/
|
||||
/** Returns the manifest of a service */
|
||||
getServiceManifest(options: {
|
||||
packageId: PackageId
|
||||
callback?: () => void
|
||||
}): Promise<Manifest>
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Health Methods - Report service health status
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reports the result of a health check to the StartOS UI.
|
||||
* Health checks appear in the service's status panel and indicate operational status.
|
||||
*
|
||||
* @param o - Health check result including the check name and result status (success/failure/starting)
|
||||
* @returns Promise resolving to null on success
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await effects.setHealth({
|
||||
* name: 'web-interface',
|
||||
* result: { result: 'success', message: 'Web UI is accessible' }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
// health
|
||||
/** sets the result of a health check */
|
||||
setHealth(o: SetHealth): Promise<null>
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Subcontainer Methods - Low-level container filesystem management
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Low-level APIs for managing subcontainer filesystems.
|
||||
* These are typically used internally by the SubContainer class.
|
||||
* Service developers should use `sdk.SubContainer.of()` instead.
|
||||
*/
|
||||
// subcontainer
|
||||
subcontainer: {
|
||||
/**
|
||||
* Creates a new container filesystem from a Docker image.
|
||||
* This is a low-level API - prefer using `sdk.SubContainer.of()` for most use cases.
|
||||
*
|
||||
* @param options.imageId - The Docker image ID to create the filesystem from
|
||||
* @param options.name - Optional name for the container (null for anonymous)
|
||||
* @returns Promise resolving to a tuple of [guid, rootPath] for the created filesystem
|
||||
*/
|
||||
/** A low level api used by SubContainer */
|
||||
createFs(options: {
|
||||
imageId: string
|
||||
name: string | null
|
||||
}): Promise<[string, string]>
|
||||
|
||||
/**
|
||||
* Destroys a container filesystem and cleans up its resources.
|
||||
* This is a low-level API - SubContainer handles cleanup automatically.
|
||||
*
|
||||
* @param options.guid - The unique identifier of the filesystem to destroy
|
||||
* @returns Promise resolving to null on success
|
||||
*/
|
||||
/** A low level api used by SubContainer */
|
||||
destroyFs(options: { guid: string }): Promise<null>
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Network Methods - Port binding, host info, and network configuration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Binds a network port and creates a host entry for the service.
|
||||
* This makes the port accessible via the StartOS networking layer (Tor, LAN, etc.).
|
||||
*
|
||||
* @param options - Binding configuration including host ID, port, protocol, and network options
|
||||
* @returns Promise resolving to null on success
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await effects.bind({
|
||||
* id: 'webui',
|
||||
* internalPort: 8080,
|
||||
* protocol: 'http'
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
// net
|
||||
// bind
|
||||
/** Creates a host connected to the specified port with the provided options */
|
||||
bind(options: BindParams): Promise<null>
|
||||
|
||||
/**
|
||||
* Gets the network address information for accessing a service's port.
|
||||
* Use this to discover how to connect to another service's exposed port.
|
||||
*
|
||||
* @param options.packageId - Target package (defaults to current package if omitted)
|
||||
* @param options.hostId - The host identifier for the binding
|
||||
* @param options.internalPort - The internal port number
|
||||
* @returns Promise resolving to network info including addresses and ports
|
||||
*/
|
||||
/** Get the port address for a service */
|
||||
getServicePortForward(options: {
|
||||
packageId?: PackageId
|
||||
hostId: HostId
|
||||
internalPort: number
|
||||
}): Promise<NetInfo>
|
||||
|
||||
/**
|
||||
* Removes all network bindings except those specified.
|
||||
* Typically called during initialization to clean up stale bindings before re-registering.
|
||||
*
|
||||
* @param options.except - Array of bindings to preserve (by host ID and port)
|
||||
* @returns Promise resolving to null on success
|
||||
*/
|
||||
/** Removes all network bindings, called in the setupInputSpec */
|
||||
clearBindings(options: {
|
||||
except: { id: HostId; internalPort: number }[]
|
||||
}): Promise<null>
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Host Info Methods - Query network host and address information
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Retrieves detailed information about a network host binding.
|
||||
*
|
||||
* @param options.packageId - Target package (defaults to current package if omitted)
|
||||
* @param options.hostId - The host identifier to query
|
||||
* @param options.callback - Optional callback invoked when host info changes (for reactive updates)
|
||||
* @returns Promise resolving to host information, or null if the host doesn't exist
|
||||
*/
|
||||
// host
|
||||
/** Returns information about the specified host, if it exists */
|
||||
getHostInfo(options: {
|
||||
packageId?: PackageId
|
||||
hostId: HostId
|
||||
callback?: () => void
|
||||
}): Promise<Host | null>
|
||||
|
||||
/**
|
||||
* Returns the internal IP address of the service's container.
|
||||
* Useful for configuring services that need to know their own network address.
|
||||
*
|
||||
* @param options.packageId - Target package (defaults to current package if omitted)
|
||||
* @param options.callback - Optional callback invoked when the IP changes
|
||||
* @returns Promise resolving to the container's IP address string
|
||||
*/
|
||||
/** Returns the IP address of the container */
|
||||
getContainerIp(options: {
|
||||
packageId?: PackageId
|
||||
callback?: () => void
|
||||
}): Promise<string>
|
||||
|
||||
/**
|
||||
* Returns the IP address of the StartOS host system.
|
||||
* Useful for services that need to communicate with the host or other system services.
|
||||
*
|
||||
* @returns Promise resolving to the StartOS IP address string
|
||||
*/
|
||||
/** Returns the IP address of StartOS */
|
||||
getOsIp(): Promise<string>
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Service Interface Methods - Expose and discover service endpoints
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Exports a service interface that appears in the StartOS UI.
|
||||
* Service interfaces are the user-visible endpoints (web UIs, APIs, etc.) that users
|
||||
* can click to access the service.
|
||||
*
|
||||
* @param options - Interface configuration including ID, name, description, type, and associated host/port
|
||||
* @returns Promise resolving to null on success
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await effects.exportServiceInterface({
|
||||
* id: 'webui',
|
||||
* name: 'Web Interface',
|
||||
* description: 'Access the web dashboard',
|
||||
* type: 'ui',
|
||||
* hostId: 'main',
|
||||
* internalPort: 8080
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
// interface
|
||||
/** Creates an interface bound to a specific host and port to show to the user */
|
||||
exportServiceInterface(options: ExportServiceInterfaceParams): Promise<null>
|
||||
|
||||
/**
|
||||
* Retrieves information about an exported service interface.
|
||||
*
|
||||
* @param options.packageId - Target package (defaults to current package if omitted)
|
||||
* @param options.serviceInterfaceId - The interface identifier to query
|
||||
* @param options.callback - Optional callback invoked when the interface changes
|
||||
* @returns Promise resolving to the interface info, or null if it doesn't exist
|
||||
*/
|
||||
/** Returns an exported service interface */
|
||||
getServiceInterface(options: {
|
||||
packageId?: PackageId
|
||||
serviceInterfaceId: ServiceInterfaceId
|
||||
callback?: () => void
|
||||
}): Promise<ServiceInterface | null>
|
||||
|
||||
/**
|
||||
* Lists all exported service interfaces for a package.
|
||||
* Useful for discovering what endpoints another service exposes.
|
||||
*
|
||||
* @param options.packageId - Target package (defaults to current package if omitted)
|
||||
* @param options.callback - Optional callback invoked when any interface changes
|
||||
* @returns Promise resolving to a record mapping interface IDs to their configurations
|
||||
*/
|
||||
/** Returns all exported service interfaces for a package */
|
||||
listServiceInterfaces(options: {
|
||||
packageId?: PackageId
|
||||
callback?: () => void
|
||||
}): Promise<Record<ServiceInterfaceId, ServiceInterface>>
|
||||
|
||||
/**
|
||||
* Removes all service interfaces except those specified.
|
||||
* Typically called during initialization to clean up stale interfaces before re-registering.
|
||||
*
|
||||
* @param options.except - Array of interface IDs to preserve
|
||||
* @returns Promise resolving to null on success
|
||||
*/
|
||||
/** Removes all service interfaces */
|
||||
clearServiceInterfaces(options: {
|
||||
except: ServiceInterfaceId[]
|
||||
}): Promise<null>
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SSL Methods - Manage TLS certificates
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Retrieves a PEM-encoded SSL certificate chain for the specified hostnames.
|
||||
* StartOS automatically manages certificate generation and renewal.
|
||||
*
|
||||
* @param options.hostnames - Array of hostnames the certificate should cover
|
||||
* @param options.algorithm - Signing algorithm: "ecdsa" (default) or "ed25519"
|
||||
* @param options.callback - Optional callback invoked when the certificate is renewed
|
||||
* @returns Promise resolving to a tuple of [certificate, chain, fullchain] PEM strings
|
||||
*/
|
||||
// ssl
|
||||
/** Returns a PEM encoded fullchain for the hostnames specified */
|
||||
getSslCertificate: (options: {
|
||||
hostnames: string[]
|
||||
algorithm?: "ecdsa" | "ed25519"
|
||||
algorithm?: 'ecdsa' | 'ed25519'
|
||||
callback?: () => void
|
||||
}) => Promise<[string, string, string]>
|
||||
|
||||
/**
|
||||
* Retrieves the PEM-encoded private key corresponding to the SSL certificate.
|
||||
*
|
||||
* @param options.hostnames - Array of hostnames (must match a previous getSslCertificate call)
|
||||
* @param options.algorithm - Signing algorithm: "ecdsa" (default) or "ed25519"
|
||||
* @returns Promise resolving to the private key PEM string
|
||||
*/
|
||||
/** Returns a PEM encoded private key corresponding to the certificate for the hostnames specified */
|
||||
getSslKey: (options: {
|
||||
hostnames: string[]
|
||||
algorithm?: "ecdsa" | "ed25519"
|
||||
algorithm?: 'ecdsa' | 'ed25519'
|
||||
}) => Promise<string>
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Data Version Methods - Track data migration state
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sets the version that this service's data has been migrated to.
|
||||
* Used by the version migration system to track which migrations have been applied.
|
||||
* Service developers typically don't call this directly - it's managed by the version graph.
|
||||
*
|
||||
* @param options.version - The version string to record, or null to clear
|
||||
* @returns Promise resolving to null on success
|
||||
*/
|
||||
/** sets the version that this service's data has been migrated to */
|
||||
setDataVersion(options: { version: string | null }): Promise<null>
|
||||
|
||||
/**
|
||||
* Returns the version that this service's data has been migrated to.
|
||||
* Used to determine which migrations need to be applied during updates.
|
||||
*
|
||||
* @returns Promise resolving to the current data version string, or null if not set
|
||||
*/
|
||||
/** returns the version that this service's data has been migrated to */
|
||||
getDataVersion(): Promise<string | null>
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// System Methods - Access system-wide configuration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Retrieves the globally configured SMTP settings from StartOS.
|
||||
* Users can configure SMTP in the StartOS settings for services to use for sending emails.
|
||||
*
|
||||
* @param options.callback - Optional callback invoked when SMTP settings change (for reactive updates)
|
||||
* @returns Promise resolving to SMTP configuration, or null if not configured
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const smtp = await effects.getSystemSmtp({})
|
||||
* if (smtp) {
|
||||
* console.log(`SMTP server: ${smtp.server}:${smtp.port}`)
|
||||
* console.log(`From address: ${smtp.from}`)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
// system
|
||||
/** Returns globally configured SMTP settings, if they exist */
|
||||
getSystemSmtp(options: { callback?: () => void }): Promise<SmtpValue | null>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as T from "../types"
|
||||
import * as IST from "../actions/input/inputSpecTypes"
|
||||
import { Action, ActionInfo } from "./setupActions"
|
||||
import { ExtractInputSpecType } from "./input/builder/inputSpec"
|
||||
import * as T from '../types'
|
||||
import * as IST from '../actions/input/inputSpecTypes'
|
||||
import { Action, ActionInfo } from './setupActions'
|
||||
import { ExtractInputSpecType } from './input/builder/inputSpec'
|
||||
|
||||
export type RunActionInput<Input> =
|
||||
| Input
|
||||
@@ -53,17 +53,17 @@ type TaskBase = {
|
||||
replayId?: string
|
||||
}
|
||||
type TaskInput<T extends ActionInfo<T.ActionId, any>> = {
|
||||
kind: "partial"
|
||||
kind: 'partial'
|
||||
value: T.DeepPartial<GetActionInputType<T>>
|
||||
}
|
||||
export type TaskOptions<T extends ActionInfo<T.ActionId, any>> = TaskBase &
|
||||
(
|
||||
| {
|
||||
when?: Exclude<T.TaskTrigger, { condition: "input-not-matches" }>
|
||||
when?: Exclude<T.TaskTrigger, { condition: 'input-not-matches' }>
|
||||
input?: TaskInput<T>
|
||||
}
|
||||
| {
|
||||
when: T.TaskTrigger & { condition: "input-not-matches" }
|
||||
when: T.TaskTrigger & { condition: 'input-not-matches' }
|
||||
input: TaskInput<T>
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { InputSpec } from "./inputSpec"
|
||||
import { List } from "./list"
|
||||
import { Value } from "./value"
|
||||
import { Variants } from "./variants"
|
||||
import { InputSpec } from './inputSpec'
|
||||
import { List } from './list'
|
||||
import { Value } from './value'
|
||||
import { Variants } from './variants'
|
||||
|
||||
export { InputSpec as InputSpec, List, Value, Variants }
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ValueSpec } from "../inputSpecTypes"
|
||||
import { Value } from "./value"
|
||||
import { _ } from "../../../util"
|
||||
import { Effects } from "../../../Effects"
|
||||
import { Parser, object } from "ts-matches"
|
||||
import { DeepPartial } from "../../../types"
|
||||
import { ValueSpec } from '../inputSpecTypes'
|
||||
import { Value } from './value'
|
||||
import { _ } from '../../../util'
|
||||
import { Effects } from '../../../Effects'
|
||||
import { Parser, object } from 'ts-matches'
|
||||
import { DeepPartial } from '../../../types'
|
||||
|
||||
export type LazyBuildOptions = {
|
||||
effects: Effects
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { InputSpec, LazyBuild } from "./inputSpec"
|
||||
import { InputSpec, LazyBuild } from './inputSpec'
|
||||
import {
|
||||
ListValueSpecText,
|
||||
Pattern,
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
UniqueBy,
|
||||
ValueSpecList,
|
||||
ValueSpecListOf,
|
||||
} from "../inputSpecTypes"
|
||||
import { Parser, arrayOf, string } from "ts-matches"
|
||||
} from '../inputSpecTypes'
|
||||
import { Parser, arrayOf, string } from 'ts-matches'
|
||||
|
||||
export class List<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
private constructor(
|
||||
@@ -55,7 +55,7 @@ export class List<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
* @description Informs the browser how to behave and which keyboard to display on mobile
|
||||
* @default "text"
|
||||
*/
|
||||
inputmode?: ListValueSpecText["inputmode"]
|
||||
inputmode?: ListValueSpecText['inputmode']
|
||||
/**
|
||||
* @description Displays a button that will generate a random string according to the provided charset and len attributes.
|
||||
*/
|
||||
@@ -65,21 +65,21 @@ export class List<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
const validator = arrayOf(string)
|
||||
return new List<string[]>(() => {
|
||||
const spec = {
|
||||
type: "text" as const,
|
||||
type: 'text' as const,
|
||||
placeholder: null,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
masked: false,
|
||||
inputmode: "text" as const,
|
||||
inputmode: 'text' as const,
|
||||
generate: null,
|
||||
patterns: aSpec.patterns || [],
|
||||
...aSpec,
|
||||
}
|
||||
const built: ValueSpecListOf<"text"> = {
|
||||
const built: ValueSpecListOf<'text'> = {
|
||||
description: null,
|
||||
warning: null,
|
||||
default: [],
|
||||
type: "list" as const,
|
||||
type: 'list' as const,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
disabled: false,
|
||||
@@ -106,7 +106,7 @@ export class List<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
minLength?: number | null
|
||||
maxLength?: number | null
|
||||
patterns?: Pattern[]
|
||||
inputmode?: ListValueSpecText["inputmode"]
|
||||
inputmode?: ListValueSpecText['inputmode']
|
||||
}
|
||||
}>,
|
||||
) {
|
||||
@@ -114,21 +114,21 @@ export class List<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
return new List<string[]>(async (options) => {
|
||||
const { spec: aSpec, ...a } = await getA(options)
|
||||
const spec = {
|
||||
type: "text" as const,
|
||||
type: 'text' as const,
|
||||
placeholder: null,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
masked: false,
|
||||
inputmode: "text" as const,
|
||||
inputmode: 'text' as const,
|
||||
generate: null,
|
||||
patterns: aSpec.patterns || [],
|
||||
...aSpec,
|
||||
}
|
||||
const built: ValueSpecListOf<"text"> = {
|
||||
const built: ValueSpecListOf<'text'> = {
|
||||
description: null,
|
||||
warning: null,
|
||||
default: [],
|
||||
type: "list" as const,
|
||||
type: 'list' as const,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
disabled: false,
|
||||
@@ -162,7 +162,7 @@ export class List<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
const { spec: previousSpecSpec, ...restSpec } = aSpec
|
||||
const built = await previousSpecSpec.build(options)
|
||||
const spec = {
|
||||
type: "object" as const,
|
||||
type: 'object' as const,
|
||||
displayAs: null,
|
||||
uniqueBy: null,
|
||||
...restSpec,
|
||||
@@ -179,7 +179,7 @@ export class List<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
warning: null,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
type: "list" as const,
|
||||
type: 'list' as const,
|
||||
disabled: false,
|
||||
...value,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { InputSpec, LazyBuild } from "./inputSpec"
|
||||
import { List } from "./list"
|
||||
import { UnionRes, UnionResStaticValidatedAs, Variants } from "./variants"
|
||||
import { InputSpec, LazyBuild } from './inputSpec'
|
||||
import { List } from './list'
|
||||
import { UnionRes, UnionResStaticValidatedAs, Variants } from './variants'
|
||||
import {
|
||||
Pattern,
|
||||
RandomString,
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
ValueSpecHidden,
|
||||
ValueSpecText,
|
||||
ValueSpecTextarea,
|
||||
} from "../inputSpecTypes"
|
||||
import { DefaultString } from "../inputSpecTypes"
|
||||
import { _, once } from "../../../util"
|
||||
} from '../inputSpecTypes'
|
||||
import { DefaultString } from '../inputSpecTypes'
|
||||
import { _, once } from '../../../util'
|
||||
import {
|
||||
Parser,
|
||||
any,
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
number,
|
||||
object,
|
||||
string,
|
||||
} from "ts-matches"
|
||||
import { DeepPartial } from "../../../types"
|
||||
} from 'ts-matches'
|
||||
import { DeepPartial } from '../../../types'
|
||||
|
||||
export const fileInfoParser = object({
|
||||
path: string,
|
||||
@@ -42,7 +42,7 @@ const testForAsRequiredParser = once(
|
||||
function asRequiredParser<Type, Input extends { required: boolean }>(
|
||||
parser: Parser<unknown, Type>,
|
||||
input: Input,
|
||||
): Parser<unknown, AsRequired<Type, Input["required"]>> {
|
||||
): Parser<unknown, AsRequired<Type, Input['required']>> {
|
||||
if (testForAsRequiredParser()(input)) return parser as any
|
||||
return parser.nullable() as any
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
spec: {
|
||||
description: null,
|
||||
warning: null,
|
||||
type: "toggle" as const,
|
||||
type: 'toggle' as const,
|
||||
disabled: false,
|
||||
immutable: a.immutable ?? false,
|
||||
...a,
|
||||
@@ -117,7 +117,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
spec: {
|
||||
description: null,
|
||||
warning: null,
|
||||
type: "toggle" as const,
|
||||
type: 'toggle' as const,
|
||||
disabled: false,
|
||||
immutable: false,
|
||||
...(await a(options)),
|
||||
@@ -191,7 +191,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
* @description Informs the browser how to behave and which keyboard to display on mobile
|
||||
* @default "text"
|
||||
*/
|
||||
inputmode?: ValueSpecText["inputmode"]
|
||||
inputmode?: ValueSpecText['inputmode']
|
||||
/**
|
||||
* @description Once set, the value can never be changed.
|
||||
* @default false
|
||||
@@ -206,7 +206,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
return new Value<AsRequired<string, Required>>(
|
||||
async () => ({
|
||||
spec: {
|
||||
type: "text" as const,
|
||||
type: 'text' as const,
|
||||
description: null,
|
||||
warning: null,
|
||||
masked: false,
|
||||
@@ -214,7 +214,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
patterns: [],
|
||||
inputmode: "text",
|
||||
inputmode: 'text',
|
||||
disabled: false,
|
||||
immutable: a.immutable ?? false,
|
||||
generate: a.generate ?? null,
|
||||
@@ -237,7 +237,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
minLength?: number | null
|
||||
maxLength?: number | null
|
||||
patterns?: Pattern[]
|
||||
inputmode?: ValueSpecText["inputmode"]
|
||||
inputmode?: ValueSpecText['inputmode']
|
||||
disabled?: string | false
|
||||
generate?: null | RandomString
|
||||
}>,
|
||||
@@ -247,7 +247,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
const a = await getA(options)
|
||||
return {
|
||||
spec: {
|
||||
type: "text" as const,
|
||||
type: 'text' as const,
|
||||
description: null,
|
||||
warning: null,
|
||||
masked: false,
|
||||
@@ -255,7 +255,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
patterns: [],
|
||||
inputmode: "text",
|
||||
inputmode: 'text',
|
||||
disabled: false,
|
||||
immutable: false,
|
||||
generate: a.generate ?? null,
|
||||
@@ -334,7 +334,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
minRows: 3,
|
||||
maxRows: 6,
|
||||
placeholder: null,
|
||||
type: "textarea" as const,
|
||||
type: 'textarea' as const,
|
||||
disabled: false,
|
||||
immutable: a.immutable ?? false,
|
||||
...a,
|
||||
@@ -371,7 +371,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
minRows: 3,
|
||||
maxRows: 6,
|
||||
placeholder: null,
|
||||
type: "textarea" as const,
|
||||
type: 'textarea' as const,
|
||||
disabled: false,
|
||||
immutable: false,
|
||||
...a,
|
||||
@@ -444,7 +444,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
return new Value<AsRequired<number, Required>>(
|
||||
() => ({
|
||||
spec: {
|
||||
type: "number" as const,
|
||||
type: 'number' as const,
|
||||
description: null,
|
||||
warning: null,
|
||||
min: null,
|
||||
@@ -482,7 +482,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
const a = await getA(options)
|
||||
return {
|
||||
spec: {
|
||||
type: "number" as const,
|
||||
type: 'number' as const,
|
||||
description: null,
|
||||
warning: null,
|
||||
min: null,
|
||||
@@ -540,7 +540,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
return new Value<AsRequired<string, Required>>(
|
||||
() => ({
|
||||
spec: {
|
||||
type: "color" as const,
|
||||
type: 'color' as const,
|
||||
description: null,
|
||||
warning: null,
|
||||
disabled: false,
|
||||
@@ -568,7 +568,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
const a = await getA(options)
|
||||
return {
|
||||
spec: {
|
||||
type: "color" as const,
|
||||
type: 'color' as const,
|
||||
description: null,
|
||||
warning: null,
|
||||
disabled: false,
|
||||
@@ -618,7 +618,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
* @description Informs the browser how to behave and which date/time component to display.
|
||||
* @default "datetime-local"
|
||||
*/
|
||||
inputmode?: ValueSpecDatetime["inputmode"]
|
||||
inputmode?: ValueSpecDatetime['inputmode']
|
||||
min?: string | null
|
||||
max?: string | null
|
||||
/**
|
||||
@@ -631,10 +631,10 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
return new Value<AsRequired<string, Required>>(
|
||||
() => ({
|
||||
spec: {
|
||||
type: "datetime" as const,
|
||||
type: 'datetime' as const,
|
||||
description: null,
|
||||
warning: null,
|
||||
inputmode: "datetime-local",
|
||||
inputmode: 'datetime-local',
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
@@ -654,7 +654,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
warning?: string | null
|
||||
default: string | null
|
||||
required: Required
|
||||
inputmode?: ValueSpecDatetime["inputmode"]
|
||||
inputmode?: ValueSpecDatetime['inputmode']
|
||||
min?: string | null
|
||||
max?: string | null
|
||||
disabled?: false | string
|
||||
@@ -665,10 +665,10 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
const a = await getA(options)
|
||||
return {
|
||||
spec: {
|
||||
type: "datetime" as const,
|
||||
type: 'datetime' as const,
|
||||
description: null,
|
||||
warning: null,
|
||||
inputmode: "datetime-local",
|
||||
inputmode: 'datetime-local',
|
||||
min: null,
|
||||
max: null,
|
||||
disabled: false,
|
||||
@@ -740,7 +740,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
spec: {
|
||||
description: null,
|
||||
warning: null,
|
||||
type: "select" as const,
|
||||
type: 'select' as const,
|
||||
disabled: false,
|
||||
immutable: a.immutable ?? false,
|
||||
...a,
|
||||
@@ -766,7 +766,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
spec: {
|
||||
description: null,
|
||||
warning: null,
|
||||
type: "select" as const,
|
||||
type: 'select' as const,
|
||||
disabled: false,
|
||||
immutable: false,
|
||||
...a,
|
||||
@@ -837,7 +837,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
return new Value<(keyof Values & string)[]>(
|
||||
() => ({
|
||||
spec: {
|
||||
type: "multiselect" as const,
|
||||
type: 'multiselect' as const,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
warning: null,
|
||||
@@ -867,7 +867,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
const a = await getA(options)
|
||||
return {
|
||||
spec: {
|
||||
type: "multiselect" as const,
|
||||
type: 'multiselect' as const,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
warning: null,
|
||||
@@ -915,7 +915,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
const built = await spec.build(options as any)
|
||||
return {
|
||||
spec: {
|
||||
type: "object" as const,
|
||||
type: 'object' as const,
|
||||
description: null,
|
||||
warning: null,
|
||||
...a,
|
||||
@@ -933,7 +933,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
required: Required
|
||||
}) {
|
||||
const buildValue = {
|
||||
type: "file" as const,
|
||||
type: 'file' as const,
|
||||
description: null,
|
||||
warning: null,
|
||||
...a,
|
||||
@@ -960,7 +960,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
return new Value<AsRequired<FileInfo, Required>, FileInfo | null>(
|
||||
async (options) => {
|
||||
const spec = {
|
||||
type: "file" as const,
|
||||
type: 'file' as const,
|
||||
description: null,
|
||||
warning: null,
|
||||
...(await a(options)),
|
||||
@@ -1034,7 +1034,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
const built = await a.variants.build(options as any)
|
||||
return {
|
||||
spec: {
|
||||
type: "union" as const,
|
||||
type: 'union' as const,
|
||||
description: null,
|
||||
warning: null,
|
||||
disabled: false,
|
||||
@@ -1109,7 +1109,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
const built = await newValues.variants.build(options as any)
|
||||
return {
|
||||
spec: {
|
||||
type: "union" as const,
|
||||
type: 'union' as const,
|
||||
description: null,
|
||||
warning: null,
|
||||
...newValues,
|
||||
@@ -1202,7 +1202,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
return new Value<T, typeof parser._TYPE>(async () => {
|
||||
return {
|
||||
spec: {
|
||||
type: "hidden" as const,
|
||||
type: 'hidden' as const,
|
||||
} as ValueSpecHidden,
|
||||
validator: parser,
|
||||
}
|
||||
@@ -1221,7 +1221,7 @@ export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
|
||||
const validator = await getParser(options)
|
||||
return {
|
||||
spec: {
|
||||
type: "hidden" as const,
|
||||
type: 'hidden' as const,
|
||||
} as ValueSpecHidden,
|
||||
validator,
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { DeepPartial } from "../../../types"
|
||||
import { ValueSpec, ValueSpecUnion } from "../inputSpecTypes"
|
||||
import { DeepPartial } from '../../../types'
|
||||
import { ValueSpec, ValueSpecUnion } from '../inputSpecTypes'
|
||||
import {
|
||||
LazyBuild,
|
||||
InputSpec,
|
||||
ExtractInputSpecType,
|
||||
ExtractInputSpecStaticValidatedAs,
|
||||
} from "./inputSpec"
|
||||
import { Parser, any, anyOf, literal, object } from "ts-matches"
|
||||
} from './inputSpec'
|
||||
import { Parser, any, anyOf, literal, object } from 'ts-matches'
|
||||
|
||||
export type UnionRes<
|
||||
VariantValues extends {
|
||||
@@ -19,10 +19,10 @@ export type UnionRes<
|
||||
> = {
|
||||
[key in keyof VariantValues]: {
|
||||
selection: key
|
||||
value: ExtractInputSpecType<VariantValues[key]["spec"]>
|
||||
value: ExtractInputSpecType<VariantValues[key]['spec']>
|
||||
other?: {
|
||||
[key2 in Exclude<keyof VariantValues & string, key>]?: DeepPartial<
|
||||
ExtractInputSpecType<VariantValues[key2]["spec"]>
|
||||
ExtractInputSpecType<VariantValues[key2]['spec']>
|
||||
>
|
||||
}
|
||||
}
|
||||
@@ -39,10 +39,10 @@ export type UnionResStaticValidatedAs<
|
||||
> = {
|
||||
[key in keyof VariantValues]: {
|
||||
selection: key
|
||||
value: ExtractInputSpecStaticValidatedAs<VariantValues[key]["spec"]>
|
||||
value: ExtractInputSpecStaticValidatedAs<VariantValues[key]['spec']>
|
||||
other?: {
|
||||
[key2 in Exclude<keyof VariantValues & string, key>]?: DeepPartial<
|
||||
ExtractInputSpecStaticValidatedAs<VariantValues[key2]["spec"]>
|
||||
ExtractInputSpecStaticValidatedAs<VariantValues[key2]['spec']>
|
||||
>
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export class Variants<
|
||||
> {
|
||||
private constructor(
|
||||
public build: LazyBuild<{
|
||||
spec: ValueSpecUnion["variants"]
|
||||
spec: ValueSpecUnion['variants']
|
||||
validator: Parser<unknown, UnionRes<VariantValues>>
|
||||
}>,
|
||||
public readonly validator: Parser<
|
||||
@@ -126,7 +126,7 @@ export class Variants<
|
||||
const staticValidators = {} as {
|
||||
[K in keyof VariantValues]: Parser<
|
||||
unknown,
|
||||
ExtractInputSpecStaticValidatedAs<VariantValues[K]["spec"]>
|
||||
ExtractInputSpecStaticValidatedAs<VariantValues[K]['spec']>
|
||||
>
|
||||
}
|
||||
for (const key in a) {
|
||||
@@ -143,7 +143,7 @@ export class Variants<
|
||||
const validators = {} as {
|
||||
[K in keyof VariantValues]: Parser<
|
||||
unknown,
|
||||
ExtractInputSpecType<VariantValues[K]["spec"]>
|
||||
ExtractInputSpecType<VariantValues[K]['spec']>
|
||||
>
|
||||
}
|
||||
const variants = {} as {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export * as constants from "./inputSpecConstants"
|
||||
export * as types from "./inputSpecTypes"
|
||||
export * as builder from "./builder"
|
||||
export * as constants from './inputSpecConstants'
|
||||
export * as types from './inputSpecTypes'
|
||||
export * as builder from './builder'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { SmtpValue } from "../../types"
|
||||
import { GetSystemSmtp, Patterns } from "../../util"
|
||||
import { InputSpec, InputSpecOf } from "./builder/inputSpec"
|
||||
import { Value } from "./builder/value"
|
||||
import { Variants } from "./builder/variants"
|
||||
import { SmtpValue } from '../../types'
|
||||
import { GetSystemSmtp, Patterns } from '../../util'
|
||||
import { InputSpec, InputSpecOf } from './builder/inputSpec'
|
||||
import { Value } from './builder/value'
|
||||
import { Variants } from './builder/variants'
|
||||
|
||||
/**
|
||||
* Base SMTP settings, to be used by StartOS for system wide SMTP
|
||||
@@ -11,12 +11,12 @@ export const customSmtp: InputSpec<SmtpValue> = InputSpec.of<
|
||||
InputSpecOf<SmtpValue>
|
||||
>({
|
||||
server: Value.text({
|
||||
name: "SMTP Server",
|
||||
name: 'SMTP Server',
|
||||
required: true,
|
||||
default: null,
|
||||
}),
|
||||
port: Value.number({
|
||||
name: "Port",
|
||||
name: 'Port',
|
||||
required: true,
|
||||
default: 587,
|
||||
min: 1,
|
||||
@@ -24,20 +24,20 @@ export const customSmtp: InputSpec<SmtpValue> = InputSpec.of<
|
||||
integer: true,
|
||||
}),
|
||||
from: Value.text({
|
||||
name: "From Address",
|
||||
name: 'From Address',
|
||||
required: true,
|
||||
default: null,
|
||||
placeholder: "Example Name <test@example.com>",
|
||||
inputmode: "email",
|
||||
placeholder: 'Example Name <test@example.com>',
|
||||
inputmode: 'email',
|
||||
patterns: [Patterns.emailWithName],
|
||||
}),
|
||||
login: Value.text({
|
||||
name: "Login",
|
||||
name: 'Login',
|
||||
required: true,
|
||||
default: null,
|
||||
}),
|
||||
password: Value.text({
|
||||
name: "Password",
|
||||
name: 'Password',
|
||||
required: false,
|
||||
default: null,
|
||||
masked: true,
|
||||
@@ -45,24 +45,24 @@ export const customSmtp: InputSpec<SmtpValue> = InputSpec.of<
|
||||
})
|
||||
|
||||
const smtpVariants = Variants.of({
|
||||
disabled: { name: "Disabled", spec: InputSpec.of({}) },
|
||||
disabled: { name: 'Disabled', spec: InputSpec.of({}) },
|
||||
system: {
|
||||
name: "System Credentials",
|
||||
name: 'System Credentials',
|
||||
spec: InputSpec.of({
|
||||
customFrom: Value.text({
|
||||
name: "Custom From Address",
|
||||
name: 'Custom From Address',
|
||||
description:
|
||||
"A custom from address for this service. If not provided, the system from address will be used.",
|
||||
'A custom from address for this service. If not provided, the system from address will be used.',
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: "<name>test@example.com",
|
||||
inputmode: "email",
|
||||
placeholder: '<name>test@example.com',
|
||||
inputmode: 'email',
|
||||
patterns: [Patterns.email],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
custom: {
|
||||
name: "Custom Credentials",
|
||||
name: 'Custom Credentials',
|
||||
spec: customSmtp,
|
||||
},
|
||||
})
|
||||
@@ -71,11 +71,11 @@ const smtpVariants = Variants.of({
|
||||
*/
|
||||
export const smtpInputSpec = Value.dynamicUnion(async ({ effects }) => {
|
||||
const smtp = await new GetSystemSmtp(effects).once()
|
||||
const disabled = smtp ? [] : ["system"]
|
||||
const disabled = smtp ? [] : ['system']
|
||||
return {
|
||||
name: "SMTP",
|
||||
description: "Optionally provide an SMTP server for sending emails",
|
||||
default: "disabled",
|
||||
name: 'SMTP',
|
||||
description: 'Optionally provide an SMTP server for sending emails',
|
||||
default: 'disabled',
|
||||
disabled,
|
||||
variants: smtpVariants,
|
||||
}
|
||||
|
||||
@@ -1,417 +1,200 @@
|
||||
/**
|
||||
* @module inputSpecTypes
|
||||
*
|
||||
* This module defines the type specifications for action input form fields.
|
||||
* These types describe the shape of form fields that appear in the StartOS UI
|
||||
* when users interact with service actions.
|
||||
*
|
||||
* Developers typically don't create these types directly - instead, use the
|
||||
* `Value` class methods (e.g., `Value.text()`, `Value.select()`) which generate
|
||||
* these specifications with proper defaults and validation.
|
||||
*
|
||||
* @see {@link Value} for the builder API
|
||||
*/
|
||||
|
||||
/**
|
||||
* A complete input specification - a record mapping field names to their specifications.
|
||||
* This is the top-level type for an action's input form.
|
||||
*/
|
||||
export type InputSpec = Record<string, ValueSpec>
|
||||
|
||||
/**
|
||||
* All available input field types.
|
||||
*
|
||||
* - `text` - Single-line text input
|
||||
* - `textarea` - Multi-line text input
|
||||
* - `number` - Numeric input with optional min/max/step
|
||||
* - `color` - Color picker
|
||||
* - `datetime` - Date and/or time picker
|
||||
* - `toggle` - Boolean on/off switch
|
||||
* - `select` - Single-selection dropdown/radio
|
||||
* - `multiselect` - Multiple-selection checkboxes
|
||||
* - `list` - Dynamic list of items (text or objects)
|
||||
* - `object` - Nested group of fields (sub-form)
|
||||
* - `file` - File upload
|
||||
* - `union` - Conditional fields based on selection (discriminated union)
|
||||
* - `hidden` - Hidden field (not displayed to user)
|
||||
*/
|
||||
export type ValueType =
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "number"
|
||||
| "color"
|
||||
| "datetime"
|
||||
| "toggle"
|
||||
| "select"
|
||||
| "multiselect"
|
||||
| "list"
|
||||
| "object"
|
||||
| "file"
|
||||
| "union"
|
||||
| "hidden"
|
||||
|
||||
/** Union type of all possible value specifications */
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'number'
|
||||
| 'color'
|
||||
| 'datetime'
|
||||
| 'toggle'
|
||||
| 'select'
|
||||
| 'multiselect'
|
||||
| 'list'
|
||||
| 'object'
|
||||
| 'file'
|
||||
| 'union'
|
||||
| 'hidden'
|
||||
export type ValueSpec = ValueSpecOf<ValueType>
|
||||
|
||||
/**
|
||||
* Maps a ValueType to its corresponding specification type.
|
||||
* Core spec types that provide metadata for validation and UI rendering.
|
||||
*/
|
||||
/** core spec types. These types provide the metadata for performing validations */
|
||||
// prettier-ignore
|
||||
export type ValueSpecOf<T extends ValueType> =
|
||||
T extends "text" ? ValueSpecText :
|
||||
T extends "textarea" ? ValueSpecTextarea :
|
||||
T extends "number" ? ValueSpecNumber :
|
||||
T extends "color" ? ValueSpecColor :
|
||||
T extends "datetime" ? ValueSpecDatetime :
|
||||
T extends "toggle" ? ValueSpecToggle :
|
||||
T extends "select" ? ValueSpecSelect :
|
||||
T extends "multiselect" ? ValueSpecMultiselect :
|
||||
T extends "list" ? ValueSpecList :
|
||||
T extends "object" ? ValueSpecObject :
|
||||
T extends "file" ? ValueSpecFile :
|
||||
T extends "union" ? ValueSpecUnion :
|
||||
export type ValueSpecOf<T extends ValueType> =
|
||||
T extends "text" ? ValueSpecText :
|
||||
T extends "textarea" ? ValueSpecTextarea :
|
||||
T extends "number" ? ValueSpecNumber :
|
||||
T extends "color" ? ValueSpecColor :
|
||||
T extends "datetime" ? ValueSpecDatetime :
|
||||
T extends "toggle" ? ValueSpecToggle :
|
||||
T extends "select" ? ValueSpecSelect :
|
||||
T extends "multiselect" ? ValueSpecMultiselect :
|
||||
T extends "list" ? ValueSpecList :
|
||||
T extends "object" ? ValueSpecObject :
|
||||
T extends "file" ? ValueSpecFile :
|
||||
T extends "union" ? ValueSpecUnion :
|
||||
T extends "hidden" ? ValueSpecHidden :
|
||||
never
|
||||
|
||||
/**
|
||||
* Specification for a single-line text input field.
|
||||
* Use `Value.text()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecText = {
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes (requires user confirmation) */
|
||||
warning: string | null
|
||||
|
||||
type: "text"
|
||||
/** Regex patterns the value must match, with descriptions for validation errors */
|
||||
type: 'text'
|
||||
patterns: Pattern[]
|
||||
/** Minimum character length */
|
||||
minLength: number | null
|
||||
/** Maximum character length */
|
||||
maxLength: number | null
|
||||
/** If true, displays input as dots (●●●) for sensitive data like passwords */
|
||||
masked: boolean
|
||||
|
||||
/** Browser input mode hint for mobile keyboards */
|
||||
inputmode: "text" | "email" | "tel" | "url"
|
||||
/** Placeholder text shown when the field is empty */
|
||||
inputmode: 'text' | 'email' | 'tel' | 'url'
|
||||
placeholder: string | null
|
||||
|
||||
/** If true, the field cannot be left empty */
|
||||
required: boolean
|
||||
/** Default value (can be a string or random string generator) */
|
||||
default: DefaultString | null
|
||||
/** If string, the field is disabled with this message explaining why */
|
||||
disabled: false | string
|
||||
/** Configuration for "Generate" button that creates random strings */
|
||||
generate: null | RandomString
|
||||
/** If true, the value cannot be changed after initial set */
|
||||
immutable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a multi-line text area input field.
|
||||
* Use `Value.textarea()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecTextarea = {
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes */
|
||||
warning: string | null
|
||||
|
||||
type: "textarea"
|
||||
/** Regex patterns the value must match */
|
||||
type: 'textarea'
|
||||
patterns: Pattern[]
|
||||
/** Placeholder text shown when the field is empty */
|
||||
placeholder: string | null
|
||||
/** Minimum character length */
|
||||
minLength: number | null
|
||||
/** Maximum character length */
|
||||
maxLength: number | null
|
||||
/** Minimum visible rows before scrolling */
|
||||
minRows: number
|
||||
/** Maximum visible rows before scrolling */
|
||||
maxRows: number
|
||||
/** If true, the field cannot be left empty */
|
||||
required: boolean
|
||||
/** Default value */
|
||||
default: string | null
|
||||
/** If string, the field is disabled with this message */
|
||||
disabled: false | string
|
||||
/** If true, the value cannot be changed after initial set */
|
||||
immutable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a numeric input field.
|
||||
* Use `Value.number()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecNumber = {
|
||||
type: "number"
|
||||
/** Minimum allowed value */
|
||||
type: 'number'
|
||||
min: number | null
|
||||
/** Maximum allowed value */
|
||||
max: number | null
|
||||
/** If true, only whole numbers are allowed */
|
||||
integer: boolean
|
||||
/** Increment/decrement step for arrow controls */
|
||||
step: number | null
|
||||
/** Unit label displayed after the input (e.g., "MB", "seconds") */
|
||||
units: string | null
|
||||
/** Placeholder text shown when the field is empty */
|
||||
placeholder: string | null
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes */
|
||||
warning: string | null
|
||||
/** If true, the field cannot be left empty */
|
||||
required: boolean
|
||||
/** Default value */
|
||||
default: number | null
|
||||
/** If string, the field is disabled with this message */
|
||||
disabled: false | string
|
||||
/** If true, the value cannot be changed after initial set */
|
||||
immutable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a color picker field.
|
||||
* Use `Value.color()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecColor = {
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes */
|
||||
warning: string | null
|
||||
|
||||
type: "color"
|
||||
/** If true, a color must be selected */
|
||||
type: 'color'
|
||||
required: boolean
|
||||
/** Default color value (hex format, e.g., "ffffff") */
|
||||
default: string | null
|
||||
/** If string, the field is disabled with this message */
|
||||
disabled: false | string
|
||||
/** If true, the value cannot be changed after initial set */
|
||||
immutable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a date/time picker field.
|
||||
* Use `Value.datetime()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecDatetime = {
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes */
|
||||
warning: string | null
|
||||
type: "datetime"
|
||||
/** If true, the field cannot be left empty */
|
||||
type: 'datetime'
|
||||
required: boolean
|
||||
/** Type of datetime picker to display */
|
||||
inputmode: "date" | "time" | "datetime-local"
|
||||
/** Minimum allowed date/time */
|
||||
inputmode: 'date' | 'time' | 'datetime-local'
|
||||
min: string | null
|
||||
/** Maximum allowed date/time */
|
||||
max: string | null
|
||||
/** Default value */
|
||||
default: string | null
|
||||
/** If string, the field is disabled with this message */
|
||||
disabled: false | string
|
||||
/** If true, the value cannot be changed after initial set */
|
||||
immutable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a single-selection dropdown or radio button group.
|
||||
* Use `Value.select()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecSelect = {
|
||||
/** Map of option values to their display labels */
|
||||
values: Record<string, string>
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes */
|
||||
warning: string | null
|
||||
type: "select"
|
||||
/** Default selected option key */
|
||||
type: 'select'
|
||||
default: string | null
|
||||
/** Disabled state: false=enabled, string=disabled with message, string[]=specific options disabled */
|
||||
disabled: false | string | string[]
|
||||
/** If true, the value cannot be changed after initial set */
|
||||
immutable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a multiple-selection checkbox group.
|
||||
* Use `Value.multiselect()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecMultiselect = {
|
||||
/** Map of option values to their display labels */
|
||||
values: Record<string, string>
|
||||
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes */
|
||||
warning: string | null
|
||||
|
||||
type: "multiselect"
|
||||
/** Minimum number of selections required */
|
||||
type: 'multiselect'
|
||||
minLength: number | null
|
||||
/** Maximum number of selections allowed */
|
||||
maxLength: number | null
|
||||
/** Disabled state: false=enabled, string=disabled with message, string[]=specific options disabled */
|
||||
disabled: false | string | string[]
|
||||
/** Default selected option keys */
|
||||
default: string[]
|
||||
/** If true, the value cannot be changed after initial set */
|
||||
immutable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a boolean toggle switch.
|
||||
* Use `Value.toggle()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecToggle = {
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes */
|
||||
warning: string | null
|
||||
|
||||
type: "toggle"
|
||||
/** Default value (on/off) */
|
||||
type: 'toggle'
|
||||
default: boolean | null
|
||||
/** If string, the field is disabled with this message */
|
||||
disabled: false | string
|
||||
/** If true, the value cannot be changed after initial set */
|
||||
immutable: boolean
|
||||
}
|
||||
/**
|
||||
* Specification for a discriminated union field (conditional sub-forms).
|
||||
* Shows different fields based on which variant is selected.
|
||||
* Use `Value.union()` with `Variants.of()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecUnion = {
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes */
|
||||
warning: string | null
|
||||
|
||||
type: "union"
|
||||
/** Map of variant keys to their display names and nested field specifications */
|
||||
type: 'union'
|
||||
variants: Record<
|
||||
string,
|
||||
{
|
||||
/** Display name for this variant option */
|
||||
name: string
|
||||
/** Fields to show when this variant is selected */
|
||||
spec: InputSpec
|
||||
}
|
||||
>
|
||||
/** Disabled state: false=enabled, string=disabled with message, string[]=specific variants disabled */
|
||||
disabled: false | string | string[]
|
||||
/** Default selected variant key */
|
||||
default: string | null
|
||||
/** If true, the value cannot be changed after initial set */
|
||||
immutable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a file upload field.
|
||||
* Use `Value.file()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecFile = {
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes */
|
||||
warning: string | null
|
||||
type: "file"
|
||||
/** Allowed file extensions (e.g., [".json", ".yaml"]) */
|
||||
type: 'file'
|
||||
extensions: string[]
|
||||
/** If true, a file must be uploaded */
|
||||
required: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a nested object (sub-form / field group).
|
||||
* Use `Value.object()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecObject = {
|
||||
/** Display label for the field group */
|
||||
name: string
|
||||
/** Help text shown below the field group */
|
||||
description: string | null
|
||||
/** Warning message (not typically used for objects) */
|
||||
warning: string | null
|
||||
type: "object"
|
||||
/** Nested field specifications */
|
||||
type: 'object'
|
||||
spec: InputSpec
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a hidden field (not displayed in the UI).
|
||||
* Use `Value.hidden()` to create this specification.
|
||||
* Useful for storing internal state that shouldn't be user-editable.
|
||||
*/
|
||||
export type ValueSpecHidden = {
|
||||
type: "hidden"
|
||||
type: 'hidden'
|
||||
}
|
||||
|
||||
/** Types of items that can appear in a list */
|
||||
export type ListValueSpecType = "text" | "object"
|
||||
|
||||
/** Maps a list item type to its specification */
|
||||
export type ListValueSpecType = 'text' | 'object'
|
||||
// prettier-ignore
|
||||
export type ListValueSpecOf<T extends ListValueSpecType> =
|
||||
export type ListValueSpecOf<T extends ListValueSpecType> =
|
||||
T extends "text" ? ListValueSpecText :
|
||||
T extends "object" ? ListValueSpecObject :
|
||||
never
|
||||
|
||||
/** Union of all list specification types */
|
||||
export type ValueSpecList = ValueSpecListOf<ListValueSpecType>
|
||||
|
||||
/**
|
||||
* Specification for a dynamic list of items.
|
||||
* Use `Value.list()` with `List.text()` or `List.obj()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecListOf<T extends ListValueSpecType> = {
|
||||
/** Display label for the list field */
|
||||
name: string
|
||||
/** Help text shown below the list */
|
||||
description: string | null
|
||||
/** Warning message shown when items change */
|
||||
warning: string | null
|
||||
type: "list"
|
||||
/** Specification for individual list items */
|
||||
type: 'list'
|
||||
spec: ListValueSpecOf<T>
|
||||
/** Minimum number of items required */
|
||||
minLength: number | null
|
||||
/** Maximum number of items allowed */
|
||||
maxLength: number | null
|
||||
/** If string, the list is disabled with this message */
|
||||
disabled: false | string
|
||||
/** Default list items */
|
||||
default:
|
||||
| string[]
|
||||
| DefaultString[]
|
||||
@@ -420,62 +203,28 @@ export type ValueSpecListOf<T extends ListValueSpecType> = {
|
||||
| readonly DefaultString[]
|
||||
| readonly Record<string, unknown>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A validation pattern with a regex and human-readable description.
|
||||
* Used to validate text input and provide meaningful error messages.
|
||||
*/
|
||||
export type Pattern = {
|
||||
/** Regular expression pattern (as a string) */
|
||||
regex: string
|
||||
/** Human-readable description shown when validation fails */
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for text items within a list.
|
||||
* Created via `List.text()`.
|
||||
*/
|
||||
export type ListValueSpecText = {
|
||||
type: "text"
|
||||
/** Regex patterns each item must match */
|
||||
type: 'text'
|
||||
patterns: Pattern[]
|
||||
/** Minimum character length per item */
|
||||
minLength: number | null
|
||||
/** Maximum character length per item */
|
||||
maxLength: number | null
|
||||
/** If true, displays items as dots (●●●) */
|
||||
masked: boolean
|
||||
|
||||
/** Configuration for "Generate" button */
|
||||
generate: null | RandomString
|
||||
/** Browser input mode hint */
|
||||
inputmode: "text" | "email" | "tel" | "url"
|
||||
/** Placeholder text for each item */
|
||||
inputmode: 'text' | 'email' | 'tel' | 'url'
|
||||
placeholder: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for object items within a list.
|
||||
* Created via `List.obj()`.
|
||||
*/
|
||||
export type ListValueSpecObject = {
|
||||
type: "object"
|
||||
/** Field specification for each object in the list */
|
||||
type: 'object'
|
||||
spec: InputSpec
|
||||
/** Constraint for ensuring unique items in the list */
|
||||
uniqueBy: UniqueBy
|
||||
/** Template string for how to display each item in the list (e.g., "{name} - {email}") */
|
||||
displayAs: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines how to determine uniqueness for list items.
|
||||
* - `null` - No uniqueness constraint
|
||||
* - `string` - Field name that must be unique (e.g., "email")
|
||||
* - `{ any: UniqueBy[] }` - Any of the specified constraints must be unique
|
||||
* - `{ all: UniqueBy[] }` - All of the specified constraints combined must be unique
|
||||
*/
|
||||
export type UniqueBy =
|
||||
| null
|
||||
| string
|
||||
@@ -485,33 +234,15 @@ export type UniqueBy =
|
||||
| {
|
||||
all: readonly UniqueBy[] | UniqueBy[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Default value for a text field - either a literal string or a random string generator.
|
||||
*/
|
||||
export type DefaultString = string | RandomString
|
||||
|
||||
/**
|
||||
* Configuration for generating random strings (e.g., passwords, tokens).
|
||||
* Used with `Value.text({ generate: ... })` to show a "Generate" button.
|
||||
*/
|
||||
export type RandomString = {
|
||||
/** Characters to use when generating (e.g., "abcdefghijklmnopqrstuvwxyz0123456789") */
|
||||
charset: string
|
||||
/** Length of the generated string */
|
||||
len: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a ValueSpec is a list of a specific item type.
|
||||
*
|
||||
* @param t - The value specification to check
|
||||
* @param s - The expected list item type ("text" or "object")
|
||||
* @returns True if the spec is a list of the specified type
|
||||
*/
|
||||
// sometimes the type checker needs just a little bit of help
|
||||
export function isValueSpecListOf<S extends ListValueSpecType>(
|
||||
t: ValueSpec,
|
||||
s: S,
|
||||
): t is ValueSpecListOf<S> & { spec: ListValueSpecOf<S> } {
|
||||
return "spec" in t && t.spec.type === s
|
||||
return 'spec' in t && t.spec.type === s
|
||||
}
|
||||
|
||||
@@ -1,86 +1,20 @@
|
||||
/**
|
||||
* @module setupActions
|
||||
*
|
||||
* This module provides the Action and Actions classes for defining user-callable
|
||||
* operations in StartOS services. Actions appear in the StartOS UI and can be
|
||||
* triggered by users or programmatically by other services.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Action, Actions, InputSpec, Value } from '@start9labs/start-sdk'
|
||||
*
|
||||
* const resetPasswordAction = Action.withInput(
|
||||
* 'reset-password',
|
||||
* { name: 'Reset Password', description: 'Reset the admin password' },
|
||||
* InputSpec.of({
|
||||
* username: Value.text({ name: 'Username', required: true, default: null })
|
||||
* }),
|
||||
* async ({ effects }) => ({ username: 'admin' }), // Pre-fill form
|
||||
* async ({ effects, input }) => {
|
||||
* // Perform the password reset
|
||||
* return { result: { type: 'single', value: 'Password reset successfully' } }
|
||||
* }
|
||||
* )
|
||||
*
|
||||
* export const actions = Actions.of().addAction(resetPasswordAction)
|
||||
* ```
|
||||
*/
|
||||
import { InputSpec } from './input/builder'
|
||||
import { ExtractInputSpecType } from './input/builder/inputSpec'
|
||||
import * as T from '../types'
|
||||
import { once } from '../util'
|
||||
import { InitScript } from '../inits'
|
||||
import { Parser } from 'ts-matches'
|
||||
|
||||
import { InputSpec } from "./input/builder"
|
||||
import { ExtractInputSpecType } from "./input/builder/inputSpec"
|
||||
import * as T from "../types"
|
||||
import { once } from "../util"
|
||||
import { InitScript } from "../inits"
|
||||
import { Parser } from "ts-matches"
|
||||
|
||||
/** @internal Input spec type or null if the action has no input */
|
||||
type MaybeInputSpec<Type> = {} extends Type ? null : InputSpec<Type>
|
||||
|
||||
/**
|
||||
* Function signature for executing an action.
|
||||
*
|
||||
* @typeParam A - The type of the validated input object
|
||||
* @param options.effects - Effects instance for system operations
|
||||
* @param options.input - The validated user input
|
||||
* @param options.spec - The input specification used to generate the form
|
||||
* @returns Promise resolving to an ActionResult to display to the user, or null/void for no result
|
||||
*/
|
||||
export type Run<A extends Record<string, any>> = (options: {
|
||||
effects: T.Effects
|
||||
input: A
|
||||
spec: T.inputSpecTypes.InputSpec
|
||||
}) => Promise<(T.ActionResult & { version: "1" }) | null | void | undefined>
|
||||
|
||||
/**
|
||||
* Function signature for pre-filling action input forms.
|
||||
* Called before displaying the input form to populate default values.
|
||||
*
|
||||
* @typeParam A - The type of the input object
|
||||
* @param options.effects - Effects instance for system operations
|
||||
* @returns Promise resolving to partial input values to pre-fill, or null for no pre-fill
|
||||
*/
|
||||
}) => Promise<(T.ActionResult & { version: '1' }) | null | void | undefined>
|
||||
export type GetInput<A extends Record<string, any>> = (options: {
|
||||
effects: T.Effects
|
||||
}) => Promise<null | void | undefined | T.DeepPartial<A>>
|
||||
|
||||
/**
|
||||
* A value that can either be static or computed dynamically from Effects.
|
||||
* Used for action metadata that may need to change based on service state.
|
||||
*
|
||||
* @typeParam T - The type of the value
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Static metadata
|
||||
* const metadata: MaybeFn<ActionMetadata> = { name: 'My Action' }
|
||||
*
|
||||
* // Dynamic metadata based on service state
|
||||
* const dynamicMetadata: MaybeFn<ActionMetadata> = async ({ effects }) => {
|
||||
* const isEnabled = await checkSomething(effects)
|
||||
* return { name: isEnabled ? 'Disable Feature' : 'Enable Feature' }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type MaybeFn<T> = T | ((options: { effects: T.Effects }) => Promise<T>)
|
||||
function callMaybeFn<T>(
|
||||
maybeFn: MaybeFn<T>,
|
||||
@@ -103,82 +37,35 @@ function mapMaybeFn<T, U>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type information interface for an Action.
|
||||
* Used for type inference in the Actions collection.
|
||||
*
|
||||
* @typeParam Id - The action's unique identifier type
|
||||
* @typeParam Type - The action's input type
|
||||
*/
|
||||
export interface ActionInfo<
|
||||
Id extends T.ActionId,
|
||||
Type extends Record<string, any>,
|
||||
> {
|
||||
/** The unique identifier for this action */
|
||||
readonly id: Id
|
||||
/** @internal Type brand for input type inference */
|
||||
readonly _INPUT: Type
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a user-callable action in a StartOS service.
|
||||
*
|
||||
* Exposed via `sdk.Action`. Actions are operations that users can trigger
|
||||
* from the StartOS UI or that can be invoked programmatically. Each action has:
|
||||
* - A unique ID
|
||||
* - Metadata (name, description, visibility, etc.)
|
||||
* - Optional input specification (form fields)
|
||||
* - A run function that executes the action
|
||||
*
|
||||
* Use `sdk.Action.withInput()` for actions that require user input, or
|
||||
* `sdk.Action.withoutInput()` for actions that run immediately.
|
||||
*
|
||||
* See the SDK documentation for detailed examples.
|
||||
*
|
||||
* @typeParam Id - The action's unique identifier type
|
||||
* @typeParam Type - The action's input type (empty object {} for no input)
|
||||
*/
|
||||
export class Action<Id extends T.ActionId, Type extends Record<string, any>>
|
||||
implements ActionInfo<Id, Type>
|
||||
{
|
||||
/** @internal Type brand for input type inference */
|
||||
readonly _INPUT: Type = null as any as Type
|
||||
|
||||
/** @internal Cache of built input specs by event ID */
|
||||
private prevInputSpec: Record<
|
||||
string,
|
||||
{ spec: T.inputSpecTypes.InputSpec; validator: Parser<unknown, Type> }
|
||||
> = {}
|
||||
|
||||
private constructor(
|
||||
/** The unique identifier for this action */
|
||||
readonly id: Id,
|
||||
private readonly metadataFn: MaybeFn<T.ActionMetadata>,
|
||||
private readonly inputSpec: MaybeInputSpec<Type>,
|
||||
private readonly getInputFn: GetInput<Type>,
|
||||
private readonly runFn: Run<Type>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates an action that requires user input before execution.
|
||||
* The input form is defined by an InputSpec.
|
||||
*
|
||||
* @typeParam Id - The action ID type
|
||||
* @typeParam InputSpecType - The input specification type
|
||||
*
|
||||
* @param id - Unique identifier for the action (used in URLs and API calls)
|
||||
* @param metadata - Action metadata (name, description, visibility, etc.) - can be static or dynamic
|
||||
* @param inputSpec - Specification for the input form fields
|
||||
* @param getInput - Function to pre-populate the form with default/previous values
|
||||
* @param run - Function to execute when the action is submitted
|
||||
* @returns A new Action instance
|
||||
*/
|
||||
static withInput<
|
||||
Id extends T.ActionId,
|
||||
InputSpecType extends InputSpec<Record<string, any>>,
|
||||
>(
|
||||
id: Id,
|
||||
metadata: MaybeFn<Omit<T.ActionMetadata, "hasInput">>,
|
||||
metadata: MaybeFn<Omit<T.ActionMetadata, 'hasInput'>>,
|
||||
inputSpec: InputSpecType,
|
||||
getInput: GetInput<ExtractInputSpecType<InputSpecType>>,
|
||||
run: Run<ExtractInputSpecType<InputSpecType>>,
|
||||
@@ -191,21 +78,9 @@ export class Action<Id extends T.ActionId, Type extends Record<string, any>>
|
||||
run,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an action that executes immediately without requiring user input.
|
||||
* Use this for simple operations like toggles, restarts, or status checks.
|
||||
*
|
||||
* @typeParam Id - The action ID type
|
||||
*
|
||||
* @param id - Unique identifier for the action
|
||||
* @param metadata - Action metadata (name, description, visibility, etc.) - can be static or dynamic
|
||||
* @param run - Function to execute when the action is triggered
|
||||
* @returns A new Action instance with no input
|
||||
*/
|
||||
static withoutInput<Id extends T.ActionId>(
|
||||
id: Id,
|
||||
metadata: MaybeFn<Omit<T.ActionMetadata, "hasInput">>,
|
||||
metadata: MaybeFn<Omit<T.ActionMetadata, 'hasInput'>>,
|
||||
run: Run<{}>,
|
||||
): Action<Id, {}> {
|
||||
return new Action(
|
||||
@@ -216,14 +91,6 @@ export class Action<Id extends T.ActionId, Type extends Record<string, any>>
|
||||
run,
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Exports the action's metadata to StartOS, making it visible in the UI.
|
||||
* Called automatically during initialization by the Actions collection.
|
||||
*
|
||||
* @param options.effects - Effects instance for system operations
|
||||
* @returns Promise resolving to the exported metadata
|
||||
* @internal
|
||||
*/
|
||||
async exportMetadata(options: {
|
||||
effects: T.Effects
|
||||
}): Promise<T.ActionMetadata> {
|
||||
@@ -237,15 +104,6 @@ export class Action<Id extends T.ActionId, Type extends Record<string, any>>
|
||||
await options.effects.action.export({ id: this.id, metadata })
|
||||
return metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and returns the input specification and pre-filled values for this action.
|
||||
* Called by StartOS when a user clicks on the action to display the input form.
|
||||
*
|
||||
* @param options.effects - Effects instance for system operations
|
||||
* @returns Promise resolving to the input specification and pre-filled values
|
||||
* @internal
|
||||
*/
|
||||
async getInput(options: { effects: T.Effects }): Promise<T.ActionInput> {
|
||||
let spec = {}
|
||||
if (this.inputSpec) {
|
||||
@@ -263,16 +121,6 @@ export class Action<Id extends T.ActionId, Type extends Record<string, any>>
|
||||
| undefined) || null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the action with the provided input.
|
||||
* Called by StartOS when a user submits the action form.
|
||||
*
|
||||
* @param options.effects - Effects instance for system operations
|
||||
* @param options.input - The user-provided input (validated against the input spec)
|
||||
* @returns Promise resolving to the action result to display, or null for no result
|
||||
* @internal
|
||||
*/
|
||||
async run(options: {
|
||||
effects: T.Effects
|
||||
input: Type
|
||||
@@ -298,77 +146,19 @@ export class Action<Id extends T.ActionId, Type extends Record<string, any>>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A collection of actions for a StartOS service.
|
||||
*
|
||||
* Exposed via `sdk.Actions`. The Actions class manages the registration and
|
||||
* lifecycle of all actions in a service. It implements InitScript so it can
|
||||
* be included in the initialization pipeline to automatically register actions
|
||||
* with StartOS.
|
||||
*
|
||||
* @typeParam AllActions - Record type mapping action IDs to Action instances
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Create an actions collection
|
||||
* export const actions = sdk.Actions.of()
|
||||
* .addAction(createUserAction)
|
||||
* .addAction(resetPasswordAction)
|
||||
* .addAction(restartAction)
|
||||
*
|
||||
* // Include in init pipeline
|
||||
* export const init = sdk.setupInit(
|
||||
* versionGraph,
|
||||
* setInterfaces,
|
||||
* actions, // Actions are registered here
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export class Actions<
|
||||
AllActions extends Record<T.ActionId, Action<T.ActionId, any>>,
|
||||
> implements InitScript
|
||||
{
|
||||
private constructor(private readonly actions: AllActions) {}
|
||||
|
||||
/**
|
||||
* Creates a new empty Actions collection.
|
||||
* Use `addAction()` to add actions to the collection.
|
||||
*
|
||||
* @returns A new empty Actions instance
|
||||
*/
|
||||
static of(): Actions<{}> {
|
||||
return new Actions({})
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an action to the collection.
|
||||
* Returns a new Actions instance with the action included (immutable pattern).
|
||||
*
|
||||
* @typeParam A - The action type being added
|
||||
* @param action - The action to add
|
||||
* @returns A new Actions instance containing all previous actions plus the new one
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const actions = Actions.of()
|
||||
* .addAction(action1)
|
||||
* .addAction(action2)
|
||||
* ```
|
||||
*/
|
||||
addAction<A extends Action<T.ActionId, any>>(
|
||||
action: A, // TODO: prevent duplicates
|
||||
): Actions<AllActions & { [id in A["id"]]: A }> {
|
||||
): Actions<AllActions & { [id in A['id']]: A }> {
|
||||
return new Actions({ ...this.actions, [action.id]: action })
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes all actions by exporting their metadata to StartOS.
|
||||
* Called automatically when included in the init pipeline.
|
||||
* Also clears any previously registered actions that are no longer in the collection.
|
||||
*
|
||||
* @param effects - Effects instance for system operations
|
||||
* @internal
|
||||
*/
|
||||
async init(effects: T.Effects): Promise<void> {
|
||||
for (let action of Object.values(this.actions)) {
|
||||
const fn = async () => {
|
||||
@@ -390,15 +180,6 @@ export class Actions<
|
||||
}
|
||||
await effects.action.clear({ except: Object.keys(this.actions) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an action from the collection by its ID.
|
||||
* Useful for programmatically invoking actions or inspecting their configuration.
|
||||
*
|
||||
* @typeParam Id - The action ID type
|
||||
* @param actionId - The ID of the action to retrieve
|
||||
* @returns The action instance
|
||||
*/
|
||||
get<Id extends T.ActionId>(actionId: Id): AllActions[Id] {
|
||||
return this.actions[actionId]
|
||||
}
|
||||
|
||||
@@ -1,181 +1,38 @@
|
||||
/**
|
||||
* @module dependencies
|
||||
*
|
||||
* This module provides utilities for checking whether service dependencies are satisfied.
|
||||
* Use `checkDependencies()` to get a helper object with methods for querying and
|
||||
* validating dependency status.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const deps = await checkDependencies(effects, ['bitcoind', 'lnd'])
|
||||
*
|
||||
* // Check if all dependencies are satisfied
|
||||
* if (deps.satisfied()) {
|
||||
* // All good, proceed
|
||||
* }
|
||||
*
|
||||
* // Or throw an error with details if not satisfied
|
||||
* deps.throwIfNotSatisfied()
|
||||
*
|
||||
* // Check specific aspects
|
||||
* if (deps.installedSatisfied('bitcoind') && deps.runningSatisfied('bitcoind')) {
|
||||
* // bitcoind is installed and running
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { ExtendedVersion, VersionRange } from "../exver"
|
||||
import { ExtendedVersion, VersionRange } from '../exver'
|
||||
import {
|
||||
PackageId,
|
||||
HealthCheckId,
|
||||
DependencyRequirement,
|
||||
CheckDependenciesResult,
|
||||
} from "../types"
|
||||
import { Effects } from "../Effects"
|
||||
} from '../types'
|
||||
import { Effects } from '../Effects'
|
||||
|
||||
/**
|
||||
* Interface providing methods to check and validate dependency satisfaction.
|
||||
* Returned by `checkDependencies()`.
|
||||
*
|
||||
* @typeParam DependencyId - The type of package IDs being checked
|
||||
*/
|
||||
export type CheckDependencies<DependencyId extends PackageId = PackageId> = {
|
||||
/**
|
||||
* Gets the requirement and current result for a specific dependency.
|
||||
* @param packageId - The package ID to query
|
||||
* @returns Object containing the requirement spec and check result
|
||||
* @throws Error if the packageId is not a known dependency
|
||||
*/
|
||||
infoFor: (packageId: DependencyId) => {
|
||||
requirement: DependencyRequirement
|
||||
result: CheckDependenciesResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a dependency is installed (regardless of version).
|
||||
* @param packageId - The package ID to check
|
||||
* @returns True if the package is installed
|
||||
*/
|
||||
installedSatisfied: (packageId: DependencyId) => boolean
|
||||
|
||||
/**
|
||||
* Checks if a dependency is installed with a satisfying version.
|
||||
* @param packageId - The package ID to check
|
||||
* @returns True if the installed version satisfies the version range requirement
|
||||
*/
|
||||
installedVersionSatisfied: (packageId: DependencyId) => boolean
|
||||
|
||||
/**
|
||||
* Checks if a "running" dependency is actually running.
|
||||
* Always returns true for "exists" dependencies.
|
||||
* @param packageId - The package ID to check
|
||||
* @returns True if the dependency is running (or only needs to exist)
|
||||
*/
|
||||
runningSatisfied: (packageId: DependencyId) => boolean
|
||||
|
||||
/**
|
||||
* Checks if all critical tasks for a dependency have been completed.
|
||||
* @param packageId - The package ID to check
|
||||
* @returns True if no critical tasks are pending
|
||||
*/
|
||||
tasksSatisfied: (packageId: DependencyId) => boolean
|
||||
|
||||
/**
|
||||
* Checks if specified health checks are passing for a dependency.
|
||||
* @param packageId - The package ID to check
|
||||
* @param healthCheckId - Specific health check to verify (optional - checks all if omitted)
|
||||
* @returns True if the health check(s) are passing
|
||||
*/
|
||||
healthCheckSatisfied: (
|
||||
packageId: DependencyId,
|
||||
healthCheckId: HealthCheckId,
|
||||
) => boolean
|
||||
|
||||
/**
|
||||
* Checks if all dependencies are fully satisfied.
|
||||
* @returns True if all dependencies meet all requirements
|
||||
*/
|
||||
satisfied: () => boolean
|
||||
|
||||
/**
|
||||
* Throws an error if the dependency is not installed.
|
||||
* @param packageId - The package ID to check
|
||||
* @throws Error with message if not installed
|
||||
*/
|
||||
throwIfInstalledNotSatisfied: (packageId: DependencyId) => null
|
||||
|
||||
/**
|
||||
* Throws an error if the installed version doesn't satisfy requirements.
|
||||
* @param packageId - The package ID to check
|
||||
* @throws Error with version mismatch details if not satisfied
|
||||
*/
|
||||
throwIfInstalledVersionNotSatisfied: (packageId: DependencyId) => null
|
||||
|
||||
/**
|
||||
* Throws an error if a "running" dependency is not running.
|
||||
* @param packageId - The package ID to check
|
||||
* @throws Error if the dependency should be running but isn't
|
||||
*/
|
||||
throwIfRunningNotSatisfied: (packageId: DependencyId) => null
|
||||
|
||||
/**
|
||||
* Throws an error if critical tasks are pending for the dependency.
|
||||
* @param packageId - The package ID to check
|
||||
* @throws Error listing pending critical tasks
|
||||
*/
|
||||
throwIfTasksNotSatisfied: (packageId: DependencyId) => null
|
||||
|
||||
/**
|
||||
* Throws an error if health checks are failing for the dependency.
|
||||
* @param packageId - The package ID to check
|
||||
* @param healthCheckId - Specific health check (optional - checks all if omitted)
|
||||
* @throws Error with health check failure details
|
||||
*/
|
||||
throwIfHealthNotSatisfied: (
|
||||
packageId: DependencyId,
|
||||
healthCheckId?: HealthCheckId,
|
||||
) => null
|
||||
|
||||
/**
|
||||
* Throws an error if any requirements are not satisfied.
|
||||
* @param packageId - Specific package to check (optional - checks all if omitted)
|
||||
* @throws Error with detailed message about what's not satisfied
|
||||
*/
|
||||
throwIfNotSatisfied: (packageId?: DependencyId) => null
|
||||
}
|
||||
/**
|
||||
* Checks the satisfaction status of service dependencies.
|
||||
* Returns a helper object with methods to query and validate dependency status.
|
||||
*
|
||||
* This is useful for:
|
||||
* - Verifying dependencies before starting operations that require them
|
||||
* - Providing detailed error messages about unsatisfied dependencies
|
||||
* - Conditionally enabling features based on dependency availability
|
||||
*
|
||||
* @typeParam DependencyId - The type of package IDs (defaults to string)
|
||||
*
|
||||
* @param effects - Effects instance for system operations
|
||||
* @param packageIds - Optional array of specific dependencies to check (checks all if omitted)
|
||||
* @returns Promise resolving to a CheckDependencies helper object
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Check all dependencies
|
||||
* const deps = await checkDependencies(effects)
|
||||
* deps.throwIfNotSatisfied() // Throws if any dependency isn't met
|
||||
*
|
||||
* // Check specific dependencies
|
||||
* const deps = await checkDependencies(effects, ['bitcoind'])
|
||||
* if (deps.runningSatisfied('bitcoind') && deps.healthCheckSatisfied('bitcoind', 'rpc')) {
|
||||
* // Safe to make RPC calls to bitcoind
|
||||
* }
|
||||
*
|
||||
* // Get detailed info
|
||||
* const info = deps.infoFor('bitcoind')
|
||||
* console.log(`Installed: ${info.result.installedVersion}`)
|
||||
* console.log(`Running: ${info.result.isRunning}`)
|
||||
* ```
|
||||
*/
|
||||
export async function checkDependencies<
|
||||
DependencyId extends PackageId = PackageId,
|
||||
>(
|
||||
@@ -216,11 +73,11 @@ export async function checkDependencies<
|
||||
}
|
||||
const runningSatisfied = (packageId: DependencyId) => {
|
||||
const dep = infoFor(packageId)
|
||||
return dep.requirement.kind !== "running" || dep.result.isRunning
|
||||
return dep.requirement.kind !== 'running' || dep.result.isRunning
|
||||
}
|
||||
const tasksSatisfied = (packageId: DependencyId) =>
|
||||
Object.entries(infoFor(packageId).result.tasks).filter(
|
||||
([_, t]) => t?.active && t.task.severity === "critical",
|
||||
([_, t]) => t?.active && t.task.severity === 'critical',
|
||||
).length === 0
|
||||
const healthCheckSatisfied = (
|
||||
packageId: DependencyId,
|
||||
@@ -229,17 +86,17 @@ export async function checkDependencies<
|
||||
const dep = infoFor(packageId)
|
||||
if (
|
||||
healthCheckId &&
|
||||
(dep.requirement.kind !== "running" ||
|
||||
(dep.requirement.kind !== 'running' ||
|
||||
!dep.requirement.healthChecks.includes(healthCheckId))
|
||||
) {
|
||||
throw new Error(`Unknown HealthCheckId ${healthCheckId}`)
|
||||
}
|
||||
const errors =
|
||||
dep.requirement.kind === "running"
|
||||
dep.requirement.kind === 'running'
|
||||
? dep.requirement.healthChecks
|
||||
.map((id) => [id, dep.result.healthChecks[id] ?? null] as const)
|
||||
.filter(([id, _]) => (healthCheckId ? id === healthCheckId : true))
|
||||
.filter(([_, res]) => res?.result !== "success")
|
||||
.filter(([_, res]) => res?.result !== 'success')
|
||||
: []
|
||||
return errors.length === 0
|
||||
}
|
||||
@@ -281,7 +138,7 @@ export async function checkDependencies<
|
||||
}
|
||||
const throwIfRunningNotSatisfied = (packageId: DependencyId) => {
|
||||
const dep = infoFor(packageId)
|
||||
if (dep.requirement.kind === "running" && !dep.result.isRunning) {
|
||||
if (dep.requirement.kind === 'running' && !dep.result.isRunning) {
|
||||
throw new Error(`${dep.result.title || packageId} is not running`)
|
||||
}
|
||||
return null
|
||||
@@ -289,11 +146,11 @@ export async function checkDependencies<
|
||||
const throwIfTasksNotSatisfied = (packageId: DependencyId) => {
|
||||
const dep = infoFor(packageId)
|
||||
const reqs = Object.entries(dep.result.tasks)
|
||||
.filter(([_, t]) => t?.active && t.task.severity === "critical")
|
||||
.filter(([_, t]) => t?.active && t.task.severity === 'critical')
|
||||
.map(([id, _]) => id)
|
||||
if (reqs.length) {
|
||||
throw new Error(
|
||||
`The following action requests have not been fulfilled: ${reqs.join(", ")}`,
|
||||
`The following action requests have not been fulfilled: ${reqs.join(', ')}`,
|
||||
)
|
||||
}
|
||||
return null
|
||||
@@ -305,27 +162,27 @@ export async function checkDependencies<
|
||||
const dep = infoFor(packageId)
|
||||
if (
|
||||
healthCheckId &&
|
||||
(dep.requirement.kind !== "running" ||
|
||||
(dep.requirement.kind !== 'running' ||
|
||||
!dep.requirement.healthChecks.includes(healthCheckId))
|
||||
) {
|
||||
throw new Error(`Unknown HealthCheckId ${healthCheckId}`)
|
||||
}
|
||||
const errors =
|
||||
dep.requirement.kind === "running"
|
||||
dep.requirement.kind === 'running'
|
||||
? dep.requirement.healthChecks
|
||||
.map((id) => [id, dep.result.healthChecks[id] ?? null] as const)
|
||||
.filter(([id, _]) => (healthCheckId ? id === healthCheckId : true))
|
||||
.filter(([_, res]) => res?.result !== "success")
|
||||
.filter(([_, res]) => res?.result !== 'success')
|
||||
: []
|
||||
if (errors.length) {
|
||||
throw new Error(
|
||||
errors
|
||||
.map(([id, e]) =>
|
||||
e
|
||||
? `Health Check ${e.name} of ${dep.result.title || packageId} failed with status ${e.result}${e.message ? `: ${e.message}` : ""}`
|
||||
? `Health Check ${e.name} of ${dep.result.title || packageId} failed with status ${e.result}${e.message ? `: ${e.message}` : ''}`
|
||||
: `Health Check ${id} of ${dep.result.title} does not exist`,
|
||||
)
|
||||
.join("; "),
|
||||
.join('; '),
|
||||
)
|
||||
}
|
||||
return null
|
||||
@@ -352,7 +209,7 @@ export async function checkDependencies<
|
||||
return []
|
||||
})
|
||||
if (err.length) {
|
||||
throw new Error(err.join("; "))
|
||||
throw new Error(err.join('; '))
|
||||
}
|
||||
return null
|
||||
})()
|
||||
|
||||
@@ -1,118 +1,41 @@
|
||||
/**
|
||||
* @module setupDependencies
|
||||
*
|
||||
* This module provides utilities for declaring and managing service dependencies.
|
||||
* Dependencies allow services to declare that they require other services to be
|
||||
* installed and/or running before they can function properly.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In dependencies.ts
|
||||
* export const setDependencies = sdk.setupDependencies(async ({ effects }) => {
|
||||
* const config = await store.read(s => s.mediaSource).const(effects)
|
||||
*
|
||||
* return {
|
||||
* // Required dependency - must be running with passing health checks
|
||||
* bitcoind: {
|
||||
* kind: 'running',
|
||||
* versionRange: '>=25.0.0',
|
||||
* healthChecks: ['rpc']
|
||||
* },
|
||||
* // Optional dependency - only required if feature is enabled
|
||||
* ...(config === 'nextcloud' ? {
|
||||
* nextcloud: { kind: 'exists', versionRange: '>=28.0.0' }
|
||||
* } : {})
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
import * as T from '../types'
|
||||
import { once } from '../util'
|
||||
|
||||
import * as T from "../types"
|
||||
import { once } from "../util"
|
||||
|
||||
/**
|
||||
* Extracts the package IDs of required (non-optional) dependencies from a manifest.
|
||||
* Used for type-safe dependency declarations.
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type
|
||||
*/
|
||||
export type RequiredDependenciesOf<Manifest extends T.SDKManifest> = {
|
||||
[K in keyof Manifest["dependencies"]]: Exclude<
|
||||
Manifest["dependencies"][K],
|
||||
[K in keyof Manifest['dependencies']]: Exclude<
|
||||
Manifest['dependencies'][K],
|
||||
undefined
|
||||
>["optional"] extends false
|
||||
>['optional'] extends false
|
||||
? K
|
||||
: never
|
||||
}[keyof Manifest["dependencies"]]
|
||||
|
||||
/**
|
||||
* Extracts the package IDs of optional dependencies from a manifest.
|
||||
* These dependencies are declared in the manifest but marked as optional.
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type
|
||||
*/
|
||||
}[keyof Manifest['dependencies']]
|
||||
export type OptionalDependenciesOf<Manifest extends T.SDKManifest> = Exclude<
|
||||
keyof Manifest["dependencies"],
|
||||
keyof Manifest['dependencies'],
|
||||
RequiredDependenciesOf<Manifest>
|
||||
>
|
||||
|
||||
/**
|
||||
* Specifies the requirements for a single dependency.
|
||||
*
|
||||
* - `kind: "running"` - The dependency must be installed AND actively running
|
||||
* with the specified health checks passing
|
||||
* - `kind: "exists"` - The dependency only needs to be installed (not necessarily running)
|
||||
*/
|
||||
type DependencyRequirement =
|
||||
| {
|
||||
/** The dependency must be running */
|
||||
kind: "running"
|
||||
/** Health check IDs that must be passing */
|
||||
kind: 'running'
|
||||
healthChecks: Array<T.HealthCheckId>
|
||||
/** Semantic version range the dependency must satisfy (e.g., ">=1.0.0") */
|
||||
versionRange: string
|
||||
}
|
||||
| {
|
||||
/** The dependency only needs to be installed */
|
||||
kind: "exists"
|
||||
/** Semantic version range the dependency must satisfy */
|
||||
kind: 'exists'
|
||||
versionRange: string
|
||||
}
|
||||
|
||||
/** @internal Type checking helper */
|
||||
type Matches<T, U> = T extends U ? (U extends T ? null : never) : never
|
||||
const _checkType: Matches<
|
||||
DependencyRequirement & { id: T.PackageId },
|
||||
T.DependencyRequirement
|
||||
> = null
|
||||
|
||||
/**
|
||||
* The return type for dependency declarations.
|
||||
* Required dependencies must always be specified; optional dependencies may be omitted.
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type
|
||||
*/
|
||||
export type CurrentDependenciesResult<Manifest extends T.SDKManifest> = {
|
||||
[K in RequiredDependenciesOf<Manifest>]: DependencyRequirement
|
||||
} & {
|
||||
[K in OptionalDependenciesOf<Manifest>]?: DependencyRequirement
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dependency setup function for use in the initialization pipeline.
|
||||
*
|
||||
* **Note:** This is exposed via `sdk.setupDependencies`. See the SDK documentation
|
||||
* for usage examples.
|
||||
*
|
||||
* The function you provide will be called during init to determine the current
|
||||
* dependency requirements, which may vary based on service configuration.
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type (inferred from SDK)
|
||||
* @param fn - Async function that returns the current dependency requirements
|
||||
* @returns An init-compatible function that sets dependencies via Effects
|
||||
*
|
||||
* @see sdk.setupDependencies for usage documentation
|
||||
*/
|
||||
export function setupDependencies<Manifest extends T.SDKManifest>(
|
||||
fn: (options: {
|
||||
effects: T.Effects
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DeepMap } from "deep-equality-data-structures"
|
||||
import * as P from "./exver"
|
||||
import { DeepMap } from 'deep-equality-data-structures'
|
||||
import * as P from './exver'
|
||||
|
||||
// prettier-ignore
|
||||
export type ValidateVersion<T extends String> =
|
||||
@@ -22,35 +22,35 @@ export type ValidateExVers<T> =
|
||||
never[]
|
||||
|
||||
type Anchor = {
|
||||
type: "Anchor"
|
||||
type: 'Anchor'
|
||||
operator: P.CmpOp
|
||||
version: ExtendedVersion
|
||||
}
|
||||
|
||||
type And = {
|
||||
type: "And"
|
||||
type: 'And'
|
||||
left: VersionRange
|
||||
right: VersionRange
|
||||
}
|
||||
|
||||
type Or = {
|
||||
type: "Or"
|
||||
type: 'Or'
|
||||
left: VersionRange
|
||||
right: VersionRange
|
||||
}
|
||||
|
||||
type Not = {
|
||||
type: "Not"
|
||||
type: 'Not'
|
||||
value: VersionRange
|
||||
}
|
||||
|
||||
type Flavor = {
|
||||
type: "Flavor"
|
||||
type: 'Flavor'
|
||||
flavor: string | null
|
||||
}
|
||||
|
||||
type FlavorNot = {
|
||||
type: "FlavorNot"
|
||||
type: 'FlavorNot'
|
||||
flavors: Set<string | null>
|
||||
}
|
||||
|
||||
@@ -107,8 +107,8 @@ function adjacentVersionRangePoints(
|
||||
}
|
||||
|
||||
function flavorAnd(a: FlavorAtom, b: FlavorAtom): FlavorAtom | null {
|
||||
if (a.type == "Flavor") {
|
||||
if (b.type == "Flavor") {
|
||||
if (a.type == 'Flavor') {
|
||||
if (b.type == 'Flavor') {
|
||||
if (a.flavor == b.flavor) {
|
||||
return a
|
||||
} else {
|
||||
@@ -122,7 +122,7 @@ function flavorAnd(a: FlavorAtom, b: FlavorAtom): FlavorAtom | null {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (b.type == "Flavor") {
|
||||
if (b.type == 'Flavor') {
|
||||
if (a.flavors.has(b.flavor)) {
|
||||
return null
|
||||
} else {
|
||||
@@ -131,7 +131,7 @@ function flavorAnd(a: FlavorAtom, b: FlavorAtom): FlavorAtom | null {
|
||||
} else {
|
||||
// TODO: use Set.union if targeting esnext or later
|
||||
return {
|
||||
type: "FlavorNot",
|
||||
type: 'FlavorNot',
|
||||
flavors: new Set([...a.flavors, ...b.flavors]),
|
||||
}
|
||||
}
|
||||
@@ -218,12 +218,12 @@ class VersionRangeTable {
|
||||
static eqFlavor(flavor: string | null): VersionRangeTables {
|
||||
return new DeepMap([
|
||||
[
|
||||
{ type: "Flavor", flavor } as FlavorAtom,
|
||||
{ type: 'Flavor', flavor } as FlavorAtom,
|
||||
new VersionRangeTable([], [true]),
|
||||
],
|
||||
// make sure the truth table is exhaustive, or `not` will not work properly.
|
||||
[
|
||||
{ type: "FlavorNot", flavors: new Set([flavor]) } as FlavorAtom,
|
||||
{ type: 'FlavorNot', flavors: new Set([flavor]) } as FlavorAtom,
|
||||
new VersionRangeTable([], [false]),
|
||||
],
|
||||
])
|
||||
@@ -241,12 +241,12 @@ class VersionRangeTable {
|
||||
): VersionRangeTables {
|
||||
return new DeepMap([
|
||||
[
|
||||
{ type: "Flavor", flavor } as FlavorAtom,
|
||||
{ type: 'Flavor', flavor } as FlavorAtom,
|
||||
new VersionRangeTable([point], [left, right]),
|
||||
],
|
||||
// make sure the truth table is exhaustive, or `not` will not work properly.
|
||||
[
|
||||
{ type: "FlavorNot", flavors: new Set([flavor]) } as FlavorAtom,
|
||||
{ type: 'FlavorNot', flavors: new Set([flavor]) } as FlavorAtom,
|
||||
new VersionRangeTable([], [false]),
|
||||
],
|
||||
])
|
||||
@@ -383,7 +383,7 @@ class VersionRangeTable {
|
||||
let sum_terms: VersionRange[] = []
|
||||
for (let [flavor, table] of tables) {
|
||||
let cmp_flavor = null
|
||||
if (flavor.type == "Flavor") {
|
||||
if (flavor.type == 'Flavor') {
|
||||
cmp_flavor = flavor.flavor
|
||||
}
|
||||
for (let i = 0; i < table.values.length; i++) {
|
||||
@@ -392,7 +392,7 @@ class VersionRangeTable {
|
||||
continue
|
||||
}
|
||||
|
||||
if (flavor.type == "FlavorNot") {
|
||||
if (flavor.type == 'FlavorNot') {
|
||||
for (let not_flavor of flavor.flavors) {
|
||||
term.push(VersionRange.flavor(not_flavor).not())
|
||||
}
|
||||
@@ -410,7 +410,7 @@ class VersionRangeTable {
|
||||
if (p != null && q != null && adjacentVersionRangePoints(p, q)) {
|
||||
term.push(
|
||||
VersionRange.anchor(
|
||||
"=",
|
||||
'=',
|
||||
new ExtendedVersion(cmp_flavor, p.upstream, p.downstream),
|
||||
),
|
||||
)
|
||||
@@ -418,7 +418,7 @@ class VersionRangeTable {
|
||||
if (p != null && p.side < 0) {
|
||||
term.push(
|
||||
VersionRange.anchor(
|
||||
">=",
|
||||
'>=',
|
||||
new ExtendedVersion(cmp_flavor, p.upstream, p.downstream),
|
||||
),
|
||||
)
|
||||
@@ -426,7 +426,7 @@ class VersionRangeTable {
|
||||
if (p != null && p.side >= 0) {
|
||||
term.push(
|
||||
VersionRange.anchor(
|
||||
">",
|
||||
'>',
|
||||
new ExtendedVersion(cmp_flavor, p.upstream, p.downstream),
|
||||
),
|
||||
)
|
||||
@@ -434,7 +434,7 @@ class VersionRangeTable {
|
||||
if (q != null && q.side < 0) {
|
||||
term.push(
|
||||
VersionRange.anchor(
|
||||
"<",
|
||||
'<',
|
||||
new ExtendedVersion(cmp_flavor, q.upstream, q.downstream),
|
||||
),
|
||||
)
|
||||
@@ -442,7 +442,7 @@ class VersionRangeTable {
|
||||
if (q != null && q.side >= 0) {
|
||||
term.push(
|
||||
VersionRange.anchor(
|
||||
"<=",
|
||||
'<=',
|
||||
new ExtendedVersion(cmp_flavor, q.upstream, q.downstream),
|
||||
),
|
||||
)
|
||||
@@ -463,26 +463,26 @@ class VersionRangeTable {
|
||||
export class VersionRange {
|
||||
constructor(public atom: Anchor | And | Or | Not | P.Any | P.None | Flavor) {}
|
||||
|
||||
toStringParens(parent: "And" | "Or" | "Not") {
|
||||
toStringParens(parent: 'And' | 'Or' | 'Not') {
|
||||
let needs = true
|
||||
switch (this.atom.type) {
|
||||
case "And":
|
||||
case "Or":
|
||||
case 'And':
|
||||
case 'Or':
|
||||
needs = parent != this.atom.type
|
||||
break
|
||||
case "Anchor":
|
||||
case "Any":
|
||||
case "None":
|
||||
needs = parent == "Not"
|
||||
case 'Anchor':
|
||||
case 'Any':
|
||||
case 'None':
|
||||
needs = parent == 'Not'
|
||||
break
|
||||
case "Not":
|
||||
case "Flavor":
|
||||
case 'Not':
|
||||
case 'Flavor':
|
||||
needs = false
|
||||
break
|
||||
}
|
||||
|
||||
if (needs) {
|
||||
return "(" + this.toString() + ")"
|
||||
return '(' + this.toString() + ')'
|
||||
} else {
|
||||
return this.toString()
|
||||
}
|
||||
@@ -490,36 +490,36 @@ export class VersionRange {
|
||||
|
||||
toString(): string {
|
||||
switch (this.atom.type) {
|
||||
case "Anchor":
|
||||
case 'Anchor':
|
||||
return `${this.atom.operator}${this.atom.version}`
|
||||
case "And":
|
||||
case 'And':
|
||||
return `${this.atom.left.toStringParens(this.atom.type)} && ${this.atom.right.toStringParens(this.atom.type)}`
|
||||
case "Or":
|
||||
case 'Or':
|
||||
return `${this.atom.left.toStringParens(this.atom.type)} || ${this.atom.right.toStringParens(this.atom.type)}`
|
||||
case "Not":
|
||||
case 'Not':
|
||||
return `!${this.atom.value.toStringParens(this.atom.type)}`
|
||||
case "Flavor":
|
||||
case 'Flavor':
|
||||
return this.atom.flavor == null ? `#` : `#${this.atom.flavor}`
|
||||
case "Any":
|
||||
return "*"
|
||||
case "None":
|
||||
return "!"
|
||||
case 'Any':
|
||||
return '*'
|
||||
case 'None':
|
||||
return '!'
|
||||
}
|
||||
}
|
||||
|
||||
private static parseAtom(atom: P.VersionRangeAtom): VersionRange {
|
||||
switch (atom.type) {
|
||||
case "Not":
|
||||
case 'Not':
|
||||
return new VersionRange({
|
||||
type: "Not",
|
||||
type: 'Not',
|
||||
value: VersionRange.parseAtom(atom.value),
|
||||
})
|
||||
case "Parens":
|
||||
case 'Parens':
|
||||
return VersionRange.parseRange(atom.expr)
|
||||
case "Anchor":
|
||||
case 'Anchor':
|
||||
return new VersionRange({
|
||||
type: "Anchor",
|
||||
operator: atom.operator || "^",
|
||||
type: 'Anchor',
|
||||
operator: atom.operator || '^',
|
||||
version: new ExtendedVersion(
|
||||
atom.version.flavor,
|
||||
new Version(
|
||||
@@ -532,7 +532,7 @@ export class VersionRange {
|
||||
),
|
||||
),
|
||||
})
|
||||
case "Flavor":
|
||||
case 'Flavor':
|
||||
return VersionRange.flavor(atom.flavor)
|
||||
default:
|
||||
return new VersionRange(atom)
|
||||
@@ -543,17 +543,17 @@ export class VersionRange {
|
||||
let result = VersionRange.parseAtom(range[0])
|
||||
for (const next of range[1]) {
|
||||
switch (next[1]?.[0]) {
|
||||
case "||":
|
||||
case '||':
|
||||
result = new VersionRange({
|
||||
type: "Or",
|
||||
type: 'Or',
|
||||
left: result,
|
||||
right: VersionRange.parseAtom(next[2]),
|
||||
})
|
||||
break
|
||||
case "&&":
|
||||
case '&&':
|
||||
default:
|
||||
result = new VersionRange({
|
||||
type: "And",
|
||||
type: 'And',
|
||||
left: result,
|
||||
right: VersionRange.parseAtom(next[2]),
|
||||
})
|
||||
@@ -565,49 +565,49 @@ export class VersionRange {
|
||||
|
||||
static parse(range: string): VersionRange {
|
||||
return VersionRange.parseRange(
|
||||
P.parse(range, { startRule: "VersionRange" }),
|
||||
P.parse(range, { startRule: 'VersionRange' }),
|
||||
)
|
||||
}
|
||||
|
||||
static anchor(operator: P.CmpOp, version: ExtendedVersion) {
|
||||
return new VersionRange({ type: "Anchor", operator, version })
|
||||
return new VersionRange({ type: 'Anchor', operator, version })
|
||||
}
|
||||
|
||||
static flavor(flavor: string | null) {
|
||||
return new VersionRange({ type: "Flavor", flavor })
|
||||
return new VersionRange({ type: 'Flavor', flavor })
|
||||
}
|
||||
|
||||
static parseEmver(range: string): VersionRange {
|
||||
return VersionRange.parseRange(
|
||||
P.parse(range, { startRule: "EmverVersionRange" }),
|
||||
P.parse(range, { startRule: 'EmverVersionRange' }),
|
||||
)
|
||||
}
|
||||
|
||||
and(right: VersionRange) {
|
||||
return new VersionRange({ type: "And", left: this, right })
|
||||
return new VersionRange({ type: 'And', left: this, right })
|
||||
}
|
||||
|
||||
or(right: VersionRange) {
|
||||
return new VersionRange({ type: "Or", left: this, right })
|
||||
return new VersionRange({ type: 'Or', left: this, right })
|
||||
}
|
||||
|
||||
not() {
|
||||
return new VersionRange({ type: "Not", value: this })
|
||||
return new VersionRange({ type: 'Not', value: this })
|
||||
}
|
||||
|
||||
static and(...xs: Array<VersionRange>) {
|
||||
let y = VersionRange.any()
|
||||
for (let x of xs) {
|
||||
if (x.atom.type == "Any") {
|
||||
if (x.atom.type == 'Any') {
|
||||
continue
|
||||
}
|
||||
if (x.atom.type == "None") {
|
||||
if (x.atom.type == 'None') {
|
||||
return x
|
||||
}
|
||||
if (y.atom.type == "Any") {
|
||||
if (y.atom.type == 'Any') {
|
||||
y = x
|
||||
} else {
|
||||
y = new VersionRange({ type: "And", left: y, right: x })
|
||||
y = new VersionRange({ type: 'And', left: y, right: x })
|
||||
}
|
||||
}
|
||||
return y
|
||||
@@ -616,27 +616,27 @@ export class VersionRange {
|
||||
static or(...xs: Array<VersionRange>) {
|
||||
let y = VersionRange.none()
|
||||
for (let x of xs) {
|
||||
if (x.atom.type == "None") {
|
||||
if (x.atom.type == 'None') {
|
||||
continue
|
||||
}
|
||||
if (x.atom.type == "Any") {
|
||||
if (x.atom.type == 'Any') {
|
||||
return x
|
||||
}
|
||||
if (y.atom.type == "None") {
|
||||
if (y.atom.type == 'None') {
|
||||
y = x
|
||||
} else {
|
||||
y = new VersionRange({ type: "Or", left: y, right: x })
|
||||
y = new VersionRange({ type: 'Or', left: y, right: x })
|
||||
}
|
||||
}
|
||||
return y
|
||||
}
|
||||
|
||||
static any() {
|
||||
return new VersionRange({ type: "Any" })
|
||||
return new VersionRange({ type: 'Any' })
|
||||
}
|
||||
|
||||
static none() {
|
||||
return new VersionRange({ type: "None" })
|
||||
return new VersionRange({ type: 'None' })
|
||||
}
|
||||
|
||||
satisfiedBy(version: Version | ExtendedVersion) {
|
||||
@@ -645,23 +645,23 @@ export class VersionRange {
|
||||
|
||||
tables(): VersionRangeTables {
|
||||
switch (this.atom.type) {
|
||||
case "Anchor":
|
||||
case 'Anchor':
|
||||
switch (this.atom.operator) {
|
||||
case "=":
|
||||
case '=':
|
||||
// `=1.2.3` is equivalent to `>=1.2.3 && <=1.2.4 && #flavor`
|
||||
return VersionRangeTable.and(
|
||||
VersionRangeTable.cmp(this.atom.version, -1, false, true),
|
||||
VersionRangeTable.cmp(this.atom.version, 1, true, false),
|
||||
)
|
||||
case ">":
|
||||
case '>':
|
||||
return VersionRangeTable.cmp(this.atom.version, 1, false, true)
|
||||
case "<":
|
||||
case '<':
|
||||
return VersionRangeTable.cmp(this.atom.version, -1, true, false)
|
||||
case ">=":
|
||||
case '>=':
|
||||
return VersionRangeTable.cmp(this.atom.version, -1, false, true)
|
||||
case "<=":
|
||||
case '<=':
|
||||
return VersionRangeTable.cmp(this.atom.version, 1, true, false)
|
||||
case "!=":
|
||||
case '!=':
|
||||
// `!=1.2.3` is equivalent to `!(>=1.2.3 && <=1.2.3 && #flavor)`
|
||||
// **not** equivalent to `(<1.2.3 || >1.2.3) && #flavor`
|
||||
return VersionRangeTable.not(
|
||||
@@ -670,7 +670,7 @@ export class VersionRange {
|
||||
VersionRangeTable.cmp(this.atom.version, 1, true, false),
|
||||
),
|
||||
)
|
||||
case "^":
|
||||
case '^':
|
||||
// `^1.2.3` is equivalent to `>=1.2.3 && <2.0.0 && #flavor`
|
||||
return VersionRangeTable.and(
|
||||
VersionRangeTable.cmp(this.atom.version, -1, false, true),
|
||||
@@ -681,7 +681,7 @@ export class VersionRange {
|
||||
false,
|
||||
),
|
||||
)
|
||||
case "~":
|
||||
case '~':
|
||||
// `~1.2.3` is equivalent to `>=1.2.3 && <1.3.0 && #flavor`
|
||||
return VersionRangeTable.and(
|
||||
VersionRangeTable.cmp(this.atom.version, -1, false, true),
|
||||
@@ -693,23 +693,23 @@ export class VersionRange {
|
||||
),
|
||||
)
|
||||
}
|
||||
case "Flavor":
|
||||
case 'Flavor':
|
||||
return VersionRangeTable.eqFlavor(this.atom.flavor)
|
||||
case "Not":
|
||||
case 'Not':
|
||||
return VersionRangeTable.not(this.atom.value.tables())
|
||||
case "And":
|
||||
case 'And':
|
||||
return VersionRangeTable.and(
|
||||
this.atom.left.tables(),
|
||||
this.atom.right.tables(),
|
||||
)
|
||||
case "Or":
|
||||
case 'Or':
|
||||
return VersionRangeTable.or(
|
||||
this.atom.left.tables(),
|
||||
this.atom.right.tables(),
|
||||
)
|
||||
case "Any":
|
||||
case 'Any':
|
||||
return true
|
||||
case "None":
|
||||
case 'None':
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -734,23 +734,23 @@ export class Version {
|
||||
) {}
|
||||
|
||||
toString(): string {
|
||||
return `${this.number.join(".")}${this.prerelease.length > 0 ? `-${this.prerelease.join(".")}` : ""}`
|
||||
return `${this.number.join('.')}${this.prerelease.length > 0 ? `-${this.prerelease.join('.')}` : ''}`
|
||||
}
|
||||
|
||||
compare(other: Version): "greater" | "equal" | "less" {
|
||||
compare(other: Version): 'greater' | 'equal' | 'less' {
|
||||
const numLen = Math.max(this.number.length, other.number.length)
|
||||
for (let i = 0; i < numLen; i++) {
|
||||
if ((this.number[i] || 0) > (other.number[i] || 0)) {
|
||||
return "greater"
|
||||
return 'greater'
|
||||
} else if ((this.number[i] || 0) < (other.number[i] || 0)) {
|
||||
return "less"
|
||||
return 'less'
|
||||
}
|
||||
}
|
||||
|
||||
if (this.prerelease.length === 0 && other.prerelease.length !== 0) {
|
||||
return "greater"
|
||||
return 'greater'
|
||||
} else if (this.prerelease.length !== 0 && other.prerelease.length === 0) {
|
||||
return "less"
|
||||
return 'less'
|
||||
}
|
||||
|
||||
const prereleaseLen = Math.max(
|
||||
@@ -760,42 +760,42 @@ export class Version {
|
||||
for (let i = 0; i < prereleaseLen; i++) {
|
||||
if (typeof this.prerelease[i] === typeof other.prerelease[i]) {
|
||||
if (this.prerelease[i] > other.prerelease[i]) {
|
||||
return "greater"
|
||||
return 'greater'
|
||||
} else if (this.prerelease[i] < other.prerelease[i]) {
|
||||
return "less"
|
||||
return 'less'
|
||||
}
|
||||
} else {
|
||||
switch (`${typeof this.prerelease[1]}:${typeof other.prerelease[i]}`) {
|
||||
case "number:string":
|
||||
return "less"
|
||||
case "string:number":
|
||||
return "greater"
|
||||
case "number:undefined":
|
||||
case "string:undefined":
|
||||
return "greater"
|
||||
case "undefined:number":
|
||||
case "undefined:string":
|
||||
return "less"
|
||||
case 'number:string':
|
||||
return 'less'
|
||||
case 'string:number':
|
||||
return 'greater'
|
||||
case 'number:undefined':
|
||||
case 'string:undefined':
|
||||
return 'greater'
|
||||
case 'undefined:number':
|
||||
case 'undefined:string':
|
||||
return 'less'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "equal"
|
||||
return 'equal'
|
||||
}
|
||||
|
||||
compareForSort(other: Version): -1 | 0 | 1 {
|
||||
switch (this.compare(other)) {
|
||||
case "greater":
|
||||
case 'greater':
|
||||
return 1
|
||||
case "equal":
|
||||
case 'equal':
|
||||
return 0
|
||||
case "less":
|
||||
case 'less':
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
static parse(version: string): Version {
|
||||
const parsed = P.parse(version, { startRule: "Version" })
|
||||
const parsed = P.parse(version, { startRule: 'Version' })
|
||||
return new Version(parsed.number, parsed.prerelease)
|
||||
}
|
||||
|
||||
@@ -815,25 +815,25 @@ export class ExtendedVersion {
|
||||
) {}
|
||||
|
||||
toString(): string {
|
||||
return `${this.flavor ? `#${this.flavor}:` : ""}${this.upstream.toString()}:${this.downstream.toString()}`
|
||||
return `${this.flavor ? `#${this.flavor}:` : ''}${this.upstream.toString()}:${this.downstream.toString()}`
|
||||
}
|
||||
|
||||
compare(other: ExtendedVersion): "greater" | "equal" | "less" | null {
|
||||
compare(other: ExtendedVersion): 'greater' | 'equal' | 'less' | null {
|
||||
if (this.flavor !== other.flavor) {
|
||||
return null
|
||||
}
|
||||
const upstreamCmp = this.upstream.compare(other.upstream)
|
||||
if (upstreamCmp !== "equal") {
|
||||
if (upstreamCmp !== 'equal') {
|
||||
return upstreamCmp
|
||||
}
|
||||
return this.downstream.compare(other.downstream)
|
||||
}
|
||||
|
||||
compareLexicographic(other: ExtendedVersion): "greater" | "equal" | "less" {
|
||||
if ((this.flavor || "") > (other.flavor || "")) {
|
||||
return "greater"
|
||||
} else if ((this.flavor || "") > (other.flavor || "")) {
|
||||
return "less"
|
||||
compareLexicographic(other: ExtendedVersion): 'greater' | 'equal' | 'less' {
|
||||
if ((this.flavor || '') > (other.flavor || '')) {
|
||||
return 'greater'
|
||||
} else if ((this.flavor || '') > (other.flavor || '')) {
|
||||
return 'less'
|
||||
} else {
|
||||
return this.compare(other)!
|
||||
}
|
||||
@@ -841,37 +841,37 @@ export class ExtendedVersion {
|
||||
|
||||
compareForSort(other: ExtendedVersion): 1 | 0 | -1 {
|
||||
switch (this.compareLexicographic(other)) {
|
||||
case "greater":
|
||||
case 'greater':
|
||||
return 1
|
||||
case "equal":
|
||||
case 'equal':
|
||||
return 0
|
||||
case "less":
|
||||
case 'less':
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
greaterThan(other: ExtendedVersion): boolean {
|
||||
return this.compare(other) === "greater"
|
||||
return this.compare(other) === 'greater'
|
||||
}
|
||||
|
||||
greaterThanOrEqual(other: ExtendedVersion): boolean {
|
||||
return ["greater", "equal"].includes(this.compare(other) as string)
|
||||
return ['greater', 'equal'].includes(this.compare(other) as string)
|
||||
}
|
||||
|
||||
equals(other: ExtendedVersion): boolean {
|
||||
return this.compare(other) === "equal"
|
||||
return this.compare(other) === 'equal'
|
||||
}
|
||||
|
||||
lessThan(other: ExtendedVersion): boolean {
|
||||
return this.compare(other) === "less"
|
||||
return this.compare(other) === 'less'
|
||||
}
|
||||
|
||||
lessThanOrEqual(other: ExtendedVersion): boolean {
|
||||
return ["less", "equal"].includes(this.compare(other) as string)
|
||||
return ['less', 'equal'].includes(this.compare(other) as string)
|
||||
}
|
||||
|
||||
static parse(extendedVersion: string): ExtendedVersion {
|
||||
const parsed = P.parse(extendedVersion, { startRule: "ExtendedVersion" })
|
||||
const parsed = P.parse(extendedVersion, { startRule: 'ExtendedVersion' })
|
||||
return new ExtendedVersion(
|
||||
parsed.flavor || null,
|
||||
new Version(parsed.upstream.number, parsed.upstream.prerelease),
|
||||
@@ -881,7 +881,7 @@ export class ExtendedVersion {
|
||||
|
||||
static parseEmver(extendedVersion: string): ExtendedVersion {
|
||||
try {
|
||||
const parsed = P.parse(extendedVersion, { startRule: "Emver" })
|
||||
const parsed = P.parse(extendedVersion, { startRule: 'Emver' })
|
||||
return new ExtendedVersion(
|
||||
parsed.flavor || null,
|
||||
new Version(parsed.upstream.number, parsed.upstream.prerelease),
|
||||
@@ -956,22 +956,22 @@ export class ExtendedVersion {
|
||||
*/
|
||||
satisfies(versionRange: VersionRange): boolean {
|
||||
switch (versionRange.atom.type) {
|
||||
case "Anchor":
|
||||
case 'Anchor':
|
||||
const otherVersion = versionRange.atom.version
|
||||
switch (versionRange.atom.operator) {
|
||||
case "=":
|
||||
case '=':
|
||||
return this.equals(otherVersion)
|
||||
case ">":
|
||||
case '>':
|
||||
return this.greaterThan(otherVersion)
|
||||
case "<":
|
||||
case '<':
|
||||
return this.lessThan(otherVersion)
|
||||
case ">=":
|
||||
case '>=':
|
||||
return this.greaterThanOrEqual(otherVersion)
|
||||
case "<=":
|
||||
case '<=':
|
||||
return this.lessThanOrEqual(otherVersion)
|
||||
case "!=":
|
||||
case '!=':
|
||||
return !this.equals(otherVersion)
|
||||
case "^":
|
||||
case '^':
|
||||
const nextMajor = versionRange.atom.version.incrementMajor()
|
||||
if (
|
||||
this.greaterThanOrEqual(otherVersion) &&
|
||||
@@ -981,7 +981,7 @@ export class ExtendedVersion {
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
case "~":
|
||||
case '~':
|
||||
const nextMinor = versionRange.atom.version.incrementMinor()
|
||||
if (
|
||||
this.greaterThanOrEqual(otherVersion) &&
|
||||
@@ -992,23 +992,23 @@ export class ExtendedVersion {
|
||||
return false
|
||||
}
|
||||
}
|
||||
case "Flavor":
|
||||
case 'Flavor':
|
||||
return versionRange.atom.flavor == this.flavor
|
||||
case "And":
|
||||
case 'And':
|
||||
return (
|
||||
this.satisfies(versionRange.atom.left) &&
|
||||
this.satisfies(versionRange.atom.right)
|
||||
)
|
||||
case "Or":
|
||||
case 'Or':
|
||||
return (
|
||||
this.satisfies(versionRange.atom.left) ||
|
||||
this.satisfies(versionRange.atom.right)
|
||||
)
|
||||
case "Not":
|
||||
case 'Not':
|
||||
return !this.satisfies(versionRange.atom.value)
|
||||
case "Any":
|
||||
case 'Any':
|
||||
return true
|
||||
case "None":
|
||||
case 'None':
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1020,34 +1020,34 @@ export const testTypeVersion = <T extends string>(t: T & ValidateVersion<T>) =>
|
||||
t
|
||||
|
||||
function tests() {
|
||||
testTypeVersion("1.2.3")
|
||||
testTypeVersion("1")
|
||||
testTypeVersion("12.34.56")
|
||||
testTypeVersion("1.2-3")
|
||||
testTypeVersion("1-3")
|
||||
testTypeVersion("1-alpha")
|
||||
testTypeVersion('1.2.3')
|
||||
testTypeVersion('1')
|
||||
testTypeVersion('12.34.56')
|
||||
testTypeVersion('1.2-3')
|
||||
testTypeVersion('1-3')
|
||||
testTypeVersion('1-alpha')
|
||||
// @ts-expect-error
|
||||
testTypeVersion("-3")
|
||||
testTypeVersion('-3')
|
||||
// @ts-expect-error
|
||||
testTypeVersion("1.2.3:1")
|
||||
testTypeVersion('1.2.3:1')
|
||||
// @ts-expect-error
|
||||
testTypeVersion("#cat:1:1")
|
||||
testTypeVersion('#cat:1:1')
|
||||
|
||||
testTypeExVer("1.2.3:1.2.3")
|
||||
testTypeExVer("1.2.3.4.5.6.7.8.9.0:1")
|
||||
testTypeExVer("100:1")
|
||||
testTypeExVer("#cat:1:1")
|
||||
testTypeExVer("1.2.3.4.5.6.7.8.9.11.22.33:1")
|
||||
testTypeExVer("1-0:1")
|
||||
testTypeExVer("1-0:1")
|
||||
testTypeExVer('1.2.3:1.2.3')
|
||||
testTypeExVer('1.2.3.4.5.6.7.8.9.0:1')
|
||||
testTypeExVer('100:1')
|
||||
testTypeExVer('#cat:1:1')
|
||||
testTypeExVer('1.2.3.4.5.6.7.8.9.11.22.33:1')
|
||||
testTypeExVer('1-0:1')
|
||||
testTypeExVer('1-0:1')
|
||||
// @ts-expect-error
|
||||
testTypeExVer("1.2-3")
|
||||
testTypeExVer('1.2-3')
|
||||
// @ts-expect-error
|
||||
testTypeExVer("1-3")
|
||||
testTypeExVer('1-3')
|
||||
// @ts-expect-error
|
||||
testTypeExVer("1.2.3.4.5.6.7.8.9.0.10:1" as string)
|
||||
testTypeExVer('1.2.3.4.5.6.7.8.9.0.10:1' as string)
|
||||
// @ts-expect-error
|
||||
testTypeExVer("1.-2:1")
|
||||
testTypeExVer('1.-2:1')
|
||||
// @ts-expect-error
|
||||
testTypeExVer("1..2.3:3")
|
||||
testTypeExVer('1..2.3:3')
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
export { S9pk } from "./s9pk"
|
||||
export { VersionRange, ExtendedVersion, Version } from "./exver"
|
||||
export { S9pk } from './s9pk'
|
||||
export { VersionRange, ExtendedVersion, Version } from './exver'
|
||||
|
||||
export * as inputSpec from "./actions/input"
|
||||
export * as ISB from "./actions/input/builder"
|
||||
export * as IST from "./actions/input/inputSpecTypes"
|
||||
export * as types from "./types"
|
||||
export * as T from "./types"
|
||||
export * as yaml from "yaml"
|
||||
export * as inits from "./inits"
|
||||
export * as matches from "ts-matches"
|
||||
export * as inputSpec from './actions/input'
|
||||
export * as ISB from './actions/input/builder'
|
||||
export * as IST from './actions/input/inputSpecTypes'
|
||||
export * as types from './types'
|
||||
export * as T from './types'
|
||||
export * as yaml from 'yaml'
|
||||
export * as inits from './inits'
|
||||
export * as matches from 'ts-matches'
|
||||
|
||||
export * as utils from "./util"
|
||||
export * as utils from './util'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./setupInit"
|
||||
export * from "./setupUninit"
|
||||
export * from './setupInit'
|
||||
export * from './setupUninit'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { VersionRange } from "../../../base/lib/exver"
|
||||
import * as T from "../../../base/lib/types"
|
||||
import { once } from "../util"
|
||||
import { VersionRange } from '../../../base/lib/exver'
|
||||
import * as T from '../../../base/lib/types'
|
||||
import { once } from '../util'
|
||||
|
||||
export type InitKind = "install" | "update" | "restore" | null
|
||||
export type InitKind = 'install' | 'update' | 'restore' | null
|
||||
|
||||
export type InitFn<Kind extends InitKind = InitKind> = (
|
||||
effects: T.Effects,
|
||||
@@ -31,7 +31,7 @@ export function setupInit(...inits: InitScriptOrFn[]): T.ExpectedExports.init {
|
||||
complete.then(() => fn()).catch(console.error),
|
||||
)
|
||||
try {
|
||||
if ("init" in init) await init.init(e, opts.kind)
|
||||
if ('init' in init) await init.init(e, opts.kind)
|
||||
else await init(e, opts.kind)
|
||||
} finally {
|
||||
res()
|
||||
@@ -43,7 +43,7 @@ export function setupInit(...inits: InitScriptOrFn[]): T.ExpectedExports.init {
|
||||
}
|
||||
|
||||
export function setupOnInit(onInit: InitScriptOrFn): InitScript {
|
||||
return "init" in onInit
|
||||
return 'init' in onInit
|
||||
? onInit
|
||||
: {
|
||||
init: async (effects, kind) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ExtendedVersion, VersionRange } from "../../../base/lib/exver"
|
||||
import * as T from "../../../base/lib/types"
|
||||
import { ExtendedVersion, VersionRange } from '../../../base/lib/exver'
|
||||
import * as T from '../../../base/lib/types'
|
||||
|
||||
export type UninitFn = (
|
||||
effects: T.Effects,
|
||||
@@ -34,14 +34,14 @@ export function setupUninit(
|
||||
): T.ExpectedExports.uninit {
|
||||
return async (opts) => {
|
||||
for (const uninit of uninits) {
|
||||
if ("uninit" in uninit) await uninit.uninit(opts.effects, opts.target)
|
||||
if ('uninit' in uninit) await uninit.uninit(opts.effects, opts.target)
|
||||
else await uninit(opts.effects, opts.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setupOnUninit(onUninit: UninitScriptOrFn): UninitScript {
|
||||
return "uninit" in onUninit
|
||||
return 'uninit' in onUninit
|
||||
? onUninit
|
||||
: {
|
||||
uninit: async (effects, target) => {
|
||||
|
||||
@@ -1,72 +1,44 @@
|
||||
/**
|
||||
* @module Host
|
||||
*
|
||||
* This module provides the MultiHost class for binding network ports and
|
||||
* exposing service interfaces through the StartOS networking layer.
|
||||
*
|
||||
* MultiHost handles the complexity of:
|
||||
* - Port binding with protocol-specific defaults
|
||||
* - Automatic SSL/TLS setup for secure protocols
|
||||
* - Integration with Tor and LAN networking
|
||||
*/
|
||||
|
||||
import { object, string } from "ts-matches"
|
||||
import { Effects } from "../Effects"
|
||||
import { Origin } from "./Origin"
|
||||
import { AddSslOptions, BindParams } from "../osBindings"
|
||||
import { Security } from "../osBindings"
|
||||
import { BindOptions } from "../osBindings"
|
||||
import { AlpnInfo } from "../osBindings"
|
||||
import { object, string } from 'ts-matches'
|
||||
import { Effects } from '../Effects'
|
||||
import { Origin } from './Origin'
|
||||
import { AddSslOptions, BindParams } from '../osBindings'
|
||||
import { Security } from '../osBindings'
|
||||
import { BindOptions } from '../osBindings'
|
||||
import { AlpnInfo } from '../osBindings'
|
||||
|
||||
export { AddSslOptions, Security, BindOptions }
|
||||
|
||||
/**
|
||||
* Known protocol definitions with their default ports and SSL variants.
|
||||
*
|
||||
* Each protocol includes:
|
||||
* - `secure` - Whether the protocol is inherently secure (SSL/TLS)
|
||||
* - `defaultPort` - Standard port for this protocol
|
||||
* - `withSsl` - The SSL variant of the protocol (if applicable)
|
||||
* - `alpn` - ALPN negotiation info for TLS
|
||||
*/
|
||||
export const knownProtocols = {
|
||||
/** HTTP - plain text web traffic, auto-upgrades to HTTPS */
|
||||
http: {
|
||||
secure: null,
|
||||
defaultPort: 80,
|
||||
withSsl: "https",
|
||||
alpn: { specified: ["http/1.1"] } as AlpnInfo,
|
||||
withSsl: 'https',
|
||||
alpn: { specified: ['http/1.1'] } as AlpnInfo,
|
||||
},
|
||||
/** HTTPS - encrypted web traffic */
|
||||
https: {
|
||||
secure: { ssl: true },
|
||||
defaultPort: 443,
|
||||
},
|
||||
/** WebSocket - plain text, auto-upgrades to WSS */
|
||||
ws: {
|
||||
secure: null,
|
||||
defaultPort: 80,
|
||||
withSsl: "wss",
|
||||
alpn: { specified: ["http/1.1"] } as AlpnInfo,
|
||||
withSsl: 'wss',
|
||||
alpn: { specified: ['http/1.1'] } as AlpnInfo,
|
||||
},
|
||||
/** Secure WebSocket */
|
||||
wss: {
|
||||
secure: { ssl: true },
|
||||
defaultPort: 443,
|
||||
},
|
||||
/** SSH - inherently secure (no SSL wrapper needed) */
|
||||
ssh: {
|
||||
secure: { ssl: false },
|
||||
defaultPort: 22,
|
||||
},
|
||||
/** DNS - domain name service */
|
||||
dns: {
|
||||
secure: { ssl: false },
|
||||
defaultPort: 53,
|
||||
},
|
||||
} as const
|
||||
|
||||
/** Protocol scheme string or null for no scheme */
|
||||
export type Scheme = string | null
|
||||
|
||||
type KnownProtocols = typeof knownProtocols
|
||||
@@ -97,47 +69,14 @@ export type BindOptionsByProtocol =
|
||||
| BindOptionsByKnownProtocol
|
||||
| (BindOptions & { protocol: null })
|
||||
|
||||
/** @internal Helper to detect if protocol is a known protocol string */
|
||||
const hasStringProtocol = object({
|
||||
protocol: string,
|
||||
}).test
|
||||
|
||||
/**
|
||||
* Manages network bindings for a service interface.
|
||||
*
|
||||
* MultiHost is the primary way to expose your service's ports to users.
|
||||
* It handles:
|
||||
* - Port binding with the StartOS networking layer
|
||||
* - Protocol-aware defaults (HTTP uses port 80, HTTPS uses 443, etc.)
|
||||
* - Automatic SSL certificate provisioning for secure protocols
|
||||
* - Creation of Origin objects for exporting service interfaces
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Create a host for the web UI
|
||||
* const webHost = sdk.MultiHost.of(effects, 'webui')
|
||||
*
|
||||
* // Bind port 3000 with HTTP (automatically adds HTTPS variant)
|
||||
* const webOrigin = await webHost.bindPort(3000, { protocol: 'http' })
|
||||
*
|
||||
* // Export the interface
|
||||
* await webOrigin.export([
|
||||
* sdk.ServiceInterfaceBuilder.of({
|
||||
* effects,
|
||||
* name: 'Web Interface',
|
||||
* id: 'webui',
|
||||
* description: 'Access the dashboard',
|
||||
* type: 'ui',
|
||||
* })
|
||||
* ])
|
||||
* ```
|
||||
*/
|
||||
export class MultiHost {
|
||||
constructor(
|
||||
readonly options: {
|
||||
/** Effects instance for system operations */
|
||||
effects: Effects
|
||||
/** Unique identifier for this host binding */
|
||||
id: string
|
||||
},
|
||||
) {}
|
||||
@@ -201,8 +140,8 @@ export class MultiHost {
|
||||
addXForwardedHeaders: false,
|
||||
preferredExternalPort: knownProtocols[sslProto].defaultPort,
|
||||
scheme: sslProto,
|
||||
alpn: "alpn" in protoInfo ? protoInfo.alpn : null,
|
||||
...("addSsl" in options ? options.addSsl : null),
|
||||
alpn: 'alpn' in protoInfo ? protoInfo.alpn : null,
|
||||
...('addSsl' in options ? options.addSsl : null),
|
||||
}
|
||||
: options.addSsl
|
||||
? {
|
||||
@@ -210,7 +149,7 @@ export class MultiHost {
|
||||
preferredExternalPort: 443,
|
||||
scheme: sslProto,
|
||||
alpn: null,
|
||||
...("addSsl" in options ? options.addSsl : null),
|
||||
...('addSsl' in options ? options.addSsl : null),
|
||||
}
|
||||
: null
|
||||
|
||||
@@ -230,8 +169,8 @@ export class MultiHost {
|
||||
private getSslProto(options: BindOptionsByKnownProtocol) {
|
||||
const proto = options.protocol
|
||||
const protoInfo = knownProtocols[proto]
|
||||
if (inObject("noAddSsl", options) && options.noAddSsl) return null
|
||||
if ("withSsl" in protoInfo && protoInfo.withSsl) return protoInfo.withSsl
|
||||
if (inObject('noAddSsl', options) && options.noAddSsl) return null
|
||||
if ('withSsl' in protoInfo && protoInfo.withSsl) return protoInfo.withSsl
|
||||
if (protoInfo.secure?.ssl) return proto
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,66 +1,16 @@
|
||||
/**
|
||||
* @module Origin
|
||||
*
|
||||
* The Origin class represents a bound network origin (protocol + host + port)
|
||||
* that can be used to export service interfaces.
|
||||
*/
|
||||
import { AddressInfo } from '../types'
|
||||
import { AddressReceipt } from './AddressReceipt'
|
||||
import { MultiHost, Scheme } from './Host'
|
||||
import { ServiceInterfaceBuilder } from './ServiceInterfaceBuilder'
|
||||
|
||||
import { AddressInfo } from "../types"
|
||||
import { AddressReceipt } from "./AddressReceipt"
|
||||
import { MultiHost, Scheme } from "./Host"
|
||||
import { ServiceInterfaceBuilder } from "./ServiceInterfaceBuilder"
|
||||
|
||||
/**
|
||||
* Represents a network origin (protocol://host:port) created from a MultiHost binding.
|
||||
*
|
||||
* Origins are created by calling `MultiHost.bindPort()` and can be used to
|
||||
* export one or more service interfaces that share the same underlying connection.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Create origin from host binding
|
||||
* const origin = await webHost.bindPort(8080, { protocol: 'http' })
|
||||
*
|
||||
* // Export multiple interfaces sharing this origin
|
||||
* await origin.export([
|
||||
* sdk.ServiceInterfaceBuilder.of({
|
||||
* effects,
|
||||
* name: 'Web UI',
|
||||
* id: 'ui',
|
||||
* type: 'ui',
|
||||
* description: 'Main web interface',
|
||||
* }),
|
||||
* sdk.ServiceInterfaceBuilder.of({
|
||||
* effects,
|
||||
* name: 'REST API',
|
||||
* id: 'api',
|
||||
* type: 'api',
|
||||
* description: 'JSON API endpoint',
|
||||
* path: '/api/v1',
|
||||
* }),
|
||||
* ])
|
||||
* ```
|
||||
*/
|
||||
export class Origin {
|
||||
constructor(
|
||||
/** The MultiHost this origin was created from */
|
||||
readonly host: MultiHost,
|
||||
/** The internal port this origin is bound to */
|
||||
readonly internalPort: number,
|
||||
/** The protocol scheme (e.g., "http", "ssh") or null */
|
||||
readonly scheme: string | null,
|
||||
/** The SSL variant scheme (e.g., "https", "wss") or null */
|
||||
readonly sslScheme: string | null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Builds an AddressInfo object for this origin with the specified options.
|
||||
* Used internally by `export()` but can be called directly for custom use cases.
|
||||
*
|
||||
* @param options - Build options including path, query params, username, and scheme overrides
|
||||
* @returns AddressInfo object describing the complete address
|
||||
* @internal
|
||||
*/
|
||||
build({
|
||||
username,
|
||||
path,
|
||||
@@ -71,9 +21,9 @@ export class Origin {
|
||||
.map(
|
||||
([key, val]) => `${encodeURIComponent(key)}=${encodeURIComponent(val)}`,
|
||||
)
|
||||
.join("&")
|
||||
.join('&')
|
||||
|
||||
const qp = qpEntries.length ? `?${qpEntries}` : ""
|
||||
const qp = qpEntries.length ? `?${qpEntries}` : ''
|
||||
|
||||
return {
|
||||
hostId: this.host.options.id,
|
||||
@@ -86,28 +36,12 @@ export class Origin {
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports one or more service interfaces for this origin.
|
||||
* @description A function to register a group of origins (<PROTOCOL> :// <HOSTNAME> : <PORT>) with StartOS
|
||||
*
|
||||
* Each service interface becomes a clickable link in the StartOS UI.
|
||||
* Multiple interfaces can share the same origin but have different
|
||||
* names, descriptions, types, paths, or query parameters.
|
||||
* The returned addressReceipt serves as proof that the addresses were registered
|
||||
*
|
||||
* @param serviceInterfaces - Array of ServiceInterfaceBuilder objects to export
|
||||
* @returns Promise resolving to array of AddressInfo with an AddressReceipt
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await origin.export([
|
||||
* sdk.ServiceInterfaceBuilder.of({
|
||||
* effects,
|
||||
* name: 'Admin Panel',
|
||||
* id: 'admin',
|
||||
* type: 'ui',
|
||||
* description: 'Administrator dashboard',
|
||||
* path: '/admin',
|
||||
* })
|
||||
* ])
|
||||
* ```
|
||||
* @param addressInfo
|
||||
* @returns
|
||||
*/
|
||||
async export(
|
||||
serviceInterfaces: ServiceInterfaceBuilder[],
|
||||
@@ -149,17 +83,9 @@ export class Origin {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for building an address from an Origin.
|
||||
* @internal
|
||||
*/
|
||||
type BuildOptions = {
|
||||
/** Override the default schemes for SSL and non-SSL connections */
|
||||
schemeOverride: { ssl: Scheme; noSsl: Scheme } | null
|
||||
/** Optional username for basic auth URLs */
|
||||
username: string | null
|
||||
/** URL path (e.g., "/api/v1") */
|
||||
path: string
|
||||
/** Query parameters to append to the URL */
|
||||
query: Record<string, string>
|
||||
}
|
||||
|
||||
@@ -1,86 +1,30 @@
|
||||
/**
|
||||
* @module ServiceInterfaceBuilder
|
||||
*
|
||||
* Provides the ServiceInterfaceBuilder class for creating service interface
|
||||
* configurations that are exported to the StartOS UI.
|
||||
*/
|
||||
|
||||
import { ServiceInterfaceType } from "../types"
|
||||
import { Effects } from "../Effects"
|
||||
import { Scheme } from "./Host"
|
||||
import { ServiceInterfaceType } from '../types'
|
||||
import { Effects } from '../Effects'
|
||||
import { Scheme } from './Host'
|
||||
|
||||
/**
|
||||
* Builder class for creating service interface configurations.
|
||||
* A helper class for creating a Network Interface
|
||||
*
|
||||
* A service interface represents a user-visible endpoint in the StartOS UI.
|
||||
* It appears as a clickable link that users can use to access your service's
|
||||
* web UI, API, or other network resources.
|
||||
* Network Interfaces are collections of web addresses that expose the same API or other resource,
|
||||
* display to the user with under a common name and description.
|
||||
*
|
||||
* Interfaces are created with this builder and then exported via `Origin.export()`.
|
||||
* All URIs on an interface inherit the same ui: bool, basic auth credentials, path, and search (query) params
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Create a basic web UI interface
|
||||
* const webInterface = sdk.ServiceInterfaceBuilder.of({
|
||||
* effects,
|
||||
* name: 'Web Interface',
|
||||
* id: 'webui',
|
||||
* description: 'Access the main web dashboard',
|
||||
* type: 'ui',
|
||||
* })
|
||||
*
|
||||
* // Create an API interface with a specific path
|
||||
* const apiInterface = sdk.ServiceInterfaceBuilder.of({
|
||||
* effects,
|
||||
* name: 'REST API',
|
||||
* id: 'api',
|
||||
* description: 'JSON API for programmatic access',
|
||||
* type: 'api',
|
||||
* path: '/api/v1',
|
||||
* })
|
||||
*
|
||||
* // Create an interface with basic auth
|
||||
* const protectedInterface = sdk.ServiceInterfaceBuilder.of({
|
||||
* effects,
|
||||
* name: 'Admin Panel',
|
||||
* id: 'admin',
|
||||
* description: 'Protected admin area',
|
||||
* type: 'ui',
|
||||
* username: 'admin',
|
||||
* masked: true, // Hide the URL from casual viewing
|
||||
* })
|
||||
*
|
||||
* // Export all interfaces on the same origin
|
||||
* await origin.export([webInterface, apiInterface, protectedInterface])
|
||||
* ```
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
export class ServiceInterfaceBuilder {
|
||||
constructor(
|
||||
readonly options: {
|
||||
/** Effects instance for system operations */
|
||||
effects: Effects
|
||||
/** Display name shown in the StartOS UI */
|
||||
name: string
|
||||
/** Unique identifier for this interface */
|
||||
id: string
|
||||
/** Description shown below the interface name */
|
||||
description: string
|
||||
/**
|
||||
* Type of interface:
|
||||
* - `"ui"` - Web interface (opens in browser)
|
||||
* - `"api"` - API endpoint (for programmatic access)
|
||||
* - `"p2p"` - Peer-to-peer endpoint (e.g., Bitcoin P2P)
|
||||
*/
|
||||
type: ServiceInterfaceType
|
||||
/** Username for basic auth URLs (null for no auth) */
|
||||
username: string | null
|
||||
/** URL path to append (e.g., "/admin", "/api/v1") */
|
||||
path: string
|
||||
/** Query parameters to append to the URL */
|
||||
query: Record<string, string>
|
||||
/** Override default protocol schemes */
|
||||
schemeOverride: { ssl: Scheme; noSsl: Scheme } | null
|
||||
/** If true, the URL is hidden/masked in the UI (for sensitive endpoints) */
|
||||
masked: boolean
|
||||
},
|
||||
) {}
|
||||
|
||||
@@ -1,84 +1,20 @@
|
||||
/**
|
||||
* @module setupInterfaces
|
||||
*
|
||||
* This module provides utilities for setting up network interfaces (service endpoints)
|
||||
* that are exposed to users through the StartOS UI.
|
||||
*
|
||||
* Service interfaces are the clickable links users see to access your service's
|
||||
* web UI, API endpoints, or peer-to-peer connections.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
|
||||
* const webHost = sdk.MultiHost.of(effects, 'webui')
|
||||
* const webOrigin = await webHost.bindPort(8080, { protocol: 'http' })
|
||||
*
|
||||
* await webOrigin.export([
|
||||
* sdk.ServiceInterfaceBuilder.of({
|
||||
* effects,
|
||||
* name: 'Web Interface',
|
||||
* id: 'webui',
|
||||
* description: 'Access the web dashboard',
|
||||
* type: 'ui',
|
||||
* })
|
||||
* ])
|
||||
*
|
||||
* return [webOrigin]
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
import * as T from '../types'
|
||||
import { once } from '../util'
|
||||
import { AddressReceipt } from './AddressReceipt'
|
||||
|
||||
import * as T from "../types"
|
||||
import { once } from "../util"
|
||||
import { AddressReceipt } from "./AddressReceipt"
|
||||
|
||||
/** @internal Type brand for interface update receipt */
|
||||
declare const UpdateServiceInterfacesProof: unique symbol
|
||||
|
||||
/**
|
||||
* Receipt type proving that service interfaces have been updated.
|
||||
* @internal
|
||||
*/
|
||||
export type UpdateServiceInterfacesReceipt = {
|
||||
[UpdateServiceInterfacesProof]: never
|
||||
}
|
||||
|
||||
/** Array of address info arrays with receipts, representing all exported interfaces */
|
||||
export type ServiceInterfacesReceipt = Array<T.AddressInfo[] & AddressReceipt>
|
||||
|
||||
/**
|
||||
* Function type for setting up service interfaces.
|
||||
* @typeParam Output - The specific receipt type returned
|
||||
*/
|
||||
export type SetServiceInterfaces<Output extends ServiceInterfacesReceipt> =
|
||||
(opts: { effects: T.Effects }) => Promise<Output>
|
||||
|
||||
/** Function type for the init-compatible interface updater */
|
||||
export type UpdateServiceInterfaces = (effects: T.Effects) => Promise<null>
|
||||
|
||||
/** Function type for the setupServiceInterfaces helper */
|
||||
export type SetupServiceInterfaces = <Output extends ServiceInterfacesReceipt>(
|
||||
fn: SetServiceInterfaces<Output>,
|
||||
) => UpdateServiceInterfaces
|
||||
|
||||
/**
|
||||
* Constant indicating no interface changes are needed.
|
||||
* Use this as a return value when interfaces don't need to be updated.
|
||||
*/
|
||||
export const NO_INTERFACE_CHANGES = {} as UpdateServiceInterfacesReceipt
|
||||
|
||||
/**
|
||||
* Creates an interface setup function for use in the initialization pipeline.
|
||||
*
|
||||
* **Note:** This is exposed via `sdk.setupInterfaces`. See the SDK documentation
|
||||
* for full usage examples and parameter descriptions.
|
||||
*
|
||||
* Internally, this wrapper:
|
||||
* - Tracks all bindings and interfaces created during setup
|
||||
* - Automatically cleans up stale bindings/interfaces that weren't recreated
|
||||
*
|
||||
* @see sdk.setupInterfaces for usage documentation
|
||||
*/
|
||||
export const setupServiceInterfaces: SetupServiceInterfaces = <
|
||||
Output extends ServiceInterfacesReceipt,
|
||||
>(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AnyVerifyingKey } from "./AnyVerifyingKey"
|
||||
import type { AnyVerifyingKey } from './AnyVerifyingKey'
|
||||
|
||||
export type AcceptSigners =
|
||||
| { signer: AnyVerifyingKey }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Guid } from "./Guid"
|
||||
import type { Guid } from './Guid'
|
||||
|
||||
export type ActionInput = {
|
||||
eventId: Guid
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ActionVisibility } from "./ActionVisibility"
|
||||
import type { AllowedStatuses } from "./AllowedStatuses"
|
||||
import type { ActionVisibility } from './ActionVisibility'
|
||||
import type { AllowedStatuses } from './AllowedStatuses'
|
||||
|
||||
export type ActionMetadata = {
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ActionResultV0 } from "./ActionResultV0"
|
||||
import type { ActionResultV1 } from "./ActionResultV1"
|
||||
import type { ActionResultV0 } from './ActionResultV0'
|
||||
import type { ActionResultV1 } from './ActionResultV1'
|
||||
|
||||
export type ActionResult =
|
||||
| ({ version: "0" } & ActionResultV0)
|
||||
| ({ version: "1" } & ActionResultV1)
|
||||
| ({ version: '0' } & ActionResultV0)
|
||||
| ({ version: '1' } & ActionResultV1)
|
||||
|
||||
@@ -11,7 +11,7 @@ export type ActionResultMember = {
|
||||
description: string | null
|
||||
} & (
|
||||
| {
|
||||
type: "single"
|
||||
type: 'single'
|
||||
/**
|
||||
* The actual string value to display
|
||||
*/
|
||||
@@ -30,7 +30,7 @@ export type ActionResultMember = {
|
||||
masked: boolean
|
||||
}
|
||||
| {
|
||||
type: "group"
|
||||
type: 'group'
|
||||
/**
|
||||
* An new group of nested values, experienced by the user as an accordion dropdown
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ActionResultValue } from "./ActionResultValue"
|
||||
import type { ActionResultValue } from './ActionResultValue'
|
||||
|
||||
export type ActionResultV1 = {
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ActionResultMember } from "./ActionResultMember"
|
||||
import type { ActionResultMember } from './ActionResultMember'
|
||||
|
||||
export type ActionResultValue =
|
||||
| {
|
||||
type: "single"
|
||||
type: 'single'
|
||||
/**
|
||||
* The actual string value to display
|
||||
*/
|
||||
@@ -22,7 +22,7 @@ export type ActionResultValue =
|
||||
masked: boolean
|
||||
}
|
||||
| {
|
||||
type: "group"
|
||||
type: 'group'
|
||||
/**
|
||||
* An new group of nested values, experienced by the user as an accordion dropdown
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ActionVisibility = "hidden" | { disabled: string } | "enabled"
|
||||
export type ActionVisibility = 'hidden' | { disabled: string } | 'enabled'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Guid } from "./Guid"
|
||||
import type { Guid } from './Guid'
|
||||
|
||||
export type AddAdminParams = { signer: Guid }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AnySignature } from "./AnySignature"
|
||||
import type { Blake3Commitment } from "./Blake3Commitment"
|
||||
import type { AnySignature } from './AnySignature'
|
||||
import type { Blake3Commitment } from './Blake3Commitment'
|
||||
|
||||
export type AddAssetParams = {
|
||||
version: string
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { LocaleString } from "./LocaleString"
|
||||
import type { LocaleString } from './LocaleString'
|
||||
|
||||
export type AddCategoryParams = { id: string; name: LocaleString }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AnySignature } from "./AnySignature"
|
||||
import type { MerkleArchiveCommitment } from "./MerkleArchiveCommitment"
|
||||
import type { AnySignature } from './AnySignature'
|
||||
import type { MerkleArchiveCommitment } from './MerkleArchiveCommitment'
|
||||
|
||||
export type AddMirrorParams = {
|
||||
url: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AnySignature } from "./AnySignature"
|
||||
import type { MerkleArchiveCommitment } from "./MerkleArchiveCommitment"
|
||||
import type { AnySignature } from './AnySignature'
|
||||
import type { MerkleArchiveCommitment } from './MerkleArchiveCommitment'
|
||||
|
||||
export type AddPackageParams = {
|
||||
urls: string[]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Guid } from "./Guid"
|
||||
import type { PackageId } from "./PackageId"
|
||||
import type { Guid } from './Guid'
|
||||
import type { PackageId } from './PackageId'
|
||||
|
||||
export type AddPackageSignerParams = {
|
||||
id: PackageId
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { PackageId } from "./PackageId"
|
||||
import type { PackageId } from './PackageId'
|
||||
|
||||
export type AddPackageToCategoryParams = { id: string; package: PackageId }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AlpnInfo } from "./AlpnInfo"
|
||||
import type { AlpnInfo } from './AlpnInfo'
|
||||
|
||||
export type AddSslOptions = {
|
||||
preferredExternalPort: number
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { HostId } from "./HostId"
|
||||
import type { HostId } from './HostId'
|
||||
|
||||
export type AddressInfo = {
|
||||
username: string | null
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { LocaleString } from "./LocaleString"
|
||||
import type { LocaleString } from './LocaleString'
|
||||
|
||||
export type Alerts = {
|
||||
install: LocaleString | null
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type Algorithm = "ecdsa" | "ed25519"
|
||||
export type Algorithm = 'ecdsa' | 'ed25519'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { PackageDataEntry } from "./PackageDataEntry"
|
||||
import type { PackageId } from "./PackageId"
|
||||
import type { PackageDataEntry } from './PackageDataEntry'
|
||||
import type { PackageId } from './PackageId'
|
||||
|
||||
export type AllPackageData = { [key: PackageId]: PackageDataEntry }
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type AllowedStatuses = "only-running" | "only-stopped" | "any"
|
||||
export type AllowedStatuses = 'only-running' | 'only-stopped' | 'any'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user