Compare commits

..

2 Commits

Author SHA1 Message Date
Matt Hill
ea130079ab Merge branch 'feat/preferred-port-design' of github.com:Start9Labs/start-os into feature/outbound-gateways 2026-02-12 08:26:34 -07:00
Matt Hill
cc5f316514 frontend plus some be types 2026-02-09 22:00:39 -07:00
83 changed files with 4692 additions and 7573 deletions

2
.gitignore vendored
View File

@@ -21,4 +21,4 @@ secrets.db
/build/lib/firmware
tmp
web/.i18n-checked
docs/USER.md
agents/USER.md

109
CLAUDE.md
View File

@@ -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

View File

@@ -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
View 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
![Select "Create a new virtual machine"](assets/create-vm/step-1.png)
![Click "Forward"](assets/create-vm/step-2.png)
![Click "Browse"](assets/create-vm/step-3.png)
![Click "+"](assets/create-vm/step-4.png)
#### make sure to set "Target Path" to the path to your results directory in start-os
![Create storage pool](assets/create-vm/step-5.png)
![Select storage pool](assets/create-vm/step-6.png)
![Select ISO](assets/create-vm/step-7.png)
![Select "Generic or unknown OS" and click "Forward"](assets/create-vm/step-8.png)
![Set Memory and CPUs](assets/create-vm/step-9.png)
![Create disk](assets/create-vm/step-10.png)
![Name VM](assets/create-vm/step-11.png)
![Create network](assets/create-vm/step-12.png)
## 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
```

View File

@@ -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

View File

@@ -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>

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
assets/btcpay.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

BIN
assets/c-lightning.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

BIN
assets/community.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

BIN
assets/logs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
assets/nextcloud.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

BIN
assets/registry.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

BIN
assets/system.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

BIN
assets/welcome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

View File

@@ -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

View File

@@ -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.

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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 {

View File

@@ -71,7 +71,7 @@ impl SignatureAuthContext for RpcContext {
.as_network()
.as_host()
.as_private_domains()
.keys()
.de()
.map(|k| k.into_iter())
.transpose(),
)

View File

@@ -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};

View File

@@ -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(())

View File

@@ -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

View File

@@ -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?;

View File

@@ -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(())

View File

@@ -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>"]

View File

@@ -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 {

View File

@@ -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")]

View File

@@ -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))
}
}

View File

@@ -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(())

View File

@@ -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,

View File

@@ -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),

View File

@@ -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>>())

View File

@@ -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(

View File

@@ -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(());

View File

@@ -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(),
);

View File

@@ -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

View File

@@ -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

View File

@@ -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 }

View File

@@ -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>
}

View File

@@ -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'

View File

@@ -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>
}

View File

@@ -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
}

View File

@@ -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 }

View 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
}

View File

@@ -13,5 +13,4 @@ export type NetworkInfo = {
gateways: { [key: GatewayId]: NetworkInterfaceInfo }
acme: { [key: AcmeProvider]: AcmeSettings }
dns: DnsSettings
defaultOutbound: string | null
}

View File

@@ -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
}

View File

@@ -25,5 +25,4 @@ export type PackageDataEntry = {
serviceInterfaces: { [key: ServiceInterfaceId]: ServiceInterface }
hosts: Hosts
storeExposedDependents: string[]
outboundGateway: string | null
}

View File

@@ -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 }

View File

@@ -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'

View File

@@ -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

View File

@@ -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" />
```

View File

@@ -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`.

View File

@@ -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 languages 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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 navez 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

View File

@@ -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

View File

@@ -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 =>

View File

@@ -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),

View File

@@ -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">

View File

@@ -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: {

View File

@@ -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>
}

View File

@@ -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)),
)

View File

@@ -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),

View File

@@ -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),
},
],
},

View File

@@ -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 {

View File

@@ -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))
}

View File

@@ -28,7 +28,7 @@ export const SYSTEM_MENU = [
},
{
icon: '@tui.mail',
item: 'SMTP',
item: 'Email',
link: 'email',
},
{

View File

@@ -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: {

View File

@@ -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',

View File

@@ -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: {