Compare commits
2 Commits
claude
...
feature/ou
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea130079ab | ||
|
|
cc5f316514 |
2
.gitignore
vendored
@@ -21,4 +21,4 @@ secrets.db
|
||||
/build/lib/firmware
|
||||
tmp
|
||||
web/.i18n-checked
|
||||
docs/USER.md
|
||||
agents/USER.md
|
||||
|
||||
109
CLAUDE.md
@@ -11,7 +11,7 @@ StartOS is an open-source Linux distribution for running personal servers. It ma
|
||||
- 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 `core/rpc-toolkit.md`)
|
||||
- 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
|
||||
@@ -49,32 +49,113 @@ When making changes across multiple layers (Rust, SDK, web, container-runtime),
|
||||
|
||||
## Architecture
|
||||
|
||||
Each major component has its own `CLAUDE.md` with detailed guidance.
|
||||
### 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
|
||||
|
||||
- **`core/`** — Rust backend daemon (startbox, start-cli, start-container, registrybox, tunnelbox)
|
||||
- **`web/`** — Angular frontend workspace (admin UI, setup wizard, marketplace, shared library)
|
||||
- **`container-runtime/`** — Node.js runtime managing service containers via JSON-RPC
|
||||
- **`sdk/`** — TypeScript SDK for packaging services (`@start9labs/start-sdk`)
|
||||
- **`patch-db/`** — Git submodule providing diff-based state synchronization
|
||||
**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 `docs/` directory contains cross-cutting documentation for AI assistants:
|
||||
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)
|
||||
- `exver.md` - Extended versioning format (used across core, sdk, and web)
|
||||
- `VERSION_BUMP.md` - Guide for bumping the StartOS version across the codebase
|
||||
|
||||
Component-specific docs live alongside their code (e.g., `core/rpc-toolkit.md`, `core/i18n-patterns.md`).
|
||||
- `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 `docs/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.
|
||||
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 `docs/TODO.md` for relevant tasks** - Show TODOs that either:
|
||||
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
|
||||
|
||||
|
||||
140
CONTRIBUTING.md
@@ -1,10 +1,11 @@
|
||||
# Contributing to StartOS
|
||||
|
||||
This guide is for contributing to the StartOS. If you are interested in packaging a service for StartOS, visit the [service packaging guide](https://github.com/Start9Labs/ai-service-packaging). If you are interested in promoting, providing technical support, creating tutorials, or helping in other ways, please visit the [Start9 website](https://start9.com/contribute).
|
||||
This guide is for contributing to the StartOS. If you are interested in packaging a service for StartOS, visit the [service packaging guide](https://docs.start9.com/latest/packaging-guide/). If you are interested in promoting, providing technical support, creating tutorials, or helping in other ways, please visit the [Start9 website](https://start9.com/contribute).
|
||||
|
||||
## Collaboration
|
||||
|
||||
- [Matrix](https://matrix.to/#/#dev-startos:matrix.start9labs.com)
|
||||
- [Matrix](https://matrix.to/#/#community-dev:matrix.start9labs.com)
|
||||
- [Telegram](https://t.me/start9_labs/47471)
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -22,7 +23,6 @@ This guide is for contributing to the StartOS. If you are interested in packagin
|
||||
```
|
||||
|
||||
See component READMEs for details:
|
||||
|
||||
- [`core`](core/README.md)
|
||||
- [`web`](web/README.md)
|
||||
- [`build`](build/README.md)
|
||||
@@ -30,36 +30,8 @@ See component READMEs for details:
|
||||
|
||||
## Environment Setup
|
||||
|
||||
### Installing Dependencies (Debian/Ubuntu)
|
||||
|
||||
> Debian/Ubuntu is the only officially supported build environment.
|
||||
> MacOS has limited build capabilities and Windows requires [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install).
|
||||
|
||||
```sh
|
||||
sudo apt update
|
||||
sudo apt install -y ca-certificates curl gpg build-essential
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
|
||||
echo "deb [arch=$(dpkg-architecture -q DEB_HOST_ARCH) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian bookworm stable" | sudo tee /etc/apt/sources.list.d/docker.list
|
||||
sudo apt update
|
||||
sudo apt install -y sed grep gawk jq gzip brotli containerd.io docker-ce docker-ce-cli docker-compose-plugin qemu-user-static binfmt-support squashfs-tools git debspawn rsync b3sum
|
||||
sudo mkdir -p /etc/debspawn/
|
||||
echo "AllowUnsafePermissions=true" | sudo tee /etc/debspawn/global.toml
|
||||
sudo usermod -aG docker $USER
|
||||
sudo su $USER
|
||||
docker run --privileged --rm tonistiigi/binfmt --install all
|
||||
docker buildx create --use
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # proceed with default installation
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash
|
||||
source ~/.bashrc
|
||||
nvm install 24
|
||||
nvm use 24
|
||||
nvm alias default 24 # this prevents your machine from reverting back to another version
|
||||
```
|
||||
|
||||
### Cloning the Repository
|
||||
|
||||
```sh
|
||||
git clone --recursive https://github.com/Start9Labs/start-os.git --branch next/major
|
||||
git clone https://github.com/Start9Labs/start-os.git --recurse-submodules
|
||||
cd start-os
|
||||
```
|
||||
|
||||
@@ -91,21 +63,19 @@ This project uses [GNU Make](https://www.gnu.org/software/make/) to build its co
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
**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
|
||||
@@ -114,72 +84,46 @@ This project uses [GNU Make](https://www.gnu.org/software/make/) to build its co
|
||||
|
||||
#### 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 |
|
||||
| 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) |
|
||||
| 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 |
|
||||
|
||||
### Creating a VM
|
||||
|
||||
Install virt-manager:
|
||||
|
||||
```sh
|
||||
sudo apt update
|
||||
sudo apt install -y virt-manager
|
||||
sudo usermod -aG libvirt $USER
|
||||
sudo su $USER
|
||||
virt-manager
|
||||
```
|
||||
|
||||
Follow the screenshot walkthrough in [`assets/create-vm/`](assets/create-vm/) to create a new virtual machine. Key steps:
|
||||
|
||||
1. Create a new virtual machine
|
||||
2. Browse for the ISO — create a storage pool pointing to your `results/` directory
|
||||
3. Select "Generic or unknown OS"
|
||||
4. Set memory and CPUs
|
||||
5. Create a disk and name the VM
|
||||
|
||||
Build an ISO first:
|
||||
|
||||
```sh
|
||||
PLATFORM=$(uname -m) ENVIRONMENT=dev make iso
|
||||
```
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -212,18 +156,15 @@ Run the formatters before committing. Configuration is handled by `rustfmt.toml`
|
||||
### 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`
|
||||
@@ -241,7 +182,6 @@ Use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
```
|
||||
|
||||
**Types:**
|
||||
|
||||
- `feat` - New feature
|
||||
- `fix` - Bug fix
|
||||
- `docs` - Documentation only
|
||||
@@ -251,10 +191,10 @@ Use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
- `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
|
||||
```
|
||||
|
||||
|
||||
134
DEVELOPMENT.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Setting up your development environment on Debian/Ubuntu
|
||||
|
||||
A step-by-step guide
|
||||
|
||||
> This is the only officially supported build environment.
|
||||
> MacOS has limited build capabilities and Windows requires [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install)
|
||||
|
||||
## Installing dependencies
|
||||
|
||||
Run the following commands one at a time
|
||||
|
||||
```sh
|
||||
sudo apt update
|
||||
sudo apt install -y ca-certificates curl gpg build-essential
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
|
||||
echo "deb [arch=$(dpkg-architecture -q DEB_HOST_ARCH) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian bookworm stable" | sudo tee /etc/apt/sources.list.d/docker.list
|
||||
sudo apt update
|
||||
sudo apt install -y sed grep gawk jq gzip brotli containerd.io docker-ce docker-ce-cli docker-compose-plugin qemu-user-static binfmt-support squashfs-tools git debspawn rsync b3sum
|
||||
sudo mkdir -p /etc/debspawn/
|
||||
echo "AllowUnsafePermissions=true" | sudo tee /etc/debspawn/global.toml
|
||||
sudo usermod -aG docker $USER
|
||||
sudo su $USER
|
||||
docker run --privileged --rm tonistiigi/binfmt --install all
|
||||
docker buildx create --use
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # proceed with default installation
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash
|
||||
source ~/.bashrc
|
||||
nvm install 24
|
||||
nvm use 24
|
||||
nvm alias default 24 # this prevents your machine from reverting back to another version
|
||||
```
|
||||
|
||||
## Cloning the repository
|
||||
|
||||
```sh
|
||||
git clone --recursive https://github.com/Start9Labs/start-os.git --branch next/major
|
||||
cd start-os
|
||||
```
|
||||
|
||||
## Building an ISO
|
||||
|
||||
```sh
|
||||
PLATFORM=$(uname -m) ENVIRONMENT=dev make iso
|
||||
```
|
||||
|
||||
This will build an ISO for your current architecture. If you are building to run on an architecture other than the one you are currently on, replace `$(uname -m)` with the correct platform for the device (one of `aarch64`, `aarch64-nonfree`, `x86_64`, `x86_64-nonfree`, `raspberrypi`)
|
||||
|
||||
## Creating a VM
|
||||
|
||||
### Install virt-manager
|
||||
|
||||
```sh
|
||||
sudo apt update
|
||||
sudo apt install -y virt-manager
|
||||
sudo usermod -aG libvirt $USER
|
||||
sudo su $USER
|
||||
```
|
||||
|
||||
### Launch virt-manager
|
||||
|
||||
```sh
|
||||
virt-manager
|
||||
```
|
||||
|
||||
### Create new virtual machine
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
#### make sure to set "Target Path" to the path to your results directory in start-os
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Updating a VM
|
||||
|
||||
The fastest way to update a VM to your latest code depends on what you changed:
|
||||
|
||||
### UI or startd:
|
||||
|
||||
```sh
|
||||
PLATFORM=$(uname -m) ENVIRONMENT=dev make update-startbox REMOTE=start9@<VM IP>
|
||||
```
|
||||
|
||||
### Container runtime or debian dependencies:
|
||||
|
||||
```sh
|
||||
PLATFORM=$(uname -m) ENVIRONMENT=dev make update-deb REMOTE=start9@<VM IP>
|
||||
```
|
||||
|
||||
### Image recipe:
|
||||
|
||||
```sh
|
||||
PLATFORM=$(uname -m) ENVIRONMENT=dev make update-squashfs REMOTE=start9@<VM IP>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
If the device you are building for is not available via ssh, it is also possible to use `magic-wormhole` to send the relevant files.
|
||||
|
||||
### Prerequisites:
|
||||
|
||||
```sh
|
||||
sudo apt update
|
||||
sudo apt install -y magic-wormhole
|
||||
```
|
||||
|
||||
As before, the fastest way to update a VM to your latest code depends on what you changed. Each of the following commands will return a command to paste into the shell of the device you would like to upgrade.
|
||||
|
||||
### UI or startd:
|
||||
|
||||
```sh
|
||||
PLATFORM=$(uname -m) ENVIRONMENT=dev make wormhole
|
||||
```
|
||||
|
||||
### Container runtime or debian dependencies:
|
||||
|
||||
```sh
|
||||
PLATFORM=$(uname -m) ENVIRONMENT=dev make wormhole-deb
|
||||
```
|
||||
|
||||
### Image recipe:
|
||||
|
||||
```sh
|
||||
PLATFORM=$(uname -m) ENVIRONMENT=dev make wormhole-squashfs
|
||||
```
|
||||
6
Makefile
@@ -236,9 +236,9 @@ update-startbox: core/target/$(RUST_ARCH)-unknown-linux-musl/$(PROFILE)/startbox
|
||||
update-deb: results/$(BASENAME).deb # better than update, but only available from debian
|
||||
@if [ -z "$(REMOTE)" ]; then >&2 echo "Must specify REMOTE" && false; fi
|
||||
$(call ssh,'sudo /usr/lib/startos/scripts/chroot-and-upgrade --create')
|
||||
$(call mkdir,/media/startos/next/var/tmp/startos-deb)
|
||||
$(call cp,results/$(BASENAME).deb,/media/startos/next/var/tmp/startos-deb/$(BASENAME).deb)
|
||||
$(call ssh,'sudo /media/startos/next/usr/lib/startos/scripts/chroot-and-upgrade --no-sync "apt-get install -y --reinstall /var/tmp/startos-deb/$(BASENAME).deb"')
|
||||
$(call mkdir,/media/startos/next/tmp/startos-deb)
|
||||
$(call cp,results/$(BASENAME).deb,/media/startos/next/tmp/startos-deb/$(BASENAME).deb)
|
||||
$(call ssh,'sudo /media/startos/next/usr/lib/startos/scripts/chroot-and-upgrade --no-sync "apt-get install -y --reinstall /tmp/startos-deb/$(BASENAME).deb"')
|
||||
|
||||
update-squashfs: results/$(BASENAME).squashfs
|
||||
@if [ -z "$(REMOTE)" ]; then >&2 echo "Must specify REMOTE" && false; fi
|
||||
|
||||
88
README.md
@@ -7,64 +7,76 @@
|
||||
<a href="https://github.com/Start9Labs/start-os/actions/workflows/startos-iso.yaml">
|
||||
<img src="https://github.com/Start9Labs/start-os/actions/workflows/startos-iso.yaml/badge.svg">
|
||||
</a>
|
||||
<a href="https://heyapollo.com/product/startos">
|
||||
<a href="https://heyapollo.com/product/startos">
|
||||
<img alt="Static Badge" src="https://img.shields.io/badge/apollo-review%20%E2%AD%90%E2%AD%90%E2%AD%90%E2%AD%90%E2%AD%90%20-slateblue">
|
||||
</a>
|
||||
<a href="https://twitter.com/start9labs">
|
||||
<img alt="X (formerly Twitter) Follow" src="https://img.shields.io/twitter/follow/start9labs">
|
||||
</a>
|
||||
<a href="https://matrix.to/#/#community:matrix.start9labs.com">
|
||||
<img alt="Static Badge" src="https://img.shields.io/badge/community-matrix-yellow?logo=matrix">
|
||||
</a>
|
||||
<a href="https://t.me/start9_labs">
|
||||
<img alt="Static Badge" src="https://img.shields.io/badge/community-telegram-blue?logo=telegram">
|
||||
</a>
|
||||
<a href="https://docs.start9.com">
|
||||
<img alt="Static Badge" src="https://img.shields.io/badge/docs-orange?label=%F0%9F%91%A4%20support">
|
||||
</a>
|
||||
<a href="https://matrix.to/#/#dev-startos:matrix.start9labs.com">
|
||||
<a href="https://matrix.to/#/#community-dev:matrix.start9labs.com">
|
||||
<img alt="Static Badge" src="https://img.shields.io/badge/developer-matrix-darkcyan?logo=matrix">
|
||||
</a>
|
||||
<a href="https://start9.com">
|
||||
<img alt="Website" src="https://img.shields.io/website?up_message=online&down_message=offline&url=https%3A%2F%2Fstart9.com&logo=website&label=%F0%9F%8C%90%20website">
|
||||
</a>
|
||||
</div>
|
||||
<br />
|
||||
<div align="center">
|
||||
<h3>
|
||||
Welcome to the era of Sovereign Computing
|
||||
</h3>
|
||||
<p>
|
||||
StartOS is an open source Linux distribution optimized for running a personal server. It facilitates the discovery, installation, network configuration, service configuration, data backup, dependency management, and health monitoring of self-hosted software services.
|
||||
</p>
|
||||
</div>
|
||||
<br />
|
||||
<p align="center">
|
||||
<img src="assets/StartOS.png" alt="StartOS" width="85%">
|
||||
</p>
|
||||
<br />
|
||||
|
||||
## What is StartOS?
|
||||
## Running StartOS
|
||||
> [!WARNING]
|
||||
> StartOS is in beta. It lacks features. It doesn't always work perfectly. Start9 servers are not plug and play. Using them properly requires some effort and patience. Please do not use StartOS or purchase a server if you are unable or unwilling to follow instructions and learn new concepts.
|
||||
|
||||
StartOS is an open-source Linux distribution for running a personal server. It handles discovery, installation, network configuration, data backup, dependency management, and health monitoring of self-hosted services.
|
||||
### 💰 Buy a Start9 server
|
||||
This is the most convenient option. Simply [buy a server](https://store.start9.com) from Start9 and plug it in.
|
||||
|
||||
**Tech stack:** Rust backend (Tokio/Axum), Angular frontend, Node.js container runtime with LXC, and a custom diff-based database ([Patch-DB](https://github.com/Start9Labs/patch-db)) for reactive state synchronization.
|
||||
### 👷 Build your own server
|
||||
This option is easier than you might imagine, and there are 4 reasons why you might prefer it:
|
||||
1. You already have hardware
|
||||
1. You want to save on shipping costs
|
||||
1. You prefer not to divulge your physical address
|
||||
1. You just like building things
|
||||
|
||||
Services run in isolated LXC containers, packaged as [S9PKs](https://github.com/Start9Labs/start-os/blob/master/core/s9pk-structure.md) — a signed, merkle-archived format that supports partial downloads and cryptographic verification.
|
||||
To pursue this option, follow one of our [DIY guides](https://start9.com/latest/diy).
|
||||
|
||||
## What can you do with it?
|
||||
## ❤️ Contributing
|
||||
There are multiple ways to contribute: work directly on StartOS, package a service for the marketplace, or help with documentation and guides. To learn more about contributing, see [here](https://start9.com/contribute/).
|
||||
|
||||
StartOS lets you self-host services that would otherwise depend on third-party cloud providers — giving you full ownership of your data and infrastructure.
|
||||
To report security issues, please email our security team - security@start9.com.
|
||||
|
||||
Browse available services on the [Start9 Marketplace](https://marketplace.start9.com/), including:
|
||||
## 🌎 Marketplace
|
||||
There are dozens of services available for StartOS, and new ones are being added all the time. Check out the full list of available services [here](https://marketplace.start9.com/marketplace). To read more about the Marketplace ecosystem, check out this [blog post](https://blog.start9.com/start9-marketplace-strategy/)
|
||||
|
||||
- **Bitcoin & Lightning** — Run a full Bitcoin node, Lightning node, BTCPay Server, and other payment infrastructure
|
||||
- **Communication** — Self-host Matrix, SimpleX, or other messaging platforms
|
||||
- **Cloud Storage** — Run Nextcloud, Vaultwarden, and other productivity tools
|
||||
## 🖥️ User Interface Screenshots
|
||||
|
||||
Services are added by the community. If a service you want isn't available, you can [package it yourself](https://github.com/Start9Labs/ai-service-packaging/).
|
||||
|
||||
## Getting StartOS
|
||||
|
||||
### Buy a Start9 server
|
||||
|
||||
The easiest path. [Buy a server](https://store.start9.com) from Start9 and plug it in.
|
||||
|
||||
### Build your own
|
||||
|
||||
Install StartOS on your own hardware. Follow one of the [DIY guides](https://start9.com/latest/diy). Reasons to go this route:
|
||||
|
||||
1. You already have compatible hardware
|
||||
2. You want to save on shipping costs
|
||||
3. You prefer not to share your physical address
|
||||
4. You enjoy building things
|
||||
|
||||
### Build from source
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for environment setup, build instructions, and development workflow.
|
||||
|
||||
## Contributing
|
||||
|
||||
There are multiple ways to contribute: work directly on StartOS, package a service for the marketplace, or help with documentation and guides. See [CONTRIBUTING.md](CONTRIBUTING.md) or visit [start9.com/contribute](https://start9.com/contribute/).
|
||||
|
||||
To report security issues, email [security@start9.com](mailto:security@start9.com).
|
||||
<p align="center">
|
||||
<img src="assets/registry.png" alt="StartOS Marketplace" width="49%">
|
||||
<img src="assets/community.png" alt="StartOS Community Registry" width="49%">
|
||||
<img src="assets/c-lightning.png" alt="StartOS NextCloud Service" width="49%">
|
||||
<img src="assets/btcpay.png" alt="StartOS BTCPay Service" width="49%">
|
||||
<img src="assets/nextcloud.png" alt="StartOS System Settings" width="49%">
|
||||
<img src="assets/system.png" alt="StartOS System Settings" width="49%">
|
||||
<img src="assets/welcome.png" alt="StartOS System Settings" width="49%">
|
||||
<img src="assets/logs.png" alt="StartOS System Settings" width="49%">
|
||||
</p>
|
||||
|
||||
@@ -217,39 +217,6 @@ Pending tasks for AI agents. Remove items when completed.
|
||||
| `sdk/base/lib/interfaces/Host.ts` | SDK `MultiHost.bindPort()` — no changes needed |
|
||||
| `core/src/db/model/public.rs` | Public DB model — port forward mapping |
|
||||
|
||||
- [ ] Extract TS-exported types into a lightweight sub-crate for fast binding generation
|
||||
|
||||
**Problem**: `make ts-bindings` compiles the entire `start-os` crate (with all dependencies: tokio,
|
||||
axum, openssl, etc.) just to run test functions that serialize type definitions to `.ts` files.
|
||||
Even in debug mode, this takes minutes. The generated output is pure type info — no runtime code
|
||||
is needed.
|
||||
|
||||
**Goal**: Generate TS bindings in seconds by isolating exported types in a small crate with minimal
|
||||
dependencies.
|
||||
|
||||
**Approach**: Create a `core/bindings-types/` sub-crate containing (or re-exporting) all 168
|
||||
`#[ts(export)]` types. This crate depends only on `serde`, `ts-rs`, `exver`, and other type-only
|
||||
crates — not on tokio, axum, openssl, etc. Then `build-ts.sh` runs `cargo test -p bindings-types`
|
||||
instead of `cargo test -p start-os`.
|
||||
|
||||
**Challenge**: The exported types are scattered across `core/src/` and reference each other and
|
||||
other crate types. Extracting them requires either moving the type definitions into the sub-crate
|
||||
(and importing them back into `start-os`) or restructuring to share a common types crate.
|
||||
|
||||
- [ ] Use auto-generated RPC types in the frontend instead of manual duplicates
|
||||
|
||||
**Problem**: The web frontend manually defines ~755 lines of API request/response types in
|
||||
`web/projects/ui/src/app/services/api/api.types.ts` that can drift from the actual Rust types.
|
||||
|
||||
**Current state**: The Rust backend already has `#[ts(export)]` on RPC param types (e.g.
|
||||
`AddTunnelParams`, `SetWifiEnabledParams`, `LoginParams`), and they are generated into
|
||||
`core/bindings/`. However, commit `71b83245b` ("Chore/unexport api ts #2585", April 2024)
|
||||
deliberately stopped building them into the SDK and had the frontend maintain its own types.
|
||||
|
||||
**Goal**: Reverse that decision — pipe the generated RPC types through the SDK into the frontend
|
||||
so `api.types.ts` can import them instead of duplicating them. This eliminates drift between
|
||||
backend and frontend API contracts.
|
||||
|
||||
- [ ] Auto-configure port forwards via UPnP/NAT-PMP/PCP - @dr-bonez
|
||||
|
||||
**Blocked by**: "Support preferred external ports besides 443" (must be implemented and tested
|
||||
BIN
assets/StartOS.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
assets/btcpay.png
Normal file
|
After Width: | Height: | Size: 396 KiB |
BIN
assets/c-lightning.png
Normal file
|
After Width: | Height: | Size: 402 KiB |
BIN
assets/community.png
Normal file
|
After Width: | Height: | Size: 591 KiB |
BIN
assets/logs.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
assets/nextcloud.png
Normal file
|
After Width: | Height: | Size: 319 KiB |
BIN
assets/registry.png
Normal file
|
After Width: | Height: | Size: 521 KiB |
BIN
assets/system.png
Normal file
|
After Width: | Height: | Size: 331 KiB |
BIN
assets/welcome.png
Normal file
|
After Width: | Height: | Size: 402 KiB |
@@ -5,7 +5,7 @@ if [ -z "$sip" ] || [ -z "$dip" ] || [ -z "$dprefix" ] || [ -z "$sport" ] || [ -
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NAME="F$(echo "$sip:$sport -> $dip/$dprefix:$dport ${src_subnet:-any}" | 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
|
||||
@@ -13,7 +13,7 @@ for kind in INPUT FORWARD ACCEPT; do
|
||||
iptables -A $kind -j "${NAME}_${kind}"
|
||||
fi
|
||||
done
|
||||
for kind in PREROUTING OUTPUT; do
|
||||
for kind in PREROUTING INPUT OUTPUT POSTROUTING; do
|
||||
if ! iptables -t nat -C $kind -j "${NAME}_${kind}" 2> /dev/null; then
|
||||
iptables -t nat -N "${NAME}_${kind}" 2> /dev/null
|
||||
iptables -t nat -A $kind -j "${NAME}_${kind}"
|
||||
@@ -26,7 +26,7 @@ trap 'err=1' ERR
|
||||
for kind in INPUT FORWARD ACCEPT; do
|
||||
iptables -F "${NAME}_${kind}" 2> /dev/null
|
||||
done
|
||||
for kind in PREROUTING OUTPUT; do
|
||||
for kind in PREROUTING INPUT OUTPUT POSTROUTING; do
|
||||
iptables -t nat -F "${NAME}_${kind}" 2> /dev/null
|
||||
done
|
||||
if [ "$UNDO" = 1 ]; then
|
||||
@@ -37,7 +37,15 @@ fi
|
||||
|
||||
# DNAT: rewrite destination for incoming packets (external traffic)
|
||||
# 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
|
||||
@@ -49,6 +57,11 @@ fi
|
||||
iptables -t nat -A ${NAME}_OUTPUT -d "$sip" -p tcp --dport "$sport" -j DNAT --to-destination "$dip:$dport"
|
||||
iptables -t nat -A ${NAME}_OUTPUT -d "$sip" -p udp --dport "$sport" -j DNAT --to-destination "$dip:$dport"
|
||||
|
||||
# MASQUERADE: rewrite source for all forwarded traffic to the destination
|
||||
# This ensures responses are routed back through the host regardless of source IP
|
||||
iptables -t nat -A ${NAME}_POSTROUTING -d "$dip" -p tcp --dport "$dport" -j MASQUERADE
|
||||
iptables -t nat -A ${NAME}_POSTROUTING -d "$dip" -p udp --dport "$dport" -j MASQUERADE
|
||||
|
||||
# Allow new connections to be forwarded to the destination
|
||||
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
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# Container Runtime — Node.js Service Manager
|
||||
|
||||
Node.js runtime that manages service containers via JSON-RPC. See `RPCSpec.md` in this directory for the full RPC protocol.
|
||||
|
||||
## 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 `../core/s9pk-structure.md` for the S9PK package format.
|
||||
@@ -1,52 +0,0 @@
|
||||
# Core — Rust Backend
|
||||
|
||||
The Rust backend daemon for StartOS.
|
||||
|
||||
## 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 `rpc-toolkit.md` for JSON-RPC handler patterns and configuration.
|
||||
|
||||
## Patch-DB Patterns
|
||||
|
||||
Patch-DB provides 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()`
|
||||
|
||||
## i18n
|
||||
|
||||
See `i18n-patterns.md` for internationalization key conventions and the `t!()` macro.
|
||||
|
||||
## Rust Utilities & Patterns
|
||||
|
||||
See `core-rust-patterns.md` for common utilities (Invoke trait, Guard pattern, mount guards, Apply trait, etc.).
|
||||
663
core/Cargo.lock
generated
@@ -7,11 +7,11 @@ source ./builder-alias.sh
|
||||
set -ea
|
||||
shopt -s expand_aliases
|
||||
|
||||
PROFILE=${PROFILE:-debug}
|
||||
PROFILE=${PROFILE:-release}
|
||||
if [ "${PROFILE}" = "release" ]; then
|
||||
BUILD_FLAGS="--release"
|
||||
else
|
||||
if [ "$PROFILE" != "debug" ]; then
|
||||
if [ "$PROFILE" != "debug"]; then
|
||||
>&2 echo "Unknown profile $PROFILE: falling back to debug..."
|
||||
PROFILE=debug
|
||||
fi
|
||||
@@ -38,7 +38,7 @@ if [[ "${ENVIRONMENT}" =~ (^|-)console($|-) ]]; then
|
||||
fi
|
||||
echo "FEATURES=\"$FEATURES\""
|
||||
echo "RUSTFLAGS=\"$RUSTFLAGS\""
|
||||
rust-zig-builder cargo test --manifest-path=./core/Cargo.toml --lib $BUILD_FLAGS --features test,$FEATURES --locked 'export_bindings_'
|
||||
rust-zig-builder cargo test --manifest-path=./core/Cargo.toml $BUILD_FLAGS --features test,$FEATURES --locked 'export_bindings_'
|
||||
if [ "$(ls -nd "core/bindings" | awk '{ print $3 }')" != "$UID" ]; then
|
||||
rust-zig-builder sh -c "chown -R $UID:$UID core/target && chown -R $UID:$UID core/bindings && chown -R $UID:$UID /usr/local/cargo"
|
||||
fi
|
||||
@@ -20,9 +20,8 @@ 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, Bindings, DerivedAddressInfo, 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::*;
|
||||
use crate::progress::FullProgress;
|
||||
@@ -92,7 +91,7 @@ impl Public {
|
||||
.collect(),
|
||||
),
|
||||
public_domains: BTreeMap::new(),
|
||||
private_domains: BTreeMap::new(),
|
||||
private_domains: BTreeSet::new(),
|
||||
},
|
||||
wifi: WifiInfo {
|
||||
enabled: true,
|
||||
@@ -243,12 +242,44 @@ 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)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn secure(&self) -> bool {
|
||||
self.secure.unwrap_or(false)
|
||||
}
|
||||
@@ -285,20 +316,7 @@ pub enum NetworkInterfaceType {
|
||||
Loopback,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
Default,
|
||||
PartialEq,
|
||||
Eq,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
Deserialize,
|
||||
Serialize,
|
||||
TS,
|
||||
clap::ValueEnum,
|
||||
)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS, clap::ValueEnum)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum GatewayType {
|
||||
|
||||
@@ -71,7 +71,7 @@ impl SignatureAuthContext for RpcContext {
|
||||
.as_network()
|
||||
.as_host()
|
||||
.as_private_domains()
|
||||
.keys()
|
||||
.de()
|
||||
.map(|k| k.into_iter())
|
||||
.transpose(),
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ use color_eyre::eyre::eyre;
|
||||
use futures::{FutureExt, StreamExt, TryStreamExt};
|
||||
use hickory_server::authority::{AuthorityObject, Catalog, MessageResponseBuilder};
|
||||
use hickory_server::proto::op::{Header, ResponseCode};
|
||||
use hickory_server::proto::rr::{Name, Record, RecordType};
|
||||
use hickory_server::proto::rr::{LowerName, Name, Record, RecordType};
|
||||
use hickory_server::resolver::config::{ResolverConfig, ResolverOpts};
|
||||
use hickory_server::server::{Request, RequestHandler, ResponseHandler, ResponseInfo};
|
||||
use hickory_server::store::forwarder::{ForwardAuthority, ForwardConfig};
|
||||
|
||||
@@ -3,8 +3,6 @@ use std::net::{IpAddr, SocketAddrV4};
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
use ipnet::IpNet;
|
||||
|
||||
use futures::channel::oneshot;
|
||||
use iddqd::{IdOrdItem, IdOrdMap};
|
||||
use rand::Rng;
|
||||
@@ -49,6 +47,16 @@ impl std::fmt::Display for ForwardRequirements {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(BTreeMap<u16, bool>);
|
||||
impl AvailablePorts {
|
||||
@@ -121,7 +129,7 @@ struct ForwardMapping {
|
||||
source: SocketAddrV4,
|
||||
target: SocketAddrV4,
|
||||
target_prefix: u8,
|
||||
src_filter: Option<IpNet>,
|
||||
src_filter: Option<SourceFilter>,
|
||||
rc: Weak<()>,
|
||||
}
|
||||
|
||||
@@ -136,7 +144,7 @@ impl PortForwardState {
|
||||
source: SocketAddrV4,
|
||||
target: SocketAddrV4,
|
||||
target_prefix: u8,
|
||||
src_filter: Option<IpNet>,
|
||||
src_filter: Option<SourceFilter>,
|
||||
) -> Result<Arc<()>, Error> {
|
||||
if let Some(existing) = self.mappings.get_mut(&source) {
|
||||
if existing.target == target && existing.src_filter == src_filter {
|
||||
@@ -233,7 +241,7 @@ enum PortForwardCommand {
|
||||
source: SocketAddrV4,
|
||||
target: SocketAddrV4,
|
||||
target_prefix: u8,
|
||||
src_filter: Option<IpNet>,
|
||||
src_filter: Option<SourceFilter>,
|
||||
respond: oneshot::Sender<Result<Arc<()>, Error>>,
|
||||
},
|
||||
Gc {
|
||||
@@ -350,7 +358,7 @@ impl PortForwardController {
|
||||
source: SocketAddrV4,
|
||||
target: SocketAddrV4,
|
||||
target_prefix: u8,
|
||||
src_filter: Option<IpNet>,
|
||||
src_filter: Option<SourceFilter>,
|
||||
) -> Result<Arc<()>, Error> {
|
||||
let (send, recv) = oneshot::channel();
|
||||
self.req
|
||||
@@ -447,7 +455,19 @@ impl InterfaceForwardEntry {
|
||||
if reqs.public_gateways.contains(gw_id) {
|
||||
None
|
||||
} else if reqs.private_ips.contains(&IpAddr::V4(ip)) {
|
||||
Some(subnet.trunc())
|
||||
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;
|
||||
};
|
||||
@@ -705,7 +725,7 @@ async fn forward(
|
||||
source: SocketAddrV4,
|
||||
target: SocketAddrV4,
|
||||
target_prefix: u8,
|
||||
src_filter: Option<&IpNet>,
|
||||
src_filter: Option<&SourceFilter>,
|
||||
) -> Result<(), Error> {
|
||||
let mut cmd = Command::new("/usr/lib/startos/scripts/forward-port");
|
||||
cmd.env("sip", source.ip().to_string())
|
||||
@@ -713,8 +733,11 @@ async fn forward(
|
||||
.env("dprefix", target_prefix.to_string())
|
||||
.env("sport", source.port().to_string())
|
||||
.env("dport", target.port().to_string());
|
||||
if let Some(subnet) = src_filter {
|
||||
cmd.env("src_subnet", subnet.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(())
|
||||
@@ -724,7 +747,7 @@ async fn unforward(
|
||||
source: SocketAddrV4,
|
||||
target: SocketAddrV4,
|
||||
target_prefix: u8,
|
||||
src_filter: Option<&IpNet>,
|
||||
src_filter: Option<&SourceFilter>,
|
||||
) -> Result<(), Error> {
|
||||
let mut cmd = Command::new("/usr/lib/startos/scripts/forward-port");
|
||||
cmd.env("UNDO", "1")
|
||||
@@ -733,8 +756,11 @@ async fn unforward(
|
||||
.env("dprefix", target_prefix.to_string())
|
||||
.env("sport", source.port().to_string())
|
||||
.env("dport", target.port().to_string());
|
||||
if let Some(subnet) = src_filter {
|
||||
cmd.env("src_subnet", subnet.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(())
|
||||
|
||||
@@ -32,7 +32,6 @@ use crate::context::{CliContext, RpcContext};
|
||||
use crate::db::model::Database;
|
||||
use crate::db::model::public::{IpInfo, NetworkInterfaceInfo, NetworkInterfaceType};
|
||||
use crate::net::forward::START9_BRIDGE_IFACE;
|
||||
use crate::net::host::all_hosts;
|
||||
use crate::net::gateway::device::DeviceProxy;
|
||||
use crate::net::web_server::{Accept, AcceptStream, MetadataVisitor, TcpMetadata};
|
||||
use crate::prelude::*;
|
||||
@@ -57,7 +56,7 @@ pub fn gateway_api<C: Context>() -> ParentHandler<C> {
|
||||
}
|
||||
|
||||
let mut table = Table::new();
|
||||
table.add_row(row![bc => "INTERFACE", "TYPE", "ADDRESSES", "WAN IP"]);
|
||||
table.add_row(row![bc => "INTERFACE", "TYPE", "PUBLIC", "ADDRESSES", "WAN IP"]);
|
||||
for (iface, info) in res {
|
||||
table.add_row(row![
|
||||
iface,
|
||||
@@ -65,6 +64,7 @@ pub fn gateway_api<C: Context>() -> ParentHandler<C> {
|
||||
.as_ref()
|
||||
.and_then(|ip_info| ip_info.device_type)
|
||||
.map_or_else(|| "UNKNOWN".to_owned(), |ty| format!("{ty:?}")),
|
||||
info.public(),
|
||||
info.ip_info.as_ref().map_or_else(
|
||||
|| "<DISCONNECTED>".to_owned(),
|
||||
|ip_info| ip_info
|
||||
@@ -94,6 +94,22 @@ pub fn gateway_api<C: Context>() -> ParentHandler<C> {
|
||||
.with_about("about.show-gateways-startos-can-listen-on")
|
||||
.with_call_remote::<CliContext>(),
|
||||
)
|
||||
.subcommand(
|
||||
"set-public",
|
||||
from_fn_async(set_public)
|
||||
.with_metadata("sync_db", Value::Bool(true))
|
||||
.no_display()
|
||||
.with_about("about.indicate-gateway-inbound-access-from-wan")
|
||||
.with_call_remote::<CliContext>(),
|
||||
)
|
||||
.subcommand(
|
||||
"unset-public",
|
||||
from_fn_async(unset_public)
|
||||
.with_metadata("sync_db", Value::Bool(true))
|
||||
.no_display()
|
||||
.with_about("about.allow-gateway-infer-inbound-access-from-wan")
|
||||
.with_call_remote::<CliContext>(),
|
||||
)
|
||||
.subcommand(
|
||||
"forget",
|
||||
from_fn_async(forget_iface)
|
||||
@@ -118,6 +134,40 @@ async fn list_interfaces(
|
||||
Ok(ctx.net_controller.net_iface.watcher.ip_info())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
|
||||
struct NetworkInterfaceSetPublicParams {
|
||||
#[arg(help = "help.arg.gateway-id")]
|
||||
gateway: GatewayId,
|
||||
#[arg(help = "help.arg.is-public")]
|
||||
public: Option<bool>,
|
||||
}
|
||||
|
||||
async fn set_public(
|
||||
ctx: RpcContext,
|
||||
NetworkInterfaceSetPublicParams { gateway, public }: NetworkInterfaceSetPublicParams,
|
||||
) -> Result<(), Error> {
|
||||
ctx.net_controller
|
||||
.net_iface
|
||||
.set_public(&gateway, Some(public.unwrap_or(true)))
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
|
||||
struct UnsetPublicParams {
|
||||
#[arg(help = "help.arg.gateway-id")]
|
||||
gateway: GatewayId,
|
||||
}
|
||||
|
||||
async fn unset_public(
|
||||
ctx: RpcContext,
|
||||
UnsetPublicParams { gateway }: UnsetPublicParams,
|
||||
) -> Result<(), Error> {
|
||||
ctx.net_controller
|
||||
.net_iface
|
||||
.set_public(&gateway, None)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
|
||||
struct ForgetGatewayParams {
|
||||
#[arg(help = "help.arg.gateway-id")]
|
||||
@@ -607,62 +657,6 @@ async fn watch_ip(
|
||||
None
|
||||
};
|
||||
|
||||
// Policy routing: track per-interface table for cleanup on scope exit
|
||||
let policy_table_id = if !matches!(
|
||||
device_type,
|
||||
Some(
|
||||
NetworkInterfaceType::Bridge
|
||||
| NetworkInterfaceType::Loopback
|
||||
)
|
||||
) {
|
||||
if_nametoindex(iface.as_str())
|
||||
.map(|idx| 1000 + idx)
|
||||
.log_err()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
struct PolicyRoutingCleanup {
|
||||
table_id: u32,
|
||||
iface: String,
|
||||
}
|
||||
impl Drop for PolicyRoutingCleanup {
|
||||
fn drop(&mut self) {
|
||||
let table_str = self.table_id.to_string();
|
||||
let iface = std::mem::take(&mut self.iface);
|
||||
tokio::spawn(async move {
|
||||
Command::new("ip")
|
||||
.arg("rule").arg("del")
|
||||
.arg("fwmark").arg(&table_str)
|
||||
.arg("lookup").arg(&table_str)
|
||||
.invoke(ErrorKind::Network)
|
||||
.await
|
||||
.log_err();
|
||||
Command::new("ip")
|
||||
.arg("route").arg("flush")
|
||||
.arg("table").arg(&table_str)
|
||||
.invoke(ErrorKind::Network)
|
||||
.await
|
||||
.log_err();
|
||||
Command::new("iptables")
|
||||
.arg("-t").arg("mangle")
|
||||
.arg("-D").arg("PREROUTING")
|
||||
.arg("-i").arg(&iface)
|
||||
.arg("-m").arg("conntrack")
|
||||
.arg("--ctstate").arg("NEW")
|
||||
.arg("-j").arg("CONNMARK")
|
||||
.arg("--set-mark").arg(&table_str)
|
||||
.invoke(ErrorKind::Network)
|
||||
.await
|
||||
.log_err();
|
||||
});
|
||||
}
|
||||
}
|
||||
let _policy_guard: Option<PolicyRoutingCleanup> =
|
||||
policy_table_id.map(|t| PolicyRoutingCleanup {
|
||||
table_id: t,
|
||||
iface: iface.as_str().to_owned(),
|
||||
});
|
||||
|
||||
loop {
|
||||
until
|
||||
.run(async {
|
||||
@@ -672,7 +666,7 @@ async fn watch_ip(
|
||||
.into_iter()
|
||||
.chain(ip6_proxy.address_data().await?)
|
||||
.collect_vec();
|
||||
let lan_ip: OrdSet<IpAddr> = [
|
||||
let lan_ip = [
|
||||
Some(ip4_proxy.gateway().await?)
|
||||
.filter(|g| !g.is_empty())
|
||||
.and_then(|g| g.parse::<IpAddr>().log_err()),
|
||||
@@ -709,122 +703,22 @@ async fn watch_ip(
|
||||
.into_iter()
|
||||
.map(IpNet::try_from)
|
||||
.try_collect()?;
|
||||
// Policy routing: ensure replies exit the same interface
|
||||
// they arrived on, eliminating the need for MASQUERADE.
|
||||
if let Some(guard) = &_policy_guard {
|
||||
let table_id = guard.table_id;
|
||||
let table_str = table_id.to_string();
|
||||
|
||||
let ipv4_gateway: Option<Ipv4Addr> =
|
||||
lan_ip.iter().find_map(|ip| match ip {
|
||||
IpAddr::V4(v4) => Some(v4),
|
||||
_ => None,
|
||||
}).copied();
|
||||
let ipv4_subnets: Vec<IpNet> = subnets
|
||||
.iter()
|
||||
.filter(|s| s.addr().is_ipv4())
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// Flush and rebuild per-interface routing table
|
||||
Command::new("ip")
|
||||
.arg("route").arg("flush")
|
||||
.arg("table").arg(&table_str)
|
||||
.invoke(ErrorKind::Network)
|
||||
.await
|
||||
.log_err();
|
||||
for subnet in &ipv4_subnets {
|
||||
let subnet_str = subnet.trunc().to_string();
|
||||
Command::new("ip")
|
||||
.arg("route").arg("add").arg(&subnet_str)
|
||||
.arg("dev").arg(iface.as_str())
|
||||
.arg("table").arg(&table_str)
|
||||
.invoke(ErrorKind::Network)
|
||||
.await
|
||||
.log_err();
|
||||
}
|
||||
{
|
||||
let mut cmd = Command::new("ip");
|
||||
cmd.arg("route").arg("add").arg("default");
|
||||
if let Some(gw) = ipv4_gateway {
|
||||
cmd.arg("via").arg(gw.to_string());
|
||||
}
|
||||
cmd.arg("dev").arg(iface.as_str())
|
||||
.arg("table").arg(&table_str);
|
||||
if ipv4_gateway.is_none() {
|
||||
cmd.arg("scope").arg("link");
|
||||
}
|
||||
cmd.invoke(ErrorKind::Network)
|
||||
.await
|
||||
.log_err();
|
||||
}
|
||||
|
||||
// Ensure global CONNMARK restore rule in mangle PREROUTING
|
||||
// (restores fwmark from conntrack mark on reply packets)
|
||||
if !Command::new("iptables")
|
||||
.arg("-t").arg("mangle")
|
||||
.arg("-C").arg("PREROUTING")
|
||||
.arg("-j").arg("CONNMARK")
|
||||
.arg("--restore-mark")
|
||||
.status().await
|
||||
.map_or(false, |s| s.success())
|
||||
{
|
||||
Command::new("iptables")
|
||||
.arg("-t").arg("mangle")
|
||||
.arg("-I").arg("PREROUTING").arg("1")
|
||||
.arg("-j").arg("CONNMARK")
|
||||
.arg("--restore-mark")
|
||||
.invoke(ErrorKind::Network)
|
||||
.await
|
||||
.log_err();
|
||||
}
|
||||
|
||||
// Mark NEW connections arriving on this interface
|
||||
// with its routing table ID via conntrack mark
|
||||
if !Command::new("iptables")
|
||||
.arg("-t").arg("mangle")
|
||||
.arg("-C").arg("PREROUTING")
|
||||
.arg("-i").arg(iface.as_str())
|
||||
.arg("-m").arg("conntrack")
|
||||
.arg("--ctstate").arg("NEW")
|
||||
.arg("-j").arg("CONNMARK")
|
||||
.arg("--set-mark").arg(&table_str)
|
||||
.status().await
|
||||
.map_or(false, |s| s.success())
|
||||
{
|
||||
Command::new("iptables")
|
||||
.arg("-t").arg("mangle")
|
||||
.arg("-A").arg("PREROUTING")
|
||||
.arg("-i").arg(iface.as_str())
|
||||
.arg("-m").arg("conntrack")
|
||||
.arg("--ctstate").arg("NEW")
|
||||
.arg("-j").arg("CONNMARK")
|
||||
.arg("--set-mark").arg(&table_str)
|
||||
.invoke(ErrorKind::Network)
|
||||
.await
|
||||
.log_err();
|
||||
}
|
||||
|
||||
// Ensure fwmark-based ip rule for this interface's table
|
||||
let rules_output = String::from_utf8(
|
||||
Command::new("ip")
|
||||
.arg("rule").arg("list")
|
||||
.invoke(ErrorKind::Network)
|
||||
.await?,
|
||||
)?;
|
||||
if !rules_output.lines().any(|l| {
|
||||
l.contains("fwmark")
|
||||
&& l.contains(&format!("lookup {table_id}"))
|
||||
}) {
|
||||
Command::new("ip")
|
||||
.arg("rule").arg("add")
|
||||
.arg("fwmark").arg(&table_str)
|
||||
.arg("lookup").arg(&table_str)
|
||||
.invoke(ErrorKind::Network)
|
||||
.await
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
// let tables = ip4_proxy.route_data().await?.into_iter().filter_map(|d|d.table).collect::<Vec<_>>();
|
||||
// if !tables.is_empty() {
|
||||
// let rules = String::from_utf8(Command::new("ip").arg("rule").arg("list").invoke(ErrorKind::Network).await?)?;
|
||||
// for table in tables {
|
||||
// for subnet in subnets.iter().filter(|s| s.addr().is_ipv4()) {
|
||||
// let subnet_string = subnet.trunc().to_string();
|
||||
// let rule = ["from", &subnet_string, "lookup", &table.to_string()];
|
||||
// if !rules.contains(&rule.join(" ")) {
|
||||
// if rules.contains(&rule[..2].join(" ")) {
|
||||
// Command::new("ip").arg("rule").arg("del").args(&rule[..2]).invoke(ErrorKind::Network).await?;
|
||||
// }
|
||||
// Command::new("ip").arg("rule").arg("add").args(rule).invoke(ErrorKind::Network).await?;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
let wan_ip = if !subnets.is_empty()
|
||||
&& !matches!(
|
||||
device_type,
|
||||
@@ -860,11 +754,12 @@ async fn watch_ip(
|
||||
|
||||
write_to.send_if_modified(
|
||||
|m: &mut OrdMap<GatewayId, NetworkInterfaceInfo>| {
|
||||
let (name, secure, gateway_type, 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
|
||||
@@ -878,6 +773,7 @@ async fn watch_ip(
|
||||
iface.clone(),
|
||||
NetworkInterfaceInfo {
|
||||
name,
|
||||
public,
|
||||
secure,
|
||||
ip_info: Some(ip_info.clone()),
|
||||
gateway_type,
|
||||
@@ -1004,12 +900,7 @@ impl NetworkInterfaceController {
|
||||
.as_server_info_mut()
|
||||
.as_network_mut()
|
||||
.as_gateways_mut()
|
||||
.ser(info)?;
|
||||
let ports = db.as_private().as_available_ports().de()?;
|
||||
for host in all_hosts(db) {
|
||||
host?.update_addresses(info, &ports)?;
|
||||
}
|
||||
Ok(())
|
||||
.ser(info)
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
@@ -1145,6 +1036,43 @@ impl NetworkInterfaceController {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_public(
|
||||
&self,
|
||||
interface: &GatewayId,
|
||||
public: Option<bool>,
|
||||
) -> Result<(), Error> {
|
||||
let mut sub = self
|
||||
.db
|
||||
.subscribe(
|
||||
"/public/serverInfo/network/gateways"
|
||||
.parse::<JsonPointer<_, _>>()
|
||||
.with_kind(ErrorKind::Database)?,
|
||||
)
|
||||
.await;
|
||||
let mut err = None;
|
||||
let changed = self.watcher.ip_info.send_if_modified(|ip_info| {
|
||||
let prev = std::mem::replace(
|
||||
&mut match ip_info.get_mut(interface).or_not_found(interface) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
err = Some(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
.public,
|
||||
public,
|
||||
);
|
||||
prev != public
|
||||
});
|
||||
if let Some(e) = err {
|
||||
return Err(e);
|
||||
}
|
||||
if changed {
|
||||
sub.recv().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn forget(&self, interface: &GatewayId) -> Result<(), Error> {
|
||||
let mut sub = self
|
||||
.db
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::util::serde::{HandlerExtSerde, display_serializable};
|
||||
pub struct HostAddress {
|
||||
pub address: InternedString,
|
||||
pub public: Option<PublicDomainConfig>,
|
||||
pub private: Option<BTreeSet<GatewayId>>,
|
||||
pub private: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
@@ -53,7 +53,7 @@ fn handle_duplicates(db: &mut DatabaseModel) -> Result<(), Error> {
|
||||
for domain in &public {
|
||||
check_domain(&mut domains, domain.clone())?;
|
||||
}
|
||||
for domain in host.as_private_domains().keys()? {
|
||||
for domain in host.as_private_domains().de()? {
|
||||
if !public.contains(&domain) {
|
||||
check_domain(&mut domains, domain)?;
|
||||
}
|
||||
@@ -63,13 +63,13 @@ fn handle_duplicates(db: &mut DatabaseModel) -> Result<(), Error> {
|
||||
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))))?;
|
||||
.mutate(|d| Ok(d.retain(|d| !domains.contains(d))))?;
|
||||
|
||||
let public = host.as_public_domains().keys()?;
|
||||
for domain in &public {
|
||||
check_domain(&mut domains, domain.clone())?;
|
||||
}
|
||||
for domain in host.as_private_domains().keys()? {
|
||||
for domain in host.as_private_domains().de()? {
|
||||
if !public.contains(&domain) {
|
||||
check_domain(&mut domains, domain)?;
|
||||
}
|
||||
@@ -146,7 +146,21 @@ pub fn address_api<C: Context, Kind: HostApiKind>()
|
||||
}
|
||||
|
||||
let mut table = Table::new();
|
||||
todo!("find a good way to represent this");
|
||||
table.add_row(row![bc => "ADDRESS", "PUBLIC", "ACME PROVIDER"]);
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
|
||||
table.print_tty(false)?;
|
||||
|
||||
@@ -193,10 +207,7 @@ pub async fn add_public_domain<Kind: HostApiKind>(
|
||||
Kind::host_for(&inheritance, db)?
|
||||
.as_public_domains_mut()
|
||||
.insert(&fqdn, &PublicDomainConfig { acme, gateway })?;
|
||||
handle_duplicates(db)?;
|
||||
let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?;
|
||||
let ports = db.as_private().as_available_ports().de()?;
|
||||
Kind::host_for(&inheritance, db)?.update_addresses(&gateways, &ports)
|
||||
handle_duplicates(db)
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
@@ -224,10 +235,7 @@ pub async fn remove_public_domain<Kind: HostApiKind>(
|
||||
.mutate(|db| {
|
||||
Kind::host_for(&inheritance, db)?
|
||||
.as_public_domains_mut()
|
||||
.remove(&fqdn)?;
|
||||
let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?;
|
||||
let ports = db.as_private().as_available_ports().de()?;
|
||||
Kind::host_for(&inheritance, db)?.update_addresses(&gateways, &ports)
|
||||
.remove(&fqdn)
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
@@ -240,24 +248,19 @@ pub async fn remove_public_domain<Kind: HostApiKind>(
|
||||
pub struct AddPrivateDomainParams {
|
||||
#[arg(help = "help.arg.fqdn")]
|
||||
pub fqdn: InternedString,
|
||||
pub gateway: GatewayId,
|
||||
}
|
||||
|
||||
pub async fn add_private_domain<Kind: HostApiKind>(
|
||||
ctx: RpcContext,
|
||||
AddPrivateDomainParams { fqdn, gateway }: AddPrivateDomainParams,
|
||||
AddPrivateDomainParams { fqdn }: AddPrivateDomainParams,
|
||||
inheritance: Kind::Inheritance,
|
||||
) -> Result<(), Error> {
|
||||
ctx.db
|
||||
.mutate(|db| {
|
||||
Kind::host_for(&inheritance, db)?
|
||||
.as_private_domains_mut()
|
||||
.upsert(&fqdn, || Ok(BTreeSet::new()))?
|
||||
.mutate(|d| Ok(d.insert(gateway)))?;
|
||||
handle_duplicates(db)?;
|
||||
let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?;
|
||||
let ports = db.as_private().as_available_ports().de()?;
|
||||
Kind::host_for(&inheritance, db)?.update_addresses(&gateways, &ports)
|
||||
.mutate(|d| Ok(d.insert(fqdn)))?;
|
||||
handle_duplicates(db)
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
@@ -275,10 +278,7 @@ pub async fn remove_private_domain<Kind: HostApiKind>(
|
||||
.mutate(|db| {
|
||||
Kind::host_for(&inheritance, db)?
|
||||
.as_private_domains_mut()
|
||||
.mutate(|d| Ok(d.remove(&domain)))?;
|
||||
let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?;
|
||||
let ports = db.as_private().as_available_ports().de()?;
|
||||
Kind::host_for(&inheritance, db)?.update_addresses(&gateways, &ports)
|
||||
.mutate(|d| Ok(d.remove(&domain)))
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::net::SocketAddr;
|
||||
use std::str::FromStr;
|
||||
|
||||
use clap::Parser;
|
||||
@@ -8,7 +7,6 @@ use rpc_toolkit::{Context, Empty, HandlerArgs, HandlerExt, ParentHandler, from_f
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::HostId;
|
||||
use crate::context::{CliContext, RpcContext};
|
||||
use crate::db::prelude::Map;
|
||||
use crate::net::forward::AvailablePorts;
|
||||
@@ -18,6 +16,7 @@ use crate::net::vhost::AlpnInfo;
|
||||
use crate::prelude::*;
|
||||
use crate::util::FromStrParser;
|
||||
use crate::util::serde::{HandlerExtSerde, display_serializable};
|
||||
use crate::HostId;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
@@ -50,36 +49,31 @@ impl FromStr for BindId {
|
||||
#[ts(export)]
|
||||
#[model = "Model<Self>"]
|
||||
pub struct DerivedAddressInfo {
|
||||
/// User override: enable these addresses (only for public IP & port)
|
||||
pub enabled: BTreeSet<SocketAddr>,
|
||||
/// User override: disable these addresses (only for domains and private IP & port)
|
||||
pub disabled: BTreeSet<(InternedString, u16)>,
|
||||
/// 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 available: BTreeSet<HostnameInfo>,
|
||||
pub possible: BTreeSet<HostnameInfo>,
|
||||
}
|
||||
|
||||
impl DerivedAddressInfo {
|
||||
/// Returns addresses that are currently enabled after applying overrides.
|
||||
/// Default: public IPs are disabled, everything else is enabled.
|
||||
/// Explicit `enabled`/`disabled` overrides take precedence.
|
||||
/// 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.available
|
||||
self.possible
|
||||
.iter()
|
||||
.filter(|h| {
|
||||
if h.public && h.metadata.is_ip() {
|
||||
// Public IPs: disabled by default, explicitly enabled via SocketAddr
|
||||
h.to_socket_addr().map_or(
|
||||
true, // should never happen, but would rather see them if it does
|
||||
|sa| self.enabled.contains(&sa),
|
||||
)
|
||||
if h.public {
|
||||
self.public_enabled.contains(h)
|
||||
} else {
|
||||
!self
|
||||
.disabled
|
||||
.contains(&(h.host.clone(), h.port.unwrap_or_default())) // disablable addresses will always have a port
|
||||
!self.private_disabled.contains(h)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
|
||||
@@ -205,9 +199,9 @@ impl BindInfo {
|
||||
options,
|
||||
net: lan,
|
||||
addresses: DerivedAddressInfo {
|
||||
enabled: addresses.enabled,
|
||||
disabled: addresses.disabled,
|
||||
available: BTreeSet::new(),
|
||||
private_disabled: addresses.private_disabled,
|
||||
public_enabled: addresses.public_enabled,
|
||||
possible: BTreeSet::new(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -334,27 +328,17 @@ pub async fn set_address_enabled<Kind: HostApiKind>(
|
||||
.as_bindings_mut()
|
||||
.mutate(|b| {
|
||||
let bind = b.get_mut(&internal_port).or_not_found(internal_port)?;
|
||||
if address.public && address.metadata.is_ip() {
|
||||
// Public IPs: toggle via SocketAddr in `enabled` set
|
||||
let sa = address.to_socket_addr().ok_or_else(|| {
|
||||
Error::new(
|
||||
eyre!("cannot convert address to socket addr"),
|
||||
ErrorKind::InvalidRequest,
|
||||
)
|
||||
})?;
|
||||
if address.public {
|
||||
if enabled {
|
||||
bind.addresses.enabled.insert(sa);
|
||||
bind.addresses.public_enabled.insert(address.clone());
|
||||
} else {
|
||||
bind.addresses.enabled.remove(&sa);
|
||||
bind.addresses.public_enabled.remove(&address);
|
||||
}
|
||||
} else {
|
||||
// Domains and private IPs: toggle via (host, port) in `disabled` set
|
||||
let port = address.port.unwrap_or(if address.ssl { 443 } else { 80 });
|
||||
let key = (address.host.clone(), port);
|
||||
if enabled {
|
||||
bind.addresses.disabled.remove(&key);
|
||||
bind.addresses.private_disabled.remove(&address);
|
||||
} else {
|
||||
bind.addresses.disabled.insert(key);
|
||||
bind.addresses.private_disabled.insert(address.clone());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -3,23 +3,19 @@ use std::future::Future;
|
||||
use std::panic::RefUnwindSafe;
|
||||
|
||||
use clap::Parser;
|
||||
use imbl::OrdMap;
|
||||
use imbl_value::InternedString;
|
||||
use itertools::Itertools;
|
||||
use patch_db::DestructureMut;
|
||||
use rpc_toolkit::{Context, Empty, HandlerExt, OrEmpty, ParentHandler, from_fn_async};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::context::RpcContext;
|
||||
use crate::db::model::DatabaseModel;
|
||||
use crate::db::model::public::NetworkInterfaceInfo;
|
||||
use crate::net::forward::AvailablePorts;
|
||||
use crate::net::host::address::{HostAddress, PublicDomainConfig, address_api};
|
||||
use crate::net::host::binding::{BindInfo, BindOptions, Bindings, binding};
|
||||
use crate::net::service_interface::{HostnameInfo, HostnameMetadata};
|
||||
use crate::prelude::*;
|
||||
use crate::{GatewayId, HostId, PackageId};
|
||||
use crate::{HostId, PackageId};
|
||||
|
||||
pub mod address;
|
||||
pub mod binding;
|
||||
@@ -31,7 +27,7 @@ pub mod binding;
|
||||
pub struct Host {
|
||||
pub bindings: Bindings,
|
||||
pub public_domains: BTreeMap<InternedString, PublicDomainConfig>,
|
||||
pub private_domains: BTreeMap<InternedString, BTreeSet<GatewayId>>,
|
||||
pub private_domains: BTreeSet<InternedString>,
|
||||
}
|
||||
|
||||
impl AsRef<Host> for Host {
|
||||
@@ -49,183 +45,20 @@ impl Host {
|
||||
.map(|(address, config)| HostAddress {
|
||||
address: address.clone(),
|
||||
public: Some(config.clone()),
|
||||
private: self.private_domains.get(address).cloned(),
|
||||
private: self.private_domains.contains(address),
|
||||
})
|
||||
.chain(
|
||||
self.private_domains
|
||||
.iter()
|
||||
.filter(|(domain, _)| !self.public_domains.contains_key(*domain))
|
||||
.map(|(domain, gateways)| HostAddress {
|
||||
address: domain.clone(),
|
||||
.filter(|a| !self.public_domains.contains_key(*a))
|
||||
.map(|address| HostAddress {
|
||||
address: address.clone(),
|
||||
public: None,
|
||||
private: Some(gateways.clone()),
|
||||
private: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
impl Model<Host> {
|
||||
pub fn update_addresses(
|
||||
&mut self,
|
||||
gateways: &OrdMap<GatewayId, NetworkInterfaceInfo>,
|
||||
available_ports: &AvailablePorts,
|
||||
) -> Result<(), Error> {
|
||||
let this = self.destructure_mut();
|
||||
for (_, bind) in this.bindings.as_entries_mut()? {
|
||||
let net = bind.as_net().de()?;
|
||||
let opt = bind.as_options().de()?;
|
||||
let mut available = BTreeSet::new();
|
||||
for (gid, g) in gateways {
|
||||
let Some(ip_info) = &g.ip_info else {
|
||||
continue;
|
||||
};
|
||||
let gateway_secure = g.secure();
|
||||
for subnet in &ip_info.subnets {
|
||||
let host = InternedString::from_display(&subnet.addr());
|
||||
let metadata = if subnet.addr().is_ipv4() {
|
||||
HostnameMetadata::Ipv4 {
|
||||
gateway: gid.clone(),
|
||||
}
|
||||
} else {
|
||||
HostnameMetadata::Ipv6 {
|
||||
gateway: gid.clone(),
|
||||
scope_id: ip_info.scope_id,
|
||||
}
|
||||
};
|
||||
if let Some(port) = net.assigned_port.filter(|_| {
|
||||
opt.secure
|
||||
.map_or(gateway_secure, |s| !(s.ssl && opt.add_ssl.is_some()))
|
||||
}) {
|
||||
available.insert(HostnameInfo {
|
||||
ssl: opt.secure.map_or(false, |s| s.ssl),
|
||||
public: false,
|
||||
host: host.clone(),
|
||||
port: Some(port),
|
||||
metadata: metadata.clone(),
|
||||
});
|
||||
}
|
||||
if let Some(port) = net.assigned_ssl_port {
|
||||
available.insert(HostnameInfo {
|
||||
ssl: true,
|
||||
public: false,
|
||||
host: host.clone(),
|
||||
port: Some(port),
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(wan_ip) = &ip_info.wan_ip {
|
||||
let host = InternedString::from_display(&wan_ip);
|
||||
let metadata = HostnameMetadata::Ipv4 {
|
||||
gateway: gid.clone(),
|
||||
};
|
||||
if let Some(port) = net.assigned_port.filter(|_| {
|
||||
opt.secure.map_or(
|
||||
false, // the public internet is never secure
|
||||
|s| !(s.ssl && opt.add_ssl.is_some()),
|
||||
)
|
||||
}) {
|
||||
available.insert(HostnameInfo {
|
||||
ssl: opt.secure.map_or(false, |s| s.ssl),
|
||||
public: true,
|
||||
host: host.clone(),
|
||||
port: Some(port),
|
||||
metadata: metadata.clone(),
|
||||
});
|
||||
}
|
||||
if let Some(port) = net.assigned_ssl_port {
|
||||
available.insert(HostnameInfo {
|
||||
ssl: true,
|
||||
public: true,
|
||||
host: host.clone(),
|
||||
port: Some(port),
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (domain, info) in this.public_domains.de()? {
|
||||
let metadata = HostnameMetadata::PublicDomain {
|
||||
gateway: info.gateway.clone(),
|
||||
};
|
||||
if let Some(port) = net.assigned_port.filter(|_| {
|
||||
opt.secure.map_or(
|
||||
false, // the public internet is never secure
|
||||
|s| !(s.ssl && opt.add_ssl.is_some()),
|
||||
)
|
||||
}) {
|
||||
available.insert(HostnameInfo {
|
||||
ssl: opt.secure.map_or(false, |s| s.ssl),
|
||||
public: true,
|
||||
host: domain.clone(),
|
||||
port: Some(port),
|
||||
metadata: metadata.clone(),
|
||||
});
|
||||
}
|
||||
if let Some(mut port) = net.assigned_ssl_port {
|
||||
if let Some(preferred) = opt
|
||||
.add_ssl
|
||||
.as_ref()
|
||||
.map(|s| s.preferred_external_port)
|
||||
.filter(|p| available_ports.is_ssl(*p))
|
||||
{
|
||||
port = preferred;
|
||||
}
|
||||
available.insert(HostnameInfo {
|
||||
ssl: true,
|
||||
public: true,
|
||||
host: domain.clone(),
|
||||
port: Some(port),
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (domain, domain_gateways) in this.private_domains.de()? {
|
||||
if let Some(port) = net.assigned_port.filter(|_| {
|
||||
opt.secure
|
||||
.map_or(true, |s| !(s.ssl && opt.add_ssl.is_some()))
|
||||
}) {
|
||||
let gateways = if opt.secure.is_some() {
|
||||
domain_gateways.clone()
|
||||
} else {
|
||||
domain_gateways
|
||||
.iter()
|
||||
.cloned()
|
||||
.filter(|g| gateways.get(g).map_or(false, |g| g.secure()))
|
||||
.collect()
|
||||
};
|
||||
available.insert(HostnameInfo {
|
||||
ssl: opt.secure.map_or(false, |s| s.ssl),
|
||||
public: true,
|
||||
host: domain.clone(),
|
||||
port: Some(port),
|
||||
metadata: HostnameMetadata::PrivateDomain { gateways },
|
||||
});
|
||||
}
|
||||
if let Some(mut port) = net.assigned_ssl_port {
|
||||
if let Some(preferred) = opt
|
||||
.add_ssl
|
||||
.as_ref()
|
||||
.map(|s| s.preferred_external_port)
|
||||
.filter(|p| available_ports.is_ssl(*p))
|
||||
{
|
||||
port = preferred;
|
||||
}
|
||||
available.insert(HostnameInfo {
|
||||
ssl: true,
|
||||
public: true,
|
||||
host: domain.clone(),
|
||||
port: Some(port),
|
||||
metadata: HostnameMetadata::PrivateDomain {
|
||||
gateways: domain_gateways,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
bind.as_addresses_mut().as_available_mut().ser(&available)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
|
||||
#[model = "Model<Self>"]
|
||||
|
||||
@@ -3,15 +3,17 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use color_eyre::eyre::eyre;
|
||||
use imbl::vector;
|
||||
use imbl_value::InternedString;
|
||||
use ipnet::IpNet;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_rustls::rustls::ClientConfig as TlsClientConfig;
|
||||
use tracing::instrument;
|
||||
|
||||
use patch_db::json_ptr::JsonPointer;
|
||||
|
||||
use crate::db::model::Database;
|
||||
use crate::db::model::public::NetworkInterfaceType;
|
||||
use crate::error::ErrorCollection;
|
||||
use crate::hostname::Hostname;
|
||||
use crate::net::dns::DnsController;
|
||||
use crate::net::forward::{
|
||||
@@ -21,13 +23,13 @@ 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::HostnameMetadata;
|
||||
use crate::net::service_interface::{GatewayInfo, HostnameInfo, IpHostname};
|
||||
use crate::net::socks::SocksController;
|
||||
use crate::net::utils::ipv6_is_local;
|
||||
use crate::net::vhost::{AlpnInfo, DynVHostTarget, ProxyTarget, VHostController};
|
||||
use crate::prelude::*;
|
||||
use crate::service::effects::callbacks::ServiceCallbacks;
|
||||
use crate::util::serde::MaybeUtf8String;
|
||||
use crate::util::sync::Watch;
|
||||
use crate::{GatewayId, HOST_IP, HostId, OptionExt, PackageId};
|
||||
|
||||
pub struct NetController {
|
||||
@@ -180,18 +182,89 @@ impl NetServiceData {
|
||||
})
|
||||
}
|
||||
|
||||
async fn clear_bindings(
|
||||
&mut self,
|
||||
ctrl: &NetController,
|
||||
except: BTreeSet<BindId>,
|
||||
) -> Result<(), Error> {
|
||||
if let Some(pkg_id) = &self.id {
|
||||
let hosts = ctrl
|
||||
.db
|
||||
.mutate(|db| {
|
||||
let mut res = Hosts::default();
|
||||
for (host_id, host) in db
|
||||
.as_public_mut()
|
||||
.as_package_data_mut()
|
||||
.as_idx_mut(pkg_id)
|
||||
.or_not_found(pkg_id)?
|
||||
.as_hosts_mut()
|
||||
.as_entries_mut()?
|
||||
{
|
||||
host.as_bindings_mut().mutate(|b| {
|
||||
for (internal_port, info) in b.iter_mut() {
|
||||
if !except.contains(&BindId {
|
||||
id: host_id.clone(),
|
||||
internal_port: *internal_port,
|
||||
}) {
|
||||
info.disable();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
res.0.insert(host_id, host.de()?);
|
||||
}
|
||||
Ok(res)
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
let mut errors = ErrorCollection::new();
|
||||
for (id, host) in hosts.0 {
|
||||
errors.handle(self.update(ctrl, id, host).await);
|
||||
}
|
||||
errors.into_result()
|
||||
} else {
|
||||
let host = ctrl
|
||||
.db
|
||||
.mutate(|db| {
|
||||
let host = db
|
||||
.as_public_mut()
|
||||
.as_server_info_mut()
|
||||
.as_network_mut()
|
||||
.as_host_mut();
|
||||
host.as_bindings_mut().mutate(|b| {
|
||||
for (internal_port, info) in b.iter_mut() {
|
||||
if !except.contains(&BindId {
|
||||
id: HostId::default(),
|
||||
internal_port: *internal_port,
|
||||
}) {
|
||||
info.disable();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
host.de()
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
self.update(ctrl, HostId::default(), host).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&mut self,
|
||||
ctrl: &NetController,
|
||||
id: HostId,
|
||||
host: Host,
|
||||
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 binds = self.binds.entry(id.clone()).or_default();
|
||||
|
||||
let peek = ctrl.db.peek().await;
|
||||
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()?;
|
||||
let host_addresses: Vec<_> = host.addresses().collect();
|
||||
|
||||
// Collect private DNS entries (domains without public config)
|
||||
@@ -204,7 +277,138 @@ impl NetServiceData {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build controller entries from enabled addresses ──
|
||||
// ── Phase 1: Compute possible addresses ──
|
||||
for (_port, bind) in host.bindings.iter_mut() {
|
||||
if !bind.enabled {
|
||||
continue;
|
||||
}
|
||||
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)
|
||||
})
|
||||
{
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
// 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,
|
||||
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 ipnet in &ip_info.subnets {
|
||||
match ipnet {
|
||||
IpNet::V4(net) => {
|
||||
if !public {
|
||||
bind.addresses.possible.insert(HostnameInfo {
|
||||
gateway: gateway.clone(),
|
||||
public,
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: Build controller entries from enabled addresses ──
|
||||
for (port, bind) in host.bindings.iter() {
|
||||
if !bind.enabled {
|
||||
continue;
|
||||
@@ -231,8 +435,11 @@ impl NetServiceData {
|
||||
let server_private_ips: BTreeSet<IpAddr> = enabled_addresses
|
||||
.iter()
|
||||
.filter(|a| !a.public)
|
||||
.flat_map(|a| a.metadata.gateways())
|
||||
.filter_map(|gw| net_ifaces.get(gw).and_then(|info| info.ip_info.as_ref()))
|
||||
.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();
|
||||
|
||||
@@ -258,36 +465,32 @@ impl NetServiceData {
|
||||
|
||||
// Domain vhosts: group by (domain, ssl_port), merge public/private sets
|
||||
for addr_info in &enabled_addresses {
|
||||
if !addr_info.ssl {
|
||||
continue;
|
||||
}
|
||||
match &addr_info.metadata {
|
||||
HostnameMetadata::PublicDomain { .. }
|
||||
| HostnameMetadata::PrivateDomain { .. } => {}
|
||||
_ => continue,
|
||||
}
|
||||
let domain = &addr_info.host;
|
||||
let domain_ssl_port = addr_info.port.unwrap_or(443);
|
||||
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 {
|
||||
for gw in addr_info.metadata.gateways() {
|
||||
target.public.insert(gw.clone());
|
||||
}
|
||||
} else {
|
||||
for gw in addr_info.metadata.gateways() {
|
||||
if let Some(info) = net_ifaces.get(gw) {
|
||||
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());
|
||||
@@ -309,14 +512,16 @@ impl NetServiceData {
|
||||
let fwd_public: BTreeSet<GatewayId> = enabled_addresses
|
||||
.iter()
|
||||
.filter(|a| a.public)
|
||||
.flat_map(|a| a.metadata.gateways())
|
||||
.cloned()
|
||||
.map(|a| a.gateway.id.clone())
|
||||
.collect();
|
||||
let fwd_private: BTreeSet<IpAddr> = enabled_addresses
|
||||
.iter()
|
||||
.filter(|a| !a.public)
|
||||
.flat_map(|a| a.metadata.gateways())
|
||||
.filter_map(|gw| net_ifaces.get(gw).and_then(|i| i.ip_info.as_ref()))
|
||||
.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(
|
||||
@@ -422,16 +627,74 @@ impl NetServiceData {
|
||||
}
|
||||
ctrl.dns.gc_private_domains(&rm)?;
|
||||
|
||||
let res = ctrl
|
||||
.db
|
||||
.mutate(|db| {
|
||||
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?;
|
||||
if let Some(pkg_id) = self.id.as_ref() {
|
||||
if res.revision.is_some() {
|
||||
if let Some(cbs) = ctrl.callbacks.get_host_info(&(pkg_id.clone(), id)) {
|
||||
cbs.call(vector![]).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_all(&mut self) -> Result<(), Error> {
|
||||
let ctrl = self.net_controller()?;
|
||||
if let Some(id) = self.id.clone() {
|
||||
for (host_id, host) in ctrl
|
||||
.db
|
||||
.peek()
|
||||
.await
|
||||
.as_public()
|
||||
.as_package_data()
|
||||
.as_idx(&id)
|
||||
.or_not_found(&id)?
|
||||
.as_hosts()
|
||||
.as_entries()?
|
||||
{
|
||||
tracing::info!("Updating host {host_id} for {id}");
|
||||
self.update(&*ctrl, host_id.clone(), host.de()?).await?;
|
||||
tracing::info!("Updated host {host_id} for {id}");
|
||||
}
|
||||
} else {
|
||||
tracing::info!("Updating host for Main UI");
|
||||
self.update(
|
||||
&*ctrl,
|
||||
HostId::default(),
|
||||
ctrl.db
|
||||
.peek()
|
||||
.await
|
||||
.as_public()
|
||||
.as_server_info()
|
||||
.as_network()
|
||||
.as_host()
|
||||
.de()?,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!("Updated host for Main UI");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NetService {
|
||||
shutdown: bool,
|
||||
data: Arc<Mutex<NetServiceData>>,
|
||||
sync_task: JoinHandle<()>,
|
||||
synced: Watch<u64>,
|
||||
}
|
||||
impl NetService {
|
||||
fn dummy() -> Self {
|
||||
@@ -445,79 +708,26 @@ impl NetService {
|
||||
binds: BTreeMap::new(),
|
||||
})),
|
||||
sync_task: tokio::spawn(futures::future::ready(())),
|
||||
synced: Watch::new(0u64),
|
||||
}
|
||||
}
|
||||
|
||||
fn new(data: NetServiceData) -> Result<Self, Error> {
|
||||
let ctrl = data.net_controller()?;
|
||||
let pkg_id = data.id.clone();
|
||||
let db = ctrl.db.clone();
|
||||
drop(ctrl);
|
||||
|
||||
let synced = Watch::new(0u64);
|
||||
let synced_writer = synced.clone();
|
||||
|
||||
let mut ip_info = data.net_controller()?.net_iface.watcher.subscribe();
|
||||
let data = Arc::new(Mutex::new(data));
|
||||
let thread_data = data.clone();
|
||||
|
||||
let sync_task = tokio::spawn(async move {
|
||||
if let Some(ref id) = pkg_id {
|
||||
let ptr: JsonPointer = format!("/public/packageData/{}/hosts", id)
|
||||
.parse()
|
||||
.unwrap();
|
||||
let mut watch = db.watch(ptr).await.typed::<Hosts>();
|
||||
loop {
|
||||
if let Err(e) = watch.changed().await {
|
||||
tracing::error!("DB watch disconnected for {id}: {e}");
|
||||
break;
|
||||
}
|
||||
if let Err(e) = async {
|
||||
let hosts = watch.peek()?.de()?;
|
||||
let mut data = thread_data.lock().await;
|
||||
let ctrl = data.net_controller()?;
|
||||
for (host_id, host) in hosts.0 {
|
||||
data.update(&*ctrl, host_id, host).await?;
|
||||
}
|
||||
Ok::<_, Error>(())
|
||||
}
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to update network info for {id}: {e}");
|
||||
tracing::debug!("{e:?}");
|
||||
}
|
||||
synced_writer.send_modify(|v| *v += 1);
|
||||
}
|
||||
} else {
|
||||
let ptr: JsonPointer = "/public/serverInfo/network/host".parse().unwrap();
|
||||
let mut watch = db.watch(ptr).await.typed::<Host>();
|
||||
loop {
|
||||
if let Err(e) = watch.changed().await {
|
||||
tracing::error!("DB watch disconnected for Main UI: {e}");
|
||||
break;
|
||||
}
|
||||
if let Err(e) = async {
|
||||
let host = watch.peek()?.de()?;
|
||||
let mut data = thread_data.lock().await;
|
||||
let ctrl = data.net_controller()?;
|
||||
data.update(&*ctrl, HostId::default(), host).await?;
|
||||
Ok::<_, Error>(())
|
||||
}
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to update network info for Main UI: {e}");
|
||||
tracing::debug!("{e:?}");
|
||||
}
|
||||
synced_writer.send_modify(|v| *v += 1);
|
||||
loop {
|
||||
if let Err(e) = thread_data.lock().await.update_all().await {
|
||||
tracing::error!("Failed to update network info: {e}");
|
||||
tracing::debug!("{e:?}");
|
||||
}
|
||||
ip_info.changed().await;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
shutdown: false,
|
||||
data,
|
||||
sync_task,
|
||||
synced,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -527,113 +737,60 @@ impl NetService {
|
||||
internal_port: u16,
|
||||
options: BindOptions,
|
||||
) -> Result<(), Error> {
|
||||
let (ctrl, pkg_id) = {
|
||||
let data = self.data.lock().await;
|
||||
(data.net_controller()?, data.id.clone())
|
||||
};
|
||||
ctrl.db
|
||||
let mut data = self.data.lock().await;
|
||||
let pkg_id = &data.id;
|
||||
let ctrl = data.net_controller()?;
|
||||
let host = ctrl
|
||||
.db
|
||||
.mutate(|db| {
|
||||
let gateways = db
|
||||
.as_public()
|
||||
.as_server_info()
|
||||
.as_network()
|
||||
.as_gateways()
|
||||
.de()?;
|
||||
let mut ports = db.as_private().as_available_ports().de()?;
|
||||
let host = host_for(db, pkg_id.as_ref(), &id)?;
|
||||
host.add_binding(&mut ports, internal_port, options)?;
|
||||
host.update_addresses(&gateways, &ports)?;
|
||||
let host = host.de()?;
|
||||
db.as_private_mut().as_available_ports_mut().ser(&ports)?;
|
||||
Ok(())
|
||||
Ok(host)
|
||||
})
|
||||
.await
|
||||
.result
|
||||
.result?;
|
||||
data.update(&*ctrl, id, host).await
|
||||
}
|
||||
|
||||
pub async fn clear_bindings(&self, except: BTreeSet<BindId>) -> Result<(), Error> {
|
||||
let (ctrl, pkg_id) = {
|
||||
let data = self.data.lock().await;
|
||||
(data.net_controller()?, data.id.clone())
|
||||
};
|
||||
ctrl.db
|
||||
.mutate(|db| {
|
||||
let gateways = db
|
||||
.as_public()
|
||||
.as_server_info()
|
||||
.as_network()
|
||||
.as_gateways()
|
||||
.de()?;
|
||||
let ports = db.as_private().as_available_ports().de()?;
|
||||
if let Some(ref pkg_id) = pkg_id {
|
||||
for (host_id, host) in db
|
||||
.as_public_mut()
|
||||
.as_package_data_mut()
|
||||
.as_idx_mut(pkg_id)
|
||||
.or_not_found(pkg_id)?
|
||||
.as_hosts_mut()
|
||||
.as_entries_mut()?
|
||||
{
|
||||
host.as_bindings_mut().mutate(|b| {
|
||||
for (internal_port, info) in b.iter_mut() {
|
||||
if !except.contains(&BindId {
|
||||
id: host_id.clone(),
|
||||
internal_port: *internal_port,
|
||||
}) {
|
||||
info.disable();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
host.update_addresses(&gateways, &ports)?;
|
||||
}
|
||||
} else {
|
||||
let host = db
|
||||
.as_public_mut()
|
||||
.as_server_info_mut()
|
||||
.as_network_mut()
|
||||
.as_host_mut();
|
||||
host.as_bindings_mut().mutate(|b| {
|
||||
for (internal_port, info) in b.iter_mut() {
|
||||
if !except.contains(&BindId {
|
||||
id: HostId::default(),
|
||||
internal_port: *internal_port,
|
||||
}) {
|
||||
info.disable();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
host.update_addresses(&gateways, &ports)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.result
|
||||
let mut data = self.data.lock().await;
|
||||
let ctrl = data.net_controller()?;
|
||||
data.clear_bindings(&*ctrl, except).await
|
||||
}
|
||||
|
||||
pub async fn sync_host(&self, _id: HostId) -> Result<(), Error> {
|
||||
let current = self.synced.peek(|v| *v);
|
||||
let mut w = self.synced.clone();
|
||||
w.wait_for(|v| *v > current).await;
|
||||
Ok(())
|
||||
pub async fn update(&self, id: HostId, host: Host) -> Result<(), Error> {
|
||||
let mut data = self.data.lock().await;
|
||||
let ctrl = data.net_controller()?;
|
||||
data.update(&*ctrl, id, host).await
|
||||
}
|
||||
|
||||
pub async fn sync_host(&self, id: HostId) -> Result<(), Error> {
|
||||
let mut data = self.data.lock().await;
|
||||
let ctrl = data.net_controller()?;
|
||||
let host = host_for(&mut ctrl.db.peek().await, data.id.as_ref(), &id)?.de()?;
|
||||
data.update(&*ctrl, id, host).await
|
||||
}
|
||||
|
||||
pub async fn remove_all(mut self) -> Result<(), Error> {
|
||||
if Weak::upgrade(&self.data.lock().await.controller).is_none() {
|
||||
self.sync_task.abort();
|
||||
let mut data = self.data.lock().await;
|
||||
if let Some(ctrl) = Weak::upgrade(&data.controller) {
|
||||
self.shutdown = true;
|
||||
data.clear_bindings(&*ctrl, Default::default()).await?;
|
||||
|
||||
drop(ctrl);
|
||||
Ok(())
|
||||
} else {
|
||||
self.shutdown = true;
|
||||
tracing::warn!("NetService dropped after NetController is shutdown");
|
||||
return Err(Error::new(
|
||||
Err(Error::new(
|
||||
eyre!("NetController is shutdown"),
|
||||
crate::ErrorKind::Network,
|
||||
));
|
||||
))
|
||||
}
|
||||
let current = self.synced.peek(|v| *v);
|
||||
self.clear_bindings(Default::default()).await?;
|
||||
let mut w = self.synced.clone();
|
||||
w.wait_for(|v| *v > current).await;
|
||||
self.sync_task.abort();
|
||||
self.shutdown = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_ip(&self) -> Ipv4Addr {
|
||||
|
||||
@@ -1,75 +1,22 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use imbl_value::{InOMap, InternedString};
|
||||
use imbl_value::InternedString;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::prelude::*;
|
||||
use crate::{GatewayId, HostId, PackageId, ServiceInterfaceId};
|
||||
use crate::{GatewayId, HostId, ServiceInterfaceId};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HostnameInfo {
|
||||
pub ssl: bool,
|
||||
pub gateway: GatewayInfo,
|
||||
pub public: bool,
|
||||
pub host: InternedString,
|
||||
pub port: Option<u16>,
|
||||
pub metadata: HostnameMetadata,
|
||||
pub hostname: IpHostname,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
#[serde(rename_all_fields = "camelCase")]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum HostnameMetadata {
|
||||
Ipv4 {
|
||||
gateway: GatewayId,
|
||||
},
|
||||
Ipv6 {
|
||||
gateway: GatewayId,
|
||||
scope_id: u32,
|
||||
},
|
||||
PrivateDomain {
|
||||
gateways: BTreeSet<GatewayId>,
|
||||
},
|
||||
PublicDomain {
|
||||
gateway: GatewayId,
|
||||
},
|
||||
Plugin {
|
||||
package: PackageId,
|
||||
#[serde(flatten)]
|
||||
#[ts(skip)]
|
||||
extra: InOMap<InternedString, Value>,
|
||||
},
|
||||
}
|
||||
|
||||
impl HostnameInfo {
|
||||
pub fn to_socket_addr(&self) -> Option<SocketAddr> {
|
||||
let ip = self.host.parse().ok()?;
|
||||
Some(SocketAddr::new(ip, self.port?))
|
||||
}
|
||||
|
||||
pub fn to_san_hostname(&self) -> InternedString {
|
||||
self.host.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl HostnameMetadata {
|
||||
pub fn is_ip(&self) -> bool {
|
||||
matches!(self, Self::Ipv4 { .. } | Self::Ipv6 { .. })
|
||||
}
|
||||
|
||||
pub fn gateways(&self) -> Box<dyn Iterator<Item = &GatewayId> + '_> {
|
||||
match self {
|
||||
Self::Ipv4 { gateway }
|
||||
| Self::Ipv6 { gateway, .. }
|
||||
| Self::PublicDomain { gateway } => Box::new(std::iter::once(gateway)),
|
||||
Self::PrivateDomain { gateways } => Box::new(gateways.iter()),
|
||||
Self::Plugin { .. } => Box::new(std::iter::empty()),
|
||||
}
|
||||
self.hostname.to_san_hostname()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +29,48 @@ pub struct GatewayInfo {
|
||||
pub public: bool,
|
||||
}
|
||||
|
||||
#[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 IpHostname {
|
||||
Ipv4 {
|
||||
value: Ipv4Addr,
|
||||
port: Option<u16>,
|
||||
ssl_port: Option<u16>,
|
||||
},
|
||||
Ipv6 {
|
||||
value: Ipv6Addr,
|
||||
#[serde(default)]
|
||||
scope_id: u32,
|
||||
port: Option<u16>,
|
||||
ssl_port: Option<u16>,
|
||||
},
|
||||
Local {
|
||||
#[ts(type = "string")]
|
||||
value: InternedString,
|
||||
port: Option<u16>,
|
||||
ssl_port: Option<u16>,
|
||||
},
|
||||
Domain {
|
||||
#[ts(type = "string")]
|
||||
value: InternedString,
|
||||
port: Option<u16>,
|
||||
ssl_port: Option<u16>,
|
||||
},
|
||||
}
|
||||
impl IpHostname {
|
||||
pub fn to_san_hostname(&self) -> InternedString {
|
||||
match self {
|
||||
Self::Ipv4 { value, .. } => InternedString::from_display(value),
|
||||
Self::Ipv6 { value, .. } => InternedString::from_display(value),
|
||||
Self::Local { value, .. } => value.clone(),
|
||||
Self::Domain { value, .. } => value.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -9,14 +9,14 @@ use async_compression::tokio::bufread::GzipEncoder;
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{self as x, Request};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use axum::routing::{any, get};
|
||||
use base64::display::Base64Display;
|
||||
use digest::Digest;
|
||||
use futures::future::ready;
|
||||
use http::header::{
|
||||
ACCEPT_ENCODING, ACCEPT_RANGES, CACHE_CONTROL, CONNECTION, CONTENT_ENCODING, CONTENT_LENGTH,
|
||||
CONTENT_RANGE, CONTENT_TYPE, ETAG, RANGE,
|
||||
CONTENT_RANGE, CONTENT_TYPE, ETAG, HOST, RANGE,
|
||||
};
|
||||
use http::request::Parts as RequestParts;
|
||||
use http::{HeaderValue, Method, StatusCode};
|
||||
@@ -36,6 +36,8 @@ use crate::middleware::auth::Auth;
|
||||
use crate::middleware::auth::session::ValidSessionToken;
|
||||
use crate::middleware::cors::Cors;
|
||||
use crate::middleware::db::SyncDb;
|
||||
use crate::net::gateway::GatewayInfo;
|
||||
use crate::net::tls::TlsHandshakeInfo;
|
||||
use crate::prelude::*;
|
||||
use crate::rpc_continuations::{Guid, RpcContinuations};
|
||||
use crate::s9pk::S9pk;
|
||||
@@ -87,6 +89,30 @@ impl UiContext for RpcContext {
|
||||
.middleware(SyncDb::new())
|
||||
}
|
||||
fn extend_router(self, router: Router) -> Router {
|
||||
async fn https_redirect_if_public_http(
|
||||
req: Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> Response {
|
||||
if req
|
||||
.extensions()
|
||||
.get::<GatewayInfo>()
|
||||
.map_or(false, |p| p.info.public())
|
||||
&& req.extensions().get::<TlsHandshakeInfo>().is_none()
|
||||
{
|
||||
Redirect::temporary(&format!(
|
||||
"https://{}{}",
|
||||
req.headers()
|
||||
.get(HOST)
|
||||
.and_then(|s| s.to_str().ok())
|
||||
.unwrap_or("localhost"),
|
||||
req.uri()
|
||||
))
|
||||
.into_response()
|
||||
} else {
|
||||
next.run(req).await
|
||||
}
|
||||
}
|
||||
|
||||
router
|
||||
.route("/proxy/{url}", {
|
||||
let ctx = self.clone();
|
||||
@@ -110,6 +136,7 @@ impl UiContext for RpcContext {
|
||||
}
|
||||
}),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(https_redirect_if_public_http))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ pub async fn add_tunnel(
|
||||
iface.clone(),
|
||||
NetworkInterfaceInfo {
|
||||
name: Some(name),
|
||||
public: None,
|
||||
secure: None,
|
||||
ip_info: None,
|
||||
gateway_type,
|
||||
@@ -175,13 +176,10 @@ pub async fn remove_tunnel(
|
||||
|
||||
ctx.db
|
||||
.mutate(|db| {
|
||||
let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?;
|
||||
let ports = db.as_private().as_available_ports().de()?;
|
||||
for host in all_hosts(db) {
|
||||
let host = host?;
|
||||
host.as_public_domains_mut()
|
||||
.mutate(|p| Ok(p.retain(|_, v| v.gateway != id)))?;
|
||||
host.update_addresses(&gateways, &ports)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -193,18 +191,18 @@ pub async fn remove_tunnel(
|
||||
|
||||
ctx.db
|
||||
.mutate(|db| {
|
||||
let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?;
|
||||
let ports = db.as_private().as_available_ports().de()?;
|
||||
for host in all_hosts(db) {
|
||||
let host = host?;
|
||||
host.as_private_domains_mut().mutate(|d| {
|
||||
for gateways in d.values_mut() {
|
||||
gateways.remove(&id);
|
||||
}
|
||||
d.retain(|_, gateways| !gateways.is_empty());
|
||||
Ok(())
|
||||
host.as_bindings_mut().mutate(|b| {
|
||||
Ok(b.values_mut().for_each(|v| {
|
||||
v.addresses
|
||||
.private_disabled
|
||||
.retain(|h| h.gateway.id != id);
|
||||
v.addresses
|
||||
.public_enabled
|
||||
.retain(|h| h.gateway.id != id);
|
||||
}))
|
||||
})?;
|
||||
host.update_addresses(&gateways, &ports)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -11,9 +11,6 @@ use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
use ts_rs::TS;
|
||||
|
||||
use patch_db::json_ptr::JsonPointer;
|
||||
|
||||
use crate::db::model::Database;
|
||||
use crate::net::ssl::FullchainCertData;
|
||||
use crate::prelude::*;
|
||||
use crate::service::effects::context::EffectContext;
|
||||
@@ -32,7 +29,7 @@ struct ServiceCallbackMap {
|
||||
get_service_interface: BTreeMap<(PackageId, ServiceInterfaceId), Vec<CallbackHandler>>,
|
||||
list_service_interfaces: BTreeMap<PackageId, Vec<CallbackHandler>>,
|
||||
get_system_smtp: Vec<CallbackHandler>,
|
||||
get_host_info: BTreeMap<(PackageId, HostId), (NonDetachingJoinHandle<()>, Vec<CallbackHandler>)>,
|
||||
get_host_info: BTreeMap<(PackageId, HostId), Vec<CallbackHandler>>,
|
||||
get_ssl_certificate: EqMap<
|
||||
(BTreeSet<InternedString>, FullchainCertData, Algorithm),
|
||||
(NonDetachingJoinHandle<()>, Vec<CallbackHandler>),
|
||||
@@ -60,7 +57,7 @@ impl ServiceCallbacks {
|
||||
});
|
||||
this.get_system_smtp
|
||||
.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0);
|
||||
this.get_host_info.retain(|_, (_, v)| {
|
||||
this.get_host_info.retain(|_, v| {
|
||||
v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0);
|
||||
!v.is_empty()
|
||||
});
|
||||
@@ -144,57 +141,29 @@ impl ServiceCallbacks {
|
||||
}
|
||||
|
||||
pub(super) fn add_get_host_info(
|
||||
self: &Arc<Self>,
|
||||
db: &TypedPatchDb<Database>,
|
||||
&self,
|
||||
package_id: PackageId,
|
||||
host_id: HostId,
|
||||
handler: CallbackHandler,
|
||||
) {
|
||||
self.mutate(|this| {
|
||||
this.get_host_info
|
||||
.entry((package_id.clone(), host_id.clone()))
|
||||
.or_insert_with(|| {
|
||||
let ptr: JsonPointer = format!(
|
||||
"/public/packageData/{}/hosts/{}",
|
||||
package_id, host_id
|
||||
)
|
||||
.parse()
|
||||
.expect("valid json pointer");
|
||||
let db = db.clone();
|
||||
let callbacks = Arc::clone(self);
|
||||
let key = (package_id, host_id);
|
||||
(
|
||||
tokio::spawn(async move {
|
||||
let mut sub = db.subscribe(ptr).await;
|
||||
while sub.recv().await.is_some() {
|
||||
if let Some(cbs) = callbacks.mutate(|this| {
|
||||
this.get_host_info
|
||||
.remove(&key)
|
||||
.map(|(_, handlers)| CallbackHandlers(handlers))
|
||||
.filter(|cb| !cb.0.is_empty())
|
||||
}) {
|
||||
if let Err(e) = cbs.call(vector![]).await {
|
||||
tracing::error!(
|
||||
"Error in host info callback: {e}"
|
||||
);
|
||||
tracing::debug!("{e:?}");
|
||||
}
|
||||
}
|
||||
// entry was removed when we consumed handlers,
|
||||
// so stop watching — a new subscription will be
|
||||
// created if the service re-registers
|
||||
break;
|
||||
}
|
||||
})
|
||||
.into(),
|
||||
Vec::new(),
|
||||
)
|
||||
})
|
||||
.1
|
||||
.entry((package_id, host_id))
|
||||
.or_default()
|
||||
.push(handler);
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_host_info(&self, id: &(PackageId, HostId)) -> Option<CallbackHandlers> {
|
||||
self.mutate(|this| {
|
||||
Some(CallbackHandlers(
|
||||
this.get_host_info.remove(id).unwrap_or_default(),
|
||||
))
|
||||
.filter(|cb| !cb.0.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn add_get_ssl_certificate(
|
||||
&self,
|
||||
ctx: EffectContext,
|
||||
|
||||
@@ -29,7 +29,6 @@ pub async fn get_host_info(
|
||||
if let Some(callback) = callback {
|
||||
let callback = callback.register(&context.seed.persistent_container);
|
||||
context.seed.ctx.callbacks.add_get_host_info(
|
||||
&context.seed.ctx.db,
|
||||
package_id.clone(),
|
||||
host_id.clone(),
|
||||
CallbackHandler::new(&context, callback),
|
||||
|
||||
@@ -58,12 +58,12 @@ pub async fn get_ssl_certificate(
|
||||
Ok(m.as_public_domains()
|
||||
.keys()?
|
||||
.into_iter()
|
||||
.chain(m.as_private_domains().keys()?)
|
||||
.chain(m.as_private_domains().de()?)
|
||||
.chain(
|
||||
m.as_bindings()
|
||||
.de()?
|
||||
.values()
|
||||
.flat_map(|b| b.addresses.available.iter().cloned())
|
||||
.flat_map(|b| b.addresses.possible.iter().cloned())
|
||||
.map(|h| h.to_san_hostname()),
|
||||
)
|
||||
.collect::<Vec<InternedString>>())
|
||||
@@ -182,12 +182,12 @@ pub async fn get_ssl_key(
|
||||
Ok(m.as_public_domains()
|
||||
.keys()?
|
||||
.into_iter()
|
||||
.chain(m.as_private_domains().keys()?)
|
||||
.chain(m.as_private_domains().de()?)
|
||||
.chain(
|
||||
m.as_bindings()
|
||||
.de()?
|
||||
.values()
|
||||
.flat_map(|b| b.addresses.available.iter().cloned())
|
||||
.flat_map(|b| b.addresses.possible.iter().cloned())
|
||||
.map(|h| h.to_san_hostname()),
|
||||
)
|
||||
.collect::<Vec<InternedString>>())
|
||||
|
||||
@@ -414,11 +414,14 @@ pub async fn show_config(
|
||||
i.iter().find_map(|(_, info)| {
|
||||
info.ip_info
|
||||
.as_ref()
|
||||
.and_then(|ip_info| ip_info.wan_ip)
|
||||
.map(IpAddr::from)
|
||||
.filter(|_| info.public())
|
||||
.iter()
|
||||
.find_map(|info| info.subnets.iter().next())
|
||||
.copied()
|
||||
})
|
||||
})
|
||||
.or_not_found("a public IP address")?
|
||||
.addr()
|
||||
};
|
||||
Ok(client
|
||||
.client_config(
|
||||
|
||||
@@ -523,27 +523,27 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
|
||||
println!(concat!(
|
||||
"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 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://docs.start9.com/device-guides/mac/ca.html\n",
|
||||
" 1. Open the Terminal app\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://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",
|
||||
" 4. Complete by trusting your Root CA: https://docs.start9.com/device-guides/linux/ca.html\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://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",
|
||||
" 4. Complete by trusting your Root CA: https://docs.start9.com/device-guides/windows/ca.html\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://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://docs.start9.com/device-guides/android/ca.html\n",
|
||||
" 1. Send the ca.crt file (created above) to yourself\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://docs.start9.com/device-guides/ios/ca.html\n",
|
||||
" 1. Send the ca.crt file (created above) to yourself\n",
|
||||
" 2. Complete by trusting your Root CA: https://staging.docs.start9.com/device-guides/ios/ca.html\n",
|
||||
));
|
||||
|
||||
return Ok(());
|
||||
|
||||
@@ -164,19 +164,6 @@ fn migrate_host(host: Option<&mut Value>) {
|
||||
// Remove hostnameInfo from host
|
||||
host.remove("hostnameInfo");
|
||||
|
||||
// Migrate privateDomains from array to object (BTreeSet -> BTreeMap<_, BTreeSet<GatewayId>>)
|
||||
if let Some(private_domains) = host.get("privateDomains").and_then(|v| v.as_array()).cloned() {
|
||||
let mut new_pd: Value = serde_json::json!({}).into();
|
||||
for domain in private_domains {
|
||||
if let Some(d) = domain.as_str() {
|
||||
if let Some(obj) = new_pd.as_object_mut() {
|
||||
obj.insert(d.into(), serde_json::json!([]).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
host.insert("privateDomains".into(), new_pd);
|
||||
}
|
||||
|
||||
// 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() {
|
||||
@@ -186,9 +173,9 @@ fn migrate_host(host: Option<&mut Value>) {
|
||||
binding_obj.insert(
|
||||
"addresses".into(),
|
||||
serde_json::json!({
|
||||
"enabled": [],
|
||||
"disabled": [],
|
||||
"available": []
|
||||
"privateDisabled": [],
|
||||
"publicEnabled": [],
|
||||
"possible": []
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
|
||||
2
debian/dpkg-build.sh
vendored
@@ -23,7 +23,7 @@ if [ "${PROJECT}" = "startos" ]; then
|
||||
else
|
||||
INSTALL_TARGET="install-${PROJECT#start-}"
|
||||
fi
|
||||
make "${INSTALL_TARGET}" DESTDIR=dpkg-workdir/$BASENAME REMOTE=
|
||||
make "${INSTALL_TARGET}" DESTDIR=dpkg-workdir/$BASENAME
|
||||
|
||||
if [ -f dpkg-workdir/$BASENAME/usr/lib/$PROJECT/depends ]; then
|
||||
if [ -n "$DEPENDS" ]; then
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# SDK — TypeScript Service Packaging
|
||||
|
||||
TypeScript SDK for packaging services for StartOS (`@start9labs/start-sdk`).
|
||||
|
||||
## Structure
|
||||
|
||||
- `base/` — Core types, ABI definitions, effects interface (`@start9labs/start-sdk-base`)
|
||||
- `package/` — Full SDK for package developers, re-exports base
|
||||
@@ -1,9 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { GatewayType } from './GatewayType'
|
||||
|
||||
export type AddTunnelParams = {
|
||||
name: string
|
||||
config: string
|
||||
type: GatewayType | null
|
||||
setAsDefaultOutbound: boolean
|
||||
}
|
||||
export type AddTunnelParams = { name: string; config: string; public: boolean }
|
||||
|
||||
@@ -3,15 +3,15 @@ import type { HostnameInfo } from './HostnameInfo'
|
||||
|
||||
export type DerivedAddressInfo = {
|
||||
/**
|
||||
* User override: enable these addresses (only for public IP & port)
|
||||
* User-controlled: private-gateway addresses the user has disabled
|
||||
*/
|
||||
enabled: Array<string>
|
||||
privateDisabled: Array<HostnameInfo>
|
||||
/**
|
||||
* User override: disable these addresses (only for domains and private IP & port)
|
||||
* User-controlled: public-gateway addresses the user has enabled
|
||||
*/
|
||||
disabled: Array<[string, number]>
|
||||
publicEnabled: Array<HostnameInfo>
|
||||
/**
|
||||
* COMPUTED: NetServiceData::update — all possible addresses for this binding
|
||||
*/
|
||||
available: Array<HostnameInfo>
|
||||
possible: Array<HostnameInfo>
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type GatewayType = 'inbound-outbound' | 'outbound-only'
|
||||
@@ -1,10 +1,9 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Bindings } from './Bindings'
|
||||
import type { GatewayId } from './GatewayId'
|
||||
import type { PublicDomainConfig } from './PublicDomainConfig'
|
||||
|
||||
export type Host = {
|
||||
bindings: Bindings
|
||||
publicDomains: { [key: string]: PublicDomainConfig }
|
||||
privateDomains: { [key: string]: Array<GatewayId> }
|
||||
privateDomains: Array<string>
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { HostnameMetadata } from './HostnameMetadata'
|
||||
import type { GatewayInfo } from './GatewayInfo'
|
||||
import type { IpHostname } from './IpHostname'
|
||||
|
||||
export type HostnameInfo = {
|
||||
ssl: boolean
|
||||
gateway: GatewayInfo
|
||||
public: boolean
|
||||
host: string
|
||||
port: number | null
|
||||
metadata: HostnameMetadata
|
||||
hostname: IpHostname
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { GatewayId } from './GatewayId'
|
||||
import type { PackageId } from './PackageId'
|
||||
|
||||
export type HostnameMetadata =
|
||||
| { kind: 'ipv4'; gateway: GatewayId }
|
||||
| { kind: 'ipv6'; gateway: GatewayId; scopeId: number }
|
||||
| { kind: 'private-domain'; gateways: Array<GatewayId> }
|
||||
| { kind: 'public-domain'; gateway: GatewayId }
|
||||
| { kind: 'plugin'; package: PackageId }
|
||||
23
sdk/base/lib/osBindings/IpHostname.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type IpHostname =
|
||||
| { kind: 'ipv4'; value: string; port: number | null; sslPort: number | null }
|
||||
| {
|
||||
kind: 'ipv6'
|
||||
value: string
|
||||
scopeId: number
|
||||
port: number | null
|
||||
sslPort: number | null
|
||||
}
|
||||
| {
|
||||
kind: 'local'
|
||||
value: string
|
||||
port: number | null
|
||||
sslPort: number | null
|
||||
}
|
||||
| {
|
||||
kind: 'domain'
|
||||
value: string
|
||||
port: number | null
|
||||
sslPort: number | null
|
||||
}
|
||||
@@ -13,5 +13,4 @@ export type NetworkInfo = {
|
||||
gateways: { [key: GatewayId]: NetworkInterfaceInfo }
|
||||
acme: { [key: AcmeProvider]: AcmeSettings }
|
||||
dns: DnsSettings
|
||||
defaultOutbound: string | null
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { GatewayType } from './GatewayType'
|
||||
import type { IpInfo } from './IpInfo'
|
||||
|
||||
export type NetworkInterfaceInfo = {
|
||||
name: string | null
|
||||
public: boolean | null
|
||||
secure: boolean | null
|
||||
ipInfo: IpInfo | null
|
||||
type: GatewayType | null
|
||||
}
|
||||
|
||||
@@ -25,5 +25,4 @@ export type PackageDataEntry = {
|
||||
serviceInterfaces: { [key: ServiceInterfaceId]: ServiceInterface }
|
||||
hosts: Hosts
|
||||
storeExposedDependents: string[]
|
||||
outboundGateway: string | null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// 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'
|
||||
|
||||
export type PackageInfoShort = { releaseNotes: LocaleString }
|
||||
export type PackageInfoShort = { releaseNotes: string }
|
||||
|
||||
@@ -85,7 +85,6 @@ export { FullIndex } from './FullIndex'
|
||||
export { FullProgress } from './FullProgress'
|
||||
export { GatewayId } from './GatewayId'
|
||||
export { GatewayInfo } from './GatewayInfo'
|
||||
export { GatewayType } from './GatewayType'
|
||||
export { GetActionInputParams } from './GetActionInputParams'
|
||||
export { GetContainerIpParams } from './GetContainerIpParams'
|
||||
export { GetHostInfoParams } from './GetHostInfoParams'
|
||||
@@ -109,7 +108,6 @@ export { HardwareRequirements } from './HardwareRequirements'
|
||||
export { HealthCheckId } from './HealthCheckId'
|
||||
export { HostId } from './HostId'
|
||||
export { HostnameInfo } from './HostnameInfo'
|
||||
export { HostnameMetadata } from './HostnameMetadata'
|
||||
export { Hosts } from './Hosts'
|
||||
export { Host } from './Host'
|
||||
export { IdMap } from './IdMap'
|
||||
@@ -123,6 +121,7 @@ export { InstalledVersionParams } from './InstalledVersionParams'
|
||||
export { InstallingInfo } from './InstallingInfo'
|
||||
export { InstallingState } from './InstallingState'
|
||||
export { InstallParams } from './InstallParams'
|
||||
export { IpHostname } from './IpHostname'
|
||||
export { IpInfo } from './IpInfo'
|
||||
export { KeyboardOptions } from './KeyboardOptions'
|
||||
export { ListPackageSignersParams } from './ListPackageSignersParams'
|
||||
|
||||
@@ -49,20 +49,19 @@ type VisibilityFilter<V extends 'public' | 'private'> = V extends 'public'
|
||||
: never
|
||||
type KindFilter<K extends FilterKinds> = K extends 'mdns'
|
||||
?
|
||||
| (HostnameInfo & { metadata: { kind: 'private-domain' } })
|
||||
| (HostnameInfo & { hostname: { kind: 'local' } })
|
||||
| KindFilter<Exclude<K, 'mdns'>>
|
||||
: K extends 'domain'
|
||||
?
|
||||
| (HostnameInfo & { metadata: { kind: 'private-domain' } })
|
||||
| (HostnameInfo & { metadata: { kind: 'public-domain' } })
|
||||
| (HostnameInfo & { hostname: { kind: 'domain' } })
|
||||
| KindFilter<Exclude<K, 'domain'>>
|
||||
: K extends 'ipv4'
|
||||
?
|
||||
| (HostnameInfo & { metadata: { kind: 'ipv4' } })
|
||||
| (HostnameInfo & { hostname: { kind: 'ipv4' } })
|
||||
| KindFilter<Exclude<K, 'ipv4'>>
|
||||
: K extends 'ipv6'
|
||||
?
|
||||
| (HostnameInfo & { metadata: { kind: 'ipv6' } })
|
||||
| (HostnameInfo & { hostname: { kind: 'ipv6' } })
|
||||
| KindFilter<Exclude<K, 'ipv6'>>
|
||||
: K extends 'ip'
|
||||
? KindFilter<Exclude<K, 'ip'> | 'ipv4' | 'ipv6'>
|
||||
@@ -109,7 +108,10 @@ type FormatReturnTy<
|
||||
export type Filled<F extends Filter = {}> = {
|
||||
hostnames: HostnameInfo[]
|
||||
|
||||
toUrl: (h: HostnameInfo) => UrlString
|
||||
toUrls: (h: HostnameInfo) => {
|
||||
url: UrlString | null
|
||||
sslUrl: UrlString | null
|
||||
}
|
||||
|
||||
format: <Format extends Formats = 'urlstring'>(
|
||||
format?: Format,
|
||||
@@ -150,29 +152,37 @@ const unique = <A>(values: A[]) => Array.from(new Set(values))
|
||||
export const addressHostToUrl = (
|
||||
{ scheme, sslScheme, username, suffix }: AddressInfo,
|
||||
hostname: HostnameInfo,
|
||||
): UrlString => {
|
||||
const effectiveScheme = hostname.ssl ? sslScheme : scheme
|
||||
let host: string
|
||||
if (hostname.metadata.kind === 'ipv6') {
|
||||
host = IPV6_LINK_LOCAL.contains(hostname.host)
|
||||
? `[${hostname.host}%${hostname.metadata.scopeId}]`
|
||||
: `[${hostname.host}]`
|
||||
} else {
|
||||
host = hostname.host
|
||||
}
|
||||
let portStr = ''
|
||||
if (hostname.port !== null) {
|
||||
): { url: UrlString | null; sslUrl: UrlString | null } => {
|
||||
const res = []
|
||||
const fmt = (scheme: string | null, host: HostnameInfo, port: number) => {
|
||||
const excludePort =
|
||||
effectiveScheme &&
|
||||
effectiveScheme in knownProtocols &&
|
||||
hostname.port ===
|
||||
knownProtocols[effectiveScheme as keyof typeof knownProtocols]
|
||||
.defaultPort
|
||||
if (!excludePort) portStr = `:${hostname.port}`
|
||||
scheme &&
|
||||
scheme in knownProtocols &&
|
||||
port === knownProtocols[scheme as keyof typeof knownProtocols].defaultPort
|
||||
let hostname
|
||||
if (host.hostname.kind === 'domain') {
|
||||
hostname = host.hostname.value
|
||||
} else if (host.hostname.kind === 'ipv6') {
|
||||
hostname = IPV6_LINK_LOCAL.contains(host.hostname.value)
|
||||
? `[${host.hostname.value}%${host.hostname.scopeId}]`
|
||||
: `[${host.hostname.value}]`
|
||||
} else {
|
||||
hostname = host.hostname.value
|
||||
}
|
||||
return `${scheme ? `${scheme}://` : ''}${
|
||||
username ? `${username}@` : ''
|
||||
}${hostname}${excludePort ? '' : `:${port}`}${suffix}`
|
||||
}
|
||||
return `${effectiveScheme ? `${effectiveScheme}://` : ''}${
|
||||
username ? `${username}@` : ''
|
||||
}${host}${portStr}${suffix}`
|
||||
let url = null
|
||||
if (hostname.hostname.port !== null) {
|
||||
url = fmt(scheme, hostname, hostname.hostname.port)
|
||||
}
|
||||
let sslUrl = null
|
||||
if (hostname.hostname.sslPort !== null) {
|
||||
sslUrl = fmt(sslScheme, hostname, hostname.hostname.sslPort)
|
||||
}
|
||||
|
||||
return { url, sslUrl }
|
||||
}
|
||||
|
||||
function filterRec(
|
||||
@@ -199,19 +209,15 @@ function filterRec(
|
||||
hostnames = hostnames.filter(
|
||||
(h) =>
|
||||
invert !==
|
||||
((kind.has('mdns') &&
|
||||
h.metadata.kind === 'private-domain' &&
|
||||
h.host.endsWith('.local')) ||
|
||||
(kind.has('domain') &&
|
||||
(h.metadata.kind === 'private-domain' ||
|
||||
h.metadata.kind === 'public-domain')) ||
|
||||
(kind.has('ipv4') && h.metadata.kind === 'ipv4') ||
|
||||
(kind.has('ipv6') && h.metadata.kind === 'ipv6') ||
|
||||
((kind.has('mdns') && h.hostname.kind === 'local') ||
|
||||
(kind.has('domain') && h.hostname.kind === 'domain') ||
|
||||
(kind.has('ipv4') && h.hostname.kind === 'ipv4') ||
|
||||
(kind.has('ipv6') && h.hostname.kind === 'ipv6') ||
|
||||
(kind.has('localhost') &&
|
||||
['localhost', '127.0.0.1', '::1'].includes(h.host)) ||
|
||||
['localhost', '127.0.0.1', '::1'].includes(h.hostname.value)) ||
|
||||
(kind.has('link-local') &&
|
||||
h.metadata.kind === 'ipv6' &&
|
||||
IPV6_LINK_LOCAL.contains(IpAddress.parse(h.host)))),
|
||||
h.hostname.kind === 'ipv6' &&
|
||||
IPV6_LINK_LOCAL.contains(IpAddress.parse(h.hostname.value)))),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -220,34 +226,23 @@ function filterRec(
|
||||
return hostnames
|
||||
}
|
||||
|
||||
function isPublicIp(h: HostnameInfo): boolean {
|
||||
return h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6')
|
||||
}
|
||||
|
||||
function enabledAddresses(addr: DerivedAddressInfo): HostnameInfo[] {
|
||||
return addr.available.filter((h) => {
|
||||
if (isPublicIp(h)) {
|
||||
// Public IPs: disabled by default, explicitly enabled via SocketAddr string
|
||||
if (h.port === null) return true
|
||||
const sa =
|
||||
h.metadata.kind === 'ipv6'
|
||||
? `[${h.host}]:${h.port}`
|
||||
: `${h.host}:${h.port}`
|
||||
return addr.enabled.includes(sa)
|
||||
} else {
|
||||
// Everything else: enabled by default, explicitly disabled via [host, port] tuple
|
||||
return !addr.disabled.some(
|
||||
([host, port]) => host === h.host && port === (h.port ?? 0),
|
||||
)
|
||||
}
|
||||
})
|
||||
return addr.possible.filter((h) =>
|
||||
h.public
|
||||
? addr.publicEnabled.some((e) => deepEqual(e, h))
|
||||
: !addr.privateDisabled.some((d) => deepEqual(d, h)),
|
||||
)
|
||||
}
|
||||
|
||||
export const filledAddress = (
|
||||
host: Host,
|
||||
addressInfo: AddressInfo,
|
||||
): FilledAddressInfo => {
|
||||
const toUrl = addressHostToUrl.bind(null, addressInfo)
|
||||
const toUrls = addressHostToUrl.bind(null, addressInfo)
|
||||
const toUrlArray = (h: HostnameInfo) => {
|
||||
const u = toUrls(h)
|
||||
return [u.url, u.sslUrl].filter((u) => u !== null)
|
||||
}
|
||||
const binding = host.bindings[addressInfo.internalPort]
|
||||
const hostnames = binding ? enabledAddresses(binding.addresses) : []
|
||||
|
||||
@@ -267,11 +262,11 @@ export const filledAddress = (
|
||||
return {
|
||||
...addressInfo,
|
||||
hostnames,
|
||||
toUrl,
|
||||
toUrls,
|
||||
format: <Format extends Formats = 'urlstring'>(format?: Format) => {
|
||||
let res: FormatReturnTy<{}, Format>[] = hostnames as any
|
||||
if (format === 'hostname-info') return res
|
||||
const urls = hostnames.map(toUrl)
|
||||
const urls = hostnames.flatMap(toUrlArray)
|
||||
if (format === 'url') res = urls.map((u) => new URL(u)) as any
|
||||
else res = urls as any
|
||||
return res
|
||||
|
||||
185
web/CLAUDE.md
@@ -1,185 +0,0 @@
|
||||
# Web — Angular Frontend
|
||||
|
||||
Angular 20 + TypeScript workspace using [Taiga UI](https://taiga-ui.dev/) component library.
|
||||
|
||||
## Projects
|
||||
|
||||
- `projects/ui/` — Main admin interface
|
||||
- `projects/setup-wizard/` — Initial setup
|
||||
- `projects/start-tunnel/` — VPN management UI
|
||||
- `projects/shared/` — Common library (API clients, components, i18n)
|
||||
- `projects/marketplace/` — Service discovery
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run start:ui # Dev server with mocks
|
||||
npm run build:ui # Production build
|
||||
npm run check # Type check all projects
|
||||
```
|
||||
|
||||
## Golden Rules
|
||||
|
||||
1. **Taiga-first.** Use Taiga components, directives, and APIs whenever possible. Avoid hand-rolled HTML/CSS unless absolutely necessary. If Taiga has a component for it, use it.
|
||||
|
||||
2. **Pattern-match.** Nearly anything we build has a similar example elsewhere in this codebase. Search for existing patterns before writing new code. Copy the conventions used in neighboring components.
|
||||
|
||||
3. **When unsure about Taiga, ask or look it up.** Use `WebFetch` against `https://taiga-ui.dev/llms-full.txt` to search for component usage, or ask the user. Taiga docs are authoritative. See [Taiga UI Docs](#taiga-ui-docs) below.
|
||||
|
||||
## Taiga UI Docs
|
||||
|
||||
Taiga provides an LLM-friendly reference at `https://taiga-ui.dev/llms-full.txt` (~2200 lines covering all components with code examples). Use `WebFetch` to search it when you need to look up a component, directive, or API:
|
||||
|
||||
```
|
||||
WebFetch url=https://taiga-ui.dev/llms-full.txt prompt="How to use TuiTextfield with a select dropdown"
|
||||
```
|
||||
|
||||
When implementing something with Taiga, **also check existing code in this project** for local patterns and conventions — Taiga usage here may have project-specific wrappers or style choices.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### API Layer (JSON-RPC)
|
||||
|
||||
All backend communication uses JSON-RPC, not REST.
|
||||
|
||||
- **`HttpService`** (`shared/src/services/http.service.ts`) — Low-level HTTP wrapper. Sends JSON-RPC POST requests via `rpcRequest()`.
|
||||
- **`ApiService`** (`ui/src/app/services/api/embassy-api.service.ts`) — Abstract class defining 100+ RPC methods. Two implementations:
|
||||
- `LiveApiService` — Production, calls the real backend
|
||||
- `MockApiService` — Development with mocks
|
||||
- **`api.types.ts`** (`ui/src/app/services/api/api.types.ts`) — Namespace `RR` with all request/response type pairs.
|
||||
|
||||
**Calling an RPC endpoint from a component:**
|
||||
```typescript
|
||||
private readonly api = inject(ApiService)
|
||||
|
||||
async doSomething() {
|
||||
await this.api.someMethod({ param: value })
|
||||
}
|
||||
```
|
||||
|
||||
The live API handles `x-patch-sequence` headers — after a mutating call, it waits for the PatchDB WebSocket to catch up before resolving. This ensures the UI always reflects the result of the call.
|
||||
|
||||
### PatchDB (Reactive State)
|
||||
|
||||
The backend pushes state diffs to the frontend via WebSocket. This is the primary way components get data.
|
||||
|
||||
- **`PatchDbSource`** (`ui/src/app/services/patch-db/patch-db-source.ts`) — Establishes a WebSocket subscription when authenticated. Buffers updates every 250ms.
|
||||
- **`DataModel`** (`ui/src/app/services/patch-db/data-model.ts`) — TypeScript type for the full database shape (`ui`, `serverInfo`, `packageData`).
|
||||
- **`PatchDB<DataModel>`** — Injected service. Use `watch$()` to observe specific paths.
|
||||
|
||||
**Watching data in a component:**
|
||||
```typescript
|
||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||
|
||||
// Watch a specific path — returns Observable, convert to Signal with toSignal()
|
||||
readonly name = toSignal(this.patch.watch$('ui', 'name'))
|
||||
readonly status = toSignal(this.patch.watch$('serverInfo', 'statusInfo'))
|
||||
readonly packages = toSignal(this.patch.watch$('packageData'))
|
||||
```
|
||||
|
||||
**In templates:** `{{ name() }}` — signals are called as functions.
|
||||
|
||||
### WebSockets
|
||||
|
||||
Three WebSocket use cases, all opened via `api.openWebsocket$<T>(guid)`:
|
||||
|
||||
1. **PatchDB** — Continuous state patches (managed by `PatchDbSource`)
|
||||
2. **Logs** — Streamed via `followServerLogs` / `followPackageLogs`, buffered every 1s
|
||||
3. **Metrics** — Real-time server metrics via `followServerMetrics`
|
||||
|
||||
### Navigation & Routing
|
||||
|
||||
- **Main app** (`ui/src/app/routing.module.ts`) — NgModule-based with guards (`AuthGuard`, `UnauthGuard`, `stateNot()`), lazy loading via `loadChildren`, `PreloadAllModules`.
|
||||
- **Portal routes** (`ui/src/app/routes/portal/portal.routes.ts`) — Modern array-based routes with `loadChildren` and `loadComponent`.
|
||||
- **Setup wizard** (`setup-wizard/src/app/app.routes.ts`) — Standalone `loadComponent()` per step.
|
||||
- Route config uses `bindToComponentInputs: true` — route params bind directly to component `@Input()`.
|
||||
|
||||
### Forms
|
||||
|
||||
Two patterns:
|
||||
|
||||
1. **Dynamic (spec-driven)** — `FormService` (`ui/src/app/services/form.service.ts`) generates `FormGroup` from IST (Input Specification Type) schemas. Supports text, textarea, number, color, datetime, object, list, union, toggle, select, multiselect, file. Used for service configuration forms.
|
||||
|
||||
2. **Manual** — Standard Angular `FormGroup`/`FormControl` with validators. Used for login, setup wizard, system settings.
|
||||
|
||||
Form controls live in `ui/src/app/routes/portal/components/form/controls/` — each extends a base `Control<Spec, Value>` class and uses Taiga input components.
|
||||
|
||||
**Dialog-based forms** use `PolymorpheusComponent` + `TuiDialogContext` for modal rendering.
|
||||
|
||||
### i18n
|
||||
|
||||
- **`i18nPipe`** (`shared/src/i18n/i18n.pipe.ts`) — Translates English keys to the active language.
|
||||
- **Dictionaries** live in `shared/src/i18n/dictionaries/` (en, es, de, fr, pl).
|
||||
- Usage in templates: `{{ 'Some English Text' | i18n }}`
|
||||
|
||||
### Services & State
|
||||
|
||||
Services often extend `Observable` and expose reactive streams via DI:
|
||||
|
||||
- **`ConnectionService`** — Combines network status + WebSocket readiness
|
||||
- **`StateService`** — Polls server availability, manages app state (`running`, `initializing`, etc.)
|
||||
- **`AuthService`** — Tracks `isVerified$`, triggers PatchDB start/stop
|
||||
- **`PatchMonitorService`** — Starts/stops PatchDB based on auth state
|
||||
- **`PatchDataService`** — Watches entire DB, updates localStorage bootstrap
|
||||
|
||||
## Component Conventions
|
||||
|
||||
- **Standalone components** preferred (no NgModule). Use `imports` array in `@Component`.
|
||||
- **`export default class`** for route components (enables direct `loadComponent` import).
|
||||
- **`inject()`** function for DI (not constructor injection).
|
||||
- **`signal()`** and `computed()`** for local reactive state.
|
||||
- **`toSignal()`** to convert Observables (e.g., PatchDB watches) to signals.
|
||||
- **`ChangeDetectionStrategy.OnPush`** on almost all components.
|
||||
- **`takeUntilDestroyed(inject(DestroyRef))`** for subscription cleanup.
|
||||
|
||||
## Common Taiga Patterns
|
||||
|
||||
### Textfield + Select (dropdown)
|
||||
```html
|
||||
<tui-textfield tuiChevron>
|
||||
<label tuiLabel>Label</label>
|
||||
<input tuiSelect />
|
||||
<tui-data-list *tuiTextfieldDropdown>
|
||||
<button tuiOption [value]="item" *ngFor="let item of items">{{ item }}</button>
|
||||
</tui-data-list>
|
||||
</tui-textfield>
|
||||
```
|
||||
Provider to remove the X clear button:
|
||||
```typescript
|
||||
providers: [tuiTextfieldOptionsProvider({ cleaner: signal(false) })]
|
||||
```
|
||||
|
||||
### Buttons
|
||||
```html
|
||||
<button tuiButton appearance="primary">Submit</button>
|
||||
<button tuiButton appearance="secondary">Cancel</button>
|
||||
<button tuiIconButton appearance="icon" iconStart="@tui.trash"></button>
|
||||
```
|
||||
|
||||
### Dialogs
|
||||
```typescript
|
||||
// Confirmation
|
||||
this.dialog.openConfirm({ label: 'Warning', data: { content: '...', yes: 'Confirm', no: 'Cancel' } })
|
||||
|
||||
// Custom component in dialog
|
||||
this.dialog.openComponent(new PolymorpheusComponent(MyComponent, injector), { label: 'Title' })
|
||||
```
|
||||
|
||||
### Toggle
|
||||
```html
|
||||
<input tuiSwitch type="checkbox" size="m" [showIcons]="false" [(ngModel)]="value" />
|
||||
```
|
||||
|
||||
### Errors & Tooltips
|
||||
```html
|
||||
<tui-error [error]="[] | tuiFieldError | async" />
|
||||
<tui-icon [tuiTooltip]="'Hint text'" />
|
||||
```
|
||||
|
||||
### Layout
|
||||
```html
|
||||
<tui-elastic-container><!-- dynamic height --></tui-elastic-container>
|
||||
<tui-scrollbar><!-- scrollable content --></tui-scrollbar>
|
||||
<tui-loader [textContent]="'Loading...' | i18n" />
|
||||
```
|
||||
@@ -1,115 +0,0 @@
|
||||
# Contributing to StartOS Web
|
||||
|
||||
For general environment setup (Node.js, cloning, etc.), see the root [CONTRIBUTING.md](../CONTRIBUTING.md).
|
||||
|
||||
## Web Setup
|
||||
|
||||
```sh
|
||||
cd web
|
||||
npm ci
|
||||
npm run build:deps
|
||||
```
|
||||
|
||||
#### Configure `config.json`
|
||||
|
||||
```sh
|
||||
cp config-sample.json config.json
|
||||
```
|
||||
|
||||
- By default, "useMocks" is set to `true`.
|
||||
- Use "maskAs" to mock the host from which the web UI is served. Valid values are `tor`, `local`, `localhost`, `ipv4`, `ipv6`, and `clearnet`.
|
||||
- Use "maskAsHttps" to mock the protocol over which the web UI is served. `true` means https; `false` means http.
|
||||
|
||||
## Development Server
|
||||
|
||||
You can develop using mocks (recommended to start) or against a live server. Code changes will live reload the browser.
|
||||
|
||||
### Using mocks
|
||||
|
||||
```sh
|
||||
npm run start:setup
|
||||
npm run start:ui
|
||||
```
|
||||
|
||||
### Proxying to a live server
|
||||
|
||||
1. In `config.json`, set "useMocks" to `false`
|
||||
|
||||
2. Copy and configure the proxy config:
|
||||
|
||||
```sh
|
||||
cp proxy.conf-sample.json proxy.conf.json
|
||||
```
|
||||
|
||||
3. Replace every instance of `<CHANGEME>` with the hostname of your remote server
|
||||
|
||||
4. Start the proxy dev server:
|
||||
|
||||
```sh
|
||||
npm run start:ui:proxy
|
||||
```
|
||||
|
||||
## Translations
|
||||
|
||||
### Currently supported languages
|
||||
|
||||
- English
|
||||
- Spanish
|
||||
- Polish
|
||||
- German
|
||||
- French
|
||||
<!-- - Korean
|
||||
- Russian
|
||||
- Japanese
|
||||
- Hebrew
|
||||
- Arabic
|
||||
- Mandarin
|
||||
- Hindi
|
||||
- Portuguese
|
||||
- Italian
|
||||
- Thai -->
|
||||
|
||||
### Adding a new translation
|
||||
|
||||
When prompting AI to translate the English dictionary, it is recommended to only give it 50-100 entries at a time. Beyond that it struggles. Remember to sanity check the results and ensure keys/values align in the resulting dictionary.
|
||||
|
||||
#### Sample AI prompt
|
||||
|
||||
Translate the English dictionary below into `<language>`. Format the result as a javascript object with the numeric values of the English dictionary as keys in the translated dictionary. These translations are for the web UI of StartOS, a graphical server operating system optimized for self-hosting. Comments may be included in the English dictionary to provide additional context.
|
||||
|
||||
#### Adding to StartOS
|
||||
|
||||
- In the `shared` project:
|
||||
1. Create a new file (`language.ts`) in `src/i18n/dictionaries`
|
||||
2. Update the `I18N_PROVIDERS` array in `src/i18n/i18n.providers.ts` (2 places)
|
||||
3. Update the `languages` array in `/src/i18n/i18n.service.ts`
|
||||
4. Add the name of the new language (lowercase) to the English dictionary in `src/i18n/dictionaries/en.ts`. Add the translations of the new language's name (lowercase) to ALL non-English dictionaries in `src/i18n/dictionaries/` (e.g., `es.ts`, `pl.ts`, etc.).
|
||||
|
||||
If you have any doubt about the above steps, check the [French example PR](https://github.com/Start9Labs/start-os/pull/2945/files) for reference.
|
||||
|
||||
- Here in this CONTRIBUTING.md:
|
||||
1. Add the language to the list of supported languages above
|
||||
|
||||
### Updating the English dictionary
|
||||
|
||||
#### Sample AI prompt
|
||||
|
||||
Translate the English dictionary below into the languages beneath the dictionary. Format the result as a javascript object with translated language as keys, mapping to a javascript object with the numeric values of the English dictionary as keys and the translations as values. These translations are for the web UI of StartOS, a graphical server operating system optimized for self-hosting. Comments may be included in the English dictionary to provide additional context.
|
||||
|
||||
English dictionary:
|
||||
|
||||
```
|
||||
'Hello': 420,
|
||||
'Goodby': 421
|
||||
```
|
||||
|
||||
Languages:
|
||||
|
||||
- Spanish
|
||||
- Polish
|
||||
- German
|
||||
- French
|
||||
|
||||
#### Adding to StartOS
|
||||
|
||||
In the `shared` project, copy/paste the translations into their corresponding dictionaries in `/src/i18n/dictionaries`.
|
||||
157
web/README.md
@@ -1,20 +1,155 @@
|
||||
# StartOS Web
|
||||
|
||||
[Angular](https://angular.dev/) + TypeScript workspace using the [Taiga UI](https://taiga-ui.dev/) component library.
|
||||
StartOS web UIs are written in [Angular/Typescript](https://angular.io/docs) and leverage the [Ionic Framework](https://ionicframework.com/) component library.
|
||||
|
||||
## Applications
|
||||
StartOS conditionally serves one of three Web UIs, depending on the state of the system and user choice.
|
||||
|
||||
StartOS serves one of these UIs depending on the state of the system:
|
||||
- **setup-wizard** - UI for setting up StartOS, served on start.local.
|
||||
- **ui** - primary UI for administering StartOS, served on various hosts unique to the instance.
|
||||
|
||||
- **ui** — Primary admin interface for managing StartOS, served on hosts unique to the instance.
|
||||
- **setup-wizard** — Initial setup UI, served on `start.local`.
|
||||
- **start-tunnel** — VPN/tunnel management UI.
|
||||
Additionally, there are two libraries for shared code:
|
||||
|
||||
## Libraries
|
||||
- **marketplace** - library code shared between the StartOS UI and Start9's [brochure marketplace](https://github.com/Start9Labs/brochure-marketplace).
|
||||
- **shared** - library code shared between the various web UIs and marketplace lib.
|
||||
|
||||
- **shared** — Common code shared between all web UIs (API clients, components, i18n).
|
||||
- **marketplace** — Library code for service discovery, shared between the StartOS UI and the marketplace.
|
||||
## Environment Setup
|
||||
|
||||
## Contributing
|
||||
#### Install NodeJS and NPM
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for environment setup, development server instructions, and translation guides.
|
||||
- [Install nodejs](https://nodejs.org/en/)
|
||||
- [Install npm](https://www.npmjs.com/get-npm)
|
||||
|
||||
#### Check that your versions match the ones below
|
||||
|
||||
```sh
|
||||
node --version
|
||||
v22.15.0
|
||||
|
||||
npm --version
|
||||
v11.3.0
|
||||
```
|
||||
|
||||
#### Install and enable the Prettier extension for your text editor
|
||||
|
||||
#### Clone StartOS and load submodules
|
||||
|
||||
```sh
|
||||
git clone https://github.com/Start9Labs/start-os.git
|
||||
cd start-os
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
#### Move to web directory and install dependencies
|
||||
|
||||
```sh
|
||||
cd web
|
||||
npm ci
|
||||
npm run build:deps
|
||||
```
|
||||
|
||||
> Note if you are on **Windows** you need to install `make` for these scripts to work. Easiest way to do so is to install [Chocolatey](https://chocolatey.org/install) and then run `choco install make`.
|
||||
|
||||
#### Copy `config-sample.json` to a new file `config.json`.
|
||||
|
||||
```sh
|
||||
cp config-sample.json config.json
|
||||
```
|
||||
|
||||
- By default, "useMocks" is set to `true`.
|
||||
- Use "maskAs" to mock the host from which the web UI is served. Valid values are `tor`, `local`, `localhost`, `ipv4`, `ipv6`, and `clearnet`.
|
||||
- Use "maskAsHttps" to mock the protocol over which the web UI is served. `true` means https; `false` means http.
|
||||
|
||||
## Running the development server
|
||||
|
||||
You can develop using mocks (recommended to start) or against a live server. Either way, any code changes will live reload the development server and refresh the browser page.
|
||||
|
||||
### Using mocks
|
||||
|
||||
#### Start the standard development server
|
||||
|
||||
```sh
|
||||
npm run start:setup
|
||||
npm run start:ui
|
||||
```
|
||||
|
||||
### Proxying to a live server
|
||||
|
||||
#### In `config.json`, set "useMocks" to `false`
|
||||
|
||||
#### Copy `proxy.conf-sample.json` to a new file `proxy.conf.json`
|
||||
|
||||
```sh
|
||||
cp proxy.conf-sample.json proxy.conf.json
|
||||
```
|
||||
|
||||
#### Replace every instance of "\<CHANGEME>\" with the hostname of your remote server
|
||||
|
||||
#### Start the proxy development server
|
||||
|
||||
```sh
|
||||
npm run start:ui:proxy
|
||||
```
|
||||
|
||||
## Translations
|
||||
|
||||
### Currently supported languages
|
||||
|
||||
- Spanish
|
||||
- Polish
|
||||
- German
|
||||
- French
|
||||
<!-- - Korean
|
||||
- Russian
|
||||
- Japanese
|
||||
- Hebrew
|
||||
- Arabic
|
||||
- Mandarin
|
||||
- Hindi
|
||||
- Portuguese
|
||||
- Italian
|
||||
- Thai -->
|
||||
|
||||
### Adding a new translation
|
||||
|
||||
When prompting AI to translate the English dictionary, it is recommended to only give it 50-100 entries at a time. Beyond that it struggles. Remember to sanity check the results and ensure keys/values align in the resulting dictionary.
|
||||
|
||||
#### Sample AI prompt
|
||||
|
||||
Translate the English dictionary below into `<language>`. Format the result as a javascript object with the numeric values of the English dictionary as keys in the translated dictionary. These translations are for the web UI of StartOS, a graphical server operating system optimized for self-hosting. Comments may be included in the English dictionary to provide additional context.
|
||||
|
||||
#### Adding to StartOS
|
||||
|
||||
- In the `shared` project:
|
||||
1. Create a new file (`language.ts`) in `src/i18n/dictionaries`
|
||||
2. Update the `I18N_PROVIDERS` array in `src/i18n/i18n.providers.ts` (2 places)
|
||||
3. Update the `languages` array in `/src/i18n/i18n.service.ts`
|
||||
4. Add the name of the new language (lowercase) to the English dictionary in `src/i18n/dictionaries/en.ts`. Add the translations of the new language’s name (lowercase) to ALL non-English dictionaries in `src/i18n/dictionaries/` (e.g., `es.ts`, `pl.ts`, etc.).
|
||||
|
||||
If you have any doubt about the above steps, check the [French example PR](https://github.com/Start9Labs/start-os/pull/2945/files) for reference.
|
||||
|
||||
- Here in this README:
|
||||
1. Add the language to the list of supported languages below
|
||||
|
||||
### Updating the English dictionary
|
||||
|
||||
#### Sample AI prompt
|
||||
|
||||
Translate the English dictionary below into the languages beneath the dictionary. Format the result as a javascript object with translated language as keys, mapping to a javascript object with the numeric values of the English dictionary as keys and the translations as values. These translations are for the web UI of StartOS, a graphical server operating system optimized for self-hosting. Comments may be included in the English dictionary to provide additional context.
|
||||
|
||||
English dictionary:
|
||||
|
||||
```
|
||||
'Hello': 420,
|
||||
'Goodby': 421
|
||||
```
|
||||
|
||||
Languages:
|
||||
|
||||
- Spanish
|
||||
- Polish
|
||||
- German
|
||||
- French
|
||||
|
||||
#### Adding to StartOS
|
||||
|
||||
In the `shared` project, copy/past the translations into their corresponding dictionaries in `/src/i18n/dictionaries`.
|
||||
|
||||
8483
web/package-lock.json
generated
@@ -5,7 +5,7 @@ export default {
|
||||
2: 'Aktualisieren',
|
||||
4: 'System',
|
||||
5: 'Allgemein',
|
||||
6: 'SMTP',
|
||||
6: 'E-Mail',
|
||||
7: 'Sicherung erstellen',
|
||||
8: 'Sicherung wiederherstellen',
|
||||
9: 'Zum Login gehen',
|
||||
@@ -385,8 +385,8 @@ export default {
|
||||
405: 'Verbunden',
|
||||
406: 'Vergessen',
|
||||
407: 'WiFi-Zugangsdaten',
|
||||
408: 'Mit verstecktem Netzwerk verbinden',
|
||||
409: 'Verbinden mit',
|
||||
408: 'Veraltet',
|
||||
409: 'Die WLAN-Unterstützung wird in StartOS v0.4.1 entfernt. Wenn Sie keinen Zugriff auf Ethernet haben, können Sie einen WLAN-Extender verwenden, um sich mit dem lokalen Netzwerk zu verbinden und dann Ihren Server über Ethernet an den Extender anschließen. Bitte wenden Sie sich bei Fragen an den Start9-Support.',
|
||||
410: 'Bekannte Netzwerke',
|
||||
411: 'Weitere Netzwerke',
|
||||
412: 'WiFi ist deaktiviert',
|
||||
@@ -691,5 +691,4 @@ export default {
|
||||
726: '',
|
||||
727: '',
|
||||
728: '',
|
||||
729: '',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -4,7 +4,7 @@ export const ENGLISH: Record<string, number> = {
|
||||
'Update': 2, // verb
|
||||
'System': 4, // as in, system preferences
|
||||
'General': 5, // as in, general settings
|
||||
'SMTP': 6,
|
||||
'Email': 6,
|
||||
'Create Backup': 7, // create a backup
|
||||
'Restore Backup': 8, // restore from backup
|
||||
'Go to login': 9,
|
||||
@@ -384,8 +384,8 @@ export const ENGLISH: Record<string, number> = {
|
||||
'Connected': 405,
|
||||
'Forget': 406, // as in, delete or remove
|
||||
'WiFi Credentials': 407,
|
||||
'Connect to hidden network': 408,
|
||||
'Connect to': 409, // followed by a network name, e.g. "Connect to MyWiFi?"
|
||||
'Deprecated': 408,
|
||||
'WiFi support will be removed in StartOS v0.4.1. If you do not have access to Ethernet, you can use a WiFi extender to connect to the local network, then connect your server to the extender via Ethernet. Please contact Start9 support with any questions or concerns.': 409,
|
||||
'Known Networks': 410,
|
||||
'Other Networks': 411,
|
||||
'WiFi is disabled': 412,
|
||||
@@ -563,7 +563,7 @@ export const ENGLISH: Record<string, number> = {
|
||||
'Requires setting a static IP address for': 591, // this is a partial sentence. An IP address will be added after "for" to complete the sentence.
|
||||
'Ideal for VPN access via': 592, // this is a partial sentence. A connection medium will be added after "via" to complete the sentence.
|
||||
'in your gateway': 593, // this is a partial sentence. It is preceded by an instruction: e.g. "do something" in your gateway. Gateway refers to a router or VPN server.
|
||||
"your router's WireGuard server": 594, // this is a partial sentence. It is preceded by "ideal for access via"
|
||||
"your router's Wireguard server": 594, // this is a partial sentence. It is preceded by "ideal for access via"
|
||||
'Requires port forwarding in gateway': 595,
|
||||
'Requires a DNS record for': 596, // this is a partial sentence. A domain name will be added after "for" to complete the sentence.
|
||||
'that resolves to': 597, // this is a partial sentence. It is preceded by "requires a DNS record for [domain] "
|
||||
@@ -681,15 +681,14 @@ export const ENGLISH: Record<string, number> = {
|
||||
'Continue to Setup': 716,
|
||||
'Set Outbound Gateway': 717,
|
||||
'Current': 718,
|
||||
'System default': 719,
|
||||
'System default)': 719,
|
||||
'Outbound Gateway': 720,
|
||||
'Select the gateway for outbound traffic': 721,
|
||||
'The type of gateway': 722,
|
||||
'Outbound Only': 723,
|
||||
'Set as default outbound': 724,
|
||||
'Route all outbound traffic through this gateway': 725,
|
||||
'WireGuard Config File': 726,
|
||||
'Wireguard Config File': 726,
|
||||
'Inbound/Outbound': 727,
|
||||
'StartTunnel (Inbound/Outbound)': 728,
|
||||
'Ethernet': 729
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default {
|
||||
2: 'Actualizar',
|
||||
4: 'Sistema',
|
||||
5: 'General',
|
||||
6: 'SMTP',
|
||||
6: 'Correo electrónico',
|
||||
7: 'Crear copia de seguridad',
|
||||
8: 'Restaurar copia de seguridad',
|
||||
9: 'Ir a inicio de sesión',
|
||||
@@ -385,8 +385,8 @@ export default {
|
||||
405: 'Conectado',
|
||||
406: 'Olvidar',
|
||||
407: 'Credenciales WiFi',
|
||||
408: 'Conectar a red oculta',
|
||||
409: 'Conectar a',
|
||||
408: 'Obsoleto',
|
||||
409: 'El soporte para WiFi será eliminado en StartOS v0.4.1. Si no tienes acceso a Ethernet, puedes usar un extensor WiFi para conectarte a la red local y luego conectar tu servidor al extensor por Ethernet. Por favor, contacta al soporte de Start9 si tienes dudas o inquietudes.',
|
||||
410: 'Redes conocidas',
|
||||
411: 'Otras redes',
|
||||
412: 'WiFi está deshabilitado',
|
||||
@@ -691,5 +691,4 @@ export default {
|
||||
726: '',
|
||||
727: '',
|
||||
728: '',
|
||||
729: '',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -5,7 +5,7 @@ export default {
|
||||
2: 'Mettre à jour',
|
||||
4: 'Système',
|
||||
5: 'Général',
|
||||
6: 'SMTP',
|
||||
6: 'Email',
|
||||
7: 'Créer une sauvegarde',
|
||||
8: 'Restaurer une sauvegarde',
|
||||
9: 'Se connecter',
|
||||
@@ -385,8 +385,8 @@ export default {
|
||||
405: 'Connecté',
|
||||
406: 'Oublier',
|
||||
407: 'Identifiants WiFi',
|
||||
408: 'Se connecter à un réseau masqué',
|
||||
409: 'Se connecter à',
|
||||
408: 'Obsolète',
|
||||
409: 'Le support WiFi sera supprimé dans StartOS v0.4.1. Si vous n’avez pas accès à internet via Ethernet, vous pouvez utiliser un répéteur WiFi pour vous connecter au réseau local, puis brancher votre serveur sur le répéteur en Ethernet. Contactez le support Start9 pour toute question.',
|
||||
410: 'Réseaux connus',
|
||||
411: 'Autres réseaux',
|
||||
412: 'Le WiFi est désactivé',
|
||||
@@ -691,5 +691,4 @@ export default {
|
||||
726: '',
|
||||
727: '',
|
||||
728: '',
|
||||
729: '',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -5,7 +5,7 @@ export default {
|
||||
2: 'Aktualizuj',
|
||||
4: 'Ustawienia',
|
||||
5: 'Ogólne',
|
||||
6: 'SMTP',
|
||||
6: 'E-mail',
|
||||
7: 'Utwórz kopię zapasową',
|
||||
8: 'Przywróć z kopii zapasowej',
|
||||
9: 'Przejdź do logowania',
|
||||
@@ -385,8 +385,8 @@ export default {
|
||||
405: 'Połączono',
|
||||
406: 'Zapomnij',
|
||||
407: 'Dane logowania WiFi',
|
||||
408: 'Połącz z ukrytą siecią',
|
||||
409: 'Połącz z',
|
||||
408: 'Przestarzałe',
|
||||
409: 'Obsługa WiFi zostanie usunięta w StartOS v0.4.1. Jeśli nie masz dostępu do sieci Ethernet, możesz użyć wzmacniacza WiFi do połączenia z siecią lokalną, a następnie podłączyć serwer do wzmacniacza przez Ethernet. W razie pytań lub wątpliwości skontaktuj się z pomocą techniczną Start9.',
|
||||
410: 'Znane sieci',
|
||||
411: 'Inne sieci',
|
||||
412: 'WiFi jest wyłączone',
|
||||
@@ -691,5 +691,4 @@ export default {
|
||||
726: '',
|
||||
727: '',
|
||||
728: '',
|
||||
729: '',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -257,9 +257,9 @@ export class InterfaceService {
|
||||
if (!binding) return []
|
||||
const addr = binding.addresses
|
||||
const enabled = addr.possible.filter(h =>
|
||||
addr.enabled.some(e => utils.deepEqual(e, h)) ||
|
||||
(!addr.disabled.some(d => utils.deepEqual(d, h)) &&
|
||||
!(h.public && (h.hostname.kind === 'ipv4' || h.hostname.kind === 'ipv6'))),
|
||||
h.public
|
||||
? addr.publicEnabled.some(e => utils.deepEqual(e, h))
|
||||
: !addr.privateDisabled.some(d => utils.deepEqual(d, h)),
|
||||
)
|
||||
return enabled.filter(
|
||||
h =>
|
||||
|
||||
@@ -134,12 +134,9 @@ export default class ServiceInterfaceRoute {
|
||||
gateways:
|
||||
gateways.map(g => ({
|
||||
enabled:
|
||||
(binding?.addresses.enabled.some(a => a.gateway.id === g.id) ||
|
||||
(!binding?.addresses.disabled.some(a => a.gateway.id === g.id) &&
|
||||
binding?.addresses.possible.some(a =>
|
||||
a.gateway.id === g.id &&
|
||||
!(a.public && (a.hostname.kind === 'ipv4' || a.hostname.kind === 'ipv6'))
|
||||
))) ?? false,
|
||||
(g.public
|
||||
? binding?.addresses.publicEnabled.some(a => a.gateway.id === g.id)
|
||||
: !binding?.addresses.privateDisabled.some(a => a.gateway.id === g.id)) ?? false,
|
||||
...g,
|
||||
})) || [],
|
||||
publicDomains: getPublicDomains(host.publicDomains, gateways),
|
||||
|
||||
@@ -28,7 +28,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
|
||||
{{ 'Back' | i18n }}
|
||||
</a>
|
||||
{{ 'SMTP' | i18n }}
|
||||
{{ 'Email' | i18n }}
|
||||
</ng-container>
|
||||
@if (form$ | async; as form) {
|
||||
<form [formGroup]="form">
|
||||
|
||||
@@ -93,7 +93,7 @@ export default class GatewaysComponent {
|
||||
},
|
||||
}),
|
||||
config: ISB.Value.union({
|
||||
name: this.i18n.transform('WireGuard Config File'),
|
||||
name: this.i18n.transform('Wireguard Config File'),
|
||||
default: 'paste',
|
||||
variants: ISB.Variants.of({
|
||||
paste: {
|
||||
|
||||
@@ -49,7 +49,7 @@ import { TuiBadge } from '@taiga-ui/kit'
|
||||
}
|
||||
@case ('wireguard') {
|
||||
<tui-icon icon="@tui.shield" />
|
||||
WireGuard'
|
||||
{{ 'WireGuard' | i18n }}
|
||||
}
|
||||
@default {
|
||||
{{ gateway.ipInfo.deviceType }}
|
||||
@@ -99,7 +99,7 @@ import { TuiBadge } from '@taiga-ui/kit'
|
||||
iconStart="@tui.arrow-up-right"
|
||||
(click)="setDefaultOutbound()"
|
||||
>
|
||||
{{ 'Set as default outbound' | i18n }}
|
||||
{{ 'Set as Default Outbound' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
}
|
||||
|
||||
@@ -160,11 +160,7 @@ export default class SystemSSHComponent {
|
||||
const loader = this.loader.open('Deleting').subscribe()
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
fingerprints.map(fingerprint =>
|
||||
this.api.deleteSshKey({ fingerprint }),
|
||||
),
|
||||
)
|
||||
await this.api.deleteSshKey({ fingerprint: '' })
|
||||
this.local$.next(
|
||||
all.filter(s => !fingerprints.includes(s.fingerprint)),
|
||||
)
|
||||
|
||||
@@ -95,12 +95,9 @@ export default class StartOsUiComponent {
|
||||
),
|
||||
gateways: gateways.map(g => ({
|
||||
enabled:
|
||||
(binding?.addresses.enabled.some(a => a.gateway.id === g.id) ||
|
||||
(!binding?.addresses.disabled.some(a => a.gateway.id === g.id) &&
|
||||
binding?.addresses.possible.some(a =>
|
||||
a.gateway.id === g.id &&
|
||||
!(a.public && (a.hostname.kind === 'ipv4' || a.hostname.kind === 'ipv6'))
|
||||
))) ?? false,
|
||||
(g.public
|
||||
? binding?.addresses.publicEnabled.some(a => a.gateway.id === g.id)
|
||||
: !binding?.addresses.privateDisabled.some(a => a.gateway.id === g.id)) ?? false,
|
||||
...g,
|
||||
})),
|
||||
publicDomains: getPublicDomains(network.host.publicDomains, gateways),
|
||||
|
||||
@@ -5,23 +5,8 @@ import {
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { NgTemplateOutlet } from '@angular/common'
|
||||
import {
|
||||
DialogService,
|
||||
ErrorService,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { filter } from 'rxjs'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDataList,
|
||||
TuiDropdown,
|
||||
TuiIcon,
|
||||
TuiTextfield,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
|
||||
import { TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiBadge, TuiFade } from '@taiga-ui/kit'
|
||||
import { TuiCell } from '@taiga-ui/layout'
|
||||
import {
|
||||
@@ -37,78 +22,50 @@ import { wifiSpec } from './wifi.const'
|
||||
@Component({
|
||||
selector: '[wifi]',
|
||||
template: `
|
||||
<ng-template #row let-network>
|
||||
@if (getSignal(network.strength); as signal) {
|
||||
<tui-icon
|
||||
background="@tui.wifi"
|
||||
[icon]="signal.icon"
|
||||
[style.background]="'var(--tui-background-neutral-2)'"
|
||||
[style.color]="signal.color"
|
||||
/>
|
||||
} @else {
|
||||
<tui-icon icon="@tui.wifi-off" />
|
||||
}
|
||||
<tui-icon
|
||||
[icon]="network.security.length ? '@tui.lock' : '@tui.lock-open'"
|
||||
/>
|
||||
<div tuiTitle>
|
||||
<strong tuiFade>
|
||||
{{ network.ssid }}
|
||||
</strong>
|
||||
</div>
|
||||
@if (network.connected) {
|
||||
<tui-badge appearance="positive">
|
||||
{{ 'Connected' | i18n }}
|
||||
</tui-badge>
|
||||
}
|
||||
@if (network.connected === false) {
|
||||
@for (network of wifi; track $index) {
|
||||
@if (network.ssid) {
|
||||
<button
|
||||
tuiIconButton
|
||||
tuiDropdown
|
||||
size="s"
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.ellipsis-vertical"
|
||||
[(tuiDropdownOpen)]="open"
|
||||
tuiCell
|
||||
[disabled]="network.connected"
|
||||
(click)="prompt(network)"
|
||||
>
|
||||
{{ 'More' | i18n }}
|
||||
<tui-data-list *tuiTextfieldDropdown>
|
||||
<div tuiTitle>
|
||||
<strong tuiFade>
|
||||
{{ network.ssid }}
|
||||
@if (network.connected) {
|
||||
<tui-badge appearance="positive">
|
||||
{{ 'Connected' | i18n }}
|
||||
</tui-badge>
|
||||
}
|
||||
</strong>
|
||||
</div>
|
||||
@if (network.connected !== undefined) {
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.wifi"
|
||||
(click)="prompt(network)"
|
||||
>
|
||||
{{ 'Connect' | i18n }}
|
||||
</button>
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.trash"
|
||||
class="g-negative"
|
||||
(click)="forget(network)"
|
||||
tuiIconButton
|
||||
size="s"
|
||||
appearance="icon"
|
||||
iconStart="@tui.trash-2"
|
||||
(click.stop)="forget(network)"
|
||||
>
|
||||
{{ 'Forget' | i18n }}
|
||||
</button>
|
||||
</tui-data-list>
|
||||
} @else {
|
||||
<tui-icon
|
||||
[icon]="network.security.length ? '@tui.lock' : '@tui.lock-open'"
|
||||
/>
|
||||
}
|
||||
@if (getSignal(network.strength); as signal) {
|
||||
<tui-icon
|
||||
background="@tui.wifi"
|
||||
[icon]="signal.icon"
|
||||
[style.background]="'var(--tui-background-neutral-2)'"
|
||||
[style.color]="signal.color"
|
||||
/>
|
||||
} @else {
|
||||
<tui-icon icon="@tui.wifi-off" />
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</ng-template>
|
||||
@for (network of wifi; track $index) {
|
||||
@if (network.ssid) {
|
||||
@if (network.connected === undefined) {
|
||||
<button tuiCell (click)="prompt(network)">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="row; context: { $implicit: network }"
|
||||
/>
|
||||
</button>
|
||||
} @else {
|
||||
<div tuiCell>
|
||||
<ng-container
|
||||
*ngTemplateOutlet="row; context: { $implicit: network }"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
@@ -118,6 +75,8 @@ import { wifiSpec } from './wifi.const'
|
||||
}
|
||||
|
||||
[tuiCell] {
|
||||
padding-inline: 1rem !important;
|
||||
|
||||
&:disabled > * {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -129,24 +88,11 @@ import { wifiSpec } from './wifi.const'
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
NgTemplateOutlet,
|
||||
TuiCell,
|
||||
TuiTitle,
|
||||
TuiBadge,
|
||||
TuiButton,
|
||||
TuiIcon,
|
||||
TuiFade,
|
||||
TuiDropdown,
|
||||
TuiDataList,
|
||||
TuiTextfield,
|
||||
i18nPipe,
|
||||
],
|
||||
imports: [TuiCell, TuiTitle, TuiBadge, TuiButton, TuiIcon, TuiFade, i18nPipe],
|
||||
})
|
||||
export class WifiTableComponent {
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly dialogs = inject(DialogService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
private readonly component = inject(SystemWifiComponent)
|
||||
@@ -156,8 +102,6 @@ export class WifiTableComponent {
|
||||
@Input()
|
||||
wifi: readonly Wifi[] = []
|
||||
|
||||
open = false
|
||||
|
||||
getSignal(signal: number) {
|
||||
if (signal < 5) {
|
||||
return null
|
||||
@@ -197,30 +141,17 @@ export class WifiTableComponent {
|
||||
|
||||
async prompt(network: Wifi): Promise<void> {
|
||||
if (!network.security.length) {
|
||||
this.dialogs
|
||||
.openConfirm({
|
||||
label: `${this.i18n.transform('Connect to')} ${network.ssid}?`,
|
||||
size: 's',
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.component.saveAndConnect(network.ssid))
|
||||
await this.component.saveAndConnect(network.ssid)
|
||||
} else {
|
||||
const ssid = wifiSpec.spec['ssid'] as IST.ValueSpecText
|
||||
const spec: IST.InputSpec = {
|
||||
...wifiSpec.spec,
|
||||
ssid: { ...ssid, disabled: 'ssid', default: network.ssid },
|
||||
}
|
||||
|
||||
this.formDialog.open<FormContext<WiFiForm>>(FormComponent, {
|
||||
label: 'Password needed',
|
||||
data: {
|
||||
spec,
|
||||
value: { ssid: network.ssid, password: '' },
|
||||
spec: wifiSpec.spec,
|
||||
buttons: [
|
||||
{
|
||||
text: this.i18n.transform('Connect')!,
|
||||
handler: async ({ password }) =>
|
||||
this.component.saveAndConnect(network.ssid, password),
|
||||
handler: async ({ ssid, password }) =>
|
||||
this.component.saveAndConnect(ssid, password),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -8,7 +8,6 @@ import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import {
|
||||
DocsLinkDirective,
|
||||
ErrorService,
|
||||
i18nKey,
|
||||
i18nPipe,
|
||||
@@ -20,9 +19,11 @@ import {
|
||||
TuiAppearance,
|
||||
TuiButton,
|
||||
TuiLoader,
|
||||
TuiNotification,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiSwitch } from '@taiga-ui/kit'
|
||||
import { TuiCardLarge } from '@taiga-ui/layout'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { catchError, defer, map, merge, Observable, of, Subject } from 'rxjs'
|
||||
import {
|
||||
@@ -46,20 +47,23 @@ import { wifiSpec } from './wifi.const'
|
||||
</a>
|
||||
WiFi
|
||||
</ng-container>
|
||||
<header tuiHeader>
|
||||
<tui-notification appearance="negative">
|
||||
<div tuiTitle>
|
||||
{{ 'Deprecated' | i18n }}
|
||||
<div tuiSubtitle>
|
||||
{{
|
||||
'WiFi support will be removed in StartOS v0.4.1. If you do not have access to Ethernet, you can use a WiFi extender to connect to the local network, then connect your server to the extender via Ethernet. Please contact Start9 support with any questions or concerns.'
|
||||
| i18n
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</tui-notification>
|
||||
</header>
|
||||
@if (status()?.interface) {
|
||||
<section class="g-card">
|
||||
<header>
|
||||
Wi-Fi
|
||||
<a
|
||||
tuiIconButton
|
||||
size="xs"
|
||||
docsLink
|
||||
path="/user-manual/wifi.html"
|
||||
appearance="icon"
|
||||
iconStart="@tui.external-link"
|
||||
>
|
||||
{{ 'Documentation' | i18n }}
|
||||
</a>
|
||||
<input
|
||||
type="checkbox"
|
||||
tuiSwitch
|
||||
@@ -88,8 +92,8 @@ import { wifiSpec } from './wifi.const'
|
||||
></div>
|
||||
}
|
||||
<p>
|
||||
<button tuiButton (click)="other(data)" appearance="flat">
|
||||
+ {{ 'Connect to hidden network' | i18n }}
|
||||
<button tuiButton (click)="other(data)">
|
||||
{{ 'Add' | i18n }}
|
||||
</button>
|
||||
</p>
|
||||
} @else {
|
||||
@@ -124,8 +128,10 @@ import { wifiSpec } from './wifi.const'
|
||||
TitleDirective,
|
||||
RouterLink,
|
||||
PlaceholderComponent,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
TuiNotification,
|
||||
i18nPipe,
|
||||
DocsLinkDirective,
|
||||
],
|
||||
})
|
||||
export default class SystemWifiComponent {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { RouterModule } from '@angular/router'
|
||||
@@ -5,9 +6,12 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiBadgeNotification } from '@taiga-ui/kit'
|
||||
import { TuiCell } from '@taiga-ui/layout'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { BadgeService } from 'src/app/services/badge.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { TitleDirective } from 'src/app/services/title.service'
|
||||
import { SYSTEM_MENU } from './system.const'
|
||||
import { map } from 'rxjs'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@@ -22,6 +26,9 @@ import { SYSTEM_MENU } from './system.const'
|
||||
tuiCell="s"
|
||||
routerLinkActive="active"
|
||||
[routerLink]="page.link"
|
||||
[style.display]="
|
||||
!(wifiEnabled$ | async) && page.item === 'WiFi' ? 'none' : null
|
||||
"
|
||||
>
|
||||
<tui-icon [icon]="page.icon" />
|
||||
<span tuiTitle>
|
||||
@@ -109,9 +116,13 @@ import { SYSTEM_MENU } from './system.const'
|
||||
TitleDirective,
|
||||
TuiBadgeNotification,
|
||||
i18nPipe,
|
||||
AsyncPipe,
|
||||
],
|
||||
})
|
||||
export class SystemComponent {
|
||||
readonly menu = SYSTEM_MENU
|
||||
readonly badge = toSignal(inject(BadgeService).getCount('system'))
|
||||
readonly wifiEnabled$ = inject<PatchDB<DataModel>>(PatchDB)
|
||||
.watch$('serverInfo', 'network', 'wifi')
|
||||
.pipe(map(wifi => !!wifi.interface && wifi.enabled))
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const SYSTEM_MENU = [
|
||||
},
|
||||
{
|
||||
icon: '@tui.mail',
|
||||
item: 'SMTP',
|
||||
item: 'Email',
|
||||
link: 'email',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2128,8 +2128,8 @@ export namespace Mock {
|
||||
assignedSslPort: 443,
|
||||
},
|
||||
addresses: {
|
||||
enabled: [],
|
||||
disabled: [],
|
||||
privateDisabled: [],
|
||||
publicEnabled: [],
|
||||
possible: [
|
||||
{
|
||||
gateway: { id: 'eth0', name: 'Ethernet', public: false },
|
||||
@@ -2214,8 +2214,8 @@ export namespace Mock {
|
||||
assignedSslPort: null,
|
||||
},
|
||||
addresses: {
|
||||
enabled: [],
|
||||
disabled: [],
|
||||
privateDisabled: [],
|
||||
publicEnabled: [],
|
||||
possible: [],
|
||||
},
|
||||
options: {
|
||||
@@ -2237,8 +2237,8 @@ export namespace Mock {
|
||||
assignedSslPort: null,
|
||||
},
|
||||
addresses: {
|
||||
enabled: [],
|
||||
disabled: [],
|
||||
privateDisabled: [],
|
||||
publicEnabled: [],
|
||||
possible: [],
|
||||
},
|
||||
options: {
|
||||
|
||||
@@ -49,7 +49,7 @@ export class LiveApiService extends ApiService {
|
||||
urls: string[],
|
||||
params: Record<string, string | number>,
|
||||
): Promise<string> {
|
||||
for (const url of urls) {
|
||||
for (let url in urls) {
|
||||
try {
|
||||
const res = await this.httpRequest<string>({
|
||||
method: 'GET',
|
||||
|
||||
@@ -40,8 +40,8 @@ export const mockPatchData: DataModel = {
|
||||
assignedSslPort: 443,
|
||||
},
|
||||
addresses: {
|
||||
enabled: [],
|
||||
disabled: [],
|
||||
privateDisabled: [],
|
||||
publicEnabled: [],
|
||||
possible: [
|
||||
{
|
||||
gateway: { id: 'eth0', name: 'Ethernet', public: false },
|
||||
@@ -516,8 +516,8 @@ export const mockPatchData: DataModel = {
|
||||
assignedSslPort: 443,
|
||||
},
|
||||
addresses: {
|
||||
enabled: [],
|
||||
disabled: [],
|
||||
privateDisabled: [],
|
||||
publicEnabled: [],
|
||||
possible: [
|
||||
{
|
||||
gateway: { id: 'eth0', name: 'Ethernet', public: false },
|
||||
@@ -602,8 +602,8 @@ export const mockPatchData: DataModel = {
|
||||
assignedSslPort: null,
|
||||
},
|
||||
addresses: {
|
||||
enabled: [],
|
||||
disabled: [],
|
||||
privateDisabled: [],
|
||||
publicEnabled: [],
|
||||
possible: [],
|
||||
},
|
||||
options: {
|
||||
@@ -625,8 +625,8 @@ export const mockPatchData: DataModel = {
|
||||
assignedSslPort: null,
|
||||
},
|
||||
addresses: {
|
||||
enabled: [],
|
||||
disabled: [],
|
||||
privateDisabled: [],
|
||||
publicEnabled: [],
|
||||
possible: [],
|
||||
},
|
||||
options: {
|
||||
|
||||