feat: support preferred external ports besides 443 (#3117)

* docs: update preferred external port design in TODO

* docs: add user-controlled public/private and port forward mapping to design

* docs: overhaul interfaces page design with view/manage split and per-address controls

* docs: move address enable/disable to overflow menu, add SSL indicator, defer UI placement decisions

* chore: remove tor from startos core

Tor is being moved from a built-in OS feature to a service. This removes
the Arti-based Tor client, onion address management, hidden service
creation, and all related code from the core backend, frontend, and SDK.

- Delete core/src/net/tor/ module (~2060 lines)
- Remove OnionAddress, TorSecretKey, TorController from all consumers
- Remove HostnameInfo::Onion and HostAddress::Onion variants
- Remove onion CRUD RPC endpoints and tor subcommand
- Remove tor key handling from account and backup/restore
- Remove ~12 tor-related Cargo dependencies (arti-client, torut, etc.)
- Remove tor UI components, API methods, mock data, and routes
- Remove OnionHostname and tor patterns/regexes from SDK
- Add v0_4_0_alpha_20 database migration to strip onion data
- Bump version to 0.4.0-alpha.20

* chore: flatten HostnameInfo from enum to struct

HostnameInfo only had one variant (Ip) after removing Tor. Flatten it
into a plain struct with fields gateway, public, hostname. Remove all
kind === 'ip' type guards and narrowing across SDK, frontend, and
container runtime. Update DB migration to strip the kind field.

* chore: format RPCSpec.md markdown table

* docs: update TODO.md with DerivedAddressInfo design, remove completed tor task

* feat: implement preferred port allocation and per-address enable/disable

- Add AvailablePorts::try_alloc() with SSL tracking (BTreeMap<u16, bool>)
- Add DerivedAddressInfo on BindInfo with private_disabled/public_enabled/possible sets
- Add Bindings wrapper with Map impl for patchdb indexed access
- Flatten HostAddress from single-variant enum to struct
- Replace set-gateway-enabled RPC with set-address-enabled
- Remove hostname_info from Host; computed addresses now in BindInfo.addresses.possible
- Compute possible addresses inline in NetServiceData::update()
- Update DB migration, SDK types, frontend, and container-runtime

* feat: replace InterfaceFilter with ForwardRequirements, add WildcardListener, complete alpha.20 bump

- Replace DynInterfaceFilter with ForwardRequirements for per-IP forward
  precision with source-subnet iptables filtering for private forwards
- Add WildcardListener (binds [::]:port) to replace the per-gateway
  NetworkInterfaceListener/SelfContainedNetworkInterfaceListener/
  UpgradableListener infrastructure
- Update forward-port script with src_subnet and excluded_src env vars
- Remove unused filter types and listener infrastructure from gateway.rs
- Add availablePorts migration (IdPool -> BTreeMap<u16, bool>) to alpha.20
- Complete version bump to 0.4.0-alpha.20 in SDK and web

* outbound gateway support (#3120)

* Multiple (#3111)

* fix alerts i18n, fix status display, better, remove usb media, hide shutdown for install complete

* trigger chnage detection for localize pipe and round out implementing localize pipe for consistency even though not needed

* Fix PackageInfoShort to handle LocaleString on releaseNotes (#3112)

* Fix PackageInfoShort to handle LocaleString on releaseNotes

* fix: filter by target_version in get_matching_models and pass otherVersions from install

* chore: add exver documentation for ai agents

* frontend plus some be types

---------

Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com>

* feat: replace SourceFilter with IpNet, add policy routing, remove MASQUERADE

* build ts types and fix i18n

* fix license display in marketplace

* wip refactor

* chore: update ts bindings for preferred port design

* feat: refactor NetService to watch DB and reconcile network state

- NetService sync task now uses PatchDB DbWatch instead of being called
  directly after DB mutations
- Read gateways from DB instead of network interface context when
  updating host addresses
- gateway sync updates all host addresses in the DB
- Add Watch<u64> channel for callers to wait on sync completion
- Fix ts-rs codegen bug with #[ts(skip)] on flattened Plugin field
- Update SDK getServiceInterface.ts for new HostnameInfo shape
- Remove unnecessary HTTPS redirect in static_server.rs
- Fix tunnel/api.rs to filter for WAN IPv4 address

* re-arrange (#3123)

* new service interfacee page

* feat: add mdns hostname metadata variant and fix vhost routing

- Add HostnameMetadata::Mdns variant to distinguish mDNS from private domains
- Mark mDNS addresses as private (public: false) since mDNS is local-only
- Fall back to null SNI entry when hostname not found in vhost mapping
- Simplify public detection in ProxyTarget filter
- Pass hostname to update_addresses for mDNS domain name generation

* looking good

* feat: add port_forwards field to Host for tracking gateway forwarding rules

* update bindings for API types, add ARCHITECTURE (#3124)

* update binding for API types, add ARCHITECTURE

* translations

* fix: add CONNMARK restore-mark to mangle OUTPUT chain

The CONNMARK --restore-mark rule was only in PREROUTING, which handles
forwarded packets. Locally-bound listeners (e.g. vhost) generate replies
through the OUTPUT chain, where the fwmark was never restored. This
caused response packets to route via the default table instead of back
through the originating interface.

* chore: reserialize db on equal version, update bindings and docs

- Run de/ser roundtrip in pre_init even when db version matches, ensuring
  all #[serde(default)] fields are populated before any typed access
- Add patchdb.md documentation for TypedDbWatch patterns
- Update TS bindings for CheckPortParams, CheckPortRes, ifconfigUrl
- Update CLAUDE.md docs with patchdb and component-level references

* fix: include public gateways for IP-based addresses in vhost targets

The server hostname vhost construction only collected private IPs,
always setting public to empty. Public IP addresses (Ipv4/Ipv6 metadata
with public=true) were never added to the vhost target's public gateway
set, causing the vhost filter to reject public traffic for IP-based
addresses.

* fix: add TLS handshake timeout and fix accept loop deadlock

Two issues in TlsListener::poll_accept:

1. No timeout on TLS handshakes: LazyConfigAcceptor waits indefinitely
   for ClientHello. Attackers that complete TCP handshake but never send
   TLS data create zombie futures in `in_progress` that never complete.
   Fix: wrap the entire handshake in tokio::time::timeout(15s).

2. Missing waker on new-connection pending path: when a TCP connection
   is accepted and the TLS handshake is pending, poll_accept returned
   Pending without calling wake_by_ref(). Since the TcpListener returned
   Ready (not Pending), no waker was registered for it. With edge-
   triggered epoll and no other wakeup source, the task sleeps forever
   and remaining connections in the kernel accept queue are never
   drained. Fix: add cx.waker().wake_by_ref() so the task immediately
   re-polls and continues draining the accept queue.

* fix: switch BackgroundJobRunner from Vec to FuturesUnordered

BackgroundJobRunner stored active jobs in a Vec<BoxFuture> and polled
ALL of them on every wakeup — O(n) per poll. Since this runs in the
same tokio::select! as the WebServer accept loop, polling overhead from
active connections directly delayed acceptance of new connections.

FuturesUnordered only polls woken futures — O(woken) instead of O(n).

* chore: update bindings and use typed params for outbound gateway API

* feat: per-service and default outbound gateway routing

Add set-outbound-gateway RPC for packages and set-default-outbound RPC
for the server, with policy routing enforcement via ip rules. Fix
connmark restore to skip packets with existing fwmarks, add bridge
subnet routes to per-interface tables, and fix squashfs path in
update-image-local.sh.

* refactor: manifest wraps PackageMetadata, move dependency_metadata to PackageVersionInfo

Manifest now embeds PackageMetadata via #[serde(flatten)] instead of
duplicating ~14 fields. icon and dependency_metadata moved from
PackageMetadata to PackageVersionInfo since they are registry-enrichment
data loaded from the S9PK archive. merge_with now returns errors on
metadata/icon/dependency_metadata mismatches instead of silently ignoring
them.

* fix: replace .status() with .invoke() for iptables/ip commands

Using .status() leaks stderr directly to system logs, causing noisy
iptables error messages. Switch all networking CLI invocations to use
.invoke() which captures stderr properly. For check-then-act patterns
(iptables -C), use .invoke().await.is_err() instead of
.status().await.map_or(false, |s| s.success()).

* feat: add check-dns gateway endpoint and fix per-interface routing tables

Add a `check-dns` RPC endpoint that verifies whether a gateway's DNS
is properly configured for private domain resolution. Uses a three-tier
check: direct match (DNS == server IP), TXT challenge probe (DNS on
LAN), or failure (DNS off-subnet).

Fix per-interface routing tables to clone all non-default routes from
the main table instead of only the interface's own subnets. This
preserves LAN reachability when the priority-75 catch-all overrides
default routing. Filter out status-only flags (linkdown, dead) that
are invalid for `ip route add`.

* refactor: rename manifest metadata fields and improve error display

Rename wrapperRepo→packageRepo, marketingSite→marketingUrl,
docsUrl→docsUrls (array), remove supportSite. Add display_src/display_dbg
helpers to Error. Fix DepInfo description type to LocaleString. Update
web UI, SDK bindings, tests, and fixtures to match. Clean up cli_attach
error handling and remove dead commented code.

* chore: bump sdk version to 0.4.0-beta.49

* chore: add createTask decoupling TODO

* chore: add TODO to clear service error state on install/update

* round out dns check, dns server check, port forward check, and gateway port forwards

* chore: add TODOs for URL plugins, NAT hairpinning, and start-tunnel OTA updates

* version instead of os query param

* interface row clickable again, bu now with a chevron!

* feat: implement URL plugins with table/row actions and prefill support

- Add URL plugin effects (register, export_url, clear_urls) in core
- Add PluginHostnameInfo, HostnameMetadata::Plugin, and plugin registration types
- Implement plugin URL table in web UI with tableAction button and rowAction overflow menus
- Thread urlPluginMetadata (packageId, hostId, interfaceId, internalPort) as prefill to actions
- Add prefill support to PackageActionData so metadata passes through form dialogs
- Add i18n translations for plugin error messages
- Clean up plugin URLs on package uninstall

* feat: split row_actions into remove_action and overflow_actions for URL plugins

* touch up URL plugins table

* show table even when no addresses

* feat: NAT hairpinning, DNS static servers, clear service error on install

- Add POSTROUTING MASQUERADE rules for container and host hairpin NAT
- Allow bridge subnet containers to reach private forwards via LAN IPs
- Pass bridge_subnet env var from forward.rs to forward-port script
- Use DB-configured static DNS servers in resolver with DB watcher
- Fall back to resolv.conf servers when no static servers configured
- Clear service error state when install/update completes successfully
- Remove completed TODO items

* feat: builder-style InputSpec API, prefill plumbing, and port forward fix

- Add addKey() and add() builder methods to InputSpec with InputSpecTools
- Move OuterType to last generic param on Value, List, and all dynamic methods
- Plumb prefill through getActionInput end-to-end (core → container-runtime → SDK)
- Filter port_forwards to enabled addresses only
- Bump SDK to 0.4.0-beta.50

* fix: propagate host locale into LXC containers and write locale.conf

* chore: remove completed URL plugins TODO

* feat: OTA updates for start-tunnel via apt repository (untested)

- Add apt repo publish script (build/apt/publish-deb.sh) for S3-hosted repo
- Add apt source config and GPG key placeholder (apt/)
- Add tunnel.update.check and tunnel.update.apply RPC endpoints
- Wire up update API in tunnel frontend (api service + mock)
- Uses systemd-run --scope to survive service restart during update

* fix: publish script dpkg-name, s3cfg fallback, and --reinstall for apply

* chore: replace OTA updates TODO with UI TODO for MattDHill

* feat: add getOutboundGateway effect and simplify VersionGraph init/uninit

Add getOutboundGateway effect across core, container-runtime, and SDK
to let services query their effective outbound gateway with callback
support. Remove preInstall/uninstall hooks from VersionGraph as they
are no longer needed.

* frontend start-tunnel updates

* chore: remove completed TODO

* feat: tor hidden service key migration

* chore: migrate from ts-matches to zod across all TypeScript packages

* feat(core): allow setting server hostname

* send prefill for tasks and hide operations to hidden fields

* fix(core): preserve plugin URLs across binding updates

BindInfo::update was replacing addresses with a new DerivedAddressInfo
that cleared the available set, wiping plugin-exported URLs whenever
bind() was called. Also simplify update_addresses plugin preservation
to use retain in place rather than collecting into a separate set.

* minor cleanup from patch-db audit

* clean up prefill flow

* frontend support for setting and changing hostname

* feat(core): refactor hostname to ServerHostnameInfo with name/hostname pair

- Rename Hostname to ServerHostnameInfo, add name + hostname fields
- Add set_hostname_rpc for changing hostname at runtime
- Migrate alpha_20: generate serverInfo.name from hostname, delete ui.name
- Extract gateway.rs helpers to fix rustfmt nesting depth issue
- Add i18n key for hostname validation error
- Update SDK bindings

* add comments to everything potentially consumer facing (#3127)

* add comments to everything potentially consumer facing

* rework smtp

---------

Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com>

* implement server name

* setup changes

* clean up copy around addresses table

* feat: add zod-deep-partial, partialValidator on InputSpec, and z.deepPartial re-export

* fix: header color in zoom (#3128)

* fix: merge version ranges when adding existing package signer (#3125)

* fix: merge version ranges when adding existing package signer

   Previously, add_package_signer unconditionally inserted the new
   version range, overwriting any existing authorization for that signer.
   Now it OR-merges the new range with the existing one, so running
   signer add multiple times accumulates permissions rather than
   replacing them.

* add --merge flag to registry package signer add

  Default behavior remains overwrite. When --merge is passed, the new
  version range is OR-merged with the existing one, allowing admins to
  accumulate permissions incrementally.

* add missing attribute to TS type

* make merge optional

* upsert instead of insert

* VersionRange::None on upsert

* fix: header color in zoom

---------

Co-authored-by: Dominion5254 <musashidisciple@proton.me>

* update snake and add about this server to system general

* chore: bump sdk to beta.53, wrap z.deepPartial with passthrough

* reset instead of reset defaults

* action failure show dialog

* chore: bump sdk to beta.54, add device-info RPC, improve SDK abort handling and InputSpec filtering

- Bump SDK version to 0.4.0-beta.54
- Add `server.device-info` RPC endpoint and `s9pk select` CLI command
- Extract `HardwareRequirements::is_compatible()` method, reuse in registry filtering
- Add `AbortedError` class with `muteUnhandled` flag, replace generic abort errors
- Handle unhandled promise rejections in container-runtime with mute support
- Improve `InputSpec.filter()` with `keepByDefault` param and boolean filter values
- Accept readonly tuples in `CommandType` and `splitCommand`
- Remove `sync_host` calls from host API handlers (binding/address changes)
- Filter mDNS hostnames by secure gateway availability
- Derive mDNS enabled state from LAN IPs in web UI
- Add "Open UI" action to address table, disable mDNS toggle
- Hide debug details in service error component
- Update rpc-toolkit docs for no-params handlers

* fix: add --no-nvram to efi grub-install to preserve built-in boot order

* update snake

* diable actions when in error state

* chore: split out nvidia variant

* misc bugfixes

* create manage-release script (untested)

* fix: preserve z namespace types for sdk consumers

* sdk version bump

* new checkPort types

* multiple bugs and better port forward ux

* fix link

* chore: todos and formatting

* fix build

---------

Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com>
Co-authored-by: Matt Hill <mattnine@protonmail.com>
Co-authored-by: Alex Inkin <alexander@inkin.ru>
Co-authored-by: Dominion5254 <musashidisciple@proton.me>
This commit is contained in:
Aiden McClelland
2026-03-04 04:37:31 -07:00
committed by GitHub
parent 26a68afdef
commit 3320391fcc
532 changed files with 21594 additions and 20559 deletions

98
web/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,98 @@
# Web Architecture
Angular 20 + TypeScript workspace using [Taiga UI](https://taiga-ui.dev/) component library.
## 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 }}`
### How dictionaries work
- **`en.ts`** is the source of truth. Keys are English strings; values are numeric IDs (e.g. `'Domain Health': 748`).
- **Other language files** (`de.ts`, `es.ts`, `fr.ts`, `pl.ts`) use those same numeric IDs as keys, mapping to translated strings (e.g. `748: 'Santé du domaine'`).
- When adding a new i18n key:
1. Add the English string and next available numeric ID to `en.ts`.
2. Add the same numeric ID with a proper translation to every other language file.
3. Always provide real translations, not empty strings.
## 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

111
web/CLAUDE.md Normal file
View File

@@ -0,0 +1,111 @@
# 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
See [ARCHITECTURE.md](ARCHITECTURE.md) for the web architecture: API layer, PatchDB state, WebSockets, routing, forms, i18n, and services.
## 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" />
```

115
web/CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,115 @@
# 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,155 +1,20 @@
# StartOS Web
StartOS web UIs are written in [Angular/Typescript](https://angular.io/docs) and leverage the [Ionic Framework](https://ionicframework.com/) component library.
[Angular](https://angular.dev/) + TypeScript workspace using the [Taiga UI](https://taiga-ui.dev/) component library.
StartOS conditionally serves one of three Web UIs, depending on the state of the system and user choice.
## Applications
- **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.
StartOS serves one of these UIs depending on the state of the system:
Additionally, there are two libraries for shared code:
- **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.
- **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.
## Libraries
## Environment Setup
- **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.
#### Install NodeJS and NPM
## Contributing
- [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`.
See [CONTRIBUTING.md](CONTRIBUTING.md) for environment setup, development server instructions, and translation guides.

2147
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "startos-ui",
"version": "0.4.0-alpha.19",
"version": "0.4.0-alpha.20",
"author": "Start9 Labs, Inc",
"homepage": "https://start9.com/",
"license": "MIT",
@@ -86,7 +86,6 @@
"pbkdf2": "^3.1.2",
"rxjs": "^7.8.2",
"tldts": "^7.0.11",
"ts-matches": "^6.3.2",
"tslib": "^2.8.1",
"uuid": "^8.3.2",
"zone.js": "^0.15.0"

View File

@@ -62,7 +62,7 @@
<div>
<!-- link to store for brochure -->
<ng-content select="[slot=store-mobile]" />
<a docsLink path="/packaging-guide">
<a docsLink path="/packaging/quick-start.html">
<span>{{ 'Package a service' | i18n }}</span>
<tui-icon tuiAppearance="icon" icon="@tui.external-link" />
</a>
@@ -86,7 +86,7 @@
<div>
<!-- link to store for brochure -->
<ng-content select="[slot=store]" />
<a docsLink path="/packaging-guide">
<a docsLink path="/packaging/quick-start.html">
<span>{{ 'Package a service' | i18n }}</span>
<tui-icon tuiAppearance="icon" icon="@tui.external-link" />
</a>

View File

@@ -23,7 +23,7 @@ import { MarketplaceLinkComponent } from './link.component'
class="item-pointer"
/>
<marketplace-link
[url]="pkg().wrapperRepo"
[url]="pkg().packageRepo"
label="StartOS package"
icon="@tui.external-link"
class="item-pointer"
@@ -37,12 +37,12 @@ import { MarketplaceLinkComponent } from './link.component'
<h2 class="additional-detail-title">{{ 'Links' | i18n }}</h2>
<div class="detail-container">
<marketplace-link
[url]="pkg().marketingSite"
[url]="pkg().marketingUrl"
label="Marketing"
icon="@tui.external-link"
class="item-pointer"
/>
@if (pkg().docsUrl; as docsUrl) {
@for (docsUrl of pkg().docsUrls; track $index) {
<marketplace-link
[url]="docsUrl"
label="Documentation"
@@ -50,12 +50,6 @@ import { MarketplaceLinkComponent } from './link.component'
class="item-pointer"
/>
}
<marketplace-link
[url]="pkg().supportSite"
label="Support"
icon="@tui.external-link"
class="item-pointer"
/>
@if (pkg().donationUrl; as donationUrl) {
<marketplace-link
[url]="donationUrl"

View File

@@ -31,23 +31,15 @@ export class AppComponent {
switch (status.status) {
case 'needs-install':
// Restore keyboard from status if it was previously set
if (status.keyboard) {
this.stateService.keyboard = status.keyboard.layout
}
// Start the install flow
await this.router.navigate(['/language'])
break
case 'incomplete':
// Store the data drive info from status
this.stateService.dataDriveGuid = status.guid
this.stateService.attach = status.attach
// Restore keyboard from status if it was previously set
if (status.keyboard) {
this.stateService.keyboard = status.keyboard.layout
if (status.guid) {
this.stateService.dataDriveGuid = status.guid
}
this.stateService.attach = status.attach
await this.router.navigate(['/language'])
break

View File

@@ -46,7 +46,7 @@ import { DocsLinkDirective } from '@start9labs/shared'
Download your server's Root CA and
<a
docsLink
path="/user-manual/trust-ca.html"
path="/start-os/user-manual/trust-ca.html"
style="color: #6866cc; font-weight: bold; text-decoration: none"
>
follow instructions

View File

@@ -8,7 +8,12 @@ import {
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import {
ErrorService,
i18nPipe,
LoadingService,
normalizeHostname,
} from '@start9labs/shared'
import { TuiAutoFocus, TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk'
import {
TuiButton,
@@ -31,31 +36,36 @@ import { StateService } from '../services/state.service'
<header tuiHeader>
<h2 tuiTitle>
{{
isRequired
? ('Set Master Password' | i18n)
isFresh
? ('Set Up Your Server' | i18n)
: ('Set New Password (Optional)' | i18n)
}}
<span tuiSubtitle>
{{
isRequired
? ('Make it good. Write it down.' | i18n)
: ('Skip to keep your existing password.' | i18n)
}}
</span>
</h2>
</header>
<form [formGroup]="form" (ngSubmit)="submit()">
<tui-textfield>
@if (isFresh) {
<tui-textfield>
<label tuiLabel>{{ 'Server Name' | i18n }}</label>
<input tuiTextfield tuiAutoFocus formControlName="name" />
</tui-textfield>
<tui-error
formControlName="name"
[error]="[] | tuiFieldError | async"
/>
@if (form.controls.name.value?.trim()) {
<p class="hostname-preview">{{ derivedHostname }}.local</p>
}
}
<tui-textfield [style.margin-top.rem]="isFresh ? 1 : 0">
<label tuiLabel>
{{
isRequired ? ('Enter Password' | i18n) : ('New Password' | i18n)
}}
{{ isFresh ? ('Password' | i18n) : ('New Password' | i18n) }}
</label>
<input
tuiTextfield
type="password"
tuiAutoFocus
[tuiAutoFocus]="!isFresh"
maxlength="64"
formControlName="password"
/>
@@ -87,14 +97,14 @@ import { StateService } from '../services/state.service'
<button
tuiButton
[disabled]="
isRequired
isFresh
? form.invalid
: form.controls.password.value && form.invalid
"
>
{{ 'Finish' | i18n }}
</button>
@if (!isRequired) {
@if (!isFresh) {
<button
tuiButton
appearance="secondary"
@@ -109,6 +119,12 @@ import { StateService } from '../services/state.service'
</section>
`,
styles: `
.hostname-preview {
color: var(--tui-text-secondary);
font: var(--tui-font-text-s);
margin-top: 0.25rem;
}
footer {
display: flex;
flex-direction: column;
@@ -149,16 +165,17 @@ export default class PasswordPage {
private readonly stateService = inject(StateService)
private readonly i18n = inject(i18nPipe)
// Password is required only for fresh install
readonly isRequired = this.stateService.setupType === 'fresh'
// Fresh install requires password and name
readonly isFresh = this.stateService.setupType === 'fresh'
readonly form = new FormGroup({
password: new FormControl('', [
...(this.isRequired ? [Validators.required] : []),
...(this.isFresh ? [Validators.required] : []),
Validators.minLength(12),
Validators.maxLength(64),
]),
confirm: new FormControl(''),
name: new FormControl('', [Validators.required]),
})
readonly validator = (value: string) => (control: AbstractControl) =>
@@ -166,6 +183,10 @@ export default class PasswordPage {
? null
: { match: this.i18n.transform('Passwords do not match') }
get derivedHostname(): string {
return normalizeHostname(this.form.controls.name.value || '')
}
async skip() {
// Skip means no new password - pass null
await this.executeSetup(null)
@@ -177,13 +198,15 @@ export default class PasswordPage {
private async executeSetup(password: string | null) {
const loader = this.loader.open('Starting setup').subscribe()
const name = this.form.controls.name.value || ''
const hostname = normalizeHostname(name)
try {
if (this.stateService.setupType === 'attach') {
await this.stateService.attachDrive(password)
} else {
// fresh, restore, or transfer - all use execute
await this.stateService.executeSetup(password)
await this.stateService.executeSetup(password, name, hostname)
}
await this.router.navigate(['/loading'])

View File

@@ -20,7 +20,7 @@ import { StateService } from '../services/state.service'
import { DocumentationComponent } from '../components/documentation.component'
import { MatrixComponent } from '../components/matrix.component'
import { RemoveMediaDialog } from '../components/remove-media.dialog'
import { SetupCompleteRes } from '../types'
import { T } from '@start9labs/start-sdk'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
@Component({
@@ -193,7 +193,7 @@ export default class SuccessPage implements AfterViewInit {
readonly stateService = inject(StateService)
result?: SetupCompleteRes
result?: T.SetupResult
lanAddress = ''
downloaded = false
usbRemoved = false
@@ -232,14 +232,11 @@ export default class SuccessPage implements AfterViewInit {
removeMedia() {
this.dialogs
.openComponent<boolean>(
new PolymorpheusComponent(RemoveMediaDialog),
{
size: 's',
dismissible: false,
closeable: false,
},
)
.openComponent<boolean>(new PolymorpheusComponent(RemoveMediaDialog), {
size: 's',
dismissible: false,
closeable: false,
})
.subscribe(() => {
this.usbRemoved = true
})

View File

@@ -1,31 +1,22 @@
import * as jose from 'node-jose'
import {
DiskInfo,
FollowLogsRes,
FullKeyboard,
SetLanguageParams,
StartOSDiskInfo,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { Observable } from 'rxjs'
import {
SetupStatusRes,
InstallOsParams,
InstallOsRes,
AttachParams,
SetupExecuteParams,
SetupCompleteRes,
EchoReq,
} from '../types'
import { InstallOsParams, InstallOsRes } from '../types'
export abstract class ApiService {
pubkey?: jose.JWK.Key
// echo
abstract echo(params: EchoReq, url: string): Promise<string>
abstract echo(params: T.EchoParams, url: string): Promise<string>
// Status & Setup
abstract getStatus(): Promise<SetupStatusRes> // setup.status
abstract getStatus(): Promise<T.SetupStatusRes> // setup.status
abstract getPubKey(): Promise<void> // setup.get-pubkey
abstract setKeyboard(params: FullKeyboard): Promise<null> // setup.set-keyboard
abstract setLanguage(params: SetLanguageParams): Promise<null> // setup.set-language
@@ -35,8 +26,8 @@ export abstract class ApiService {
abstract installOs(params: InstallOsParams): Promise<InstallOsRes> // setup.install-os
// Setup execution
abstract attach(params: AttachParams): Promise<T.SetupProgress> // setup.attach
abstract execute(params: SetupExecuteParams): Promise<T.SetupProgress> // setup.execute
abstract attach(params: T.AttachParams): Promise<T.SetupProgress> // setup.attach
abstract execute(params: T.SetupExecuteParams): Promise<T.SetupProgress> // setup.execute
// Recovery helpers
abstract verifyCifs(
@@ -44,12 +35,12 @@ export abstract class ApiService {
): Promise<Record<string, StartOSDiskInfo>> // setup.cifs.verify
// Completion
abstract complete(): Promise<SetupCompleteRes> // setup.complete
abstract complete(): Promise<T.SetupResult> // setup.complete
abstract exit(): Promise<void> // setup.exit
abstract shutdown(): Promise<void> // setup.shutdown
// Logs & Progress
abstract initFollowLogs(): Promise<FollowLogsRes> // setup.logs.follow
abstract initFollowLogs(): Promise<T.LogFollowResponse> // setup.logs.follow
abstract openWebsocket$<T>(guid: string): Observable<T>
// Restart (for error recovery)

View File

@@ -2,7 +2,6 @@ import { Inject, Injectable, DOCUMENT } from '@angular/core'
import {
DiskInfo,
encodeBase64,
FollowLogsRes,
FullKeyboard,
HttpService,
isRpcError,
@@ -16,15 +15,7 @@ import * as jose from 'node-jose'
import { Observable } from 'rxjs'
import { webSocket } from 'rxjs/webSocket'
import { ApiService } from './api.service'
import {
SetupStatusRes,
InstallOsParams,
InstallOsRes,
AttachParams,
SetupExecuteParams,
SetupCompleteRes,
EchoReq,
} from '../types'
import { InstallOsParams, InstallOsRes } from '../types'
@Injectable({
providedIn: 'root',
@@ -47,12 +38,12 @@ export class LiveApiService extends ApiService {
})
}
async echo(params: EchoReq, url: string): Promise<string> {
async echo(params: T.EchoParams, url: string): Promise<string> {
return this.rpcRequest({ method: 'echo', params }, url)
}
async getStatus() {
return this.rpcRequest<SetupStatusRes>({
return this.rpcRequest<T.SetupStatusRes>({
method: 'setup.status',
params: {},
})
@@ -102,14 +93,14 @@ export class LiveApiService extends ApiService {
})
}
async attach(params: AttachParams) {
async attach(params: T.AttachParams) {
return this.rpcRequest<T.SetupProgress>({
method: 'setup.attach',
params,
})
}
async execute(params: SetupExecuteParams) {
async execute(params: T.SetupExecuteParams) {
if (params.recoverySource?.type === 'backup') {
const target = params.recoverySource.target
if (target.type === 'cifs') {
@@ -124,14 +115,14 @@ export class LiveApiService extends ApiService {
}
async initFollowLogs() {
return this.rpcRequest<FollowLogsRes>({
return this.rpcRequest<T.LogFollowResponse>({
method: 'setup.logs.follow',
params: {},
})
}
async complete() {
const res = await this.rpcRequest<SetupCompleteRes>({
const res = await this.rpcRequest<T.SetupResult>({
method: 'setup.complete',
params: {},
})

View File

@@ -2,7 +2,6 @@ import { Injectable } from '@angular/core'
import {
DiskInfo,
encodeBase64,
FollowLogsRes,
FullKeyboard,
pauseFor,
SetLanguageParams,
@@ -12,15 +11,7 @@ import { T } from '@start9labs/start-sdk'
import * as jose from 'node-jose'
import { interval, map, Observable } from 'rxjs'
import { ApiService } from './api.service'
import {
SetupStatusRes,
InstallOsParams,
InstallOsRes,
AttachParams,
SetupExecuteParams,
SetupCompleteRes,
EchoReq,
} from '../types'
import { InstallOsParams, InstallOsRes } from '../types'
@Injectable({
providedIn: 'root',
@@ -54,7 +45,7 @@ export class MockApiService extends ApiService {
}
}
async echo(params: EchoReq, url: string): Promise<string> {
async echo(params: T.EchoParams, url: string): Promise<string> {
if (url) {
const num = Math.floor(Math.random() * 10) + 1
if (num > 8) return params.message
@@ -64,23 +55,27 @@ export class MockApiService extends ApiService {
return params.message
}
async getStatus(): Promise<SetupStatusRes> {
async getStatus(): Promise<T.SetupStatusRes> {
await pauseFor(500)
this.statusIndex++
if (this.statusIndex === 1) {
return { status: 'needs-install', keyboard: null }
return { status: 'needs-install' }
// return {
// status: 'incomplete',
// attach: false,
// guid: 'mock-data-guid',
// keyboard: null,
// }
}
if (this.statusIndex > 3) {
return { status: 'complete' }
return {
status: 'complete',
hostname: 'adjective-noun',
rootCa: encodeBase64(ROOT_CA),
needsRestart: this.installCompleted,
}
}
return {
@@ -148,7 +143,7 @@ export class MockApiService extends ApiService {
}
}
async attach(params: AttachParams): Promise<T.SetupProgress> {
async attach(params: T.AttachParams): Promise<T.SetupProgress> {
await pauseFor(1000)
this.statusIndex = 1 // Jump to running state
return {
@@ -157,7 +152,7 @@ export class MockApiService extends ApiService {
}
}
async execute(params: SetupExecuteParams): Promise<T.SetupProgress> {
async execute(params: T.SetupExecuteParams): Promise<T.SetupProgress> {
await pauseFor(1000)
this.statusIndex = 1 // Jump to running state
return {
@@ -166,7 +161,7 @@ export class MockApiService extends ApiService {
}
}
async initFollowLogs(): Promise<FollowLogsRes> {
async initFollowLogs(): Promise<T.LogFollowResponse> {
await pauseFor(500)
return {
startCursor: 'fakestartcursor',
@@ -174,7 +169,7 @@ export class MockApiService extends ApiService {
}
}
async complete(): Promise<SetupCompleteRes> {
async complete(): Promise<T.SetupResult> {
await pauseFor(500)
return {
hostname: 'adjective-noun',

View File

@@ -57,9 +57,13 @@ export class StateService {
/**
* Called for fresh, restore, and transfer flows
* password is required for fresh, optional for restore/transfer
* Password is required for fresh, optional for restore/transfer
*/
async executeSetup(password: string | null): Promise<void> {
async executeSetup(
password: string | null,
name: string,
hostname: string,
): Promise<void> {
let recoverySource: T.RecoverySource<T.EncryptedWire> | null = null
if (this.recoverySource) {
@@ -79,6 +83,8 @@ export class StateService {
await this.api.execute({
guid: this.dataDriveGuid,
password: password ? await this.api.encrypt(password) : null,
name,
hostname,
recoverySource,
})
}

View File

@@ -1,31 +1,6 @@
import {
DiskInfo,
FullKeyboard,
PartitionInfo,
StartOSDiskInfo,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { DiskInfo, PartitionInfo, StartOSDiskInfo } from '@start9labs/shared'
// === Echo ===
export type EchoReq = {
message: string
}
// === Setup Status ===
export type SetupStatusRes =
| { status: 'needs-install'; keyboard: FullKeyboard | null }
| {
status: 'incomplete'
guid: string
attach: boolean
keyboard: FullKeyboard | null
}
| { status: 'running'; progress: T.FullProgress; guid: string }
| { status: 'complete' }
// === Install OS ===
// === Install OS === (no binding available)
export interface InstallOsParams {
osDrive: string // e.g. /dev/sda
@@ -40,48 +15,6 @@ export interface InstallOsRes {
attach: boolean
}
// === Attach ===
export interface AttachParams {
password: T.EncryptedWire | null
guid: string // data drive
}
// === Execute ===
export interface SetupExecuteParams {
guid: string
password: T.EncryptedWire | null // null = keep existing password (for restore/transfer)
recoverySource:
| {
type: 'migrate'
guid: string
}
| {
type: 'backup'
target:
| { type: 'disk'; logicalname: string }
| {
type: 'cifs'
hostname: string
path: string
username: string
password: string | null
}
password: T.EncryptedWire
serverId: string
}
| null
}
// === Complete ===
export interface SetupCompleteRes {
hostname: string // unique.local
rootCa: string
needsRestart: boolean
}
// === Disk Info Helpers ===
export type StartOSDiskInfoWithId = StartOSDiskInfo & {

View File

@@ -0,0 +1 @@
<svg fill="#FF0000" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Adobe</title><path d="M13.966 22.624l-1.69-4.281H8.122l3.892-9.144 5.662 13.425zM8.884 1.376H0v21.248zm15.116 0h-8.884L24 22.624Z"/></svg>

After

Width:  |  Height:  |  Size: 231 B

View File

@@ -0,0 +1 @@
<svg fill="#FF9900" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Amazon</title><path d="M.045 18.02c.072-.116.187-.124.348-.022 3.636 2.11 7.594 3.166 11.87 3.166 2.852 0 5.668-.533 8.447-1.595l.315-.14c.138-.06.234-.1.293-.13.226-.088.39-.046.525.13.12.174.09.336-.12.48-.256.19-.6.41-1.006.654-1.244.743-2.64 1.316-4.185 1.726a17.617 17.617 0 01-10.951-.577 17.88 17.88 0 01-5.43-3.35c-.1-.074-.151-.15-.151-.22 0-.047.021-.09.051-.13zm6.565-6.218c0-1.005.247-1.863.743-2.577.495-.71 1.17-1.25 2.04-1.615.796-.335 1.756-.575 2.912-.72.39-.046 1.033-.103 1.92-.174v-.37c0-.93-.105-1.558-.3-1.875-.302-.43-.78-.65-1.44-.65h-.182c-.48.046-.896.196-1.246.46-.35.27-.575.63-.675 1.096-.06.3-.206.465-.435.51l-2.52-.315c-.248-.06-.372-.18-.372-.39 0-.046.007-.09.022-.15.247-1.29.855-2.25 1.82-2.88.976-.616 2.1-.975 3.39-1.05h.54c1.65 0 2.957.434 3.888 1.29.135.15.27.3.405.48.12.165.224.314.283.45.075.134.15.33.195.57.06.254.105.42.135.51.03.104.062.3.076.615.01.313.02.493.02.553v5.28c0 .376.06.72.165 1.036.105.313.21.54.315.674l.51.674c.09.136.136.256.136.36 0 .12-.06.226-.18.314-1.2 1.05-1.86 1.62-1.963 1.71-.165.135-.375.15-.63.045a6.062 6.062 0 01-.526-.496l-.31-.347a9.391 9.391 0 01-.317-.42l-.3-.435c-.81.886-1.603 1.44-2.4 1.665-.494.15-1.093.227-1.83.227-1.11 0-2.04-.343-2.76-1.034-.72-.69-1.08-1.665-1.08-2.94l-.05-.076zm3.753-.438c0 .566.14 1.02.425 1.364.285.34.675.512 1.155.512.045 0 .106-.007.195-.02.09-.016.134-.023.166-.023.614-.16 1.08-.553 1.424-1.178.165-.28.285-.58.36-.91.09-.32.12-.59.135-.8.015-.195.015-.54.015-1.005v-.54c-.84 0-1.484.06-1.92.18-1.275.36-1.92 1.17-1.92 2.43l-.035-.02zm9.162 7.027c.03-.06.075-.11.132-.17.362-.243.714-.41 1.05-.5a8.094 8.094 0 011.612-.24c.14-.012.28 0 .41.03.65.06 1.05.168 1.172.33.063.09.099.228.099.39v.15c0 .51-.149 1.11-.424 1.8-.278.69-.664 1.248-1.156 1.68-.073.06-.14.09-.197.09-.03 0-.06 0-.09-.012-.09-.044-.107-.12-.064-.24.54-1.26.806-2.143.806-2.64 0-.15-.03-.27-.087-.344-.145-.166-.55-.257-1.224-.257-.243 0-.533.016-.87.046-.363.045-.7.09-1 .135-.09 0-.148-.014-.18-.044-.03-.03-.036-.047-.02-.077 0-.017.006-.03.02-.063v-.06z"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1 @@
<svg fill="#D4A27F" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"/></svg>

After

Width:  |  Height:  |  Size: 296 B

View File

@@ -0,0 +1 @@
<svg fill="#DDDDDD" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Apple</title><path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"/></svg>

After

Width:  |  Height:  |  Size: 665 B

View File

@@ -0,0 +1 @@
<svg fill="#0052CC" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Atlassian</title><path d="M7.12 11.084a.683.683 0 00-1.16.126L.075 22.974a.703.703 0 00.63 1.018h8.19a.678.678 0 00.63-.39c1.767-3.65.696-9.203-2.406-12.52zM11.434.386a15.515 15.515 0 00-.906 15.317l3.95 7.9a.703.703 0 00.628.388h8.19a.703.703 0 00.63-1.017L12.63.38a.664.664 0 00-1.196.006z"/></svg>

After

Width:  |  Height:  |  Size: 393 B

View File

@@ -0,0 +1 @@
<svg fill="#0061D5" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Box</title><path d="M.959 5.523c-.54 0-.959.42-.959.899v7.549a4.59 4.59 0 004.613 4.494 4.717 4.717 0 004.135-2.457c.779 1.438 2.337 2.457 4.074 2.457 2.577 0 4.674-2.037 4.674-4.613.06-2.457-2.037-4.495-4.613-4.495-1.738 0-3.295.959-4.074 2.397-.78-1.438-2.338-2.397-4.135-2.397-1.079 0-2.038.36-2.817.899V6.422a.92.92 0 00-.898-.899zM17.602 9.26a.95.95 0 00-.704.158c-.36.3-.479.899-.18 1.318l2.397 3.116-2.396 3.115c-.3.42-.24.96.18 1.26.419.3 1.016.298 1.316-.122l2.039-2.636 2.096 2.697c.3.36.899.419 1.318.12.36-.3.42-.84.121-1.259l-2.338-3.115 2.338-3.057c.3-.419.298-1.018-.121-1.318-.48-.3-1.019-.24-1.318.18l-2.096 2.576-2.04-2.695c-.149-.18-.373-.3-.612-.338zM4.613 11.154c1.558 0 2.817 1.26 2.817 2.758 0 1.558-1.259 2.756-2.817 2.756-1.558 0-2.816-1.198-2.816-2.756 0-1.498 1.258-2.758 2.816-2.758zm8.27 0c1.558 0 2.816 1.26 2.816 2.758-.06 1.558-1.318 2.756-2.816 2.756-1.558 0-2.817-1.198-2.817-2.756 0-1.498 1.259-2.758 2.817-2.758Z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg fill="#F38020" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Cloudflare</title><path d="M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg fill="#632CA6" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Datadog</title><path d="M19.57 17.04l-1.997-1.316-1.665 2.782-1.937-.567-1.706 2.604.087.82 9.274-1.71-.538-5.794zm-8.649-2.498l1.488-.204c.241.108.409.15.697.223.45.117.97.23 1.741-.16.18-.088.553-.43.704-.625l6.096-1.106.622 7.527-10.444 1.882zm11.325-2.712l-.602.115L20.488 0 .789 2.285l2.427 19.693 2.306-.334c-.184-.263-.471-.581-.96-.989-.68-.564-.44-1.522-.039-2.127.53-1.022 3.26-2.322 3.106-3.956-.056-.594-.15-1.368-.702-1.898-.02.22.017.432.017.432s-.227-.289-.34-.683c-.112-.15-.2-.199-.319-.4-.085.233-.073.503-.073.503s-.186-.437-.216-.807c-.11.166-.137.48-.137.48s-.241-.69-.186-1.062c-.11-.323-.436-.965-.343-2.424.6.421 1.924.321 2.44-.439.171-.251.288-.939-.086-2.293-.24-.868-.835-2.16-1.066-2.651l-.028.02c.122.395.374 1.223.47 1.625.293 1.218.372 1.642.234 2.204-.116.488-.397.808-1.107 1.165-.71.358-1.653-.514-1.713-.562-.69-.55-1.224-1.447-1.284-1.883-.062-.477.275-.763.445-1.153-.243.07-.514.192-.514.192s.323-.334.722-.624c.165-.109.262-.178.436-.323a9.762 9.762 0 0 0-.456.003s.42-.227.855-.392c-.318-.014-.623-.003-.623-.003s.937-.419 1.678-.727c.509-.208 1.006-.147 1.286.257.367.53.752.817 1.569.996.501-.223.653-.337 1.284-.509.554-.61.99-.688.99-.688s-.216.198-.274.51c.314-.249.66-.455.66-.455s-.134.164-.259.426l.03.043c.366-.22.797-.394.797-.394s-.123.156-.268.358c.277-.002.838.012 1.056.037 1.285.028 1.552-1.374 2.045-1.55.618-.22.894-.353 1.947.68.903.888 1.609 2.477 1.259 2.833-.294.295-.874-.115-1.516-.916a3.466 3.466 0 0 1-.716-1.562 1.533 1.533 0 0 0-.497-.85s.23.51.23.96c0 .246.03 1.165.424 1.68-.039.076-.057.374-.1.43-.458-.554-1.443-.95-1.604-1.067.544.445 1.793 1.468 2.273 2.449.453.927.186 1.777.416 1.997.065.063.976 1.197 1.15 1.767.306.994.019 2.038-.381 2.685l-1.117.174c-.163-.045-.273-.068-.42-.153.08-.143.241-.5.243-.572l-.063-.111c-.348.492-.93.97-1.414 1.245-.633.359-1.363.304-1.838.156-1.348-.415-2.623-1.327-2.93-1.566 0 0-.01.191.048.234.34.383 1.119 1.077 1.872 1.56l-1.605.177.759 5.908c-.337.048-.39.071-.757.124-.325-1.147-.946-1.895-1.624-2.332-.599-.384-1.424-.47-2.214-.314l-.05.059a2.851 2.851 0 0 1 1.863.444c.654.413 1.181 1.481 1.375 2.124.248.822.42 1.7-.248 2.632-.476.662-1.864 1.028-2.986.237.3.481.705.876 1.25.95.809.11 1.577-.03 2.106-.574.452-.464.69-1.434.628-2.456l.714-.104.258 1.834 11.827-1.424zM15.05 6.848c-.034.075-.085.125-.007.37l.004.014.013.032.032.073c.14.287.295.558.552.696.067-.011.136-.019.207-.023.242-.01.395.028.492.08.009-.048.01-.119.005-.222-.018-.364.072-.982-.626-1.308-.264-.122-.634-.084-.757.068a.302.302 0 0 1 .058.013c.186.066.06.13.027.207m1.958 3.392c-.092-.05-.52-.03-.821.005-.574.068-1.193.267-1.328.372-.247.191-.135.523.047.66.511.382.96.638 1.432.575.29-.038.546-.497.728-.914.124-.288.124-.598-.058-.698m-5.077-2.942c.162-.154-.805-.355-1.556.156-.554.378-.571 1.187-.041 1.646.053.046.096.078.137.104a4.77 4.77 0 0 1 1.396-.412c.113-.125.243-.345.21-.745-.044-.542-.455-.456-.146-.749"/></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1 @@
<svg fill="#5865F2" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Discord</title><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg fill="#0061FF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Dropbox</title><path d="M6 1.807L0 5.629l6 3.822 6.001-3.822L6 1.807zM18 1.807l-6 3.822 6 3.822 6-3.822-6-3.822zM0 13.274l6 3.822 6.001-3.822L6 9.452l-6 3.822zM18 9.452l-6 3.822 6 3.822 6-3.822-6-3.822zM6 18.371l6.001 3.822 6-3.822-6-3.822L6 18.371z"/></svg>

After

Width:  |  Height:  |  Size: 351 B

View File

@@ -0,0 +1 @@
<svg fill="#DDDDDD" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>

After

Width:  |  Height:  |  Size: 837 B

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitLab</title><path fill="#E24329" d="M12 22.1l4.42-13.6H7.58L12 22.1z"/><path fill="#FC6D26" d="M12 22.1L7.58 8.5H1.39L12 22.1z"/><path fill="#FCA326" d="M1.39 8.5l-.94 2.9c-.09.27.01.56.24.73L12 22.1 1.39 8.5z"/><path fill="#E24329" d="M1.39 8.5h6.19L4.92 1.27a.43.43 0 0 0-.82 0L1.39 8.5z"/><path fill="#FC6D26" d="M12 22.1l4.42-13.6h6.19L12 22.1z"/><path fill="#FCA326" d="M22.61 8.5l.94 2.9c.09.27-.01.56-.24.73L12 22.1l10.61-13.6z"/><path fill="#E24329" d="M22.61 8.5h-6.19l2.66-7.23a.43.43 0 0 1 .82 0l2.71 7.23z"/></svg>

After

Width:  |  Height:  |  Size: 607 B

View File

@@ -0,0 +1 @@
<svg fill="#1BDBDB" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GoDaddy</title><path d="M20.702 2.29c-2.494-1.554-5.778-1.187-8.706.654C9.076 1.104 5.79.736 3.3 2.29c-3.941 2.463-4.42 8.806-1.07 14.167 2.47 3.954 6.333 6.269 9.77 6.226 3.439.043 7.301-2.273 9.771-6.226 3.347-5.361 2.872-11.704-1.069-14.167zM4.042 15.328a12.838 12.838 0 01-1.546-3.541 10.12 10.12 0 01-.336-3.338c.15-1.98.956-3.524 2.27-4.345 1.315-.822 3.052-.87 4.903-.137.281.113.556.24.825.382A15.11 15.11 0 007.5 7.54c-2.035 3.255-2.655 6.878-1.945 9.765a13.247 13.247 0 01-1.514-1.98zm17.465-3.541a12.866 12.866 0 01-1.547 3.54 13.25 13.25 0 01-1.513 1.984c.635-2.589.203-5.76-1.353-8.734a.39.39 0 00-.563-.153l-4.852 3.032a.397.397 0 00-.126.546l.712 1.139a.395.395 0 00.547.126l3.145-1.965c.101.306.203.606.28.916.296 1.086.41 2.214.335 3.337-.15 1.982-.956 3.525-2.27 4.347a4.437 4.437 0 01-2.25.65h-.101a4.432 4.432 0 01-2.25-.65c-1.314-.822-2.121-2.365-2.27-4.347-.074-1.123.039-2.251.335-3.337a13.212 13.212 0 014.05-6.482 10.148 10.148 0 012.849-1.765c1.845-.733 3.586-.685 4.9.137 1.316.822 2.122 2.365 2.271 4.345a10.146 10.146 0 01-.33 3.334z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google</title><path fill="#4285F4" d="M23.745 12.27c0-.79-.07-1.54-.19-2.27h-11.3v4.51h6.47c-.29 1.48-1.14 2.73-2.4 3.58v3h3.86c2.26-2.09 3.56-5.17 3.56-8.82z"/><path fill="#34A853" d="M12.255 24c3.24 0 5.95-1.08 7.93-2.91l-3.86-3c-1.08.72-2.45 1.16-4.07 1.16-3.13 0-5.78-2.11-6.73-4.96h-3.98v3.09C3.515 21.3 7.565 24 12.255 24z"/><path fill="#FBBC04" d="M5.525 14.29c-.25-.72-.38-1.49-.38-2.29s.14-1.57.38-2.29V6.62h-3.98a11.86 11.86 0 0 0 0 10.76l3.98-3.09z"/><path fill="#EA4335" d="M12.255 4.75c1.77 0 3.35.61 4.6 1.8l3.42-3.42C18.205 1.19 15.495 0 12.255 0c-4.69 0-8.74 2.7-10.71 6.62l3.98 3.09c.95-2.85 3.6-4.96 6.73-4.96z"/></svg>

After

Width:  |  Height:  |  Size: 716 B

View File

@@ -0,0 +1 @@
<svg fill="#FF7A59" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>HubSpot</title><path d="M18.164 7.93V5.084a2.198 2.198 0 001.267-1.978v-.067A2.2 2.2 0 0017.238.845h-.067a2.2 2.2 0 00-2.193 2.193v.067a2.196 2.196 0 001.252 1.973l.013.006v2.852a6.22 6.22 0 00-2.969 1.31l.012-.01-7.828-6.095A2.497 2.497 0 104.3 4.656l-.012.006 7.697 5.991a6.176 6.176 0 00-1.038 3.446c0 1.343.425 2.588 1.147 3.607l-.013-.02-2.342 2.343a1.968 1.968 0 00-.58-.095h-.002a2.033 2.033 0 102.033 2.033 1.978 1.978 0 00-.1-.595l.005.014 2.317-2.317a6.247 6.247 0 104.782-11.134l-.036-.005zm-.964 9.378a3.206 3.206 0 113.215-3.207v.002a3.206 3.206 0 01-3.207 3.207z"/></svg>

After

Width:  |  Height:  |  Size: 678 B

View File

@@ -0,0 +1 @@
<svg fill="#3693F5" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>iCloud</title><path d="M13.762 4.29a6.51 6.51 0 0 0-5.669 3.332 3.571 3.571 0 0 0-1.558-.36 3.571 3.571 0 0 0-3.516 3A4.918 4.918 0 0 0 0 14.796a4.918 4.918 0 0 0 4.92 4.914 4.93 4.93 0 0 0 .617-.045h14.42c2.305-.272 4.041-2.258 4.043-4.589v-.009a4.594 4.594 0 0 0-3.727-4.508 6.51 6.51 0 0 0-6.511-6.27z"/></svg>

After

Width:  |  Height:  |  Size: 406 B

View File

@@ -0,0 +1 @@
<svg fill="#D32D27" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>LastPass</title><path d="M22.629,6.857c0-0.379,0.304-0.686,0.686-0.686C23.693,6.171,24,6.483,24,6.857 v10.286c0,0.379-0.304,0.686-0.686,0.686c-0.379,0-0.686-0.312-0.686-0.686V6.857z M2.057,10.286c1.136,0,2.057,0.921,2.057,2.057 S3.193,14.4,2.057,14.4S0,13.479,0,12.343S0.921,10.286,2.057,10.286z M9.6,10.286c1.136,0,2.057,0.921,2.057,2.057 S10.736,14.4,9.6,14.4s-2.057-0.921-2.057-2.057S8.464,10.286,9.6,10.286z M17.143,10.286c1.136,0,2.057,0.921,2.057,2.057 S18.279,14.4,17.143,14.4s-2.057-0.921-2.057-2.057S16.007,10.286,17.143,10.286z"/></svg>

After

Width:  |  Height:  |  Size: 639 B

View File

@@ -0,0 +1 @@
<svg fill="#0668E1" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Meta</title><path d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Microsoft</title><rect x="1" y="1" width="10" height="10" fill="#F25022"/><rect x="13" y="1" width="10" height="10" fill="#7FBA00"/><rect x="1" y="13" width="10" height="10" fill="#00A4EF"/><rect x="13" y="13" width="10" height="10" fill="#FFB900"/></svg>

After

Width:  |  Height:  |  Size: 323 B

View File

@@ -0,0 +1 @@
<svg fill="#47A248" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>MongoDB</title><path d="M17.193 9.555c-1.264-5.58-4.252-7.414-4.573-8.115-.28-.394-.53-.954-.735-1.44-.036.495-.055.685-.523 1.184-.723.566-4.438 3.682-4.74 10.02-.282 5.912 4.27 9.435 4.888 9.884l.07.05A73.49 73.49 0 0111.91 24h.481c.114-1.032.284-2.056.51-3.07.417-.296.604-.463.85-.693a11.342 11.342 0 003.639-8.464c.01-.814-.103-1.662-.197-2.218zm-5.336 8.195s0-8.291.275-8.29c.213 0 .49 10.695.49 10.695-.381-.045-.765-1.76-.765-2.405z"/></svg>

After

Width:  |  Height:  |  Size: 542 B

View File

@@ -0,0 +1 @@
<svg fill="#E50914" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Netflix</title><path d="m5.398 0 8.348 23.602c2.346.059 4.856.398 4.856.398L10.113 0H5.398zm8.489 0v9.172l4.715 13.33V0h-4.715zM5.398 1.5V24c1.873-.225 2.81-.312 4.715-.398V14.83L5.398 1.5z"/></svg>

After

Width:  |  Height:  |  Size: 291 B

View File

@@ -0,0 +1 @@
<svg fill="#DDDDDD" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Notion</title><path d="M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L17.86 1.968c-.42-.326-.981-.7-2.055-.607L3.01 2.295c-.466.046-.56.28-.374.466zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.841-.046.935-.56.935-1.167V6.354c0-.606-.233-.933-.748-.887l-15.177.887c-.56.047-.747.327-.747.933zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514-.748 0-.935-.234-1.495-.933l-4.577-7.186v6.952L12.21 19s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233 4.764 7.279v-6.44l-1.215-.139c-.093-.514.28-.887.747-.933zM1.936 1.035l13.31-.98c1.634-.14 2.055-.047 3.082.7l4.249 2.986c.7.513.934.653.934 1.213v16.378c0 1.026-.373 1.634-1.68 1.726l-15.458.934c-.98.047-1.448-.093-1.962-.747l-3.129-4.06c-.56-.747-.793-1.306-.793-1.96V2.667c0-.839.374-1.54 1.447-1.632z"/></svg>

After

Width:  |  Height:  |  Size: 993 B

View File

@@ -0,0 +1 @@
<svg fill="#3B66BC" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>1Password</title><path d="M12 .007C5.373.007 0 5.376 0 11.999c0 6.624 5.373 11.994 12 11.994S24 18.623 24 12C24 5.376 18.627.007 12 .007Zm-.895 4.857h1.788c.484 0 .729.002.914.096a.86.86 0 0 1 .377.377c.094.185.095.428.095.912v6.016c0 .12 0 .182-.015.238a.427.427 0 0 1-.067.137.923.923 0 0 1-.174.162l-.695.564c-.113.092-.17.138-.191.194a.216.216 0 0 0 0 .15c.02.055.078.101.191.193l.695.565c.094.076.14.115.174.162.03.042.053.087.067.137a.936.936 0 0 1 .015.238v2.746c0 .484-.001.727-.095.912a.86.86 0 0 1-.377.377c-.185.094-.43.096-.914.096h-1.788c-.484 0-.726-.002-.912-.096a.86.86 0 0 1-.377-.377c-.094-.185-.095-.428-.095-.912v-6.016c0-.12 0-.182.015-.238a.437.437 0 0 1 .067-.139c.034-.047.08-.083.174-.16l.695-.564c.113-.092.17-.138.191-.194a.216.216 0 0 0 0-.15c-.02-.055-.078-.101-.191-.193l-.695-.565a.92.92 0 0 1-.174-.162.437.437 0 0 1-.067-.139.92.92 0 0 1-.015-.236V6.25c0-.484.001-.727.095-.912a.86.86 0 0 1 .377-.377c.186-.094.428-.096.912-.096z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg fill="#412991" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>PayPal</title><path fill="#009CDE" d="M20.067 8.478c.492.876.764 1.906.764 3.108 0 4.474-3.702 8.062-8.17 8.062H9.02l-1.462 9.352H3.3l.157-.98h3.065l1.462-9.353h3.642c4.468 0 8.17-3.587 8.17-8.061a7.562 7.562 0 0 0-.271-2.034" transform="translate(0 -5)"/><path fill="#003087" d="M18.95 5.878C18.003 4.139 16.084 3 13.6 3H5.836L1.8 29h4.257l1.462-9.352h3.642c4.468 0 8.17-3.588 8.17-8.062 0-2.533-.894-4.541-2.38-5.708M9.021 17.648H5.52l2.308-12.72h5.772c1.588 0 2.89.498 3.65 1.374.52.6.83 1.39.93 2.318.02.213.03.432.02.653-.15 3.37-3.143 6.375-6.64 6.375h-3.54" transform="translate(0 -5)"/></svg>

After

Width:  |  Height:  |  Size: 679 B

View File

@@ -0,0 +1 @@
<svg fill="#00A1E0" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Salesforce</title><path d="M10.006 5.415a4.195 4.195 0 013.045-1.306c1.56 0 2.954.9 3.69 2.205.63-.3 1.35-.45 2.1-.45 2.85 0 5.159 2.34 5.159 5.22s-2.31 5.22-5.176 5.22c-.345 0-.69-.044-1.02-.104a3.75 3.75 0 01-3.3 1.95c-.6 0-1.155-.15-1.65-.375A4.314 4.314 0 018.88 20.4a4.302 4.302 0 01-4.05-2.82c-.27.062-.54.076-.825.076-2.204 0-4.005-1.8-4.005-4.05 0-1.5.811-2.805 2.01-3.51-.255-.57-.39-1.2-.39-1.846 0-2.58 2.1-4.65 4.65-4.65 1.53 0 2.85.705 3.72 1.8"/></svg>

After

Width:  |  Height:  |  Size: 559 B

View File

@@ -0,0 +1 @@
<svg fill="#7AB55C" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Shopify</title><path d="M15.337 23.979l7.216-1.561s-2.604-17.613-2.625-17.73c-.018-.116-.114-.192-.211-.192s-1.929-.136-1.929-.136-1.275-1.274-1.439-1.411c-.045-.037-.075-.057-.121-.074l-.914 21.104h.023zM11.71 11.305s-.81-.424-1.774-.424c-1.447 0-1.504.906-1.504 1.141 0 1.232 3.24 1.715 3.24 4.629 0 2.295-1.44 3.76-3.406 3.76-2.354 0-3.54-1.465-3.54-1.465l.646-2.086s1.245 1.066 2.28 1.066c.675 0 .975-.545.975-.932 0-1.619-2.654-1.694-2.654-4.359-.034-2.237 1.571-4.416 4.827-4.416 1.257 0 1.875.361 1.875.361l-.945 2.715-.02.01zM11.17.83c.136 0 .271.038.405.135-.984.465-2.064 1.639-2.508 3.992-.656.213-1.293.405-1.889.578C7.697 3.75 8.951.84 11.17.84V.83zm1.235 2.949v.135c-.754.232-1.583.484-2.394.736.466-1.777 1.333-2.645 2.085-2.971.193.501.309 1.176.309 2.1zm.539-2.234c.694.074 1.141.867 1.429 1.755-.349.114-.735.231-1.158.366v-.252c0-.752-.096-1.371-.271-1.871v.002zm2.992 1.289c-.02 0-.06.021-.078.021s-.289.075-.714.21c-.423-1.233-1.176-2.37-2.508-2.37h-.115C12.135.209 11.669 0 11.265 0 8.159 0 6.675 3.877 6.21 5.846c-1.194.365-2.063.636-2.16.674-.675.213-.694.232-.772.87-.075.462-1.83 14.063-1.83 14.063L15.009 24l.927-21.166z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Slack</title><path fill="#E01E5A" d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z"/><path fill="#36C5F0" d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z"/><path fill="#2EB67D" d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312z"/><path fill="#ECB22E" d="M15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg fill="#1DB954" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Spotify</title><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>

After

Width:  |  Height:  |  Size: 712 B

View File

@@ -0,0 +1 @@
<svg fill="#006AFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Square</title><path d="M4.01 0A4.01 4.01 0 000 4.01v15.98c0 2.21 1.8 4 4.01 4.01h15.98C22.2 24 24 22.2 24 19.99V4A4.01 4.01 0 0019.99 0H4zm1.62 4.36h12.74c.7 0 1.26.57 1.26 1.27v12.74c0 .7-.56 1.27-1.26 1.27H5.63c-.7 0-1.26-.57-1.26-1.27V5.63a1.27 1.27 0 011.26-1.27zm3.83 4.35a.73.73 0 00-.73.73v5.09c0 .4.32.72.72.72h5.1a.73.73 0 00.73-.72V9.44a.73.73 0 00-.73-.73h-5.1Z"/></svg>

After

Width:  |  Height:  |  Size: 474 B

View File

@@ -0,0 +1 @@
<svg fill="#DDDDDD" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Squarespace</title><path d="M22.655 8.719c-1.802-1.801-4.726-1.801-6.564 0l-7.351 7.35c-.45.45-.45 1.2 0 1.65.45.449 1.2.449 1.65 0l7.351-7.351c.899-.899 2.362-.899 3.264 0 .9.9.9 2.364 0 3.264l-7.239 7.239c.9.899 2.362.899 3.263 0l5.589-5.589c1.836-1.838 1.836-4.763.037-6.563zm-2.475 2.437c-.451-.45-1.201-.45-1.65 0l-7.354 7.389c-.9.899-2.361.899-3.262 0-.45-.45-1.2-.45-1.65 0s-.45 1.2 0 1.649c1.801 1.801 4.726 1.801 6.564 0l7.351-7.35c.449-.487.449-1.239.001-1.688zm-2.439-7.35c-1.801-1.801-4.726-1.801-6.564 0l-7.351 7.351c-.45.449-.45 1.199 0 1.649s1.2.45 1.65 0l7.395-7.351c.9-.899 2.371-.899 3.27 0 .451.45 1.201.45 1.65 0 .421-.487.421-1.199-.029-1.649h-.021zm-2.475 2.437c-.45-.45-1.2-.45-1.65 0l-7.351 7.389c-.899.9-2.363.9-3.265 0-.9-.899-.9-2.363 0-3.264l7.239-7.239c-.9-.9-2.362-.9-3.263 0L1.35 8.719c-1.8 1.8-1.8 4.725 0 6.563 1.801 1.801 4.725 1.801 6.564 0l7.35-7.351c.451-.488.451-1.238 0-1.688h.002z"/></svg>

After

Width:  |  Height:  |  Size: 1022 B

View File

@@ -0,0 +1 @@
<svg fill="#635BFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Stripe</title><path d="M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.594-7.305h.003z"/></svg>

After

Width:  |  Height:  |  Size: 603 B

View File

@@ -0,0 +1 @@
<svg fill="#F22F46" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Twilio</title><path d="M12 0C5.381-.008.008 5.352 0 11.971V12c0 6.64 5.359 12 12 12 6.64 0 12-5.36 12-12 0-6.641-5.36-12-12-12zm0 20.801c-4.846.015-8.786-3.904-8.801-8.75V12c-.014-4.846 3.904-8.786 8.75-8.801H12c4.847-.014 8.786 3.904 8.801 8.75V12c.015 4.847-3.904 8.786-8.75 8.801H12zm5.44-11.76c0 1.359-1.12 2.479-2.481 2.479-1.366-.007-2.472-1.113-2.479-2.479 0-1.361 1.12-2.481 2.479-2.481 1.361 0 2.481 1.12 2.481 2.481zm0 5.919c0 1.36-1.12 2.48-2.481 2.48-1.367-.008-2.473-1.114-2.479-2.48 0-1.359 1.12-2.479 2.479-2.479 1.361-.001 2.481 1.12 2.481 2.479zm-5.919 0c0 1.36-1.12 2.48-2.479 2.48-1.368-.007-2.475-1.113-2.481-2.48 0-1.359 1.12-2.479 2.481-2.479 1.358-.001 2.479 1.12 2.479 2.479zm0-5.919c0 1.359-1.12 2.479-2.479 2.479-1.367-.007-2.475-1.112-2.481-2.479 0-1.361 1.12-2.481 2.481-2.481 1.358 0 2.479 1.12 2.479 2.481z"/></svg>

After

Width:  |  Height:  |  Size: 938 B

View File

@@ -0,0 +1 @@
<svg fill="#0C6EFC" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Wix</title><path d="m0 7.354 2.113 9.292h.801a1.54 1.54 0 0 0 1.506-1.218l1.351-6.34a.171.171 0 0 1 .167-.137c.08 0 .15.058.167.137l1.352 6.34a1.54 1.54 0 0 0 1.506 1.218h.805l2.113-9.292h-.565c-.62 0-1.159.43-1.296 1.035l-1.26 5.545-1.106-5.176a1.76 1.76 0 0 0-2.19-1.324c-.639.176-1.113.716-1.251 1.365l-1.094 5.127-1.26-5.537A1.33 1.33 0 0 0 .563 7.354H0zm13.992 0a.951.951 0 0 0-.951.95v8.342h.635a.952.952 0 0 0 .951-.95V7.353h-.635zm1.778 0 3.158 4.66-3.14 4.632h1.325c.368 0 .712-.181.918-.486l1.756-2.59a.12.12 0 0 1 .197 0l1.754 2.59c.206.305.55.486.918.486h1.326l-3.14-4.632L24 7.354h-1.326c-.368 0-.712.181-.918.486l-1.772 2.617a.12.12 0 0 1-.197 0L18.014 7.84a1.108 1.108 0 0 0-.918-.486H15.77z"/></svg>

After

Width:  |  Height:  |  Size: 808 B

View File

@@ -0,0 +1 @@
<svg fill="#0B5CFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Zoom</title><path d="M5.033 14.649H.743a.74.74 0 0 1-.686-.458.74.74 0 0 1 .16-.808L3.19 10.41H1.06A1.06 1.06 0 0 1 0 9.35h3.957c.301 0 .57.18.686.458a.74.74 0 0 1-.161.808L1.51 13.59h2.464c.585 0 1.06.475 1.06 1.06zM24 11.338c0-1.14-.927-2.066-2.066-2.066-.61 0-1.158.265-1.537.686a2.061 2.061 0 0 0-1.536-.686c-1.14 0-2.066.926-2.066 2.066v3.311a1.06 1.06 0 0 0 1.06-1.06v-2.251a1.004 1.004 0 0 1 2.013 0v2.251c0 .586.474 1.06 1.06 1.06v-3.311a1.004 1.004 0 0 1 2.012 0v2.251c0 .586.475 1.06 1.06 1.06zM16.265 12a2.728 2.728 0 1 1-5.457 0 2.728 2.728 0 0 1 5.457 0zm-1.06 0a1.669 1.669 0 1 0-3.338 0 1.669 1.669 0 0 0 3.338 0zm-4.82 0a2.728 2.728 0 1 1-5.458 0 2.728 2.728 0 0 1 5.457 0zm-1.06 0a1.669 1.669 0 1 0-3.338 0 1.669 1.669 0 0 0 3.338 0z"/></svg>

After

Width:  |  Height:  |  Size: 852 B

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -45,6 +45,9 @@ import { i18nKey } from '../i18n/i18n.providers'
</button>
}
</tui-textfield>
@if (error) {
<p class="error">{{ error }}</p>
}
<footer class="g-buttons">
<button
tuiButton
@@ -65,6 +68,10 @@ import { i18nKey } from '../i18n/i18n.providers'
color: var(--tui-status-warning);
}
.error {
color: var(--tui-status-negative);
}
.button {
pointer-events: auto;
margin-left: 0.25rem;
@@ -83,6 +90,7 @@ export class PromptModal {
masked = this.options.useMask
value = this.options.initialValue || ''
error = ''
get options(): PromptOptions {
return this.context.data
@@ -94,6 +102,12 @@ export class PromptModal {
submit(value: string) {
if (value || !this.options.required) {
const { pattern, patternError } = this.options
if (pattern && !new RegExp(pattern).test(value)) {
this.error = patternError || 'Invalid input'
return
}
this.error = ''
this.context.$implicit.next(value)
}
}
@@ -110,4 +124,6 @@ export interface PromptOptions {
required?: boolean
useMask?: boolean
initialValue?: string | null
pattern?: string
patternError?: string
}

View File

@@ -29,7 +29,7 @@ export class DocsLinkDirective {
const path = this.path()
const relative = path.startsWith('/') ? path : `/${path}`
return `https://docs.start9.com${relative}?os=${this.version}${this.fragment()}`
return `https://docs.start9.com${relative}?version=${this.version}${this.fragment()}`
})
constructor() {

View File

@@ -251,7 +251,7 @@ export default {
260: 'Anzeigen/Verbergen',
261: 'Anzeigen',
262: 'Diesen QR-Code scannen',
263: 'Auf Standard zurücksetzen',
263: 'Zurücksetzen',
264: 'Durch diese Änderung funktionieren die folgenden Dienste möglicherweise nicht mehr richtig und könnten abstürzen',
265: 'Fehler beim Starten des Dienstes',
266: 'Problem',
@@ -553,33 +553,8 @@ export default {
580: 'Aktualisierung erforderlich',
581: 'Ihre Benutzeroberfläche ist zwischengespeichert und veraltet. Versuchen Sie, die PWA mit der Schaltfläche unten neu zu laden. Wenn Sie diese Nachricht weiterhin sehen, deinstallieren und installieren Sie die PWA erneut.',
582: 'Ihre Benutzeroberfläche ist zwischengespeichert und veraltet. Führen Sie einen Hard-Refresh der Seite durch, um die neueste Benutzeroberfläche zu erhalten.',
583: 'Erfordert Vertrauen in die Root-CA Ihres Servers',
584: 'Verbindungen können manchmal langsam oder unzuverlässig sein',
585: 'Öffentlich, wenn Sie die Adresse öffentlich teilen, andernfalls privat',
586: 'Erfordert ein Tor-fähiges Gerät oder einen Browser',
587: 'Sollte nur für Apps benötigt werden, die SSL erzwingen',
588: 'Ideal für anonyme, zensurresistente Bereitstellung und Fernzugriff',
589: 'Ideal für lokalen Zugriff',
590: 'Erfordert die Verbindung mit demselben lokalen Netzwerk (LAN) wie Ihr Server, entweder physisch oder über VPN',
591: 'Erfordert die Einstellung einer statischen IP-Adresse für',
592: 'Ideal für VPN-Zugriff über',
593: 'in Ihrem Gateway',
594: 'der Wireguard-Server Ihres Routers',
595: 'Erfordert Portweiterleitung im Gateway',
596: 'Erfordert einen DNS-Eintrag für',
597: 'der sich auflöst zu',
598: 'Nicht empfohlen für VPN-Zugriff. VPNs unterstützen keine „.local“-Domains ohne erweiterte Konfiguration',
599: 'Kann für Clearnet-Zugriff verwendet werden',
600: 'In den meisten Fällen nicht empfohlen. Öffentliche Domains sind üblicher und werden bevorzugt',
601: 'Lokal',
602: 'Kann für lokalen Zugriff verwendet werden',
603: 'Ideal für öffentlichen Zugriff über das Internet',
604: 'Kann für persönlichen Zugriff über das öffentliche Internet verwendet werden, aber ein VPN ist privater und sicherer',
605: 'wenn die Verwendung von IP-Adressen und Ports unerwünscht ist',
606: 'Host',
607: 'Wert',
608: 'Zweck',
609: 'alle Subdomains von',
610: 'Dynamisches DNS',
611: 'Keine Service-Schnittstellen',
612: 'Grund',
@@ -630,10 +605,8 @@ export default {
657: 'Wählen Sie das Laufwerk mit Ihren bestehenden StartOS-Daten aus',
658: 'Laufwerk auswählen',
659: 'Keine StartOS-Datenlaufwerke gefunden',
660: 'Master-Passwort festlegen',
660: 'Richten Sie Ihren Server ein',
661: 'Neues Passwort festlegen (optional)',
662: 'Machen Sie es gut. Schreiben Sie es auf.',
663: 'Überspringen, um Ihr bestehendes Passwort beizubehalten.',
664: 'Passwort eingeben',
665: 'Passwort bestätigen',
666: 'Fertigstellen',
@@ -679,4 +652,56 @@ export default {
714: 'Installation abgeschlossen!',
715: 'StartOS wurde erfolgreich installiert.',
716: 'Weiter zur Einrichtung',
717: 'Ausgehendes Gateway festlegen',
718: 'Aktuell',
719: 'Systemstandard',
720: 'Ausgehendes Gateway',
721: 'Gateway für ausgehenden Datenverkehr auswählen',
722: 'Der Typ des Gateways',
723: 'Nur ausgehend',
724: 'Als Standard für ausgehenden Verkehr festlegen',
725: 'Gesamten ausgehenden Datenverkehr über dieses Gateway leiten',
726: 'WireGuard-Konfigurationsdatei',
727: 'Eingehend/Ausgehend',
728: 'StartTunnel (Eingehend/Ausgehend)',
729: 'Ethernet',
730: 'Domain hinzufügen',
731: 'Öffentliche Domain',
732: 'Private Domain',
733: 'Ausblenden',
734: 'Standard ausgehend',
735: 'Zertifikat',
736: 'Selbstsigniert',
737: 'Portweiterleitung',
739: 'DNS',
740: 'Anweisungen',
741: 'In Ihrem Domain-Registrar für',
742: 'diesen DNS-Eintrag erstellen',
743: 'In Ihrem Gateway',
744: 'diese Portweiterleitungsregel erstellen',
745: 'Externer Port',
747: 'Interner Port',
749: 'DNS-Server-Konfiguration',
750: 'muss konfiguriert werden, um',
751: 'die LAN-IP-Adresse dieses Servers',
752: 'als DNS-Server zu verwenden',
753: 'DNS-Server',
754: 'Portweiterleitungen anzeigen',
755: 'Schnittstelle(n)',
756: 'Keine Portweiterleitungsregeln',
757: 'Portweiterleitungsregeln am Gateway erforderlich',
763: 'Sie sind derzeit über Ihre .local-Adresse verbunden. Das Ändern des Hostnamens erfordert einen Wechsel zur neuen .local-Adresse.',
764: 'Hostname geändert',
765: 'Neue Adresse öffnen',
766: 'Ihr Server ist jetzt erreichbar unter',
767: 'Servername',
768: 'Adressanforderungen',
769: 'Version, Root-CA und mehr',
770: 'Details',
771: 'Spiel vorbei',
772: 'Beliebige Taste drücken oder tippen zum Starten',
773: 'Beliebige Taste drücken oder tippen zum Neustarten',
774: 'Der Portstatus kann nicht ermittelt werden, solange der Dienst nicht läuft',
775: 'Diese Adresse funktioniert nicht aus Ihrem lokalen Netzwerk aufgrund einer Router-Hairpinning-Einschränkung',
776: 'Aktion nicht gefunden',
} satisfies i18n

View File

@@ -250,7 +250,7 @@ export const ENGLISH: Record<string, number> = {
'Reveal/Hide': 260,
'Reveal': 261,
'Scan this QR': 262,
'Reset defaults': 263,
'Reset': 263,
'As a result of this change, the following services will no longer work properly and may crash': 264,
'Service Launch Error': 265,
'Issue': 266,
@@ -552,33 +552,8 @@ export const ENGLISH: Record<string, number> = {
'Refresh Needed': 580,
'Your user interface is cached and out of date. Attempt to reload the PWA using the button below. If you continue to see this message, uninstall and reinstall the PWA.': 581,
'Your user interface is cached and out of date. Hard refresh the page to get the latest UI.': 582,
"Requires trusting your server's Root CA": 583,
'Connections can be slow or unreliable at times': 584,
'Public if you share the address publicly, otherwise private': 585,
'Requires using a Tor-enabled device or browser': 586,
'Should only needed for apps that enforce SSL': 587,
'Ideal for anonymous, censorship-resistant hosting and remote access': 588,
'Ideal for local access': 589,
'Requires being connected to the same Local Area Network (LAN) as your server, either physically or via VPN': 590,
'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"
'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] "
'Not recommended for VPN access. VPNs do not support ".local" domains without advanced configuration': 598,
'Can be used for clearnet access': 599,
'Not recommended in most cases. Using a public domain is more common and preferred': 600,
'Local': 601, // as in, not remote
'Can be used for local access': 602,
'Ideal for public access via the Internet': 603,
'Can be used for personal access via the public Internet, but a VPN is more private and secure': 604,
'when using IP addresses and ports is undesirable': 605, // this is a partial sentence. It is preceded by "Good for connections "
'Host': 606, // as in, a network host
'Value': 607, // as in, the value in a column of a table
'Purpose': 608, // as in, the reason for a thing to exist
'all subdomains of': 609, // this is a partial sentence. A domain name will be added after "of" to complete the sentence.
'Dynamic DNS': 610,
'No service interfaces': 611, // as in, there are no available interfaces (API, UI, etc) for this software application
'Reason': 612, // as in, an explanation for something
@@ -630,10 +605,8 @@ export const ENGLISH: Record<string, number> = {
'Select the drive containing your existing StartOS data': 657,
'Select Drive': 658,
'No StartOS data drives found': 659,
'Set Master Password': 660,
'Set Up Your Server': 660,
'Set New Password (Optional)': 661,
'Make it good. Write it down.': 662,
'Skip to keep your existing password.': 663,
'Enter Password': 664,
'Confirm Password': 665,
'Finish': 666,
@@ -679,4 +652,56 @@ export const ENGLISH: Record<string, number> = {
'Installation Complete!': 714,
'StartOS has been installed successfully.': 715,
'Continue to Setup': 716,
'Set Outbound Gateway': 717,
'Current': 718,
'System default': 719,
'Outbound Gateway': 720,
'Select the gateway for outbound traffic': 721,
'The type of gateway': 722,
'Outbound Only': 723,
'Set as default outbound': 724,
'Route all outbound traffic through this gateway': 725,
'WireGuard Config File': 726,
'Inbound/Outbound': 727,
'StartTunnel (Inbound/Outbound)': 728,
'Ethernet': 729,
'Add Domain': 730,
'Public Domain': 731,
'Private Domain': 732,
'Hide': 733,
'default outbound': 734,
'Certificate': 735,
'Self signed': 736,
'Port Forwarding': 737,
'DNS': 739,
'Instructions': 740,
'In your domain registrar for': 741, // partial sentence, followed by a domain name
'create this DNS record': 742,
'In your gateway': 743, // partial sentence, followed by a gateway name
'create this port forwarding rule': 744,
'External Port': 745,
'Internal Port': 747,
'DNS Server Config': 749,
'must be configured to use': 750,
'the LAN IP address of this server': 751,
'as its DNS server': 752,
'DNS Server': 753,
'View port forwards': 754,
'Interface(s)': 755,
'No port forwarding rules': 756,
'Port forwarding rules required on gateway': 757,
'You are currently connected via your .local address. Changing the hostname will require you to switch to the new .local address.': 763,
'Hostname Changed': 764,
'Open new address': 765,
'Your server is now reachable at': 766,
'Server Name': 767,
'Address Requirements': 768,
'Version, Root CA, and more': 769,
'Details': 770,
'Game Over': 771,
'Press any key or tap to start': 772,
'Press any key or tap to play again': 773,
'Port status cannot be determined while service is not running': 774,
'This address will not work from your local network due to a router hairpinning limitation': 775,
'Action not found': 776,
}

View File

@@ -251,7 +251,7 @@ export default {
260: 'Mostrar/Ocultar',
261: 'Mostrar',
262: 'Escanea este QR',
263: 'Restablecer valores predeterminados',
263: 'Restablecer',
264: 'Como resultado de este cambio, los siguientes servicios dejarán de funcionar correctamente y pueden fallar',
265: 'Error al iniciar el servicio',
266: 'Problema',
@@ -553,33 +553,8 @@ export default {
580: 'Actualización necesaria',
581: 'Tu interfaz de usuario está en caché y desactualizada. Intenta recargar la PWA usando el botón de abajo. Si sigues viendo este mensaje, desinstala y vuelve a instalar la PWA.',
582: 'Tu interfaz de usuario está en caché y desactualizada. Haz un hard refresh de la página para obtener la última interfaz.',
583: 'Requiere confiar en la CA raíz de tu servidor',
584: 'Las conexiones pueden ser lentas o poco confiables a veces',
585: 'Público si compartes la dirección públicamente, de lo contrario privado',
586: 'Requiere un dispositivo o navegador habilitado para Tor',
587: 'Solo debería ser necesario para aplicaciones que imponen SSL',
588: 'Ideal para alojamiento y acceso remoto anónimo y resistente a la censura',
589: 'Ideal para acceso local',
590: 'Requiere estar conectado a la misma red de área local (LAN) que tu servidor, ya sea físicamente o mediante VPN',
591: 'Requiere configurar una dirección IP estática para',
592: 'Ideal para acceso VPN a través de',
593: 'en tu gateway',
594: 'el servidor Wireguard de tu router',
595: 'Requiere reenvío de puertos en el gateway',
596: 'Requiere un registro DNS para',
597: 'que se resuelva en',
598: 'No recomendado para acceso VPN. Las VPN no admiten dominios “.local” sin configuración avanzada',
599: 'Se puede usar para acceso a clearnet',
600: 'No recomendado en la mayoría de los casos. Los dominios públicos son más comunes y preferidos',
601: 'Local',
602: 'Se puede usar para acceso local',
603: 'Ideal para acceso público a través de Internet',
604: 'Puede usarse para acceso personal a través de Internet público, pero una VPN es más privada y segura',
605: 'cuando el uso de direcciones IP y puertos no es deseable',
606: 'Host',
607: 'Valor',
608: 'Propósito',
609: 'todos los subdominios de',
610: 'DNS dinámico',
611: 'Sin interfaces de servicio',
612: 'Razón',
@@ -630,10 +605,8 @@ export default {
657: 'Seleccione la unidad que contiene sus datos StartOS existentes',
658: 'Seleccionar unidad',
659: 'No se encontraron unidades de datos StartOS',
660: 'Establecer contraseña maestra',
660: 'Configure su servidor',
661: 'Establecer nueva contraseña (opcional)',
662: 'Que sea buena. Escríbala.',
663: 'Omitir para mantener su contraseña existente.',
664: 'Introducir contraseña',
665: 'Confirmar contraseña',
666: 'Finalizar',
@@ -679,4 +652,56 @@ export default {
714: '¡Instalación completada!',
715: 'StartOS se ha instalado correctamente.',
716: 'Continuar con la configuración',
717: 'Establecer puerta de enlace saliente',
718: 'Actual',
719: 'Predeterminado del sistema',
720: 'Puerta de enlace saliente',
721: 'Selecciona la puerta de enlace para el tráfico saliente',
722: 'El tipo de puerta de enlace',
723: 'Solo saliente',
724: 'Establecer como saliente predeterminado',
725: 'Enrutar todo el tráfico saliente a través de esta puerta de enlace',
726: 'Archivo de configuración WireGuard',
727: 'Entrante/Saliente',
728: 'StartTunnel (Entrante/Saliente)',
729: 'Ethernet',
730: 'Agregar dominio',
731: 'Dominio público',
732: 'Dominio privado',
733: 'Ocultar',
734: 'saliente predeterminado',
735: 'Certificado',
736: 'Autofirmado',
737: 'Reenvío de puertos',
739: 'DNS',
740: 'Instrucciones',
741: 'En su registrador de dominios para',
742: 'cree este registro DNS',
743: 'En su puerta de enlace',
744: 'cree esta regla de reenvío de puertos',
745: 'Puerto externo',
747: 'Puerto interno',
749: 'Configuración del servidor DNS',
750: 'debe estar configurada para usar',
751: 'la dirección IP LAN de este servidor',
752: 'como su servidor DNS',
753: 'Servidor DNS',
754: 'Ver redirecciones de puertos',
755: 'Interfaz/Interfaces',
756: 'Sin reglas de redirección de puertos',
757: 'Reglas de redirección de puertos requeridas en la puerta de enlace',
763: 'Actualmente está conectado a través de su dirección .local. Cambiar el nombre de host requerirá que cambie a la nueva dirección .local.',
764: 'Nombre de host cambiado',
765: 'Abrir nueva dirección',
766: 'Su servidor ahora es accesible en',
767: 'Nombre del servidor',
768: 'Requisitos de dirección',
769: 'Versión, CA raíz y más',
770: 'Detalles',
771: 'Fin del juego',
772: 'Pulsa cualquier tecla o toca para empezar',
773: 'Pulsa cualquier tecla o toca para jugar de nuevo',
774: 'El estado del puerto no se puede determinar mientras el servicio no está en ejecución',
775: 'Esta dirección no funcionará desde tu red local debido a una limitación de hairpinning del router',
776: 'Acción no encontrada',
} satisfies i18n

View File

@@ -251,7 +251,7 @@ export default {
260: 'Afficher/Masquer',
261: 'Afficher',
262: 'Scannez ce QR',
263: 'Réinitialiser aux paramètres dusine',
263: 'Réinitialiser',
264: 'Suite à ce changement, les services suivants ne fonctionneront plus correctement et pourraient planter',
265: 'Erreur de lancement du service',
266: 'Problème',
@@ -553,33 +553,8 @@ export default {
580: 'Actualisation nécessaire',
581: 'Votre interface utilisateur est mise en cache et obsolète. Essayez de recharger le PWA à laide du bouton ci-dessous. Si vous continuez à voir ce message, désinstallez puis réinstallez le PWA.',
582: 'Votre interface utilisateur est mise en cache et obsolète. Faites un rafraîchissement forcé de la page pour obtenir la dernière interface.',
583: 'Nécessite de faire confiance à lautorité de certification racine de votre serveur',
584: 'Les connexions peuvent parfois être lentes ou peu fiables',
585: 'Public si vous partagez ladresse publiquement, sinon privé',
586: 'Nécessite un appareil ou un navigateur compatible Tor',
587: 'Ne devrait être nécessaire que pour les applications qui imposent SSL',
588: 'Idéal pour lhébergement et laccès à distance anonymes et résistants à la censure',
589: 'Idéal pour un accès local',
590: 'Nécessite dêtre connecté au même réseau local (LAN) que votre serveur, soit physiquement, soit via VPN',
591: 'Nécessite de définir une adresse IP statique pour',
592: 'Idéal pour un accès VPN via',
593: 'dans votre passerelle',
594: 'le serveur Wireguard de votre routeur',
595: 'Nécessite un transfert de port dans la passerelle',
596: 'Nécessite un enregistrement DNS pour',
597: 'qui se résout en',
598: 'Non recommandé pour laccès VPN. Les VPN ne prennent pas en charge les domaines « .local » sans configuration avancée',
599: 'Peut être utilisé pour un accès clearnet',
600: 'Non recommandé dans la plupart des cas. Les domaines publics sont plus courants et préférés',
601: 'Local',
602: 'Peut être utilisé pour un accès local',
603: 'Idéal pour un accès public via Internet',
604: 'Peut être utilisé pour un accès personnel via lInternet public, mais un VPN est plus privé et plus sécurisé',
605: 'lorsque lutilisation des adresses IP et des ports est indésirable',
606: 'Hôte',
607: 'Valeur',
608: 'But',
609: 'tous les sous-domaines de',
610: 'DNS dynamique',
611: 'Aucune interface de service',
612: 'Raison',
@@ -630,10 +605,8 @@ export default {
657: 'Sélectionnez le disque contenant vos données StartOS existantes',
658: 'Sélectionner le disque',
659: 'Aucun disque de données StartOS trouvé',
660: 'Définir le mot de passe maître',
660: 'Configurez votre serveur',
661: 'Définir un nouveau mot de passe (facultatif)',
662: 'Choisissez-le bien. Notez-le.',
663: 'Ignorer pour conserver votre mot de passe existant.',
664: 'Saisir le mot de passe',
665: 'Confirmer le mot de passe',
666: 'Terminer',
@@ -679,4 +652,56 @@ export default {
714: 'Installation terminée !',
715: 'StartOS a été installé avec succès.',
716: 'Continuer vers la configuration',
717: 'Définir la passerelle sortante',
718: 'Actuel',
719: 'Par défaut du système',
720: 'Passerelle sortante',
721: 'Sélectionnez la passerelle pour le trafic sortant',
722: 'Le type de passerelle',
723: 'Sortant uniquement',
724: 'Définir comme sortant par défaut',
725: 'Acheminer tout le trafic sortant via cette passerelle',
726: 'Fichier de configuration WireGuard',
727: 'Entrant/Sortant',
728: 'StartTunnel (Entrant/Sortant)',
729: 'Ethernet',
730: 'Ajouter un domaine',
731: 'Domaine public',
732: 'Domaine privé',
733: 'Masquer',
734: 'sortant par défaut',
735: 'Certificat',
736: 'Auto-signé',
737: 'Redirection de ports',
739: 'DNS',
740: 'Instructions',
741: 'Dans votre registraire de domaine pour',
742: 'créez cet enregistrement DNS',
743: 'Dans votre passerelle',
744: 'créez cette règle de redirection de port',
745: 'Port externe',
747: 'Port interne',
749: 'Configuration du serveur DNS',
750: 'doit être configurée pour utiliser',
751: "l'adresse IP LAN de ce serveur",
752: 'comme serveur DNS',
753: 'Serveur DNS',
754: 'Voir les redirections de ports',
755: 'Interface(s)',
756: 'Aucune règle de redirection de port',
757: 'Règles de redirection de ports requises sur la passerelle',
763: "Vous êtes actuellement connecté via votre adresse .local. Changer le nom d'hôte nécessitera de passer à la nouvelle adresse .local.",
764: "Nom d'hôte modifié",
765: 'Ouvrir la nouvelle adresse',
766: 'Votre serveur est maintenant accessible à',
767: 'Nom du serveur',
768: "Exigences de l'adresse",
769: 'Version, CA racine et plus',
770: 'Détails',
771: 'Partie terminée',
772: "Appuyez sur une touche ou touchez l'écran pour commencer",
773: "Appuyez sur une touche ou touchez l'écran pour rejouer",
774: "L'état du port ne peut pas être déterminé tant que le service n'est pas en cours d'exécution",
775: "Cette adresse ne fonctionnera pas depuis votre réseau local en raison d'une limitation de hairpinning du routeur",
776: 'Action introuvable',
} satisfies i18n

View File

@@ -251,7 +251,7 @@ export default {
260: 'Pokaż/Ukryj',
261: 'Pokaż',
262: 'Zeskanuj ten kod QR',
263: 'Przywróć ustawienia domyślne',
263: 'Resetuj',
264: 'W wyniku tej zmiany następujące serwisy mogą przestać działać poprawnie lub ulec awarii',
265: 'Błąd uruchomienia serwisu',
266: 'Problem',
@@ -553,33 +553,8 @@ export default {
580: 'Wymagane odświeżenie',
581: 'Twój interfejs użytkownika jest w pamięci podręcznej i jest nieaktualny. Spróbuj ponownie załadować PWA za pomocą przycisku poniżej. Jeśli nadal widzisz ten komunikat, odinstaluj i ponownie zainstaluj PWA.',
582: 'Twój interfejs użytkownika jest w pamięci podręcznej i jest nieaktualny. Wykonaj twarde odświeżenie strony, aby uzyskać najnowszy interfejs.',
583: 'Wymaga zaufania do głównego CA twojego serwera',
584: 'Połączenia mogą być czasami wolne lub niestabilne',
585: 'Publiczne, jeśli udostępniasz adres publicznie, w przeciwnym razie prywatne',
586: 'Wymaga urządzenia lub przeglądarki obsługującej Tor',
587: 'Powinno być wymagane tylko dla aplikacji wymuszających SSL',
588: 'Idealne do anonimowego, odpornego na cenzurę hostingu i zdalnego dostępu',
589: 'Idealne do dostępu lokalnego',
590: 'Wymaga połączenia z tą samą siecią lokalną (LAN) co serwer, fizycznie lub przez VPN',
591: 'Wymaga ustawienia statycznego adresu IP dla',
592: 'Idealne do dostępu VPN przez',
593: 'w twojej bramie',
594: 'serwer Wireguard twojego routera',
595: 'Wymaga przekierowania portów w bramie',
596: 'Wymaga rekordu DNS dla',
597: 'który rozwiązuje się na',
598: 'Niezalecane do dostępu VPN. VPN-y nie obsługują domen „.local” bez zaawansowanej konfiguracji',
599: 'Może być używane do dostępu do clearnet',
600: 'Niezalecane w większości przypadków. Domeny publiczne są bardziej powszechne i preferowane',
601: 'Lokalne',
602: 'Może być używane do dostępu lokalnego',
603: 'Idealne do publicznego dostępu przez Internet',
604: 'Może być używane do osobistego dostępu przez publiczny Internet, ale VPN jest bardziej prywatny i bezpieczny',
605: 'gdy używanie adresów IP i portów jest niepożądane',
606: 'Host',
607: 'Wartość',
608: 'Cel',
609: 'wszystkie subdomeny',
610: 'Dynamiczny DNS',
611: 'Brak interfejsów usług',
612: 'Powód',
@@ -630,10 +605,8 @@ export default {
657: 'Wybierz dysk zawierający istniejące dane StartOS',
658: 'Wybierz dysk',
659: 'Nie znaleziono dysków danych StartOS',
660: 'Ustaw hasło główne',
660: 'Skonfiguruj swój serwer',
661: 'Ustaw nowe hasło (opcjonalnie)',
662: 'Zadbaj o nie. Zapisz je.',
663: 'Pomiń, aby zachować istniejące hasło.',
664: 'Wprowadź hasło',
665: 'Potwierdź hasło',
666: 'Zakończ',
@@ -679,4 +652,56 @@ export default {
714: 'Instalacja zakończona!',
715: 'StartOS został pomyślnie zainstalowany.',
716: 'Przejdź do konfiguracji',
717: 'Ustaw bramę wychodzącą',
718: 'Bieżący',
719: 'Domyślne systemu',
720: 'Brama wychodząca',
721: 'Wybierz bramę dla ruchu wychodzącego',
722: 'Typ bramy',
723: 'Tylko wychodzący',
724: 'Ustaw jako domyślne wychodzące',
725: 'Kieruj cały ruch wychodzący przez tę bramę',
726: 'Plik konfiguracyjny WireGuard',
727: 'Przychodzący/Wychodzący',
728: 'StartTunnel (Przychodzący/Wychodzący)',
729: 'Ethernet',
730: 'Dodaj domenę',
731: 'Domena publiczna',
732: 'Domena prywatna',
733: 'Ukryj',
734: 'domyślne wychodzące',
735: 'Certyfikat',
736: 'Samopodpisany',
737: 'Przekierowanie portów',
739: 'DNS',
740: 'Instrukcje',
741: 'W rejestratorze domeny dla',
742: 'utwórz ten rekord DNS',
743: 'W bramie',
744: 'utwórz tę regułę przekierowania portów',
745: 'Port zewnętrzny',
747: 'Port wewnętrzny',
749: 'Konfiguracja serwera DNS',
750: 'musi być skonfigurowana do używania',
751: 'adresu IP LAN tego serwera',
752: 'jako serwera DNS',
753: 'Serwer DNS',
754: 'Wyświetl przekierowania portów',
755: 'Interfejs(y)',
756: 'Brak reguł przekierowania portów',
757: 'Reguły przekierowania portów wymagane na bramce',
763: 'Jesteś obecnie połączony przez adres .local. Zmiana nazwy hosta będzie wymagać przełączenia na nowy adres .local.',
764: 'Nazwa hosta zmieniona',
765: 'Otwórz nowy adres',
766: 'Twój serwer jest teraz dostępny pod adresem',
767: 'Nazwa serwera',
768: 'Wymagania adresu',
769: 'Wersja, Root CA i więcej',
770: 'Szczegóły',
771: 'Koniec gry',
772: 'Naciśnij dowolny klawisz lub dotknij, aby rozpocząć',
773: 'Naciśnij dowolny klawisz lub dotknij, aby zagrać ponownie',
774: 'Status portu nie może być określony, gdy usługa nie jest uruchomiona',
775: 'Ten adres nie będzie działać z Twojej sieci lokalnej z powodu ograniczenia hairpinning routera',
776: 'Nie znaleziono akcji',
} satisfies i18n

View File

@@ -61,3 +61,4 @@ export * from './util/to-local-iso-string'
export * from './util/unused'
export * from './util/keyboards'
export * from './util/languages'
export * from './util/hostname'

View File

@@ -30,7 +30,6 @@ export function getErrorMessage(e: HttpError | string, link?: string): string {
'Request Error. Your browser blocked the request. This is usually caused by a corrupt browser cache or an overly aggressive ad blocker. Please clear your browser cache and/or adjust your ad blocker and try again'
} else if (!e.message) {
message = 'Unknown Error'
link = 'https://docs.start9.com/help/common-issues.html'
} else {
message = e.message
}

View File

@@ -12,13 +12,13 @@ import {
switchMap,
timer,
} from 'rxjs'
import { FollowLogsReq, FollowLogsRes, Log } from '../types/api'
import { T } from '@start9labs/start-sdk'
import { Constructor } from '../types/constructor'
import { convertAnsi } from '../util/convert-ansi'
interface Api {
initFollowLogs: (params: FollowLogsReq) => Promise<FollowLogsRes>
openWebsocket$: (guid: string) => Observable<Log>
initFollowLogs: (params: {}) => Promise<T.LogFollowResponse>
openWebsocket$: (guid: string) => Observable<T.LogEntry>
}
export function provideSetupLogsService(

View File

@@ -1,27 +1,3 @@
export type FollowLogsReq = {}
export type FollowLogsRes = {
startCursor: string
guid: string
}
export type FetchLogsReq = {
before: boolean
cursor?: string
limit?: number
}
export type FetchLogsRes = {
entries: Log[]
startCursor?: string
endCursor?: string
}
export interface Log {
timestamp: string
message: string
bootId: string
}
export type DiskListResponse = DiskInfo[]
export interface DiskInfo {

View File

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

View File

@@ -1,4 +1,4 @@
import { Log } from '../types/api'
import { T } from '@start9labs/start-sdk'
import { toLocalIsoString } from './to-local-iso-string'
import Convert from 'ansi-to-html'
@@ -8,7 +8,7 @@ const CONVERT = new Convert({
escapeXML: true,
})
export function convertAnsi(entries: readonly Log[]): string {
export function convertAnsi(entries: readonly T.LogEntry[]): string {
return entries
.map(
({ timestamp, message }) =>

View File

@@ -0,0 +1,24 @@
/**
* TS port of the Rust `normalize()` function from core/src/hostname.rs.
* Converts a free-text name into a valid hostname.
*/
export function normalizeHostname(name: string): string {
let prevWasDash = true
let normalized = ''
for (const c of name) {
if (/[a-zA-Z0-9]/.test(c)) {
prevWasDash = false
normalized += c.toLowerCase()
} else if ((c === '-' || /\s/.test(c)) && !prevWasDash) {
prevWasDash = true
normalized += '-'
}
}
while (normalized.endsWith('-')) {
normalized = normalized.slice(0, -1)
}
return normalized || 'start9'
}

View File

@@ -3,6 +3,7 @@ import { Component, inject } from '@angular/core'
import { RouterOutlet } from '@angular/router'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { PatchService } from './services/patch.service'
import { UpdateService } from './services/update.service'
@Component({
selector: 'app-root',
@@ -24,4 +25,6 @@ export class App {
readonly subscription = inject(PatchService)
.pipe(takeUntilDestroyed())
.subscribe()
readonly updates = inject(UpdateService)
}

View File

@@ -2,9 +2,11 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { Router, RouterLink, RouterLinkActive } from '@angular/router'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { TuiBadgeNotification } from '@taiga-ui/kit'
import { ApiService } from 'src/app/services/api/api.service'
import { AuthService } from 'src/app/services/auth.service'
import { SidebarService } from 'src/app/services/sidebar.service'
import { UpdateService } from 'src/app/services/update.service'
@Component({
selector: 'nav',
@@ -22,6 +24,19 @@ import { SidebarService } from 'src/app/services/sidebar.service'
{{ route.name }}
</a>
}
<a
tuiButton
size="s"
appearance="flat-grayscale"
routerLinkActive="active"
iconStart="@tui.settings"
routerLink="settings"
>
Settings
@if (update.hasUpdate()) {
<tui-badge-notification size="s" appearance="positive" />
}
</a>
</div>
<button
tuiButton
@@ -57,6 +72,11 @@ import { SidebarService } from 'src/app/services/sidebar.service'
&.active {
background: var(--tui-background-neutral-1);
}
tui-badge-notification {
margin-inline-start: auto;
background: var(--tui-status-positive);
}
}
button {
@@ -78,7 +98,7 @@ import { SidebarService } from 'src/app/services/sidebar.service'
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, RouterLink, RouterLinkActive],
imports: [TuiButton, TuiBadgeNotification, RouterLink, RouterLinkActive],
host: {
'[class._expanded]': 'sidebars.start()',
'(document:click)': 'sidebars.start.set(false)',
@@ -92,6 +112,7 @@ export class Nav {
protected readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
protected readonly update = inject(UpdateService)
protected readonly routes = [
{
@@ -109,11 +130,6 @@ export class Nav {
icon: '@tui.globe',
link: 'port-forwards',
},
{
name: 'Settings',
icon: '@tui.settings',
link: 'settings',
},
] as const
protected async logout() {

View File

@@ -118,7 +118,7 @@ import { MappedDevice, PortForwardsData } from './utils'
@if (show80) {
<label tuiLabel>
<input tuiCheckbox type="checkbox" formControlName="also80" />
Also forward port 80 to port 5443? This is needed for HTTP to HTTPS
Also forward port 80 to port 443? This is needed for HTTP to HTTPS
redirects (recommended)
</label>
}
@@ -173,7 +173,7 @@ export class PortForwardsAdd {
protected checkShow80() {
const { externalport, internalport } = this.form.getRawValue()
this.show80 = externalport === 443 && internalport === 5443
this.show80 = externalport === 443 && internalport === 443
}
protected async onSave() {
@@ -194,10 +194,10 @@ export class PortForwardsAdd {
target: `${device!.ip}:${internalport}`,
})
if (externalport === 443 && internalport === 5443 && also80) {
if (externalport === 443 && internalport === 443 && also80) {
await this.api.addForward({
source: `${externalip}:80`,
target: `${device!.ip}:5443`,
target: `${device!.ip}:443`,
})
}
} catch (e: any) {

View File

@@ -0,0 +1,142 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
NonNullableFormBuilder,
ReactiveFormsModule,
ValidatorFn,
Validators,
} from '@angular/forms'
import { ErrorService } from '@start9labs/shared'
import { TuiAutoFocus, TuiValidator } from '@taiga-ui/cdk'
import {
TuiAlertService,
TuiButton,
TuiDialogContext,
TuiError,
TuiTextfield,
} from '@taiga-ui/core'
import {
TuiButtonLoading,
TuiFieldErrorPipe,
tuiValidationErrorsProvider,
} from '@taiga-ui/kit'
import { TuiForm } from '@taiga-ui/layout'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { map } from 'rxjs'
import { ApiService } from 'src/app/services/api/api.service'
@Component({
template: `
<form tuiForm [formGroup]="form">
<tui-textfield>
<label tuiLabel>New password</label>
<input tuiTextfield tuiAutoFocus formControlName="password" />
</tui-textfield>
<tui-error
formControlName="password"
[error]="[] | tuiFieldError | async"
/>
<tui-textfield>
<label tuiLabel>Confirm new password</label>
<input
tuiTextfield
formControlName="confirm"
[tuiValidator]="matchValidator()"
/>
</tui-textfield>
<tui-error
formControlName="confirm"
[error]="[] | tuiFieldError | async"
/>
<footer>
<button
tuiButton
(click)="onSave()"
[loading]="loading()"
[disabled]="formInvalid()"
>
Save
</button>
</footer>
</form>
`,
providers: [
tuiValidationErrorsProvider({
required: 'This field is required',
minlength: 'Password must be at least 8 characters',
maxlength: 'Password cannot exceed 64 characters',
match: 'Passwords do not match',
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncPipe,
ReactiveFormsModule,
TuiAutoFocus,
TuiButton,
TuiButtonLoading,
TuiError,
TuiFieldErrorPipe,
TuiForm,
TuiTextfield,
TuiValidator,
],
})
export class ChangePasswordDialog {
private readonly context = injectContext<TuiDialogContext<void>>()
private readonly api = inject(ApiService)
private readonly alerts = inject(TuiAlertService)
private readonly errorService = inject(ErrorService)
protected readonly loading = signal(false)
protected readonly form = inject(NonNullableFormBuilder).group({
password: [
'',
[Validators.required, Validators.minLength(8), Validators.maxLength(64)],
],
confirm: [
'',
[Validators.required, Validators.minLength(8), Validators.maxLength(64)],
],
})
protected readonly matchValidator = toSignal(
this.form.controls.password.valueChanges.pipe(
map(
(password): ValidatorFn =>
({ value }) =>
value === password ? null : { match: true },
),
),
{ initialValue: Validators.nullValidator },
)
protected readonly formInvalid = toSignal(
this.form.statusChanges.pipe(map(() => this.form.invalid)),
{ initialValue: this.form.invalid },
)
protected async onSave() {
this.loading.set(true)
try {
await this.api.setPassword({ password: this.form.getRawValue().password })
this.alerts
.open('Password changed', { label: 'Success', appearance: 'positive' })
.subscribe()
this.context.$implicit.complete()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading.set(false)
}
}
}
export const CHANGE_PASSWORD = new PolymorpheusComponent(ChangePasswordDialog)

View File

@@ -1,142 +1,101 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
NonNullableFormBuilder,
ReactiveFormsModule,
ValidatorFn,
Validators,
} from '@angular/forms'
import { ErrorService } from '@start9labs/shared'
import { tuiMarkControlAsTouchedAndValidate, TuiValidator } from '@taiga-ui/cdk'
import {
TuiAlertService,
TuiAppearance,
TuiButton,
TuiError,
TuiTextfield,
TuiTitle,
} from '@taiga-ui/core'
import {
TuiButtonLoading,
TuiFieldErrorPipe,
tuiValidationErrorsProvider,
} from '@taiga-ui/kit'
import { TuiCard, TuiForm, TuiHeader } from '@taiga-ui/layout'
import { map } from 'rxjs'
import { ApiService } from 'src/app/services/api/api.service'
import { TuiAppearance, TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiDialogService } from '@taiga-ui/experimental'
import { TuiBadge, TuiButtonLoading } from '@taiga-ui/kit'
import { TuiCard, TuiCell } from '@taiga-ui/layout'
import { UpdateService } from 'src/app/services/update.service'
import { CHANGE_PASSWORD } from './change-password'
@Component({
template: `
<form tuiCardLarge tuiAppearance="neutral" tuiForm [formGroup]="form">
<header tuiHeader>
<h2 tuiTitle>
Settings
<span tuiSubtitle>Change password</span>
</h2>
</header>
<tui-textfield>
<label tuiLabel>New password</label>
<input formControlName="password" tuiTextfield />
</tui-textfield>
<tui-error
formControlName="password"
[error]="[] | tuiFieldError | async"
/>
<tui-textfield>
<label tuiLabel>Confirm new password</label>
<input
formControlName="confirm"
tuiTextfield
[tuiValidator]="matchValidator()"
/>
</tui-textfield>
<tui-error
formControlName="confirm"
[error]="[] | tuiFieldError | async"
/>
<footer>
<button tuiButton (click)="onSave()" [loading]="loading()">Save</button>
</footer>
</form>
<div tuiCardLarge tuiAppearance="neutral">
<div tuiCell>
<span tuiTitle>
<strong>
Version
@if (update.hasUpdate()) {
<tui-badge appearance="positive" size="s">
Update Available
</tui-badge>
}
</strong>
<span tuiSubtitle>Current: {{ update.installed() ?? '—' }}</span>
</span>
@if (update.hasUpdate()) {
<button tuiButton size="s" [loading]="applying()" (click)="onApply()">
Update to {{ update.candidate() }}
</button>
} @else {
<button
tuiButton
size="s"
appearance="secondary"
[loading]="checking()"
(click)="onCheckUpdate()"
>
Check for updates
</button>
}
</div>
<div tuiCell>
<span tuiTitle>
<strong>Change password</strong>
</span>
<button tuiButton size="s" (click)="onChangePassword()">Change</button>
</div>
</div>
`,
providers: [
tuiValidationErrorsProvider({
required: 'This field is required',
minlength: 'Password must be at least 8 characters',
maxlength: 'Password cannot exceed 64 characters',
match: 'Passwords do not match',
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ReactiveFormsModule,
AsyncPipe,
TuiCard,
TuiForm,
TuiHeader,
TuiCell,
TuiTitle,
TuiTextfield,
TuiError,
TuiFieldErrorPipe,
TuiButton,
TuiButtonLoading,
TuiValidator,
TuiBadge,
TuiAppearance,
],
})
export default class Settings {
private readonly api = inject(ApiService)
private readonly alerts = inject(TuiAlertService)
private readonly dialogs = inject(TuiDialogService)
private readonly errorService = inject(ErrorService)
protected readonly loading = signal(false)
protected readonly form = inject(NonNullableFormBuilder).group({
password: [
'',
[Validators.required, Validators.minLength(8), Validators.maxLength(64)],
],
confirm: [
'',
[Validators.required, Validators.minLength(8), Validators.maxLength(64)],
],
})
protected readonly update = inject(UpdateService)
protected readonly checking = signal(false)
protected readonly applying = signal(false)
protected readonly matchValidator = toSignal(
this.form.controls.password.valueChanges.pipe(
map(
(password): ValidatorFn =>
({ value }) =>
value === password ? null : { match: true },
),
),
{ initialValue: Validators.nullValidator },
)
protected onChangePassword(): void {
this.dialogs.open(CHANGE_PASSWORD, { label: 'Change Password' }).subscribe()
}
protected async onSave() {
if (this.form.invalid) {
tuiMarkControlAsTouchedAndValidate(this.form)
return
}
this.loading.set(true)
protected async onCheckUpdate() {
this.checking.set(true)
try {
await this.api.setPassword({ password: this.form.getRawValue().password })
this.alerts
.open('Password changed', { label: 'Success', appearance: 'positive' })
.subscribe()
this.form.reset()
await this.update.checkUpdate()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading.set(false)
this.checking.set(false)
}
}
protected async onApply() {
this.applying.set(true)
try {
await this.update.applyUpdate()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.applying.set(false)
}
}
}

View File

@@ -25,6 +25,9 @@ export abstract class ApiService {
// forwards
abstract addForward(params: AddForwardReq): Promise<null> // port-forward.add
abstract deleteForward(params: DeleteForwardReq): Promise<null> // port-forward.remove
// update
abstract checkUpdate(): Promise<TunnelUpdateResult> // update.check
abstract applyUpdate(): Promise<TunnelUpdateResult> // update.apply
}
export type SubscribeRes = {
@@ -62,3 +65,9 @@ export type AddForwardReq = {
export type DeleteForwardReq = {
source: string
}
export type TunnelUpdateResult = {
status: string
installed: string
candidate: string
}

View File

@@ -16,6 +16,7 @@ import {
DeleteSubnetReq,
LoginReq,
SubscribeRes,
TunnelUpdateResult,
UpsertDeviceReq,
UpsertSubnetReq,
} from './api.service'
@@ -103,6 +104,16 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'port-forward.remove', params })
}
// update
async checkUpdate(): Promise<TunnelUpdateResult> {
return this.rpcRequest({ method: 'update.check', params: {} })
}
async applyUpdate(): Promise<TunnelUpdateResult> {
return this.rpcRequest({ method: 'update.apply', params: {} })
}
// private
private async upsertSubnet(params: UpsertSubnetReq): Promise<null> {

View File

@@ -9,6 +9,7 @@ import {
DeleteSubnetReq,
LoginReq,
SubscribeRes,
TunnelUpdateResult,
UpsertDeviceReq,
UpsertSubnetReq,
} from './api.service'
@@ -196,6 +197,24 @@ export class MockApiService extends ApiService {
return null
}
async checkUpdate(): Promise<TunnelUpdateResult> {
await pauseFor(1000)
return {
status: 'update-available',
installed: '0.4.0-alpha.19',
candidate: '0.4.0-alpha.20',
}
}
async applyUpdate(): Promise<TunnelUpdateResult> {
await pauseFor(2000)
return {
status: 'updating',
installed: '0.4.0-alpha.19',
candidate: '0.4.0-alpha.20',
}
}
private async mockRevision<T>(patch: Operation<T>[]): Promise<void> {
const revision = {
id: ++this.sequence,

View File

@@ -39,14 +39,14 @@ export const mockTunnelData: TunnelData = {
},
},
portForwards: {
'69.1.1.42:443': '10.59.0.2:5443',
'69.1.1.42:443': '10.59.0.2:443',
'69.1.1.42:3000': '10.59.0.2:3000',
},
gateways: {
eth0: {
name: null,
public: null,
secure: null,
type: null,
ipInfo: {
name: 'Wired Connection 1',
scopeId: 1,

View File

@@ -0,0 +1,120 @@
import { Component, computed, inject, Injectable, signal } from '@angular/core'
import { toObservable } from '@angular/core/rxjs-interop'
import { ErrorService } from '@start9labs/shared'
import { TuiLoader } from '@taiga-ui/core'
import { TuiDialogService } from '@taiga-ui/experimental'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import {
catchError,
EMPTY,
filter,
from,
interval,
Subscription,
switchMap,
takeWhile,
} from 'rxjs'
import { ApiService, TunnelUpdateResult } from './api/api.service'
import { AuthService } from './auth.service'
@Component({
template: '<tui-loader size="xl" [textContent]="text" />',
imports: [TuiLoader],
})
class UpdatingDialog {
protected readonly text = 'StartTunnel is updating...'
}
@Injectable({
providedIn: 'root',
})
export class UpdateService {
private readonly api = inject(ApiService)
private readonly auth = inject(AuthService)
private readonly dialogs = inject(TuiDialogService)
private readonly errorService = inject(ErrorService)
readonly result = signal<TunnelUpdateResult | null>(null)
readonly hasUpdate = computed(
() => this.result()?.status === 'update-available',
)
readonly installed = computed(() => this.result()?.installed ?? null)
readonly candidate = computed(() => this.result()?.candidate ?? null)
private polling = false
private updatingDialog: Subscription | null = null
constructor() {
toObservable(this.auth.authenticated)
.pipe(filter(Boolean))
.subscribe(() => this.initCheck())
}
async checkUpdate(): Promise<void> {
const result = await this.api.checkUpdate()
this.setResult(result)
}
async applyUpdate(): Promise<void> {
const result = await this.api.applyUpdate()
this.setResult(result)
}
private setResult(result: TunnelUpdateResult): void {
this.result.set(result)
if (result.status === 'updating') {
this.showUpdatingDialog()
this.startPolling()
} else {
this.hideUpdatingDialog()
}
}
private async initCheck(): Promise<void> {
try {
await this.checkUpdate()
} catch (e: any) {
this.errorService.handleError(e)
}
}
private startPolling(): void {
if (this.polling) return
this.polling = true
interval(5000)
.pipe(
switchMap(() =>
from(this.api.checkUpdate()).pipe(catchError(() => EMPTY)),
),
takeWhile(result => result.status === 'updating', true),
)
.subscribe({
next: result => this.result.set(result),
complete: () => {
this.polling = false
this.hideUpdatingDialog()
},
error: () => {
this.polling = false
this.hideUpdatingDialog()
},
})
}
private showUpdatingDialog(): void {
if (this.updatingDialog) return
this.updatingDialog = this.dialogs
.open(new PolymorpheusComponent(UpdatingDialog), {
closable: false,
dismissible: false,
})
.subscribe({ complete: () => (this.updatingDialog = null) })
}
private hideUpdatingDialog(): void {
this.updatingDialog?.unsubscribe()
this.updatingDialog = null
}
}

View File

@@ -12,7 +12,7 @@ import { TuiCell } from '@taiga-ui/layout'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { BackupReport } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
import { getManifest } from '../utils/get-package-data'
import { DataModel } from '../services/patch-db/data-model'
@@ -60,7 +60,7 @@ export class BackupsReportModal {
readonly data =
injectContext<
TuiDialogContext<void, { content: BackupReport; createdAt: string }>
TuiDialogContext<void, { content: T.BackupReport; createdAt: string }>
>().data
readonly pkgTitles = toSignal(

View File

@@ -69,7 +69,7 @@ export default class LogsPage implements OnInit {
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
startCursor?: string
startCursor?: string | null
loading = false
logs: string[] = []
scrollTop = 0
@@ -98,7 +98,7 @@ export default class LogsPage implements OnInit {
try {
const response = await this.api.diagnosticGetLogs({
cursor: this.startCursor,
cursor: this.startCursor ?? undefined,
before: !!this.startCursor,
limit: 200,
})

View File

@@ -46,7 +46,7 @@
tuiButton
docsLink
size="s"
path="/user-manual/trust-ca.html"
path="/start-os/user-manual/trust-ca.html"
iconEnd="@tui.external-link"
>
{{ 'View instructions' | i18n }}

View File

@@ -50,7 +50,12 @@ import { ABOUT } from './about.component'
</button>
</tui-opt-group>
<tui-opt-group label="" safeLinks>
<a tuiOption docsLink iconStart="@tui.book-open" path="/user-manual">
<a
tuiOption
docsLink
iconStart="@tui.book-open"
path="/start-os/user-manual/index.html"
>
{{ 'User manual' | i18n }}
</a>
<a

View File

@@ -5,7 +5,13 @@ import {
input,
signal,
} from '@angular/core'
import { CopyService, DialogService, i18nPipe } from '@start9labs/shared'
import {
CopyService,
DialogService,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import {
TuiButton,
@@ -16,37 +22,71 @@ import {
} from '@taiga-ui/core'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
import { InterfaceAddressItemComponent } from './item.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { GatewayAddress, MappedServiceInterface } from '../interface.service'
import { DomainHealthService } from './domain-health.service'
@Component({
selector: 'td[actions]',
template: `
<div class="desktop">
@if (interface.address().masked) {
<button
tuiIconButton
appearance="flat-grayscale"
[iconStart]="
interface.currentlyMasked() ? '@tui.eye' : '@tui.eye-off'
"
(click)="interface.currentlyMasked.set(!interface.currentlyMasked())"
>
{{ 'Reveal/Hide' | i18n }}
</button>
}
@if (interface.address().ui) {
@if (address().ui) {
<a
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.external-link"
target="_blank"
rel="noopener noreferrer"
[href]="href()"
rel="noreferrer"
[attr.href]="address().enabled ? address().url : null"
[class.disabled]="!address().enabled"
>
{{ 'Open' | i18n }}
{{ 'Open UI' | i18n }}
</a>
}
@if (address().deletable) {
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.trash"
(click)="deleteDomain()"
>
{{ 'Delete' | i18n }}
</button>
}
@if (address().hostnameInfo.metadata.kind === 'public-domain') {
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.settings"
(click)="showDnsValidation()"
>
{{ 'Address Requirements' | i18n }}
</button>
}
@if (address().hostnameInfo.metadata.kind === 'private-domain') {
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.settings"
(click)="showPrivateDnsValidation()"
>
{{ 'Address Requirements' | i18n }}
</button>
}
@if (
address().hostnameInfo.metadata.kind === 'ipv4' &&
address().access === 'public' &&
address().hostnameInfo.port !== null
) {
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.settings"
(click)="showPortForwardValidation()"
>
{{ 'Address Requirements' | i18n }}
</button>
}
<button
tuiIconButton
appearance="flat-grayscale"
@@ -59,7 +99,7 @@ import { InterfaceAddressItemComponent } from './item.component'
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.copy"
(click)="copyService.copy(href())"
(click)="copyService.copy(address().url)"
>
{{ 'Copy URL' | i18n }}
</button>
@@ -75,30 +115,29 @@ import { InterfaceAddressItemComponent } from './item.component'
>
{{ 'Actions' | i18n }}
<tui-data-list *tuiTextfieldDropdown (click)="open.set(false)">
@if (interface.address().ui) {
@if (address().ui) {
<a
tuiOption
new
iconStart="@tui.external-link"
target="_blank"
rel="noopener noreferrer"
[href]="href()"
rel="noreferrer"
[attr.href]="address().enabled ? address().url : null"
[class.disabled]="!address().enabled"
>
{{ 'Open' | i18n }}
{{ 'Open UI' | i18n }}
</a>
}
@if (interface.address().masked) {
<button
tuiOption
new
iconStart="@tui.eye"
(click)="
interface.currentlyMasked.set(!interface.currentlyMasked())
"
>
{{ 'Reveal/Hide' | i18n }}
</button>
}
<button
tuiOption
new
[iconStart]="
address().enabled ? '@tui.toggle-right' : '@tui.toggle-left'
"
(click)="toggleEnabled()"
>
{{ (address().enabled ? 'Disable' : 'Enable') | i18n }}
</button>
<button tuiOption new iconStart="@tui.qr-code" (click)="showQR()">
{{ 'Show QR' | i18n }}
</button>
@@ -106,10 +145,53 @@ import { InterfaceAddressItemComponent } from './item.component'
tuiOption
new
iconStart="@tui.copy"
(click)="copyService.copy(href())"
(click)="copyService.copy(address().url)"
>
{{ 'Copy URL' | i18n }}
</button>
@if (address().hostnameInfo.metadata.kind === 'public-domain') {
<button
tuiOption
new
iconStart="@tui.settings"
(click)="showDnsValidation()"
>
{{ 'Address Requirements' | i18n }}
</button>
}
@if (address().hostnameInfo.metadata.kind === 'private-domain') {
<button
tuiOption
new
iconStart="@tui.settings"
(click)="showPrivateDnsValidation()"
>
{{ 'Address Requirements' | i18n }}
</button>
}
@if (
address().hostnameInfo.metadata.kind === 'ipv4' &&
address().hostnameInfo.port !== null
) {
<button
tuiOption
new
iconStart="@tui.settings"
(click)="showPortForwardValidation()"
>
{{ 'Address Requirements' | i18n }}
</button>
}
@if (address().deletable) {
<button
tuiOption
new
iconStart="@tui.trash"
(click)="deleteDomain()"
>
{{ 'Delete' | i18n }}
</button>
}
</tui-data-list>
</button>
</div>
@@ -123,6 +205,11 @@ import { InterfaceAddressItemComponent } from './item.component'
white-space: nowrap;
}
.disabled {
pointer-events: none;
opacity: var(--tui-disabled-opacity);
}
.mobile {
display: none;
}
@@ -136,40 +223,130 @@ import { InterfaceAddressItemComponent } from './item.component'
display: block;
}
}
:host-context(tbody.uncommon-hidden) {
.desktop {
height: 0;
visibility: hidden;
}
.mobile {
display: none;
}
}
`,
imports: [TuiButton, TuiDropdown, TuiDataList, i18nPipe, TuiTextfield],
providers: [tuiButtonOptionsProvider({ appearance: 'icon' })],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddressActionsComponent {
readonly isMobile = inject(TUI_IS_MOBILE)
readonly dialog = inject(DialogService)
private readonly isMobile = inject(TUI_IS_MOBILE)
private readonly dialog = inject(DialogService)
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly domainHealth = inject(DomainHealthService)
readonly copyService = inject(CopyService)
readonly interface = inject(InterfaceAddressItemComponent)
readonly open = signal(false)
readonly href = input.required<string>()
readonly bullets = input.required<string[]>()
readonly address = input.required<GatewayAddress>()
readonly packageId = input('')
readonly value = input<MappedServiceInterface | undefined>()
readonly disabled = input.required<boolean>()
readonly gatewayId = input('')
showQR() {
this.dialog
.openComponent(new PolymorpheusComponent(QRModal), {
size: 'auto',
closeable: this.isMobile,
data: this.href(),
data: this.address().url,
})
.subscribe()
}
async toggleEnabled() {
const addr = this.address()
const iface = this.value()
if (!iface) return
const enabled = !addr.enabled
const addressJson = JSON.stringify(addr.hostnameInfo)
const loader = this.loader.open('Saving').subscribe()
try {
if (this.packageId()) {
await this.api.pkgBindingSetAddressEnabled({
internalPort: iface.addressInfo.internalPort,
address: addressJson,
enabled,
package: this.packageId(),
host: iface.addressInfo.hostId,
})
} else {
await this.api.serverBindingSetAddressEnabled({
internalPort: 80,
address: addressJson,
enabled,
})
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
showDnsValidation() {
const port = this.address().hostnameInfo.port
if (port === null) return
this.domainHealth.showPublicDomainSetup(
this.address().hostnameInfo.hostname,
this.gatewayId(),
port,
)
}
showPrivateDnsValidation() {
this.domainHealth.showPrivateDomainSetup(this.gatewayId())
}
showPortForwardValidation() {
const port = this.address().hostnameInfo.port
if (port === null) return
this.domainHealth.showPortForwardSetup(this.gatewayId(), port)
}
async deleteDomain() {
const addr = this.address()
const iface = this.value()
if (!iface) return
const confirmed = await this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.toPromise()
if (!confirmed) return
const loader = this.loader.open('Removing').subscribe()
try {
const host = addr.hostnameInfo.hostname
if (addr.hostnameInfo.metadata.kind === 'public-domain') {
if (this.packageId()) {
await this.api.pkgRemovePublicDomain({
fqdn: host,
package: this.packageId(),
host: iface.addressInfo.hostId,
})
} else {
await this.api.osUiRemovePublicDomain({ fqdn: host })
}
} else if (addr.hostnameInfo.metadata.kind === 'private-domain') {
if (this.packageId()) {
await this.api.pkgRemovePrivateDomain({
fqdn: host,
package: this.packageId(),
host: iface.addressInfo.hostId,
})
} else {
await this.api.osUiRemovePrivateDomain({ fqdn: host })
}
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,117 +1,273 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { TuiAccordion } from '@taiga-ui/experimental'
import { TuiElasticContainer, TuiSkeleton } from '@taiga-ui/kit'
import {
ChangeDetectionStrategy,
Component,
inject,
input,
signal,
} from '@angular/core'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { ISB, utils } from '@start9labs/start-sdk'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { firstValueFrom } from 'rxjs'
import {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { MappedServiceInterface } from '../interface.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { toAuthorityName } from 'src/app/utils/acme'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import {
GatewayAddressGroup,
MappedServiceInterface,
} from '../interface.service'
import { DomainHealthService } from './domain-health.service'
import { InterfaceAddressItemComponent } from './item.component'
@Component({
selector: 'section[addresses]',
selector: 'section[gatewayGroup]',
template: `
<header>{{ 'Addresses' | i18n }}</header>
<tui-elastic-container>
<table [appTable]="['Type', 'Access', 'Gateway', 'URL', null]">
<th [style.width.rem]="2"></th>
@for (address of addresses()?.common; track $index) {
<tr [address]="address" [isRunning]="isRunning()"></tr>
} @empty {
@if (addresses()) {
<tr>
<td colspan="6">
<app-placeholder icon="@tui.list-x">
{{ 'No addresses' | i18n }}
</app-placeholder>
</td>
</tr>
} @else {
@for (_ of [0, 1]; track $index) {
<tr>
<td colspan="6">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td>
</tr>
}
}
}
<tbody [class.uncommon-hidden]="!uncommon">
@if (addresses()?.uncommon?.length && uncommon) {
<tr [style.background]="'var(--tui-background-neutral-1)'">
<td colspan="6"></td>
</tr>
}
@for (address of addresses()?.uncommon; track $index) {
<tr [address]="address" [isRunning]="isRunning()"></tr>
}
</tbody>
@if (addresses()?.uncommon?.length) {
<caption [style.caption-side]="'bottom'">
<button
tuiButton
size="m"
appearance="secondary-grayscale"
(click)="uncommon = !uncommon"
>
@if (uncommon) {
Hide uncommon
} @else {
Show uncommon
}
</button>
</caption>
}
</table>
</tui-elastic-container>
<header>
{{ gatewayGroup().gatewayName }}
<button
tuiDropdown
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
[(tuiDropdownOpen)]="addOpen"
>
{{ 'Add Domain' | i18n }}
<tui-data-list *tuiTextfieldDropdown (click)="addOpen.set(false)">
<button tuiOption new (click)="addPublicDomain()">
{{ 'Public Domain' | i18n }}
</button>
<button tuiOption new (click)="addPrivateDomain()">
{{ 'Private Domain' | i18n }}
</button>
</tui-data-list>
</button>
</header>
<table
[appTable]="['Enabled', 'Type', 'Certificate Authority', 'URL', null]"
>
@for (address of gatewayGroup().addresses; track $index) {
<tr
[address]="address"
[packageId]="packageId()"
[value]="value()"
[isRunning]="isRunning()"
[gatewayId]="gatewayGroup().gatewayId"
></tr>
} @empty {
<tr>
<td colspan="5">
<app-placeholder icon="@tui.list-x">
{{ 'No addresses' | i18n }}
</app-placeholder>
</td>
</tr>
}
</table>
`,
styles: `
:host ::ng-deep {
th:nth-child(2) {
th:first-child {
width: 5rem;
}
th:nth-child(3) {
width: 4rem;
}
}
.g-table:has(caption) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
[tuiButton] {
width: 100%;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
:host-context(tui-root._mobile) {
[tuiButton] {
border-radius: var(--tui-radius-xs);
margin-block-end: 0.75rem;
}
}
`,
host: { class: 'g-card' },
imports: [
TuiSkeleton,
TuiButton,
TuiDropdown,
TuiDataList,
TuiTextfield,
TableComponent,
PlaceholderComponent,
i18nPipe,
InterfaceAddressItemComponent,
TuiElasticContainer,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceAddressesComponent {
readonly addresses = input.required<
MappedServiceInterface['addresses'] | undefined
>()
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly i18n = inject(i18nPipe)
private readonly domainHealth = inject(DomainHealthService)
readonly gatewayGroup = input.required<GatewayAddressGroup>()
readonly packageId = input('')
readonly value = input<MappedServiceInterface | undefined>()
readonly isRunning = input.required<boolean>()
uncommon = false
readonly addOpen = signal(false)
async addPrivateDomain() {
this.formDialog.open<FormContext<{ fqdn: string }>>(FormComponent, {
label: 'New private domain',
size: 's',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: this.i18n.transform('Domain'),
description: this.i18n.transform(
'Enter a fully qualified domain name. Since the domain is for private use, it can be any domain you want, even one you do not control.',
),
required: true,
default: null,
patterns: [utils.Patterns.domain],
}),
}),
),
buttons: [
{
text: this.i18n.transform('Save')!,
handler: async (value: { fqdn: string }) =>
this.savePrivateDomain(value.fqdn),
},
],
},
})
}
async addPublicDomain() {
const iface = this.value()
if (!iface) return
const network = await firstValueFrom(
this.patch.watch$('serverInfo', 'network'),
)
const authorities = Object.keys(network.acme).reduce<
Record<string, string>
>(
(obj, url) => ({
...obj,
[url]: toAuthorityName(url),
}),
{ local: toAuthorityName(null) },
)
const addSpec = ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: this.i18n.transform('Domain'),
description: this.i18n.transform(
'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com.',
),
required: true,
default: null,
patterns: [utils.Patterns.domain],
}).map(f => f.toLocaleLowerCase()),
...(iface.addSsl
? {
authority: ISB.Value.select({
name: this.i18n.transform('Certificate Authority'),
description: this.i18n.transform(
'Select a Certificate Authority to issue SSL/TLS certificates for this domain',
),
values: authorities,
default: Object.keys(network.acme)[0] || 'local',
}),
}
: {}),
})
this.formDialog.open(FormComponent, {
label: 'Add public domain',
size: 's',
data: {
spec: await configBuilderToSpec(addSpec),
buttons: [
{
text: this.i18n.transform('Save')!,
handler: (input: typeof addSpec._TYPE) =>
this.savePublicDomain(input.fqdn, input.authority),
},
],
},
})
}
private async savePrivateDomain(fqdn: string): Promise<boolean> {
const iface = this.value()
const gatewayId = this.gatewayGroup().gatewayId
const loader = this.loader.open('Saving').subscribe()
try {
if (this.packageId()) {
await this.api.pkgAddPrivateDomain({
fqdn,
gateway: gatewayId,
package: this.packageId(),
host: iface?.addressInfo.hostId || '',
})
} else {
await this.api.osUiAddPrivateDomain({ fqdn, gateway: gatewayId })
}
await this.domainHealth.checkPrivateDomain(gatewayId)
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
private async savePublicDomain(
fqdn: string,
authority?: 'local' | string,
): Promise<boolean> {
const iface = this.value()
const gatewayId = this.gatewayGroup().gatewayId
const loader = this.loader.open('Saving').subscribe()
const params = {
fqdn,
gateway: gatewayId,
acme: !authority || authority === 'local' ? null : authority,
}
try {
if (this.packageId()) {
await this.api.pkgAddPublicDomain({
...params,
package: this.packageId(),
host: iface?.addressInfo.hostId || '',
})
} else {
await this.api.osUiAddPublicDomain(params)
}
const port = this.gatewayGroup().addresses.find(
a => a.access === 'public' && a.hostnameInfo.port !== null,
)?.hostnameInfo.port
if (port !== undefined && port !== null) {
await this.domainHealth.checkPublicDomain(fqdn, gatewayId, port)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,294 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ErrorService, i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiDialogContext, TuiIcon, TuiLoader } from '@taiga-ui/core'
import {
TuiButtonLoading,
TuiSwitch,
tuiSwitchOptionsProvider,
} from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PortCheckIconComponent } from 'src/app/routes/portal/components/port-check-icon.component'
import { PortCheckWarningsComponent } from 'src/app/routes/portal/components/port-check-warnings.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { T } from '@start9labs/start-sdk'
import { parse } from 'tldts'
export type DnsGateway = T.NetworkInterfaceInfo & {
id: string
ipInfo: T.IpInfo
}
export type DomainValidationData = {
fqdn: string
gateway: DnsGateway
port: number
initialResults?: { dnsPass: boolean; portResult: T.CheckPortRes | null }
}
@Component({
selector: 'domain-validation',
template: `
@let wanIp = context.data.gateway.ipInfo.wanIp || ('Error' | i18n);
@let gatewayName =
context.data.gateway.name || context.data.gateway.ipInfo.name;
<h2>{{ 'DNS' | i18n }}</h2>
<p>
{{ 'In your domain registrar for' | i18n }} {{ domain }},
{{ 'create this DNS record' | i18n }}
</p>
@if (context.data.gateway.ipInfo.deviceType !== 'wireguard') {
<label>
IP
<input
type="checkbox"
appearance="flat"
tuiSwitch
[(ngModel)]="ddns"
(ngModelChange)="dnsPass.set(undefined)"
/>
{{ 'Dynamic DNS' | i18n }}
</label>
}
<table [appTable]="[null, 'Type', 'Host', 'Value', null]">
<tr>
<td class="status">
@if (dnsLoading()) {
<tui-loader size="s" />
} @else if (dnsPass() === true) {
<tui-icon class="g-positive" icon="@tui.check" />
} @else if (dnsPass() === false) {
<tui-icon class="g-negative" icon="@tui.x" />
} @else {
<tui-icon class="g-secondary" icon="@tui.minus" />
}
</td>
<td>{{ ddns ? 'ALIAS' : 'A' }}</td>
<td>*</td>
<td>{{ ddns ? '[DDNS Address]' : wanIp }}</td>
<td>
<button
tuiButton
size="s"
[loading]="dnsLoading()"
(click)="testDns()"
>
{{ 'Test' | i18n }}
</button>
</td>
</tr>
</table>
<h2>{{ 'Port Forwarding' | i18n }}</h2>
<p>
{{ 'In your gateway' | i18n }} "{{ gatewayName }}",
{{ 'create this port forwarding rule' | i18n }}
</p>
@let portRes = portResult();
<table [appTable]="[null, 'External Port', 'Internal Port', null]">
<tr>
<td class="status">
<port-check-icon [result]="portRes" [loading]="portLoading()" />
</td>
<td>{{ context.data.port }}</td>
<td>{{ context.data.port }}</td>
<td>
<button
tuiButton
size="s"
[loading]="portLoading()"
(click)="testPort()"
>
{{ 'Test' | i18n }}
</button>
</td>
</tr>
</table>
<port-check-warnings [result]="portRes" />
@if (!isManualMode) {
<footer class="g-buttons padding-top">
<button
tuiButton
appearance="flat"
[disabled]="allPass()"
(click)="context.completeWith()"
>
{{ 'Later' | i18n }}
</button>
<button
tuiButton
[disabled]="!allPass()"
(click)="context.completeWith()"
>
{{ 'Done' | i18n }}
</button>
</footer>
}
`,
styles: `
label {
display: flex;
gap: 0.75rem;
align-items: center;
margin: 1rem 0;
}
h2 {
margin: 2rem 0 0 0;
}
p {
margin-top: 0.5rem;
}
tui-icon {
font-size: 1.3rem;
vertical-align: text-bottom;
}
.status {
width: 3.2rem;
}
.padding-top {
padding-top: 2rem;
}
td:last-child {
text-align: end;
}
footer {
margin-top: 1.5rem;
}
:host-context(tui-root._mobile) table {
thead {
display: table-header-group !important;
}
tr {
display: table-row !important;
box-shadow: none !important;
}
td,
th {
padding: 0.5rem 0.5rem !important;
font: var(--tui-font-text-s) !important;
color: var(--tui-text-primary) !important;
font-weight: normal !important;
}
th {
font-weight: bold !important;
}
}
`,
providers: [
tuiSwitchOptionsProvider({
appearance: () => 'glass',
icon: () => '',
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,
i18nPipe,
TableComponent,
TuiSwitch,
FormsModule,
TuiButtonLoading,
TuiIcon,
TuiLoader,
PortCheckIconComponent,
PortCheckWarningsComponent,
],
})
export class DomainValidationComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly ddns = false
readonly context =
injectContext<TuiDialogContext<void, DomainValidationData>>()
readonly domain =
parse(this.context.data.fqdn).domain || this.context.data.fqdn
readonly dnsLoading = signal(false)
readonly portLoading = signal(false)
readonly dnsPass = signal<boolean | undefined>(undefined)
readonly portResult = signal<T.CheckPortRes | undefined>(undefined)
readonly allPass = computed(() => {
const result = this.portResult()
return (
this.dnsPass() === true &&
!!result?.openInternally &&
!!result?.openExternally
)
})
readonly isManualMode = !this.context.data.initialResults
constructor() {
const initial = this.context.data.initialResults
if (initial) {
this.dnsPass.set(initial.dnsPass)
if (initial.portResult) this.portResult.set(initial.portResult)
}
}
async testDns() {
this.dnsLoading.set(true)
try {
const ip = await this.api.queryDns({
fqdn: this.context.data.fqdn,
})
this.dnsPass.set(ip === this.context.data.gateway.ipInfo.wanIp)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.dnsLoading.set(false)
}
}
async testPort() {
this.portLoading.set(true)
try {
const result = await this.api.checkPort({
gateway: this.context.data.gateway.id,
port: this.context.data.port,
})
this.portResult.set(result)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.portLoading.set(false)
}
}
}
export const DOMAIN_VALIDATION = new PolymorpheusComponent(
DomainValidationComponent,
)

View File

@@ -0,0 +1,190 @@
import { inject, Injectable } from '@angular/core'
import { DialogService, ErrorService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { PatchDB } from 'patch-db-client'
import { firstValueFrom } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { DOMAIN_VALIDATION, DnsGateway } from './dns.component'
import { PORT_FORWARD_VALIDATION } from './port-forward.component'
import { PRIVATE_DNS_VALIDATION } from './private-dns.component'
@Injectable({ providedIn: 'root' })
export class DomainHealthService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly dialog = inject(DialogService)
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
async checkPublicDomain(
fqdn: string,
gatewayId: string,
port: number,
): Promise<void> {
try {
const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return
const [dnsPass, portResult] = await Promise.all([
this.api
.queryDns({ fqdn })
.then(ip => ip === gateway.ipInfo.wanIp)
.catch(() => false),
this.api
.checkPort({ gateway: gatewayId, port })
.catch((): null => null),
])
const portOk =
!!portResult?.openInternally &&
!!portResult?.openExternally &&
!!portResult?.hairpinning
if (!dnsPass || !portOk) {
setTimeout(
() =>
this.openPublicDomainModal(fqdn, gateway, port, {
dnsPass,
portResult,
}),
250,
)
}
} catch (e: any) {
this.errorService.handleError(e)
}
}
async checkPrivateDomain(gatewayId: string): Promise<void> {
try {
const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return
const configured = await this.api
.checkDns({ gateway: gatewayId })
.catch(() => false)
if (!configured) {
setTimeout(
() => this.openPrivateDomainModal(gateway, { configured }),
250,
)
}
} catch (e: any) {
this.errorService.handleError(e)
}
}
async showPublicDomainSetup(
fqdn: string,
gatewayId: string,
port: number,
): Promise<void> {
try {
const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return
this.openPublicDomainModal(fqdn, gateway, port)
} catch (e: any) {
this.errorService.handleError(e)
}
}
async checkPortForward(gatewayId: string, port: number): Promise<void> {
try {
const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return
const portResult = await this.api
.checkPort({ gateway: gatewayId, port })
.catch((): null => null)
const portOk =
!!portResult?.openInternally &&
!!portResult?.openExternally &&
!!portResult?.hairpinning
if (!portOk) {
setTimeout(
() => this.openPortForwardModal(gateway, port, { portResult }),
250,
)
}
} catch (e: any) {
this.errorService.handleError(e)
}
}
async showPortForwardSetup(gatewayId: string, port: number): Promise<void> {
try {
const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return
this.openPortForwardModal(gateway, port)
} catch (e: any) {
this.errorService.handleError(e)
}
}
async showPrivateDomainSetup(gatewayId: string): Promise<void> {
try {
const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return
this.openPrivateDomainModal(gateway)
} catch (e: any) {
this.errorService.handleError(e)
}
}
private async getGatewayData(gatewayId: string): Promise<DnsGateway | null> {
const network = await firstValueFrom(
this.patch.watch$('serverInfo', 'network'),
)
const gateway = network.gateways[gatewayId]
if (!gateway?.ipInfo) return null
return { id: gatewayId, ...gateway, ipInfo: gateway.ipInfo }
}
private openPublicDomainModal(
fqdn: string,
gateway: DnsGateway,
port: number,
initialResults?: { dnsPass: boolean; portResult: T.CheckPortRes | null },
) {
this.dialog
.openComponent(DOMAIN_VALIDATION, {
label: 'Address Requirements',
size: 'm',
data: { fqdn, gateway, port, initialResults },
})
.subscribe()
}
private openPortForwardModal(
gateway: DnsGateway,
port: number,
initialResults?: { portResult: T.CheckPortRes | null },
) {
this.dialog
.openComponent(PORT_FORWARD_VALIDATION, {
label: 'Address Requirements',
size: 'm',
data: { gateway, port, initialResults },
})
.subscribe()
}
private openPrivateDomainModal(
gateway: DnsGateway,
initialResults?: { configured: boolean },
) {
this.dialog
.openComponent(PRIVATE_DNS_VALIDATION, {
label: 'Address Requirements',
size: 'm',
data: { gateway, initialResults },
})
.subscribe()
}
}

View File

@@ -6,121 +6,127 @@ import {
input,
signal,
} from '@angular/core'
import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { TuiObfuscatePipe } from '@taiga-ui/cdk'
import { TuiButton, TuiIcon } from '@taiga-ui/core'
import { TuiBadge } from '@taiga-ui/kit'
import { DisplayAddress } from '../interface.service'
import { FormsModule } from '@angular/forms'
import { TuiSwitch } from '@taiga-ui/kit'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { GatewayAddress, MappedServiceInterface } from '../interface.service'
import { AddressActionsComponent } from './actions.component'
import { DomainHealthService } from './domain-health.service'
@Component({
selector: 'tr[address]',
host: {
'[class._disabled]': '!address().enabled',
},
template: `
@if (address(); as address) {
<td [style.padding-inline-end]="0">
<div class="wrapper">
<button
tuiIconButton
appearance="flat-grayscale"
(click)="viewDetails()"
<td>
<input
type="checkbox"
tuiSwitch
size="s"
[showIcons]="false"
[disabled]="
toggling() || address.hostnameInfo.metadata.kind === 'mdns'
"
[ngModel]="address.enabled"
(ngModelChange)="onToggleEnabled()"
/>
</td>
<td class="type">
<tui-icon
[icon]="address.access === 'public' ? '@tui.globe' : '@tui.house'"
/>
{{ address.type }}
</td>
<td>
{{ address.certificate }}
</td>
<td>
<div class="url">
<span
[title]="address.masked && currentlyMasked() ? '' : address.url"
>
{{ 'Address details' | i18n }}
<tui-icon
class="info"
icon="@tui.info"
background="@tui.info-filled"
/>
</button>
</div>
</td>
<td>
<div class="wrapper">{{ address.type }}</div>
</td>
<td>
<div class="wrapper">
@if (address.access === 'public') {
<tui-badge size="s" appearance="primary-success">
{{ 'public' | i18n }}
</tui-badge>
} @else if (address.access === 'private') {
<tui-badge size="s" appearance="primary-destructive">
{{ 'private' | i18n }}
</tui-badge>
} @else {
-
{{ address.url | tuiObfuscate: recipe() }}
</span>
@if (address.masked) {
<button
tuiIconButton
appearance="flat-grayscale"
size="xs"
[iconStart]="currentlyMasked() ? '@tui.eye' : '@tui.eye-off'"
(click)="currentlyMasked.set(!currentlyMasked())"
>
{{ (currentlyMasked() ? 'Reveal' : 'Hide') | i18n }}
</button>
}
</div>
</td>
<td [style.grid-area]="'1 / 1 / 1 / 3'">
<div class="wrapper" [title]="address.gatewayName">
{{ address.gatewayName || '-' }}
</div>
</td>
<td [style.grid-area]="'3 / 1 / 3 / 3'">
<div
class="wrapper"
[title]="address.masked && currentlyMasked() ? '' : address.url"
>
{{ address.url | tuiObfuscate: recipe() }}
</div>
</td>
<td
actions
[address]="address"
[packageId]="packageId()"
[value]="value()"
[disabled]="!isRunning()"
[href]="address.url"
[bullets]="address.bullets"
[gatewayId]="gatewayId()"
[style.width.rem]="5"
></td>
}
`,
styles: `
:host {
white-space: nowrap;
grid-template-columns: fit-content(10rem) 1fr 2rem 2rem;
td:last-child {
padding-inline-start: 0;
}
}
.info {
background: var(--tui-status-info);
&::after {
mask-size: 1.5rem;
}
.type tui-icon {
font-size: 1.3rem;
margin-right: 0.7rem;
vertical-align: middle;
}
:host-context(.uncommon-hidden) {
.wrapper {
height: 0;
visibility: hidden;
}
.url {
display: flex;
align-items: center;
gap: 0.25rem;
td,
& {
padding-block: 0 !important;
border: hidden;
span {
white-space: normal;
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
}
div {
white-space: normal;
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
:host-context(tui-root._mobile) {
padding-inline-start: 0.75rem !important;
&::before {
content: '';
position: absolute;
inset-inline-start: 0;
top: 0.25rem;
bottom: 0.25rem;
width: 4px;
background: var(--tui-status-positive);
border-radius: 2px;
}
&._disabled::before {
background: var(--tui-background-neutral-1-hover);
}
td {
width: auto !important;
align-content: center;
}
td:first-child {
grid-area: 1 / 3 / 4 / 3;
display: none;
}
td:nth-child(2) {
@@ -129,37 +135,104 @@ import { AddressActionsComponent } from './actions.component'
color: var(--tui-text-primary);
padding-inline-end: 0.5rem;
}
td:nth-child(3) {
grid-area: 2 / 1 / 2 / 3;
}
td:nth-child(4) {
grid-area: 3 / 1 / 3 / 3;
}
td:last-child {
grid-area: 1 / 3 / 4 / 5;
align-self: center;
justify-self: end;
}
}
`,
imports: [
i18nPipe,
AddressActionsComponent,
TuiBadge,
TuiObfuscatePipe,
TuiButton,
TuiIcon,
TuiObfuscatePipe,
TuiSwitch,
FormsModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceAddressItemComponent {
private readonly dialogs = inject(DialogService)
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
private readonly loader = inject(LoadingService)
private readonly domainHealth = inject(DomainHealthService)
readonly address = input.required<DisplayAddress>()
readonly address = input.required<GatewayAddress>()
readonly packageId = input('')
readonly value = input<MappedServiceInterface | undefined>()
readonly isRunning = input.required<boolean>()
readonly gatewayId = input('')
readonly toggling = signal(false)
readonly currentlyMasked = signal(true)
readonly recipe = computed(() =>
this.address()?.masked && this.currentlyMasked() ? 'mask' : 'none',
)
viewDetails() {
this.dialogs
.openAlert(
`<ul>${this.address()
.bullets.map(b => `<li>${b}</li>`)
.join('')}</ul>` as i18nKey,
{ label: 'About this address' as i18nKey },
)
.subscribe()
async onToggleEnabled() {
const addr = this.address()
const iface = this.value()
if (!iface) return
this.toggling.set(true)
const enabled = !addr.enabled
const addressJson = JSON.stringify(addr.hostnameInfo)
const loader = this.loader.open('Saving').subscribe()
try {
if (this.packageId()) {
await this.api.pkgBindingSetAddressEnabled({
internalPort: iface.addressInfo.internalPort,
address: addressJson,
enabled,
package: this.packageId(),
host: iface.addressInfo.hostId,
})
} else {
await this.api.serverBindingSetAddressEnabled({
internalPort: 80,
address: addressJson,
enabled,
})
}
if (enabled) {
const kind = addr.hostnameInfo.metadata.kind
if (kind === 'public-domain' && addr.hostnameInfo.port !== null) {
await this.domainHealth.checkPublicDomain(
addr.hostnameInfo.hostname,
this.gatewayId(),
addr.hostnameInfo.port,
)
} else if (kind === 'private-domain') {
await this.domainHealth.checkPrivateDomain(this.gatewayId())
} else if (
kind === 'ipv4' &&
addr.access === 'public' &&
addr.hostnameInfo.port !== null
) {
await this.domainHealth.checkPortForward(
this.gatewayId(),
addr.hostnameInfo.port,
)
}
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
this.toggling.set(false)
}
}
}

View File

@@ -0,0 +1,361 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
signal,
} from '@angular/core'
import { CopyService, DialogService, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
import { ActionService } from 'src/app/services/action.service'
import {
MappedServiceInterface,
PluginAddress,
PluginAddressGroup,
} from '../interface.service'
@Component({
selector: 'section[pluginGroup]',
template: `
<header>
@if (pluginGroup().pluginPkgInfo; as pkgInfo) {
<img [src]="pkgInfo.icon" alt="" class="plugin-icon" />
}
{{ pluginGroup().pluginName }}
@if (pluginGroup().tableAction; as action) {
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="runTableAction()"
>
{{ action.metadata.name }}
</button>
}
</header>
<table [appTable]="['Protocol', 'URL', null]">
@for (address of pluginGroup().addresses; track $index; let i = $index) {
<tr>
<td>{{ address.hostnameInfo.ssl ? 'HTTPS' : 'HTTP' }}</td>
<td [style.grid-area]="'2 / 1 / 2 / 2'">
<span class="url">{{ address.url }}</span>
</td>
<td [style.width.rem]="5">
<div class="desktop">
@if (address.hostnameInfo.metadata.kind === 'plugin') {
@if (address.hostnameInfo.metadata.removeAction) {
@if (
pluginGroup().pluginActions[
address.hostnameInfo.metadata.removeAction
];
as meta
) {
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.trash"
(click)="
runRowAction(
address.hostnameInfo.metadata.removeAction,
meta,
address
)
"
>
{{ meta.name }}
</button>
}
}
}
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.qr-code"
(click)="showQR(address.url)"
>
{{ 'Show QR' | i18n }}
</button>
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.copy"
(click)="copyService.copy(address.url)"
>
{{ 'Copy URL' | i18n }}
</button>
@if (address.hostnameInfo.metadata.kind === 'plugin') {
@if (address.hostnameInfo.metadata.overflowActions.length) {
<button
tuiIconButton
tuiDropdown
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[tuiAppearanceState]="overflowOpen() === i ? 'hover' : null"
[tuiDropdownOpen]="overflowOpen() === i"
(tuiDropdownOpenChange)="
overflowOpen.set($event ? i : null)
"
>
{{ 'More' | i18n }}
<tui-data-list
*tuiTextfieldDropdown
(click)="overflowOpen.set(null)"
>
@for (
actionId of address.hostnameInfo.metadata
.overflowActions;
track actionId
) {
@if (pluginGroup().pluginActions[actionId]; as meta) {
<button
tuiOption
new
(click)="runRowAction(actionId, meta, address)"
>
{{ meta.name }}
</button>
}
}
</tui-data-list>
</button>
}
}
</div>
<div class="mobile">
<button
tuiDropdown
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[tuiAppearanceState]="open() ? 'hover' : null"
[(tuiDropdownOpen)]="open"
>
{{ 'Actions' | i18n }}
<tui-data-list *tuiTextfieldDropdown (click)="open.set(false)">
@if (address.hostnameInfo.metadata.kind === 'plugin') {
@if (address.hostnameInfo.metadata.removeAction) {
@if (
pluginGroup().pluginActions[
address.hostnameInfo.metadata.removeAction
];
as meta
) {
<button
tuiOption
new
iconStart="@tui.trash"
(click)="
runRowAction(
address.hostnameInfo.metadata.removeAction,
meta,
address
)
"
>
{{ meta.name }}
</button>
}
}
}
<button
tuiOption
new
iconStart="@tui.qr-code"
(click)="showQR(address.url)"
>
{{ 'Show QR' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.copy"
(click)="copyService.copy(address.url)"
>
{{ 'Copy URL' | i18n }}
</button>
@if (address.hostnameInfo.metadata.kind === 'plugin') {
@for (
actionId of address.hostnameInfo.metadata.overflowActions;
track actionId
) {
@if (pluginGroup().pluginActions[actionId]; as meta) {
<button
tuiOption
new
(click)="runRowAction(actionId, meta, address)"
>
{{ meta.name }}
</button>
}
}
}
</tui-data-list>
</button>
</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="3">
<app-placeholder icon="@tui.list-x">
{{ 'No addresses' | i18n }}
</app-placeholder>
</td>
</tr>
}
</table>
`,
styles: `
.plugin-icon {
height: 1.25rem;
margin-right: 0.25rem;
border-radius: 100%;
}
:host ::ng-deep {
th:first-child {
width: 5rem;
}
}
.desktop {
display: flex;
white-space: nowrap;
}
.url {
white-space: normal;
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
.mobile {
display: none;
}
:host-context(tui-root._mobile) {
.desktop {
display: none;
}
.mobile {
display: block;
}
tr {
grid-template-columns: 1fr auto;
}
td {
width: auto !important;
align-content: center;
}
}
`,
host: { class: 'g-card' },
imports: [
TuiButton,
TuiDropdown,
TuiDataList,
TuiTextfield,
TableComponent,
PlaceholderComponent,
i18nPipe,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PluginAddressesComponent {
private readonly isMobile = inject(TUI_IS_MOBILE)
private readonly dialog = inject(DialogService)
private readonly actionService = inject(ActionService)
readonly copyService = inject(CopyService)
readonly open = signal(false)
readonly overflowOpen = signal<number | null>(null)
readonly pluginGroup = input.required<PluginAddressGroup>()
readonly packageId = input('')
readonly value = input<MappedServiceInterface | undefined>()
showQR(url: string) {
this.dialog
.openComponent(new PolymorpheusComponent(QRModal), {
size: 'auto',
closeable: this.isMobile,
data: url,
})
.subscribe()
}
runTableAction() {
const group = this.pluginGroup()
if (!group.tableAction || !group.pluginPkgInfo) return
const iface = this.value()
if (!iface) return
const { addressInfo } = iface
this.actionService.present({
pkgInfo: group.pluginPkgInfo,
actionInfo: group.tableAction,
prefill: {
urlPluginMetadata: {
packageId: this.packageId() || null,
hostId: addressInfo.hostId,
interfaceId: iface.id,
internalPort: addressInfo.internalPort,
},
},
})
}
runRowAction(
actionId: string,
metadata: T.ActionMetadata,
address: PluginAddress,
) {
const group = this.pluginGroup()
if (!group.pluginPkgInfo) return
const iface = this.value()
if (!iface) return
const { hostnameInfo } = address
const { addressInfo } = iface
const hostMeta = hostnameInfo.metadata
if (hostMeta.kind !== 'plugin') return
this.actionService.present({
pkgInfo: group.pluginPkgInfo,
actionInfo: { id: actionId, metadata },
prefill: {
urlPluginMetadata: {
packageId: this.packageId() || null,
hostId: addressInfo.hostId,
interfaceId: iface.id,
internalPort: addressInfo.internalPort,
hostname: hostnameInfo.hostname,
port: hostnameInfo.port,
ssl: hostnameInfo.ssl,
public: hostnameInfo.public,
info: hostMeta.info,
},
},
})
}
}

View File

@@ -0,0 +1,187 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core'
import { ErrorService, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiButton, TuiDialogContext } from '@taiga-ui/core'
import { TuiButtonLoading } from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PortCheckIconComponent } from 'src/app/routes/portal/components/port-check-icon.component'
import { PortCheckWarningsComponent } from 'src/app/routes/portal/components/port-check-warnings.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DnsGateway } from './dns.component'
export type PortForwardValidationData = {
gateway: DnsGateway
port: number
initialResults?: { portResult: T.CheckPortRes | null }
}
@Component({
selector: 'port-forward-validation',
template: `
@let gatewayName =
context.data.gateway.name || context.data.gateway.ipInfo.name;
<h2>{{ 'Port Forwarding' | i18n }}</h2>
<p>
{{ 'In your gateway' | i18n }} "{{ gatewayName }}",
{{ 'create this port forwarding rule' | i18n }}
</p>
@let portRes = portResult();
<table [appTable]="[null, 'External Port', 'Internal Port', null]">
<tr>
<td class="status">
<port-check-icon [result]="portRes" [loading]="loading()" />
</td>
<td>{{ context.data.port }}</td>
<td>{{ context.data.port }}</td>
<td>
<button tuiButton size="s" [loading]="loading()" (click)="testPort()">
{{ 'Test' | i18n }}
</button>
</td>
</tr>
</table>
<port-check-warnings [result]="portRes" />
@if (!isManualMode) {
<footer class="g-buttons padding-top">
<button
tuiButton
appearance="flat"
[disabled]="portOk()"
(click)="context.completeWith()"
>
{{ 'Later' | i18n }}
</button>
<button
tuiButton
[disabled]="!portOk()"
(click)="context.completeWith()"
>
{{ 'Done' | i18n }}
</button>
</footer>
}
`,
styles: `
h2 {
margin: 2rem 0 0 0;
}
p {
margin-top: 0.5rem;
}
tui-icon {
font-size: 1.3rem;
vertical-align: text-bottom;
}
.status {
width: 3.2rem;
}
.padding-top {
padding-top: 2rem;
}
td:last-child {
text-align: end;
}
footer {
margin-top: 1.5rem;
}
:host-context(tui-root._mobile) table {
thead {
display: table-header-group !important;
}
tr {
display: table-row !important;
box-shadow: none !important;
}
td,
th {
padding: 0.5rem 0.5rem !important;
font: var(--tui-font-text-s) !important;
color: var(--tui-text-primary) !important;
font-weight: normal !important;
}
th {
font-weight: bold !important;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,
i18nPipe,
TableComponent,
TuiButtonLoading,
PortCheckIconComponent,
PortCheckWarningsComponent,
],
})
export class PortForwardValidationComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly context =
injectContext<TuiDialogContext<void, PortForwardValidationData>>()
readonly loading = signal(false)
readonly portResult = signal<T.CheckPortRes | undefined>(undefined)
readonly portOk = computed(() => {
const result = this.portResult()
return (
!!result?.openInternally &&
!!result?.openExternally &&
!!result?.hairpinning
)
})
readonly isManualMode = !this.context.data.initialResults
constructor() {
const initial = this.context.data.initialResults
if (initial) {
if (initial.portResult) this.portResult.set(initial.portResult)
}
}
async testPort() {
this.loading.set(true)
try {
const result = await this.api.checkPort({
gateway: this.context.data.gateway.id,
port: this.context.data.port,
})
this.portResult.set(result)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading.set(false)
}
}
}
export const PORT_FORWARD_VALIDATION = new PolymorpheusComponent(
PortForwardValidationComponent,
)

View File

@@ -0,0 +1,180 @@
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core'
import { ErrorService, i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiDialogContext, TuiIcon, TuiLoader } from '@taiga-ui/core'
import { TuiButtonLoading } from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DnsGateway } from './dns.component'
export type PrivateDnsValidationData = {
gateway: DnsGateway
initialResults?: { configured: boolean }
}
@Component({
selector: 'private-dns-validation',
template: `
@let gatewayName =
context.data.gateway.name || context.data.gateway.ipInfo.name;
@let internalIp = context.data.gateway.ipInfo.lanIp[0] || ('Error' | i18n);
<h2>{{ 'DNS Server Config' | i18n }}</h2>
<p>
{{ 'Gateway' | i18n }} "{{ gatewayName }}"
{{ 'must be configured to use' | i18n }}
{{ internalIp }}
({{ 'the LAN IP address of this server' | i18n }})
{{ 'as its DNS server' | i18n }}.
</p>
<table [appTable]="[null, 'Gateway', 'DNS Server', null]">
<tr>
<td class="status">
@if (loading()) {
<tui-loader size="s" />
} @else if (pass() === true) {
<tui-icon class="g-positive" icon="@tui.check" />
} @else if (pass() === false) {
<tui-icon class="g-negative" icon="@tui.x" />
} @else {
<tui-icon class="g-secondary" icon="@tui.minus" />
}
</td>
<td>{{ gatewayName }}</td>
<td>{{ internalIp }}</td>
<td>
<button tuiButton size="s" [loading]="loading()" (click)="testDns()">
{{ 'Test' | i18n }}
</button>
</td>
</tr>
</table>
@if (!isManualMode) {
<footer class="g-buttons padding-top">
<button
tuiButton
appearance="flat"
[disabled]="pass() === true"
(click)="context.completeWith()"
>
{{ 'Later' | i18n }}
</button>
<button
tuiButton
[disabled]="pass() !== true"
(click)="context.completeWith()"
>
{{ 'Done' | i18n }}
</button>
</footer>
}
`,
styles: `
h2 {
margin: 2rem 0 0 0;
}
p {
margin-top: 0.5rem;
}
tui-icon {
font-size: 1rem;
vertical-align: text-bottom;
}
.status {
width: 3.2rem;
}
.padding-top {
padding-top: 2rem;
}
td:last-child {
text-align: end;
}
footer {
margin-top: 1.5rem;
}
:host-context(tui-root._mobile) table {
thead {
display: table-header-group !important;
}
tr {
display: table-row !important;
box-shadow: none !important;
}
td,
th {
padding: 0.5rem 0.5rem !important;
font: var(--tui-font-text-s) !important;
color: var(--tui-text-primary) !important;
font-weight: normal !important;
}
th {
font-weight: bold !important;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,
i18nPipe,
TableComponent,
TuiButtonLoading,
TuiIcon,
TuiLoader,
],
})
export class PrivateDnsValidationComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly context =
injectContext<TuiDialogContext<void, PrivateDnsValidationData>>()
readonly loading = signal(false)
readonly pass = signal<boolean | undefined>(undefined)
readonly isManualMode = !this.context.data.initialResults
constructor() {
const initial = this.context.data.initialResults
if (initial) {
this.pass.set(initial.configured)
}
}
async testDns() {
this.loading.set(true)
try {
const result = await this.api.checkDns({
gateway: this.context.data.gateway.id,
})
this.pass.set(result)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading.set(false)
}
}
}
export const PRIVATE_DNS_VALIDATION = new PolymorpheusComponent(
PrivateDnsValidationComponent,
)

View File

@@ -1,114 +0,0 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
input,
inject,
} from '@angular/core'
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiSkeleton, TuiSwitch, TuiTooltip } from '@taiga-ui/kit'
import { FormsModule } from '@angular/forms'
import { i18nPipe, LoadingService, ErrorService } from '@start9labs/shared'
import { TuiCell } from '@taiga-ui/layout'
import { InterfaceGateway } from './interface.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { InterfaceComponent } from './interface.component'
@Component({
selector: 'section[gateways]',
template: `
<header>{{ 'Gateways' | i18n }}</header>
@for (gateway of gateways(); track $index) {
<label tuiCell="s" [style.background]="">
<span tuiTitle [style.opacity]="1">{{ gateway.name }}</span>
@if (!interface.packageId() && !gateway.public) {
<tui-icon
[tuiTooltip]="
'Cannot disable private gateways for StartOS UI' | i18n
"
/>
}
<input
type="checkbox"
tuiSwitch
size="s"
[showIcons]="false"
[ngModel]="gateway.enabled"
(ngModelChange)="onToggle(gateway)"
[disabled]="!interface.packageId() && !gateway.public"
/>
</label>
} @empty {
@for (_ of [0, 1]; track $index) {
<label tuiCell="s">
<span tuiTitle [tuiSkeleton]="true">{{ 'Loading' | i18n }}</span>
</label>
}
}
`,
styles: `
:host {
grid-column: span 3;
}
[tuiCell]:has([tuiTooltip]) {
background: none !important;
}
:host-context(tui-root:not(._mobile)) {
&:has(+ section table) header {
background: transparent;
}
}
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
FormsModule,
TuiSwitch,
i18nPipe,
TuiCell,
TuiTitle,
TuiSkeleton,
TuiIcon,
TuiTooltip,
],
})
export class InterfaceGatewaysComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly interface = inject(InterfaceComponent)
readonly gateways = input.required<InterfaceGateway[] | undefined>()
async onToggle(gateway: InterfaceGateway) {
const addressInfo = this.interface.value()!.addressInfo
const pkgId = this.interface.packageId()
const loader = this.loader.open().subscribe()
try {
if (pkgId) {
await this.api.pkgBindingToggleGateway({
gateway: gateway.id,
enabled: !gateway.enabled,
internalPort: addressInfo.internalPort,
host: addressInfo.hostId,
package: pkgId,
})
} else {
await this.api.serverBindingToggleGateway({
gateway: gateway.id,
enabled: !gateway.enabled,
internalPort: 80,
})
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,26 +1,27 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { tuiButtonOptionsProvider } from '@taiga-ui/core'
import { MappedServiceInterface } from './interface.service'
import { InterfaceGatewaysComponent } from './gateways.component'
import { InterfaceTorDomainsComponent } from './tor-domains.component'
import { PublicDomainsComponent } from './public-domains/pd.component'
import { InterfacePrivateDomainsComponent } from './private-domains.component'
import { InterfaceAddressesComponent } from './addresses/addresses.component'
import { PluginAddressesComponent } from './addresses/plugin.component'
@Component({
selector: 'service-interface',
template: `
<div>
<section [gateways]="value()?.gateways"></section>
@for (group of value()?.gatewayGroups; track group.gatewayId) {
<section
[publicDomains]="value()?.publicDomains"
[addSsl]="value()?.addSsl || false"
[gatewayGroup]="group"
[packageId]="packageId()"
[value]="value()"
[isRunning]="isRunning()"
></section>
<section [torDomains]="value()?.torDomains"></section>
<section [privateDomains]="value()?.privateDomains"></section>
</div>
<hr [style.width.rem]="10" />
<section [addresses]="value()?.addresses" [isRunning]="true"></section>
}
@for (group of value()?.pluginGroups; track group.pluginId) {
<section
[pluginGroup]="group"
[packageId]="packageId()"
[value]="value()"
></section>
}
`,
styles: `
:host {
@@ -30,33 +31,16 @@ import { InterfaceAddressesComponent } from './addresses/addresses.component'
color: var(--tui-text-secondary);
font: var(--tui-font-text-l);
div {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: inherit;
flex-direction: column;
}
::ng-deep [tuiSkeleton] {
width: 100%;
height: 1rem;
border-radius: var(--tui-radius-s);
}
}
:host-context(tui-root._mobile) div {
display: flex;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [tuiButtonOptionsProvider({ size: 'xs' })],
imports: [
InterfaceGatewaysComponent,
InterfaceTorDomainsComponent,
PublicDomainsComponent,
InterfacePrivateDomainsComponent,
InterfaceAddressesComponent,
],
imports: [InterfaceAddressesComponent, PluginAddressesComponent],
})
export class InterfaceComponent {
readonly packageId = input('')

View File

@@ -2,113 +2,91 @@ import { inject, Injectable } from '@angular/core'
import { T, utils } from '@start9labs/start-sdk'
import { ConfigService } from 'src/app/services/config.service'
import { GatewayPlus } from 'src/app/services/gateway.service'
import { PublicDomain } from './public-domains/pd.service'
import { i18nKey, i18nPipe } from '@start9labs/shared'
import {
PrimaryStatus,
renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service'
import { toAuthorityName } from 'src/app/utils/acme'
import { getManifest } from 'src/app/utils/get-package-data'
type AddressWithInfo = {
url: string
info: T.HostnameInfo
gateway?: GatewayPlus
showSsl: boolean
masked: boolean
ui: boolean
function isPublicIp(h: T.HostnameInfo): boolean {
return h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6')
}
function cmpWithRankedPredicates<T extends AddressWithInfo>(
a: T,
b: T,
preds: ((x: T) => boolean)[],
): -1 | 0 | 1 {
for (const pred of preds) {
for (let [x, y, sign] of [[a, b, 1] as const, [b, a, -1] as const]) {
if (pred(y) && !pred(x)) return sign
}
}
return 0
export function isLanIp(h: T.HostnameInfo): boolean {
return !h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6')
}
type TorAddress = AddressWithInfo & { info: { kind: 'onion' } }
function filterTor(a: AddressWithInfo): a is TorAddress {
return a.info.kind === 'onion'
}
function cmpTor(a: TorAddress, b: TorAddress): -1 | 0 | 1 {
return cmpWithRankedPredicates(a, b, [x => !x.showSsl])
}
type LanAddress = AddressWithInfo & { info: { kind: 'ip'; public: false } }
function filterLan(a: AddressWithInfo): a is LanAddress {
return a.info.kind === 'ip' && !a.info.public
}
function cmpLan(host: T.Host, a: LanAddress, b: LanAddress): -1 | 0 | 1 {
return cmpWithRankedPredicates(a, b, [
x =>
x.info.hostname.kind === 'domain' &&
!!host.privateDomains.find(d => d === x.info.hostname.value), // private domain
x => x.info.hostname.kind === 'local', // .local
x => x.info.hostname.kind === 'ipv4', // ipv4
x => x.info.hostname.kind === 'ipv6', // ipv6
// remainder: public domains accessible privately
])
}
type VpnAddress = AddressWithInfo & {
info: {
kind: 'ip'
public: false
hostname: { kind: 'ipv4' | 'ipv6' | 'domain' }
function isEnabled(addr: T.DerivedAddressInfo, h: T.HostnameInfo): boolean {
if (isPublicIp(h)) {
if (h.port === null) return true
const sa =
h.metadata.kind === 'ipv6'
? `[${h.hostname}]:${h.port}`
: `${h.hostname}:${h.port}`
return addr.enabled.includes(sa)
} else {
return !addr.disabled.some(
([hostname, port]) => hostname === h.hostname && port === (h.port ?? 0),
)
}
}
function filterVpn(a: AddressWithInfo): a is VpnAddress {
return (
a.info.kind === 'ip' && !a.info.public && a.info.hostname.kind !== 'local'
)
}
function cmpVpn(host: T.Host, a: VpnAddress, b: VpnAddress): -1 | 0 | 1 {
return cmpWithRankedPredicates(a, b, [
x =>
x.info.hostname.kind === 'domain' &&
!!host.privateDomains.find(d => d === x.info.hostname.value), // private domain
x => x.info.hostname.kind === 'ipv4', // ipv4
x => x.info.hostname.kind === 'ipv6', // ipv6
// remainder: public domains accessible privately
])
}
type ClearnetAddress = AddressWithInfo & {
info: {
kind: 'ip'
public: true
hostname: { kind: 'ipv4' | 'ipv6' | 'domain' }
function getGatewayIds(h: T.HostnameInfo): string[] {
switch (h.metadata.kind) {
case 'ipv4':
case 'ipv6':
case 'public-domain':
return [h.metadata.gateway]
case 'mdns':
case 'private-domain':
return h.metadata.gateways
case 'plugin':
return []
}
}
function filterClearnet(a: AddressWithInfo): a is ClearnetAddress {
return a.info.kind === 'ip' && a.info.public
}
function cmpClearnet(
function getCertificate(
h: T.HostnameInfo,
host: T.Host,
a: ClearnetAddress,
b: ClearnetAddress,
): -1 | 0 | 1 {
return cmpWithRankedPredicates(a, b, [
x =>
x.info.hostname.kind === 'domain' &&
x.info.gateway.id === host.publicDomains[x.info.hostname.value]?.gateway, // public domain for this gateway
x => x.gateway?.public ?? false, // public gateway
x => x.info.hostname.kind === 'ipv4', // ipv4
x => x.info.hostname.kind === 'ipv6', // ipv6
// remainder: private domains / domains public on other gateways
])
addSsl: T.AddSslOptions | null,
secure: T.Security | null,
): string {
if (!h.ssl) return '-'
if (h.metadata.kind === 'public-domain') {
const config = host.publicDomains[h.hostname]
return config ? toAuthorityName(config.acme) : toAuthorityName(null)
}
if (addSsl) return toAuthorityName(null)
if (secure?.ssl) return 'Self signed'
return '-'
}
export function getPublicDomains(
publicDomains: Record<string, T.PublicDomainConfig>,
gateways: GatewayPlus[],
): PublicDomain[] {
return Object.entries(publicDomains).map(([fqdn, info]) => ({
fqdn,
acme: info.acme,
gateway: gateways.find(g => g.id === info.gateway) || null,
}))
function sortDomainsFirst(a: GatewayAddress, b: GatewayAddress): number {
const isDomain = (addr: GatewayAddress) =>
addr.hostnameInfo.metadata.kind === 'public-domain' ||
(addr.hostnameInfo.metadata.kind === 'private-domain' &&
!addr.hostnameInfo.hostname.endsWith('.local'))
return Number(isDomain(b)) - Number(isDomain(a))
}
function getAddressType(h: T.HostnameInfo): string {
switch (h.metadata.kind) {
case 'ipv4':
return 'IPv4'
case 'ipv6':
return 'IPv6'
case 'public-domain':
case 'private-domain':
return h.hostname
case 'mdns':
return 'mDNS'
case 'plugin':
return 'Plugin'
}
}
@Injectable({
@@ -116,95 +94,144 @@ export function getPublicDomains(
})
export class InterfaceService {
private readonly config = inject(ConfigService)
private readonly i18n = inject(i18nPipe)
getAddresses(
getGatewayGroups(
serviceInterface: T.ServiceInterface,
host: T.Host,
gateways: GatewayPlus[],
): MappedServiceInterface['addresses'] {
const hostnamesInfos = this.hostnameInfo(serviceInterface, host)
const addresses = {
common: [],
uncommon: [],
}
if (!hostnamesInfos.length) return addresses
): GatewayAddressGroup[] {
const binding = host.bindings[serviceInterface.addressInfo.internalPort]
if (!binding) return []
const addr = binding.addresses
const masked = serviceInterface.masked
const ui = serviceInterface.type === 'ui'
const { addSsl, secure } = binding.options
const allAddressesWithInfo: AddressWithInfo[] = hostnamesInfos.flatMap(
h => {
const { url, sslUrl } = utils.addressHostToUrl(
serviceInterface.addressInfo,
h,
)
const info = h
const gateway =
h.kind === 'ip'
? gateways.find(g => h.gateway.id === g.id)
: undefined
const res = []
if (url) {
res.push({
url,
info,
gateway,
showSsl: false,
masked,
ui,
})
}
if (sslUrl) {
res.push({
url: sslUrl,
info,
gateway,
showSsl: !!url,
masked,
ui,
})
}
return res
},
)
const groupMap = new Map<string, GatewayAddress[]>()
const gatewayMap = new Map<string, GatewayPlus>()
const torAddrs = allAddressesWithInfo.filter(filterTor).sort(cmpTor)
const lanAddrs = allAddressesWithInfo
.filter(filterLan)
.sort((a, b) => cmpLan(host, a, b))
const vpnAddrs = allAddressesWithInfo
.filter(filterVpn)
.sort((a, b) => cmpVpn(host, a, b))
const clearnetAddrs = allAddressesWithInfo
.filter(filterClearnet)
.sort((a, b) => cmpClearnet(host, a, b))
let bestAddrs = [
(clearnetAddrs[0]?.gateway?.public ||
clearnetAddrs[0]?.info.hostname.kind === 'domain') &&
clearnetAddrs[0],
lanAddrs[0],
vpnAddrs[0],
torAddrs[0],
]
.filter(a => !!a)
.reduce((acc, x) => {
if (!acc.includes(x)) acc.push(x)
return acc
}, [] as AddressWithInfo[])
return {
common: bestAddrs.map(a => this.toDisplayAddress(a, host.publicDomains)),
uncommon: allAddressesWithInfo
.filter(a => !bestAddrs.includes(a))
.map(a => this.toDisplayAddress(a, host.publicDomains)),
for (const gateway of gateways) {
groupMap.set(gateway.id, [])
gatewayMap.set(gateway.id, gateway)
}
for (const h of addr.available) {
const gatewayIds = getGatewayIds(h)
for (const gid of gatewayIds) {
const list = groupMap.get(gid)
if (!list) continue
list.push({
enabled: isEnabled(addr, h),
type: getAddressType(h),
access: h.public ? 'public' : 'private',
url: utils.addressHostToUrl(serviceInterface.addressInfo, h),
hostnameInfo: h,
masked,
ui,
deletable:
h.metadata.kind === 'private-domain' ||
h.metadata.kind === 'public-domain',
certificate: getCertificate(h, host, addSsl, secure),
})
}
}
return gateways
.filter(g => (groupMap.get(g.id)?.length ?? 0) > 0)
.map(g => {
const addresses = groupMap.get(g.id)!.sort(sortDomainsFirst)
// Derive mDNS enabled state from LAN IPs on this gateway
const lanIps = addresses.filter(a => isLanIp(a.hostnameInfo))
for (const a of addresses) {
if (a.hostnameInfo.metadata.kind === 'mdns') {
a.enabled = lanIps.some(ip => ip.enabled)
}
}
return {
gatewayId: g.id,
gatewayName: g.name,
addresses,
}
})
}
getPluginGroups(
serviceInterface: T.ServiceInterface,
host: T.Host,
allPackageData?: Record<string, T.PackageDataEntry>,
): PluginAddressGroup[] {
const binding = host.bindings[serviceInterface.addressInfo.internalPort]
if (!binding) return []
const addr = binding.addresses
const masked = serviceInterface.masked
const groupMap = new Map<string, PluginAddress[]>()
for (const h of addr.available) {
if (h.metadata.kind !== 'plugin') continue
const url = utils.addressHostToUrl(serviceInterface.addressInfo, h)
const pluginId = h.metadata.packageId
if (!groupMap.has(pluginId)) {
groupMap.set(pluginId, [])
}
groupMap.get(pluginId)!.push({
url,
hostnameInfo: h,
masked,
})
}
// Also include URL plugins that have no addresses yet
if (allPackageData) {
for (const [pkgId, pkg] of Object.entries(allPackageData)) {
if (pkg.plugin?.url && !groupMap.has(pkgId)) {
groupMap.set(pkgId, [])
}
}
}
return Array.from(groupMap.entries()).map(([pluginId, addresses]) => {
const pluginPkg = allPackageData?.[pluginId]
const pluginActions = pluginPkg?.actions ?? {}
const tableActionId = pluginPkg?.plugin?.url?.tableAction ?? null
const tableActionMeta = tableActionId
? pluginActions[tableActionId]
: undefined
const tableAction =
tableActionId && tableActionMeta
? { id: tableActionId, metadata: tableActionMeta }
: null
let pluginPkgInfo: PluginPkgInfo | null = null
if (pluginPkg) {
const manifest = getManifest(pluginPkg)
pluginPkgInfo = {
id: manifest.id,
title: manifest.title,
icon: pluginPkg.icon,
status: renderPkgStatus(pluginPkg).primary,
}
}
return {
pluginId,
pluginName:
pluginPkgInfo?.title ??
pluginId.charAt(0).toUpperCase() + pluginId.slice(1),
addresses,
tableAction,
pluginPkgInfo,
pluginActions,
}
})
}
/** ${scheme}://${username}@${host}:${externalPort}${suffix} */
launchableAddress(ui: T.ServiceInterface, host: T.Host): string {
const addresses = utils.filledAddress(host, ui.addressInfo)
@@ -214,9 +241,8 @@ export class InterfaceService {
kind: 'domain',
visibility: 'public',
})
const tor = addresses.filter({ kind: 'onion' })
const wanIp = addresses.filter({ kind: 'ipv4', visibility: 'public' })
const bestPublic = [publicDomains, tor, wanIp].flatMap(h =>
const bestPublic = [publicDomains, wanIp].flatMap(h =>
h.format('urlstring'),
)[0]
const privateDomains = addresses.filter({
@@ -235,7 +261,7 @@ export class InterfaceService {
matching = addresses.nonLocal
.filter({
kind: 'ipv4',
predicate: h => h.hostname.value === this.config.hostname,
predicate: h => h.hostname === this.config.hostname,
})
.format('urlstring')[0]
onLan = true
@@ -244,7 +270,7 @@ export class InterfaceService {
matching = addresses.nonLocal
.filter({
kind: 'ipv6',
predicate: h => h.hostname.value === this.config.hostname,
predicate: h => h.hostname === this.config.hostname,
})
.format('urlstring')[0]
break
@@ -254,9 +280,6 @@ export class InterfaceService {
.format('urlstring')[0]
onLan = true
break
case 'tor':
matching = tor.format('urlstring')[0]
break
case 'mdns':
matching = mdns.format('urlstring')[0]
onLan = true
@@ -268,237 +291,50 @@ export class InterfaceService {
if (bestPublic) return bestPublic
return ''
}
}
private hostnameInfo(
serviceInterface: T.ServiceInterface,
host: T.Host,
): T.HostnameInfo[] {
let hostnameInfo =
host.hostnameInfo[serviceInterface.addressInfo.internalPort]
return (
hostnameInfo?.filter(
h =>
this.config.accessType === 'localhost' ||
!(
h.kind === 'ip' &&
((h.hostname.kind === 'ipv6' &&
utils.IPV6_LINK_LOCAL.contains(h.hostname.value)) ||
h.gateway.id === 'lo')
),
) || []
)
}
export type GatewayAddress = {
enabled: boolean
type: string
access: 'public' | 'private'
url: string
hostnameInfo: T.HostnameInfo
masked: boolean
ui: boolean
deletable: boolean
certificate: string
}
private toDisplayAddress(
{ info, url, gateway, showSsl, masked, ui }: AddressWithInfo,
publicDomains: Record<string, T.PublicDomainConfig>,
): DisplayAddress {
let access: DisplayAddress['access']
let gatewayName: DisplayAddress['gatewayName']
let type: DisplayAddress['type']
let bullets: any[]
export type GatewayAddressGroup = {
gatewayId: string
gatewayName: string
addresses: GatewayAddress[]
}
const rootCaRequired = this.i18n.transform(
"Requires trusting your server's Root CA",
)
export type PluginAddress = {
url: string
hostnameInfo: T.HostnameInfo
masked: boolean
}
// ** Tor **
if (info.kind === 'onion') {
access = null
gatewayName = null
type = 'Tor'
bullets = [
this.i18n.transform('Connections can be slow or unreliable at times'),
this.i18n.transform(
'Public if you share the address publicly, otherwise private',
),
this.i18n.transform('Requires using a Tor-enabled device or browser'),
]
// Tor (SSL)
if (showSsl) {
bullets = [rootCaRequired, ...bullets]
// Tor (NON-SSL)
} else {
bullets.unshift(
this.i18n.transform(
'Ideal for anonymous, censorship-resistant hosting and remote access',
),
)
}
// ** Not Tor **
} else {
const port = info.hostname.sslPort || info.hostname.port
gatewayName = info.gateway.name
export type PluginPkgInfo = {
id: string
title: string
icon: string
status: PrimaryStatus
}
const gatewayLanIpv4 = gateway?.lanIpv4[0]
const isWireguard = gateway?.ipInfo.deviceType === 'wireguard'
const localIdeal = this.i18n.transform('Ideal for local access')
const lanRequired = this.i18n.transform(
'Requires being connected to the same Local Area Network (LAN) as your server, either physically or via VPN',
)
const staticRequired = `${this.i18n.transform('Requires setting a static IP address for')} ${gatewayLanIpv4} ${this.i18n.transform('in your gateway')}`
const vpnAccess = this.i18n.transform('Ideal for VPN access via')
const routerWireguard = this.i18n.transform(
"your router's Wireguard server",
)
const portForwarding = this.i18n.transform(
'Requires port forwarding in gateway',
)
const dnsFor = this.i18n.transform('Requires a DNS record for')
const resolvesTo = this.i18n.transform('that resolves to')
// * Local *
if (info.hostname.kind === 'local') {
type = this.i18n.transform('Local')
access = 'private'
bullets = [
localIdeal,
this.i18n.transform(
'Not recommended for VPN access. VPNs do not support ".local" domains without advanced configuration',
),
lanRequired,
rootCaRequired,
]
// * IPv4 *
} else if (info.hostname.kind === 'ipv4') {
type = 'IPv4'
if (info.public) {
access = 'public'
bullets = [
this.i18n.transform('Can be used for clearnet access'),
this.i18n.transform(
'Not recommended in most cases. Using a public domain is more common and preferred',
),
rootCaRequired,
]
if (!info.gateway.public) {
bullets.push(
`${portForwarding} "${gatewayName}": ${port} -> ${port}`,
)
}
} else {
access = 'private'
if (isWireguard) {
bullets = [`${vpnAccess} StartTunnel`, rootCaRequired]
} else {
bullets = [
localIdeal,
`${vpnAccess} ${routerWireguard}`,
lanRequired,
rootCaRequired,
staticRequired,
]
}
}
// * IPv6 *
} else if (info.hostname.kind === 'ipv6') {
type = 'IPv6'
access = 'private'
bullets = [
this.i18n.transform('Can be used for local access'),
lanRequired,
rootCaRequired,
]
// * Domain *
} else {
type = this.i18n.transform('Domain')
if (info.public) {
access = 'public'
bullets = [
`${dnsFor} ${info.hostname.value} ${resolvesTo} ${gateway?.ipInfo.wanIp}`,
]
if (!info.gateway.public) {
bullets.push(
`${portForwarding} "${gatewayName}": ${port} -> ${port === 443 ? 5443 : port}`,
)
}
if (publicDomains[info.hostname.value]?.acme) {
bullets.unshift(
this.i18n.transform('Ideal for public access via the Internet'),
)
} else {
bullets = [
this.i18n.transform(
'Can be used for personal access via the public Internet, but a VPN is more private and secure',
),
this.i18n.transform(
`Not good for public access, since the certificate is signed by your Server's Root CA`,
),
rootCaRequired,
...bullets,
]
}
} else {
access = 'private'
const ipPortBad = this.i18n.transform(
'when using IP addresses and ports is undesirable',
)
const customDnsRequired = `${dnsFor} ${info.hostname.value} ${resolvesTo} ${gatewayLanIpv4}`
if (isWireguard) {
bullets = [
`${vpnAccess} StartTunnel ${ipPortBad}`,
customDnsRequired,
rootCaRequired,
]
} else {
bullets = [
`${localIdeal} ${ipPortBad}`,
`${vpnAccess} ${routerWireguard} ${ipPortBad}`,
customDnsRequired,
rootCaRequired,
lanRequired,
staticRequired,
]
}
}
}
}
if (showSsl) {
type = `${type} (SSL)`
bullets.unshift(
this.i18n.transform('Should only needed for apps that enforce SSL'),
)
}
return {
url,
access,
gatewayName,
type,
bullets,
masked,
ui,
}
}
export type PluginAddressGroup = {
pluginId: string
pluginName: string
addresses: PluginAddress[]
tableAction: { id: string; metadata: T.ActionMetadata } | null
pluginPkgInfo: PluginPkgInfo | null
pluginActions: Record<string, T.ActionMetadata>
}
export type MappedServiceInterface = T.ServiceInterface & {
gateways: InterfaceGateway[]
torDomains: string[]
publicDomains: PublicDomain[]
privateDomains: string[]
addresses: {
common: DisplayAddress[]
uncommon: DisplayAddress[]
}
gatewayGroups: GatewayAddressGroup[]
pluginGroups: PluginAddressGroup[]
addSsl: boolean
}
export type InterfaceGateway = GatewayPlus & {
enabled: boolean
}
export type DisplayAddress = {
type: string
access: 'public' | 'private' | null
gatewayName: string | null
url: string
bullets: i18nKey[]
masked: boolean
ui: boolean
}

View File

@@ -1,184 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import {
DialogService,
DocsLinkDirective,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB, utils } from '@start9labs/start-sdk'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiSkeleton } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { filter } from 'rxjs'
import {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { InterfaceComponent } from './interface.component'
@Component({
selector: 'section[privateDomains]',
template: `
<header>
{{ 'Private Domains' | i18n }}
<a
tuiIconButton
docsLink
path="/user-manual/connecting-locally.html"
fragment="private-domains"
appearance="icon"
iconStart="@tui.external-link"
>
{{ 'Documentation' | i18n }}
</a>
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="add()"
>
{{ 'Add' | i18n }}
</button>
</header>
@for (domain of privateDomains(); track domain) {
<div tuiCell="s">
<span tuiTitle>{{ domain }}</span>
<button
tuiIconButton
iconStart="@tui.trash"
appearance="action-destructive"
(click)="remove(domain)"
[disabled]="!privateDomains()"
>
{{ 'Delete' | i18n }}
</button>
</div>
} @empty {
@if (privateDomains()) {
<app-placeholder icon="@tui.globe-lock">
{{ 'No private domains' | i18n }}
</app-placeholder>
} @else {
@for (_ of [0, 1]; track $index) {
<label tuiCell="s">
<span tuiTitle [tuiSkeleton]="true">{{ 'Loading' | i18n }}</span>
</label>
}
}
}
`,
styles: `
:host {
grid-column: span 4;
overflow-wrap: break-word;
}
`,
host: { class: 'g-card' },
imports: [
TuiCell,
TuiTitle,
TuiButton,
PlaceholderComponent,
i18nPipe,
DocsLinkDirective,
TuiSkeleton,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfacePrivateDomainsComponent {
private readonly dialog = inject(DialogService)
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly interface = inject(InterfaceComponent)
private readonly i18n = inject(i18nPipe)
readonly privateDomains = input.required<readonly string[] | undefined>()
async add() {
this.formDialog.open<FormContext<{ fqdn: string }>>(FormComponent, {
label: 'New private domain',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: this.i18n.transform('Domain'),
description: this.i18n.transform(
'Enter a fully qualified domain name. Since the domain is for private use, it can be any domain you want, even one you do not control.',
),
required: true,
default: null,
patterns: [utils.Patterns.domain],
}),
}),
),
buttons: [
{
text: this.i18n.transform('Save')!,
handler: async value => this.save(value.fqdn),
},
],
},
})
}
async remove(fqdn: string) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Removing').subscribe()
try {
if (this.interface.packageId()) {
await this.api.pkgRemovePrivateDomain({
fqdn,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
await this.api.osUiRemovePrivateDomain({ fqdn })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
})
}
private async save(fqdn: string): Promise<boolean> {
const loader = this.loader.open('Saving').subscribe()
try {
if (this.interface.packageId) {
await this.api.pkgAddPrivateDomain({
fqdn,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
await this.api.osUiAddPrivateDomain({ fqdn })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,167 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ErrorService, i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiDialogContext, TuiIcon } from '@taiga-ui/core'
import {
TuiButtonLoading,
TuiSwitch,
tuiSwitchOptionsProvider,
} from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { parse } from 'tldts'
import { GatewayWithId } from './pd.service'
@Component({
selector: 'dns',
template: `
<p>{{ context.data.message }}</p>
@let wanIp = context.data.gateway.ipInfo.wanIp || ('Error' | i18n);
@if (context.data.gateway.ipInfo.deviceType !== 'wireguard') {
<label>
IP
<input
type="checkbox"
tuiSwitch
[(ngModel)]="ddns"
(ngModelChange)="pass.set(undefined)"
/>
{{ 'Dynamic DNS' | i18n }}
</label>
}
<table [appTable]="['Type', 'Host', 'Value', 'Purpose']">
@for (row of rows(); track $index) {
<tr>
<td>
@if (pass() === true) {
<tui-icon class="g-positive" icon="@tui.check" />
} @else if (pass() === false) {
<tui-icon class="g-negative" icon="@tui.x" />
}
{{ ddns ? 'ALIAS' : 'A' }}
</td>
<td>{{ row.host }}</td>
<td>{{ ddns ? '[DDNS Address]' : wanIp }}</td>
<td>{{ row.purpose }}</td>
</tr>
}
</table>
<footer class="g-buttons">
<button tuiButton [loading]="loading()" (click)="testDns()">
{{ 'Test' | i18n }}
</button>
</footer>
`,
styles: `
label {
display: flex;
gap: 0.75rem;
align-items: center;
margin: 1rem 0;
}
tui-icon {
font-size: 1rem;
vertical-align: text-bottom;
}
`,
providers: [
tuiSwitchOptionsProvider({
appearance: () => 'primary',
icon: () => '',
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,
i18nPipe,
TableComponent,
TuiSwitch,
FormsModule,
TuiButtonLoading,
TuiIcon,
],
})
export class DnsComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly i18n = inject(i18nPipe)
readonly ddns = false
readonly context =
injectContext<
TuiDialogContext<
void,
{ fqdn: string; gateway: GatewayWithId; message: string }
>
>()
readonly loading = signal(false)
readonly pass = signal<boolean | undefined>(undefined)
readonly rows = computed<{ host: string; purpose: string }[]>(() => {
const { domain, subdomain } = parse(this.context.data.fqdn)
if (!subdomain) {
return [
{
host: '@',
purpose: domain!,
},
]
}
const segments = subdomain.split('.').slice(1)
const subdomains = this.i18n.transform('all subdomains of')
return [
{
host: subdomain,
purpose: `only ${subdomain}`,
},
...segments.map((_, i) => {
const parent = segments.slice(i).join('.')
return {
host: `*.${parent}`,
purpose: `${subdomains} ${parent}`,
}
}),
{
host: '*',
purpose: `${subdomains} ${domain}`,
},
]
})
async testDns() {
this.pass.set(undefined)
this.loading.set(true)
try {
const ip = await this.api.queryDns({
fqdn: this.context.data.fqdn,
})
this.pass.set(ip === this.context.data.gateway.ipInfo.wanIp)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading.set(false)
}
}
}
export const DNS = new PolymorpheusComponent(DnsComponent)

View File

@@ -1,85 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { TuiSkeleton } from '@taiga-ui/kit'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { PublicDomainsItemComponent } from './pd.item.component'
import { PublicDomain, PublicDomainService } from './pd.service'
@Component({
selector: 'section[publicDomains]',
template: `
<header>
{{ 'Public Domains' | i18n }}
<a
tuiIconButton
docsLink
path="/user-manual/connecting-remotely/clearnet.html"
appearance="icon"
iconStart="@tui.external-link"
>
{{ 'Documentation' | i18n }}
</a>
@if (service.data()) {
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="service.add(addSsl())"
[disabled]="!publicDomains()"
>
{{ 'Add' | i18n }}
</button>
}
</header>
@if (publicDomains()?.length === 0) {
<app-placeholder icon="@tui.globe">
{{ 'No public domains' | i18n }}
</app-placeholder>
} @else {
<table [appTable]="['Domain', 'Gateway', 'Certificate Authority', null]">
@for (domain of publicDomains(); track $index) {
<tr [publicDomain]="domain" [addSsl]="addSsl()"></tr>
} @empty {
@for (_ of [0]; track $index) {
<tr>
<td colspan="4">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td>
</tr>
}
}
</table>
}
`,
styles: `
:host {
grid-column: span 7;
}
`,
host: { class: 'g-card' },
providers: [PublicDomainService],
imports: [
TuiButton,
TableComponent,
PlaceholderComponent,
i18nPipe,
DocsLinkDirective,
PublicDomainsItemComponent,
TuiSkeleton,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PublicDomainsComponent {
readonly service = inject(PublicDomainService)
readonly publicDomains = input.required<readonly PublicDomain[] | undefined>()
readonly addSsl = input.required<boolean>()
}

View File

@@ -1,119 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core'
import { i18nPipe, i18nKey } from '@start9labs/shared'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { PublicDomain, PublicDomainService } from './pd.service'
import { toAuthorityName } from 'src/app/utils/acme'
@Component({
selector: 'tr[publicDomain]',
template: `
<td>{{ publicDomain().fqdn }}</td>
<td>{{ publicDomain().gateway?.name }}</td>
<td class="authority">{{ authority() }}</td>
<td>
<button
tuiIconButton
tuiDropdown
size="s"
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[tuiAppearanceState]="open ? 'hover' : null"
[(tuiDropdownOpen)]="open"
>
{{ 'More' | i18n }}
<tui-data-list *tuiTextfieldDropdown>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.eye"
(click)="
service.showDns(
publicDomain().fqdn,
publicDomain().gateway!,
dnsMessage()
)
"
>
{{ 'View DNS' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.pencil"
(click)="service.edit(publicDomain(), addSsl())"
>
{{ 'Edit' | i18n }}
</button>
</tui-opt-group>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="service.remove(publicDomain().fqdn)"
>
{{ 'Delete' | i18n }}
</button>
</tui-opt-group>
</tui-data-list>
</button>
</td>
`,
styles: `
:host {
grid-template-columns: min-content 1fr min-content;
}
td:nth-child(2) {
order: -1;
grid-column: span 2;
}
td:last-child {
grid-area: 1 / 3 / 3;
align-self: center;
text-align: right;
}
:host-context(tui-root._mobile) {
.authority {
grid-column: span 2;
}
tui-badge {
vertical-align: bottom;
margin-inline-start: 0.25rem;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiDataList, TuiDropdown, i18nPipe, TuiTextfield],
})
export class PublicDomainsItemComponent {
protected readonly service = inject(PublicDomainService)
open = false
readonly publicDomain = input.required<PublicDomain>()
readonly addSsl = input.required<boolean>()
readonly authority = computed(() =>
toAuthorityName(this.publicDomain().acme, this.addSsl()),
)
readonly dnsMessage = computed<i18nKey>(
() =>
`Create one of the DNS records below to cause ${this.publicDomain().fqdn} to resolve to ${this.publicDomain().gateway?.ipInfo.wanIp}` as i18nKey,
)
}

View File

@@ -1,277 +0,0 @@
import { inject, Injectable } from '@angular/core'
import {
DialogService,
ErrorService,
i18nKey,
LoadingService,
i18nPipe,
} from '@start9labs/shared'
import { toSignal } from '@angular/core/rxjs-interop'
import { ISB, T, utils } from '@start9labs/start-sdk'
import { filter, map } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { toAuthorityName } from 'src/app/utils/acme'
import { InterfaceComponent } from '../interface.component'
import { DNS } from './dns.component'
export type PublicDomain = {
fqdn: string
gateway: GatewayWithId | null
acme: string | null
}
export type GatewayWithId = T.NetworkInterfaceInfo & {
id: string
ipInfo: T.IpInfo
}
@Injectable()
export class PublicDomainService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
private readonly dialog = inject(DialogService)
private readonly interface = inject(InterfaceComponent)
private readonly i18n = inject(i18nPipe)
readonly data = toSignal(
this.patch.watch$('serverInfo', 'network').pipe(
map(({ gateways, acme }) => ({
gateways: Object.entries(gateways)
.filter(([_, g]) => g.ipInfo)
.map(([id, g]) => ({ id, ...g })) as GatewayWithId[],
authorities: Object.keys(acme).reduce<Record<string, string>>(
(obj, url) => ({
...obj,
[url]: toAuthorityName(url),
}),
{ local: toAuthorityName(null) },
),
})),
),
)
async add(addSsl: boolean) {
const addSpec = ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: this.i18n.transform('Domain'),
description: this.i18n.transform(
'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com.',
),
required: true,
default: null,
patterns: [utils.Patterns.domain],
}).map(f => f.toLocaleLowerCase()),
...this.gatewaySpec(),
...(addSsl
? this.authoritySpec()
: ({} as ReturnType<typeof this.authoritySpec>)),
})
this.formDialog.open(FormComponent, {
label: 'Add public domain',
data: {
spec: await configBuilderToSpec(addSpec),
buttons: [
{
text: 'Save',
handler: (input: typeof addSpec._TYPE) =>
this.save(input.fqdn, input.gateway, input.authority),
},
],
},
})
}
async edit(domain: PublicDomain, addSsl: boolean) {
const editSpec = ISB.InputSpec.of({
...this.gatewaySpec(),
...(addSsl
? this.authoritySpec()
: ({} as ReturnType<typeof this.authoritySpec>)),
})
this.formDialog.open(FormComponent, {
label: 'Edit public domain',
data: {
spec: await configBuilderToSpec(editSpec),
buttons: [
{
text: 'Save',
handler: ({ gateway, authority }: typeof editSpec._TYPE) =>
this.save(domain.fqdn, gateway, authority),
},
],
value: {
gateway: domain.gateway!.id,
authority: domain.acme,
},
},
})
}
remove(fqdn: string) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Deleting').subscribe()
try {
if (this.interface.packageId()) {
await this.api.pkgRemovePublicDomain({
fqdn,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
await this.api.osUiRemovePublicDomain({ fqdn })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
})
}
showDns(fqdn: string, gateway: GatewayWithId, message: i18nKey) {
this.dialog
.openComponent(DNS, {
label: 'DNS Records',
size: 'l',
data: {
fqdn,
gateway,
message,
},
})
.subscribe()
}
private async save(
fqdn: string,
gatewayId: string,
authority?: 'local' | string,
) {
const gateway = this.data()!.gateways.find(g => g.id === gatewayId)!
const loader = this.loader.open('Saving').subscribe()
const params = {
fqdn,
gateway: gatewayId,
acme: !authority || authority === 'local' ? null : authority,
}
try {
let ip: string | null
if (this.interface.packageId()) {
ip = await this.api.pkgAddPublicDomain({
...params,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
ip = await this.api.osUiAddPublicDomain(params)
}
const wanIp = gateway.ipInfo.wanIp
let message = this.i18n.transform(
'Create one of the DNS records below.',
) as i18nKey
if (!ip) {
setTimeout(
() =>
this.showDns(
fqdn,
gateway,
`${this.i18n.transform('No DNS record detected for')} ${fqdn}. ${message}` as i18nKey,
),
250,
)
} else if (ip !== wanIp) {
setTimeout(
() =>
this.showDns(
fqdn,
gateway,
`${this.i18n.transform('Invalid DNS record')}. ${fqdn} ${this.i18n.transform('resolves to')} ${ip}. ${message}` as i18nKey,
),
250,
)
} else {
setTimeout(
() =>
this.dialog
.openAlert(
`${fqdn} ${this.i18n.transform('resolves to')} ${wanIp}` as i18nKey,
{ label: 'DNS record detected!', appearance: 'positive' },
)
.subscribe(),
250,
)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
private gatewaySpec() {
const data = this.data()!
const gateways = data.gateways.filter(
({ ipInfo: { deviceType } }) =>
deviceType !== 'loopback' && deviceType !== 'bridge',
)
return {
gateway: ISB.Value.dynamicSelect(() => ({
name: this.i18n.transform('Gateway'),
description: this.i18n.transform(
'Select a gateway to use for this domain.',
),
values: gateways.reduce<Record<string, string>>(
(obj, gateway) => ({
[gateway.id]: gateway.name || gateway.ipInfo.name,
...obj,
}),
{ '~/system/gateways': this.i18n.transform('New gateway') },
),
default: '',
disabled: gateways
.filter(g => !g.ipInfo.wanIp || utils.CGNAT.contains(g.ipInfo.wanIp))
.map(g => g.id),
})),
}
}
private authoritySpec() {
const data = this.data()!
return {
authority: ISB.Value.select({
name: this.i18n.transform('Certificate Authority'),
description: this.i18n.transform(
'Select a Certificate Authority to issue SSL/TLS certificates for this domain',
),
values: data.authorities,
default: '',
}),
}
}
}

View File

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

View File

@@ -3,10 +3,9 @@ import {
convertAnsi,
DownloadHTMLService,
ErrorService,
FetchLogsReq,
FetchLogsRes,
LoadingService,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { LogsComponent } from './logs.component'
@Directive({
@@ -19,7 +18,7 @@ export class LogsDownloadDirective {
private readonly downloadHtml = inject(DownloadHTMLService)
@Input({ required: true })
logsDownload!: (params: FetchLogsReq) => Promise<FetchLogsRes>
logsDownload!: (params: T.LogsParams) => Promise<T.LogResponse>
@HostListener('click')
async download() {

View File

@@ -18,7 +18,7 @@ export class LogsFetchDirective {
switchMap(() =>
from(
this.component.fetchLogs({
cursor: this.component.startCursor,
cursor: this.component.startCursor ?? undefined,
before: true,
limit: 400,
}),

Some files were not shown because too many files have changed in this diff Show More