diff --git a/web/projects/ui/src/app/routes/login/ca-wizard/ca-wizard.component.ts b/web/projects/ui/src/app/routes/login/ca-wizard/ca-wizard.component.ts index 51d0ffebc..6ebf64034 100644 --- a/web/projects/ui/src/app/routes/login/ca-wizard/ca-wizard.component.ts +++ b/web/projects/ui/src/app/routes/login/ca-wizard/ca-wizard.component.ts @@ -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() { diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/gateways.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/gateways.component.ts index 84139e581..77baa6a00 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/gateways.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/gateways.component.ts @@ -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" /> } @@ -37,7 +37,7 @@ import { InterfaceGateway } from './interface.utils' }) export class InterfaceGatewaysComponent { readonly gateways = input.required() - readonly osUi = input.required() + readonly isOs = input.required() async onToggle(gateway: InterfaceGateway) {} } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts index 8b8f83eeb..5fc504985 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts @@ -11,7 +11,7 @@ import { InterfaceAddressesComponent } from './addresses/addresses.component' template: `
-
+
@@ -48,5 +48,4 @@ export class InterfaceComponent { readonly packageId = input('') readonly value = input.required() readonly isRunning = input.required() - readonly osUi = input(false) } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.utils.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.utils.ts index b98db2de8..06fe430cf 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.utils.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.utils.ts @@ -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) + .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 = { diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/interface-item.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/interface-item.component.ts index 3f44f8969..ed574275e 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/interface-item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/interface-item.component.ts @@ -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() { diff --git a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/ui-launch.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/ui-launch.component.ts index 513b47edb..f15eee122 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/ui-launch.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/ui-launch.component.ts @@ -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) { diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts index a27745c58..095748506 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts @@ -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, } }) diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts index 9316d4d7f..d6294dcfc 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts @@ -35,7 +35,7 @@ import { TitleDirective } from 'src/app/services/title.service' @if (ui(); as ui) { - + } `, host: { class: 'g-subpage' }, @@ -90,6 +90,7 @@ export default class StartOsUiComponent { ], torDomains: [], clearnetDomains: [], + isOs: true, } }), ), diff --git a/web/projects/ui/src/app/services/config.service.ts b/web/projects/ui/src/app/services/config.service.ts index f4b337a06..ed9043b26 100644 --- a/web/projects/ui/src/app/services/config.service.ts +++ b/web/projects/ui/src/app/services/config.service.ts @@ -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) - .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') }