start sorting addresses

This commit is contained in:
Matt Hill
2025-08-07 13:47:27 -06:00
parent b864816033
commit 4d5ff1a97b
9 changed files with 254 additions and 290 deletions

View File

@@ -37,7 +37,7 @@ export class CAWizardComponent {
} }
launchHttps() { launchHttps() {
this.document.defaultView?.open(`https://${this.config.getHost()}`, '_self') this.document.defaultView?.open(`https://${this.config.host}`, '_self')
} }
private async testHttps() { private async testHttps() {

View File

@@ -26,7 +26,7 @@ import { InterfaceGateway } from './interface.utils'
[showIcons]="false" [showIcons]="false"
[ngModel]="gateway.enabled" [ngModel]="gateway.enabled"
(ngModelChange)="onToggle(gateway)" (ngModelChange)="onToggle(gateway)"
[disabled]="osUi() && !gateway.public" [disabled]="isOs() && !gateway.public"
/> />
</label> </label>
} }
@@ -37,7 +37,7 @@ import { InterfaceGateway } from './interface.utils'
}) })
export class InterfaceGatewaysComponent { export class InterfaceGatewaysComponent {
readonly gateways = input.required<InterfaceGateway[]>() readonly gateways = input.required<InterfaceGateway[]>()
readonly osUi = input.required<boolean>() readonly isOs = input.required<boolean>()
async onToggle(gateway: InterfaceGateway) {} async onToggle(gateway: InterfaceGateway) {}
} }

View File

@@ -11,7 +11,7 @@ import { InterfaceAddressesComponent } from './addresses/addresses.component'
template: ` template: `
<!-- @TODO Alex / Matt translation in all nested components --> <!-- @TODO Alex / Matt translation in all nested components -->
<div [style.display]="'grid'"> <div [style.display]="'grid'">
<section [gateways]="value().gateways" [osUi]="osUi()"></section> <section [gateways]="value().gateways" [isOs]="value().isOs"></section>
<section [torDomains]="value().torDomains"></section> <section [torDomains]="value().torDomains"></section>
<section [clearnetDomains]="value().clearnetDomains"></section> <section [clearnetDomains]="value().clearnetDomains"></section>
</div> </div>
@@ -48,5 +48,4 @@ export class InterfaceComponent {
readonly packageId = input('') readonly packageId = input('')
readonly value = input.required<MappedServiceInterface>() readonly value = input.required<MappedServiceInterface>()
readonly isRunning = input.required<boolean>() readonly isRunning = input.required<boolean>()
readonly osUi = input(false)
} }

View File

@@ -1,113 +1,245 @@
import { T } from '@start9labs/start-sdk' import { inject, Injectable } from '@angular/core'
import { T, utils } from '@start9labs/start-sdk'
import { ConfigService } from 'src/app/services/config.service' import { ConfigService } from 'src/app/services/config.service'
export function getAddresses( type AddressWithInfo = {
serviceInterface: T.ServiceInterface, address: URL
host: T.Host, info: T.HostnameInfo
config: ConfigService, }
): MappedServiceInterface['addresses'] {
const addressInfo = serviceInterface.addressInfo
const hostnames =
host.hostnameInfo[addressInfo.internalPort]?.filter(
h =>
config.isLocalhost() ||
h.kind !== 'ip' ||
h.hostname.kind !== 'ipv6' ||
!h.hostname.value.startsWith('fe80::'),
) || []
if (config.isLocalhost()) { function filterTor(a: AddressWithInfo): a is (AddressWithInfo & { info: { kind: 'onion' } }) {
const local = hostnames.find( return a.info.kind === 'onion'
h => h.kind === 'ip' && h.hostname.kind === 'local', }
)
if (local) { function cmpTor(a: AddressWithInfo, b: AddressWithInfo): -1 | 0 | 1 {
hostnames.unshift({ if (!filterTor(a) || !filterTor(b)) return 0
kind: 'ip', for (let [x, y, sign] of [[a, b, 1] as const, [b, a, -1] as const]) {
gatewayId: 'lo', if (x.address.protocol === 'http:' && y.address.protocol === 'https:')
public: false, return sign
hostname: { }
kind: 'local', return 0
port: local.hostname.port, }
sslPort: local.hostname.sslPort,
value: 'localhost', function filterLan(a: AddressWithInfo): a is (AddressWithInfo & { info: { kind: 'ip', public: false } }) {
}, return a.info.kind === 'ip' && !a.info.public
}
function cmpLan(host: T.Host, a: AddressWithInfo, b: AddressWithInfo): -1 | 0 | 1 {
if (!filterLan(a) || !filterLan(b)) return 0
for (let [x, y, sign] of [[a, b, 1] as const, [b, a, -1] as const]) {
if (x.info.kind === 'domain' && host.domains.)
return sign
}
return 0
}
@Injectable({
providedIn: 'root',
})
export class InterfaceService {
private readonly config = inject(ConfigService)
getAddresses(
serviceInterface: T.ServiceInterface,
host: T.Host,
): MappedServiceInterface['addresses'] {
const hostnamesInfos = this.hostnameInfo(serviceInterface, host)
const addresses = {
common: [],
uncommon: [],
}
if (!hostnamesInfos.length) return addresses
hostnamesInfos.forEach(h => {
const addresses = utils.addressHostToUrl(serviceInterface.addressInfo, h)
addresses.forEach(url => {
if (h.kind === 'onion') {
tor.push({
protocol: /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(url)
? new URL(url).protocol.replace(':', '').toUpperCase()
: null,
url,
})
} else {
const hostnameKind = h.hostname.kind
if (
h.public ||
(hostnameKind === 'domain' &&
host.domains[h.hostname.domain]?.public)
) {
clearnet.push({
url,
disabled: !h.public,
isDomain: hostnameKind == 'domain',
authority:
hostnameKind == 'domain'
? host.domains[h.hostname.domain]?.acme || null
: null,
})
} else {
local.push({
nid:
hostnameKind === 'local'
? 'Local'
: `${h.gatewayId} (${hostnameKind})`,
url,
})
}
}
}) })
})
return {
common: common.filter(
(value, index, self) =>
index === self.findIndex(t => t.url === value.url),
),
uncommon: uncommon.filter(
(value, index, self) =>
index === self.findIndex(t => t.url === value.url),
),
} }
} }
const common: Address[] = [ /** ${scheme}://${username}@${host}:${externalPort}${suffix} */
{ launchableAddress(ui: T.ServiceInterface, host: T.Host): string {
type: 'Local', const hostnameInfos = this.hostnameInfo(ui, host)
description: '',
gateway: 'Wired Connection 1',
url: 'https://test.local:1234',
},
{
type: 'IPv4 (LAN)',
description: '',
gateway: 'Wired Connection 1',
url: 'https://192.168.1.10.local:1234',
},
]
const uncommon: Address[] = [
{
type: 'IPv4 (WAN)',
description: '',
gateway: 'Wired Connection 1',
url: 'https://72.72.72.72',
},
]
// hostnames.forEach(h => { if (!hostnameInfos.length) return ''
// const addresses = utils.addressHostToUrl(addressInfo, h)
// addresses.forEach(url => { const addressInfo = ui.addressInfo
// if (h.kind === 'onion') { const username = addressInfo.username ? addressInfo.username + '@' : ''
// tor.push({ const suffix = addressInfo.suffix || ''
// protocol: /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(url) const url = new URL(`https://${username}placeholder${suffix}`)
// ? new URL(url).protocol.replace(':', '').toUpperCase() const use = (hostname: {
// : null, value: string
// url, port: number | null
// }) sslPort: number | null
// } else { }) => {
// const hostnameKind = h.hostname.kind url.hostname = hostname.value
const useSsl =
hostname.port && hostname.sslPort
? this.config.isHttps()
: !!hostname.sslPort
url.protocol = useSsl
? `${addressInfo.sslScheme || 'https'}:`
: `${addressInfo.scheme || 'http'}:`
const port = useSsl ? hostname.sslPort : hostname.port
const omitPort = useSsl
? ui.addressInfo.sslScheme === 'https' && port === 443
: ui.addressInfo.scheme === 'http' && port === 80
if (!omitPort && port) url.port = String(port)
}
const useFirst = (
hostnames: (
| {
value: string
port: number | null
sslPort: number | null
}
| undefined
)[],
) => {
const first = hostnames.find(h => h)
if (first) {
use(first)
}
return !!first
}
// if ( const ipHostnames = hostnameInfos
// h.public || .filter(h => h.kind === 'ip')
// (hostnameKind === 'domain' && host.domains[h.hostname.domain]?.public) .map(h => h.hostname) as T.IpHostname[]
// ) { const domainHostname = ipHostnames
// clearnet.push({ .filter(h => h.kind === 'domain')
// url, .map(h => h as T.IpHostname & { kind: 'domain' })
// disabled: !h.public, .map(h => ({
// isDomain: hostnameKind == 'domain', value: h.domain,
// authority: sslPort: h.sslPort,
// hostnameKind == 'domain' port: h.port,
// ? host.domains[h.hostname.domain]?.acme || null }))[0]
// : null, const wanIpHostname = hostnameInfos
// }) .filter(h => h.kind === 'ip' && h.public && h.hostname.kind !== 'domain')
// } else { .map(h => h.hostname as Exclude<T.IpHostname, { kind: 'domain' }>)
// local.push({ .map(h => ({
// nid: value: h.value,
// hostnameKind === 'local' sslPort: h.sslPort,
// ? 'Local' port: h.port,
// : `${h.gatewayId} (${hostnameKind})`, }))[0]
// url, const onionHostname = hostnameInfos
// }) .filter(h => h.kind === 'onion')
// } .map(h => h as T.HostnameInfo & { kind: 'onion' })
// } .map(h => ({
// }) value: h.hostname.value,
// }) sslPort: h.hostname.sslPort,
port: h.hostname.port,
}))[0]
const localHostname = ipHostnames
.filter(h => h.kind === 'local')
.map(h => h as T.IpHostname & { kind: 'local' })
.map(h => ({ value: h.value, sslPort: h.sslPort, port: h.port }))[0]
return { if (this.config.isClearnet()) {
common: common.filter( if (
(value, index, self) => !useFirst([domainHostname, wanIpHostname, onionHostname, localHostname])
index === self.findIndex(t => t.url === value.url), ) {
), return ''
uncommon: uncommon.filter( }
(value, index, self) => } else if (this.config.isTor()) {
index === self.findIndex(t => t.url === value.url), if (
), !useFirst([onionHostname, domainHostname, wanIpHostname, localHostname])
) {
return ''
}
} else if (this.config.isIpv6()) {
const ipv6Hostname = ipHostnames.find(h => h.kind === 'ipv6') as {
kind: 'ipv6'
value: string
scopeId: number
port: number | null
sslPort: number | null
}
if (!useFirst([ipv6Hostname, localHostname])) {
return ''
}
} else {
// ipv4 or .local or localhost
if (!localHostname) return ''
use({
value: this.config.hostname,
port: localHostname.port,
sslPort: localHostname.sslPort,
})
}
return url.href
}
private hostnameInfo(
serviceInterface: T.ServiceInterface,
host: T.Host,
): T.HostnameInfo[] {
let hostnameInfo =
host.hostnameInfo[serviceInterface.addressInfo.internalPort]
return (
hostnameInfo?.filter(
h =>
this.config.isLocalhost() ||
!(
h.kind === 'ip' &&
((h.hostname.kind === 'ipv6' &&
h.hostname.value.startsWith('fe80::')) ||
h.gatewayId === 'lo')
),
) || []
)
} }
} }
@@ -119,6 +251,7 @@ export type MappedServiceInterface = T.ServiceInterface & {
common: Address[] common: Address[]
uncommon: Address[] uncommon: Address[]
} }
isOs: boolean
} }
export type InterfaceGateway = { export type InterfaceGateway = {

View File

@@ -108,7 +108,9 @@ export class ServiceInterfaceItemComponent {
} }
get href() { get href() {
return this.config.launchableAddress(this.info, this.pkg.hosts) const host = this.pkg.hosts[this.info.addressInfo.hostId]
if (!host) return ''
return this.config.launchableAddress(this.info, host)
} }
openUI() { openUI() {

View File

@@ -86,7 +86,9 @@ export class UILaunchComponent {
} }
getHref(ui: T.ServiceInterface): string { getHref(ui: T.ServiceInterface): string {
return this.config.launchableAddress(ui, this.pkg.hosts) const host = this.pkg.hosts[ui.addressInfo.hostId]
if (!host) return ''
return this.config.launchableAddress(ui, host)
} }
openUI(ui: T.ServiceInterface) { openUI(ui: T.ServiceInterface) {

View File

@@ -15,7 +15,6 @@ import { TuiBadge, TuiBreadcrumbs } from '@taiga-ui/kit'
import { TuiHeader } from '@taiga-ui/layout' import { TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component' import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
import { ConfigService } from 'src/app/services/config.service' import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model' import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service' import { TitleDirective } from 'src/app/services/title.service'
@@ -120,10 +119,11 @@ export default class ServiceInterfaceRoute {
return { return {
...item, ...item,
addresses: getAddresses(item, host, this.config), addresses: this.config.getAddresses(item, host),
gateways: [], gateways: [],
torDomains: [], torDomains: [],
clearnetDomains: [], clearnetDomains: [],
isOs: false,
} }
}) })

View File

@@ -35,7 +35,7 @@ import { TitleDirective } from 'src/app/services/title.service'
</hgroup> </hgroup>
</header> </header>
@if (ui(); as ui) { @if (ui(); as ui) {
<service-interface [value]="ui" [isRunning]="true" [osUi]="true" /> <service-interface [value]="ui" [isRunning]="true" />
} }
`, `,
host: { class: 'g-subpage' }, host: { class: 'g-subpage' },
@@ -90,6 +90,7 @@ export default class StartOsUiComponent {
], ],
torDomains: [], torDomains: [],
clearnetDomains: [], clearnetDomains: [],
isOs: true,
} }
}), }),
), ),

View File

@@ -1,7 +1,6 @@
import { Inject, Injectable, DOCUMENT } from '@angular/core' import { Inject, Injectable, DOCUMENT } from '@angular/core'
import { WorkspaceConfig } from '@start9labs/shared' import { WorkspaceConfig } from '@start9labs/shared'
import { T, utils } from '@start9labs/start-sdk' import { T, utils } from '@start9labs/start-sdk'
import { PackageDataEntry } from './patch-db/data-model'
const { const {
gitHash, gitHash,
@@ -32,25 +31,23 @@ export class ConfigService {
return useMocks ? mocks.maskAs === 'tor' : this.hostname.endsWith('.onion') return useMocks ? mocks.maskAs === 'tor' : this.hostname.endsWith('.onion')
} }
isLocal(): boolean {
return useMocks
? mocks.maskAs === 'local'
: this.hostname.endsWith('.local')
}
isLocalhost(): boolean { isLocalhost(): boolean {
return useMocks return useMocks
? mocks.maskAs === 'localhost' ? mocks.maskAs === 'localhost'
: this.hostname === 'localhost' || this.hostname === '127.0.0.1' : this.hostname === 'localhost' || this.hostname === '127.0.0.1'
} }
isIpv4(): boolean { isLanHttp(): boolean {
return useMocks return !this.isTor() && !this.isLocalhost() && !this.isHttps()
? mocks.maskAs === 'ipv4'
: new RegExp(utils.Patterns.ipv4.regex).test(this.hostname)
} }
isLanIpv4(): boolean { private isLocal(): boolean {
return useMocks
? mocks.maskAs === 'local'
: this.hostname.endsWith('.local')
}
private isLanIpv4(): boolean {
return useMocks return useMocks
? mocks.maskAs === 'ipv4' ? mocks.maskAs === 'ipv4'
: new RegExp(utils.Patterns.ipv4.regex).test(this.hostname) && : new RegExp(utils.Patterns.ipv4.regex).test(this.hostname) &&
@@ -79,177 +76,7 @@ export class ConfigService {
!this.isIpv6() !this.isIpv6()
} }
isLanHttp(): boolean {
return !this.isTor() && !this.isLocalhost() && !this.isHttps()
}
isHttps(): boolean { isHttps(): boolean {
return useMocks ? mocks.maskAsHttps : this.protocol === 'https:' return useMocks ? mocks.maskAsHttps : this.protocol === 'https:'
} }
isSecure(): boolean {
return window.isSecureContext || this.isTor()
}
isLaunchable(
state: T.PackageState['state'],
status: T.MainStatus['main'],
): boolean {
return state === 'installed' && status === 'running'
}
/** ${scheme}://${username}@${host}:${externalPort}${suffix} */
launchableAddress(ui: T.ServiceInterface, hosts: T.Hosts): string {
const host = hosts[ui.addressInfo.hostId]
if (!host) return ''
let hostnameInfo = host.hostnameInfo[ui.addressInfo.internalPort]
hostnameInfo =
hostnameInfo?.filter(
h =>
this.isLocalhost() ||
h.kind !== 'ip' ||
h.hostname.kind !== 'ipv6' ||
!h.hostname.value.startsWith('fe80::'),
) || []
if (this.isLocalhost()) {
const local = hostnameInfo.find(
h => h.kind === 'ip' && h.hostname.kind === 'local',
)
if (local) {
hostnameInfo.unshift({
kind: 'ip',
gatewayId: 'lo',
public: false,
hostname: {
kind: 'local',
port: local.hostname.port,
sslPort: local.hostname.sslPort,
value: 'localhost',
},
})
}
}
if (!hostnameInfo) return ''
const addressInfo = ui.addressInfo
const username = addressInfo.username ? addressInfo.username + '@' : ''
const suffix = addressInfo.suffix || ''
const url = new URL(`https://${username}placeholder${suffix}`)
const use = (hostname: {
value: string
port: number | null
sslPort: number | null
}) => {
url.hostname = hostname.value
const useSsl =
hostname.port && hostname.sslPort ? this.isHttps() : !!hostname.sslPort
url.protocol = useSsl
? `${addressInfo.sslScheme || 'https'}:`
: `${addressInfo.scheme || 'http'}:`
const port = useSsl ? hostname.sslPort : hostname.port
const omitPort = useSsl
? ui.addressInfo.sslScheme === 'https' && port === 443
: ui.addressInfo.scheme === 'http' && port === 80
if (!omitPort && port) url.port = String(port)
}
const useFirst = (
hostnames: (
| {
value: string
port: number | null
sslPort: number | null
}
| undefined
)[],
) => {
const first = hostnames.find(h => h)
if (first) {
use(first)
}
return !!first
}
const ipHostnames = hostnameInfo
.filter(h => h.kind === 'ip')
.map(h => h.hostname) as T.IpHostname[]
const domainHostname = ipHostnames
.filter(h => h.kind === 'domain')
.map(h => h as T.IpHostname & { kind: 'domain' })
.map(h => ({
value: h.domain,
sslPort: h.sslPort,
port: h.port,
}))[0]
const wanIpHostname = hostnameInfo
.filter(h => h.kind === 'ip' && h.public && h.hostname.kind !== 'domain')
.map(h => h.hostname as Exclude<T.IpHostname, { kind: 'domain' }>)
.map(h => ({
value: h.value,
sslPort: h.sslPort,
port: h.port,
}))[0]
const onionHostname = hostnameInfo
.filter(h => h.kind === 'onion')
.map(h => h as T.HostnameInfo & { kind: 'onion' })
.map(h => ({
value: h.hostname.value,
sslPort: h.hostname.sslPort,
port: h.hostname.port,
}))[0]
const localHostname = ipHostnames
.filter(h => h.kind === 'local')
.map(h => h as T.IpHostname & { kind: 'local' })
.map(h => ({ value: h.value, sslPort: h.sslPort, port: h.port }))[0]
if (this.isClearnet()) {
if (
!useFirst([domainHostname, wanIpHostname, onionHostname, localHostname])
) {
return ''
}
} else if (this.isTor()) {
if (
!useFirst([onionHostname, domainHostname, wanIpHostname, localHostname])
) {
return ''
}
} else if (this.isIpv6()) {
const ipv6Hostname = ipHostnames.find(h => h.kind === 'ipv6') as {
kind: 'ipv6'
value: string
scopeId: number
port: number | null
sslPort: number | null
}
if (!useFirst([ipv6Hostname, localHostname])) {
return ''
}
} else {
// ipv4 or .local or localhost
if (!localHostname) return ''
use({
value: this.hostname,
port: localHostname.port,
sslPort: localHostname.sslPort,
})
}
return url.href
}
getHost(): string {
return this.host
}
}
export function hasUi(
interfaces: PackageDataEntry['serviceInterfaces'],
): boolean {
return Object.values(interfaces).some(iface => iface.type === 'ui')
} }