Merge branch 'feature/consolidate-setup' of github.com:Start9Labs/start-os into feature/consolidate-setup

This commit is contained in:
Aiden McClelland
2026-01-16 17:03:36 -07:00
9 changed files with 70 additions and 152 deletions

View File

@@ -1,95 +0,0 @@
# StartTunnel
A self-hosted WireGuard VPN optimized for creating VLANs and reverse tunneling to personal servers.
You can think of StartTunnel as "virtual router in the cloud".
Use it for private remote access to self-hosted services running on a personal server, or to expose self-hosted services to the public Internet without revealing the host server's IP address.
## Features
- **Create Subnets**: Each subnet creates a private, virtual local area network (VLAN), similar to the LAN created by a home router.
- **Add Devices**: When you add a device (server, phone, laptop) to a subnet, it receives a LAN IP address on that subnet as well as a unique WireGuard config that must be copied, downloaded, or scanned into the device.
- **Forward Ports**: Forwarding a port creates a "reverse tunnel", exposing a specific port on a specific device to the public Internet.
## Installation
1. Rent a low cost VPS. For most use cases, the cheapest option should be enough.
- It must have a dedicated public IP address.
- For compute (CPU), memory (RAM), and storage (disk), choose the minimum spec.
- For transfer (bandwidth), it depends on (1) your use case and (2) your home Internet's _upload_ speed. Even if you intend to serve large files or stream content from your server, there is no reason to pay for speeds that exceed your home Internet's upload speed.
1. Provision the VPS with the latest version of Debian.
1. Access the VPS via SSH.
1. Run the StartTunnel install script:
curl -fsSL https://start9labs.github.io/start-tunnel | sh
1. [Initialize the web interface](#web-interface) (recommended)
## Updating
Simply re-run the install command:
```sh
curl -fsSL https://start9labs.github.io/start-tunnel | sh
```
## CLI
By default, StartTunnel is managed via the `start-tunnel` command line interface, which is self-documented.
```
start-tunnel --help
```
## Web Interface
Enable the web interface (recommended in most cases) to access your StartTunnel from the browser or via API.
1. Initialize the web interface.
start-tunnel web init
1. If your VPS has multiple public IP addresses, you will be prompted to select the IP address at which to host the web interface.
1. When prompted, enter the port at which to host the web interface. The default is 8443, and we recommend using it. If you change the default, choose an uncommon port to avoid future conflicts.
1. To access your StartTunnel web interface securely over HTTPS, you need an SSL certificate. When prompted, select whether to autogenerate a certificate or provide your own. _This is only for accessing your StartTunnel web interface_.
1. You will receive a success message with 3 pieces of information:
- **<https://IP:port>**: the URL where you can reach your personal web interface.
- **Password**: an autogenerated password for your interface. If you lose/forget it, you can reset it using the start-tunnel CLI.
- **Root Certificate Authority**: the Root CA of your StartTunnel instance.
1. If you autogenerated your SSL certificate, visiting the `https://IP:port` URL in the browser will warn you that the website is insecure. This is expected. You have two options for getting past this warning:
- option 1 (recommended): [Trust your StartTunnel Root CA on your connecting device](#trusting-your-starttunnel-root-ca).
- Option 2: bypass the warning in the browser, creating a one-time security exception.
### Trusting your StartTunnel Root CA
1. Copy the contents of your Root CA (starting with -----BEGIN CERTIFICATE----- and ending with -----END CERTIFICATE-----).
2. Open a text editor:
- Linux: gedit, nano, or any editor
- Mac: TextEdit
- Windows: Notepad
3. Paste the contents of your Root CA.
4. Save the file with a `.crt` extension (e.g. `start-tunnel.crt`) (make sure it saves as plain text, not rich text).
5. Trust the Root CA on your client device(s):
- [Linux](https://staging.docs.start9.com/device-guides/linux/ca.html)
- [Mac](https://staging.docs.start9.com/device-guides/mac/ca.html)
- [Windows](https://staging.docs.start9.com/device-guides/windows/ca.html)
- [Android/Graphene](https://staging.docs.start9.com/device-guides/android/ca.html)
- [iOS](https://staging.docs.start9.com/device-guides/ios/ca.html)

View File

@@ -122,13 +122,13 @@ export default class LanguagePage {
constructor() {
if (this.selected) {
this.i18nService.setLanguage(this.selected.name)
this.i18nService.setLang(this.selected.name)
}
}
onLanguageChange(language: Language) {
if (language) {
this.i18nService.setLanguage(language.name)
this.i18nService.setLang(language.name)
}
}

View File

@@ -56,7 +56,7 @@ export const ENGLISH = {
'Beginning shutdown': 57,
'Add': 58,
'Ok': 59,
'french': 60,
'fr_FR': 60,
'This value cannot be changed once set': 61,
'Continue': 62,
'Click or drop file here': 63,
@@ -462,10 +462,10 @@ export const ENGLISH = {
'StartOS UI': 485,
'WiFi': 486,
'Documentation': 487, // as in, a website to view documentation
'spanish': 488,
'polish': 489,
'german': 490,
'english': 491,
'es_ES': 488,
'pl_PL': 489,
'de_DE': 490,
'en_US': 491,
'Start Menu': 492,
'Install Progress': 493,
'Downloading': 494,
@@ -670,7 +670,7 @@ export const ENGLISH = {
'Preserve': 706,
'Overwrite': 707,
'Unlock': 708,
'Drive': 709, // as in, a storage device
'Drive': 709, // the noun, a storage device
'Transfer': 710, // the verb
'The list is empty': 711,
'Restart now': 712,

View File

@@ -6,7 +6,7 @@ import {
TuiLanguageSwitcherService,
} from '@taiga-ui/i18n'
import { ENGLISH } from './dictionaries/en'
import { i18nService } from './i18n.service'
import { i18nService, Languages } from './i18n.service'
export type i18nKey = keyof typeof ENGLISH
export type i18n = Record<(typeof ENGLISH)[i18nKey], string>
@@ -20,7 +20,7 @@ export const I18N_LOADER = new InjectionToken<
>('')
export const I18N_STORAGE = new InjectionToken<
(lang: TuiLanguageName) => Promise<void>
(lang: Languages) => Promise<void>
>('', {
factory: () => () => Promise.resolve(),
})

View File

@@ -2,6 +2,20 @@ import { inject, Injectable, signal } from '@angular/core'
import { TuiLanguageName, TuiLanguageSwitcherService } from '@taiga-ui/i18n'
import { I18N, I18N_LOADER, I18N_STORAGE } from './i18n.providers'
export const languages = ['en_US', 'es_ES', 'de_DE', 'fr_FR', 'pl_PL'] as const
export type Languages = (typeof languages)[number]
/**
* Maps POSIX locale strings to TUI language names
*/
export const LANGUAGE_TO_TUI: Record<Languages, TuiLanguageName> = {
en_US: 'english',
es_ES: 'spanish',
de_DE: 'german',
fr_FR: 'french',
pl_PL: 'polish',
}
@Injectable({
providedIn: 'root',
})
@@ -12,20 +26,32 @@ export class i18nService extends TuiLanguageSwitcherService {
readonly loading = signal(false)
override setLanguage(language: TuiLanguageName = 'english'): void {
/**
* Current language as POSIX locale string
*/
get lang(): Languages {
return (
(Object.entries(LANGUAGE_TO_TUI).find(
([, tui]) => tui === this.language,
)?.[0] as Languages) || 'en_US'
)
}
setLang(language: Languages = 'en_US'): void {
const tuiLang = LANGUAGE_TO_TUI[language]
const current = this.language
super.setLanguage(language)
super.setLanguage(tuiLang)
this.loading.set(true)
if (current === language) {
this.i18nLoader(language).then(value => {
if (current === tuiLang) {
this.i18nLoader(tuiLang).then(value => {
this.i18n.set(value)
this.loading.set(false)
})
} else {
this.store(language).then(() =>
this.i18nLoader(language).then(value => {
this.i18nLoader(tuiLang).then(value => {
this.i18n.set(value)
this.loading.set(false)
}),
@@ -33,12 +59,3 @@ export class i18nService extends TuiLanguageSwitcherService {
}
}
}
export const languages = [
'english',
'spanish',
'polish',
'german',
'french',
] as const
export type Languages = (typeof languages)[number]

View File

@@ -18,22 +18,22 @@ export interface Language {
* Available languages with their metadata
*/
export const LANGUAGES: Language[] = [
{ code: 'en', name: 'english', nativeName: 'English' },
{ code: 'es', name: 'spanish', nativeName: 'Español' },
{ code: 'de', name: 'german', nativeName: 'Deutsch' },
{ code: 'fr', name: 'french', nativeName: 'Français' },
{ code: 'pl', name: 'polish', nativeName: 'Polski' },
{ code: 'en', name: 'en_US', nativeName: 'English' },
{ code: 'es', name: 'es_ES', nativeName: 'Español' },
{ code: 'de', name: 'de_DE', nativeName: 'Deutsch' },
{ code: 'fr', name: 'fr_FR', nativeName: 'Français' },
{ code: 'pl', name: 'pl_PL', nativeName: 'Polski' },
]
/**
* Maps i18n language names to ISO language codes
* Maps POSIX locale strings to ISO language codes
*/
export const LANGUAGE_TO_CODE: Record<Languages, LanguageCode> = {
english: 'en',
spanish: 'es',
german: 'de',
french: 'fr',
polish: 'pl',
en_US: 'en',
es_ES: 'es',
de_DE: 'de',
fr_FR: 'fr',
pl_PL: 'pl',
}
/**

View File

@@ -42,6 +42,6 @@ export class AppComponent {
.watch$('serverInfo', 'language')
.pipe(takeUntilDestroyed())
.subscribe(language => {
this.i18n.setLanguage(language || 'english')
this.i18n.setLang(language || 'en_US')
})
}

View File

@@ -19,13 +19,13 @@ import {
i18nService,
Keyboard,
KeyboardCode,
languages,
Languages,
Language,
LANGUAGES,
LANGUAGE_TO_CODE,
LoadingService,
} from '@start9labs/shared'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import { TuiAnimated, TuiContext, TuiStringHandler } from '@taiga-ui/cdk'
import { TuiAnimated } from '@taiga-ui/cdk'
import {
TuiAppearance,
TuiButton,
@@ -110,20 +110,16 @@ import { KeyboardSelectComponent } from './keyboard-select.component'
<tui-icon icon="@tui.languages" />
<span tuiTitle>
<strong>{{ 'Language' | i18n }}</strong>
<span tuiSubtitle [style.text-transform]="'capitalize'">
@if (language; as lang) {
{{ lang | i18n }}
} @else {
{{ i18nService.language }}
}
<span tuiSubtitle>
{{ currentLanguage?.nativeName }}
</span>
</span>
<button
tuiButtonSelect
tuiButton
[loading]="i18nService.loading()"
[ngModel]="i18nService.language"
(ngModelChange)="i18nService.setLanguage($event)"
[ngModel]="currentLanguage"
(ngModelChange)="onLanguageChange($event)"
>
{{ 'Change' | i18n }}
<tui-data-list-wrapper
@@ -131,9 +127,12 @@ import { KeyboardSelectComponent } from './keyboard-select.component'
new
size="l"
[items]="languages"
[itemContent]="translation"
[itemContent]="languageContent"
/>
</button>
<ng-template #languageContent let-item>
{{ item.nativeName }}
</ng-template>
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.monitor" />
@@ -297,17 +296,14 @@ export default class SystemGeneralComponent {
readonly score = toSignal(this.patch.watch$('ui', 'snakeHighScore'))
readonly os = inject(OSService)
readonly i18nService = inject(i18nService)
readonly languages = languages
readonly translation: TuiStringHandler<TuiContext<Languages>> = ({
$implicit,
}) => {
const [head = '', ...result] = this.i18n.transform($implicit) || ''
readonly languages = LANGUAGES
return [head.toUpperCase(), ...result].join('')
get currentLanguage(): Language | undefined {
return LANGUAGES.find(lang => lang.name === this.i18nService.lang)
}
get language(): Languages | undefined {
return this.languages.find(lang => lang === this.i18nService.language)
onLanguageChange(language: Language) {
this.i18nService.setLang(language.name)
}
// Expose shared utilities for template use

View File

@@ -219,7 +219,7 @@ export const mockPatchData: DataModel = {
ram: 8 * 1024 * 1024 * 1024,
devices: [],
kiosk: true,
language: 'english',
language: 'en_US',
keyboard: {
layout: 'us',
model: null,