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() {
this.document.defaultView?.open(`https://${this.config.getHost()}`, '_self')
this.document.defaultView?.open(`https://${this.config.host}`, '_self')
}
private async testHttps() {

View File

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

View File

@@ -11,7 +11,7 @@ import { InterfaceAddressesComponent } from './addresses/addresses.component'
template: `
<!-- @TODO Alex / Matt translation in all nested components -->
<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 [clearnetDomains]="value().clearnetDomains"></section>
</div>
@@ -48,5 +48,4 @@ export class InterfaceComponent {
readonly packageId = input('')
readonly value = input.required<MappedServiceInterface>()
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'
export function getAddresses(
serviceInterface: T.ServiceInterface,
host: T.Host,
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::'),
) || []
type AddressWithInfo = {
address: URL
info: T.HostnameInfo
}
if (config.isLocalhost()) {
const local = hostnames.find(
h => h.kind === 'ip' && h.hostname.kind === 'local',
)
function filterTor(a: AddressWithInfo): a is (AddressWithInfo & { info: { kind: 'onion' } }) {
return a.info.kind === 'onion'
}
if (local) {
hostnames.unshift({
kind: 'ip',
gatewayId: 'lo',
public: false,
hostname: {
kind: 'local',
port: local.hostname.port,
sslPort: local.hostname.sslPort,
value: 'localhost',
},
function cmpTor(a: AddressWithInfo, b: AddressWithInfo): -1 | 0 | 1 {
if (!filterTor(a) || !filterTor(b)) return 0
for (let [x, y, sign] of [[a, b, 1] as const, [b, a, -1] as const]) {
if (x.address.protocol === 'http:' && y.address.protocol === 'https:')
return sign
}
return 0
}
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[] = [
{
type: 'Local',
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',
},
]
/** ${scheme}://${username}@${host}:${externalPort}${suffix} */
launchableAddress(ui: T.ServiceInterface, host: T.Host): string {
const hostnameInfos = this.hostnameInfo(ui, host)
// hostnames.forEach(h => {
// const addresses = utils.addressHostToUrl(addressInfo, h)
if (!hostnameInfos.length) return ''
// 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
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.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 (
// 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,
// })
// }
// }
// })
// })
const ipHostnames = hostnameInfos
.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 = hostnameInfos
.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 = 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 {
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),
),
if (this.config.isClearnet()) {
if (
!useFirst([domainHostname, wanIpHostname, onionHostname, localHostname])
) {
return ''
}
} else if (this.config.isTor()) {
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[]
uncommon: Address[]
}
isOs: boolean
}
export type InterfaceGateway = {

View File

@@ -108,7 +108,9 @@ export class ServiceInterfaceItemComponent {
}
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() {

View File

@@ -86,7 +86,9 @@ export class UILaunchComponent {
}
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) {

View File

@@ -15,7 +15,6 @@ import { TuiBadge, TuiBreadcrumbs } from '@taiga-ui/kit'
import { TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
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 { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
@@ -120,10 +119,11 @@ export default class ServiceInterfaceRoute {
return {
...item,
addresses: getAddresses(item, host, this.config),
addresses: this.config.getAddresses(item, host),
gateways: [],
torDomains: [],
clearnetDomains: [],
isOs: false,
}
})

View File

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

View File

@@ -1,7 +1,6 @@
import { Inject, Injectable, DOCUMENT } from '@angular/core'
import { WorkspaceConfig } from '@start9labs/shared'
import { T, utils } from '@start9labs/start-sdk'
import { PackageDataEntry } from './patch-db/data-model'
const {
gitHash,
@@ -32,25 +31,23 @@ export class ConfigService {
return useMocks ? mocks.maskAs === 'tor' : this.hostname.endsWith('.onion')
}
isLocal(): boolean {
return useMocks
? mocks.maskAs === 'local'
: this.hostname.endsWith('.local')
}
isLocalhost(): boolean {
return useMocks
? mocks.maskAs === 'localhost'
: this.hostname === 'localhost' || this.hostname === '127.0.0.1'
}
isIpv4(): boolean {
return useMocks
? mocks.maskAs === 'ipv4'
: new RegExp(utils.Patterns.ipv4.regex).test(this.hostname)
isLanHttp(): boolean {
return !this.isTor() && !this.isLocalhost() && !this.isHttps()
}
isLanIpv4(): boolean {
private isLocal(): boolean {
return useMocks
? mocks.maskAs === 'local'
: this.hostname.endsWith('.local')
}
private isLanIpv4(): boolean {
return useMocks
? mocks.maskAs === 'ipv4'
: new RegExp(utils.Patterns.ipv4.regex).test(this.hostname) &&
@@ -79,177 +76,7 @@ export class ConfigService {
!this.isIpv6()
}
isLanHttp(): boolean {
return !this.isTor() && !this.isLocalhost() && !this.isHttps()
}
isHttps(): boolean {
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')
}