diff --git a/.gitignore b/.gitignore index caa8625f2..93ebb8f85 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ deploy/cloud/tars .vscode/ /lifecycle/tools/ .workflow/ +**/.impeccable/ **.env* !scripts/cloud/sealos.env diff --git a/frontend/providers/applaunchpad/__tests__/unit/pages/api/platform/getInitData.test.ts b/frontend/providers/applaunchpad/__tests__/unit/pages/api/platform/getInitData.test.ts index 82384b7cc..2b41ef861 100644 --- a/frontend/providers/applaunchpad/__tests__/unit/pages/api/platform/getInitData.test.ts +++ b/frontend/providers/applaunchpad/__tests__/unit/pages/api/platform/getInitData.test.ts @@ -15,7 +15,8 @@ const createConfig = (): AppConfigType => ({ common: { guideEnabled: false, apiEnabled: false, - gpuEnabled: false + gpuEnabled: false, + networkStorageEnabled: false }, launchpad: { infrastructure: { @@ -33,6 +34,13 @@ const createConfig = (): AppConfigType => ({ gtmId: null, currencySymbol: Coin.shellCoin, pvcStorageMax: 20, + imagePorts: { + enabled: false + }, + publicDomain: { + customPrefixEnabled: false, + reservedPrefixes: [] + }, eventAnalyze: { enabled: false }, @@ -45,12 +53,6 @@ const createConfig = (): AppConfigType => ({ fileManger: { uploadLimit: 50, downloadLimit: 100 - }, - checkIcpReg: { - enabled: false, - endpoint: '', - accessKeyID: '', - accessKeySecret: '' } } }); @@ -63,4 +65,26 @@ describe('getServerEnv', () => { expect(env.HTTP_PORT).toBe(':80'); expect(env.DISABLE_HTTPS).toBe(true); }); + + it('defaults new branch feature gates to disabled', () => { + const env = getServerEnv(createConfig()); + + expect(env.IMAGE_PORTS_ENABLED).toBe(false); + expect(env.CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED).toBe(false); + }); + + it('returns enabled branch feature gates from config', () => { + const config = createConfig(); + config.launchpad.imagePorts = { enabled: true }; + config.launchpad.publicDomain = { + customPrefixEnabled: true, + reservedPrefixes: ['admin'] + }; + + const env = getServerEnv(config); + + expect(env.IMAGE_PORTS_ENABLED).toBe(true); + expect(env.CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED).toBe(true); + expect(env.PUBLIC_DOMAIN_RESERVED_PREFIXES).toEqual(['admin']); + }); }); diff --git a/frontend/providers/applaunchpad/__tests__/unit/types/request-schema.test.ts b/frontend/providers/applaunchpad/__tests__/unit/types/request-schema.test.ts new file mode 100644 index 000000000..674b6646f --- /dev/null +++ b/frontend/providers/applaunchpad/__tests__/unit/types/request-schema.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + UpdateAppResourcesSchema as V1UpdateAppResourcesSchema, + transformFromLegacySchema as transformFromLegacySchemaV1 +} from '@/types/request_schema'; +import { + UpdateAppResourcesSchema as V2UpdateAppResourcesSchema, + transformFromLegacySchema as transformFromLegacySchemaV2 +} from '@/types/v2alpha/request_schema'; + +function setCustomPublicDomainPrefixEnabled(enabled: boolean) { + (globalThis as any).AppConfig = { + cloud: { + domain: 'cloud.example.com' + }, + launchpad: { + publicDomain: { + customPrefixEnabled: enabled, + reservedPrefixes: [] + } + } + }; +} + +function createLegacyApp() { + return { + appName: 'demo', + imageName: 'nginx:latest', + secret: { use: false }, + runCMD: '', + cmdParam: '', + hpa: { use: false }, + replicas: 1, + cpu: 200, + memory: 512, + networks: [ + { + serviceName: 'service-demo', + networkName: 'network-demo', + portName: 'port-demo', + port: 80, + protocol: 'TCP', + appProtocol: 'HTTP', + openPublicDomain: true, + publicDomain: 'demo-prefix', + customDomain: '', + domain: 'cloud.example.com', + openNodePort: false + } + ], + envs: [], + configMapList: [], + storeList: [], + kind: 'deployment', + id: 'demo-id', + createTime: '2026-06-16 00:00', + status: { value: 'Running' }, + openapi: { + status: { + observedGeneration: 1, + replicas: 1, + availableReplicas: 1, + updatedReplicas: 1, + isPause: false + } + } + } as any; +} + +describe('request schema publicDomain feature gate', () => { + afterEach(() => { + delete (globalThis as any).AppConfig; + }); + + it('omits v1 publicDomain prefixes from GET responses when custom prefixes are disabled', () => { + setCustomPublicDomainPrefixEnabled(false); + + const response = transformFromLegacySchemaV1(createLegacyApp()); + + expect(response.ports?.[0]).not.toHaveProperty('publicDomain'); + expect( + V1UpdateAppResourcesSchema.safeParse({ + resource: { cpu: 0.2 }, + ports: response.ports + }).success + ).toBe(true); + }); + + it('omits v2alpha publicDomain prefixes from GET responses when custom prefixes are disabled', () => { + setCustomPublicDomainPrefixEnabled(false); + + const response = transformFromLegacySchemaV2(createLegacyApp(), 'demo', 'ns-demo'); + + expect(response.ports?.[0]).not.toHaveProperty('publicDomain'); + expect( + V2UpdateAppResourcesSchema.safeParse({ + quota: { cpu: 0.2 }, + ports: response.ports + }).success + ).toBe(true); + }); + + it('still rejects explicit custom prefixes while the feature gate is disabled', () => { + setCustomPublicDomainPrefixEnabled(false); + + expect( + V1UpdateAppResourcesSchema.safeParse({ + ports: [{ portName: 'port-demo', publicDomain: 'demo-prefix' }] + }).success + ).toBe(false); + expect( + V2UpdateAppResourcesSchema.safeParse({ + ports: [{ portName: 'port-demo', publicDomain: 'demo-prefix' }] + }).success + ).toBe(false); + }); + + it('keeps publicDomain prefixes in responses when custom prefixes are enabled', () => { + setCustomPublicDomainPrefixEnabled(true); + + expect(transformFromLegacySchemaV1(createLegacyApp()).ports?.[0]).toHaveProperty( + 'publicDomain', + 'demo-prefix' + ); + expect(transformFromLegacySchemaV2(createLegacyApp(), 'demo', 'ns-demo').ports?.[0]).toHaveProperty( + 'publicDomain', + 'demo-prefix' + ); + }); +}); diff --git a/frontend/providers/applaunchpad/__tests__/unit/utils/image-exposed-ports.test.ts b/frontend/providers/applaunchpad/__tests__/unit/utils/image-exposed-ports.test.ts new file mode 100644 index 000000000..0db7bf2b1 --- /dev/null +++ b/frontend/providers/applaunchpad/__tests__/unit/utils/image-exposed-ports.test.ts @@ -0,0 +1,262 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { lookup } from 'dns/promises'; +import { request as httpRequest } from 'http'; +import { request as httpsRequest } from 'https'; +import { EventEmitter } from 'events'; +import { Readable } from 'stream'; +import type { IncomingMessage, RequestOptions } from 'http'; +import { getImageExposedPorts, parseExposedPorts, parseImageRef } from '@/utils/image-exposed-ports'; + +vi.mock('dns/promises', () => ({ + lookup: vi.fn() +})); + +vi.mock('http', () => ({ + request: vi.fn() +})); + +vi.mock('https', () => ({ + request: vi.fn() +})); + +const lookupMock = vi.mocked(lookup); +const httpRequestMock = vi.mocked(httpRequest); +const httpsRequestMock = vi.mocked(httpsRequest); + +type MockRegistryResponse = { + body?: unknown; + headers?: Record; + status?: number; +}; + +type MockRequestCall = { + url: URL; + options: RequestOptions; +}; + +const requestCalls: MockRequestCall[] = []; + +function createResponse(response: MockRegistryResponse) { + const body = + typeof response.body === 'string' + ? response.body + : JSON.stringify(response.body ?? {}); + const stream = Readable.from([body]) as IncomingMessage; + stream.statusCode = response.status ?? 200; + stream.headers = { + 'content-type': 'application/json', + ...(response.headers || {}) + }; + return stream; +} + +function createRequestMock(responses: MockRegistryResponse[]) { + return vi.fn((url: string | URL, options: RequestOptions, callback: (res: IncomingMessage) => void) => { + const request = new EventEmitter() as EventEmitter & { end: () => void }; + const parsedUrl = typeof url === 'string' ? new URL(url) : url; + requestCalls.push({ url: parsedUrl, options }); + + request.end = () => { + const lookupFn = options.lookup; + const finish = () => { + const response = responses.shift(); + if (!response) { + request.emit('error', new Error(`Unexpected request to ${parsedUrl.toString()}`)); + return; + } + callback(createResponse(response)); + }; + + if (!lookupFn) { + finish(); + return; + } + + lookupFn(parsedUrl.hostname, {}, (error) => { + if (error) { + request.emit('error', error); + return; + } + finish(); + }); + }; + return request; + }); +} + +function mockRegistryRequests(...responses: MockRegistryResponse[]) { + const requestMock = createRequestMock([...responses]); + httpRequestMock.mockImplementation(requestMock); + httpsRequestMock.mockImplementation(requestMock); + return requestMock; +} + +afterEach(() => { + vi.clearAllMocks(); + lookupMock.mockReset(); + requestCalls.length = 0; +}); + +describe('parseExposedPorts', () => { + it('parses, normalizes, deduplicates, and sorts exposed ports', () => { + expect( + parseExposedPorts({ + '8080/tcp': {}, + '80/TCP': {}, + '53/udp': {}, + '80/tcp': {}, + '70000/tcp': {}, + bad: {} + }) + ).toEqual([ + { port: 53, protocol: 'UDP' }, + { port: 80, protocol: 'TCP' }, + { port: 8080, protocol: 'TCP' } + ]); + }); +}); + +describe('parseImageRef', () => { + it('keeps digest references intact and strips optional tags from the repository', () => { + expect(parseImageRef('nginx:1.27@sha256:abc123')).toEqual({ + registry: 'registry-1.docker.io', + repository: 'library/nginx', + reference: 'sha256:abc123' + }); + }); + + it('respects private registry overrides without rewriting repository paths', () => { + expect(parseImageRef('team/api:1.0.0', 'registry.example.com')).toEqual({ + registry: 'registry.example.com', + repository: 'team/api', + reference: '1.0.0' + }); + }); +}); + +describe('getImageExposedPorts registry safety', () => { + it('rejects loopback registry hosts before fetch', async () => { + const requestMock = mockRegistryRequests(); + + await expect(getImageExposedPorts('localhost:5000/team/api:latest')).rejects.toThrow( + 'Registry host is not allowed' + ); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it('rejects registry hosts that resolve to private addresses', async () => { + const requestMock = mockRegistryRequests(); + lookupMock.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]); + + await expect(getImageExposedPorts('registry.example.com/team/api:latest')).rejects.toThrow( + 'Registry host resolves to a private address' + ); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it('rejects registry redirects to private addresses', async () => { + lookupMock + .mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }]) + .mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }]) + .mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }]) + .mockResolvedValueOnce([{ address: '169.254.169.254', family: 4 }]); + const requestMock = mockRegistryRequests({ + status: 302, + headers: { + location: 'https://metadata.example/latest/meta-data' + } + }); + + await expect(getImageExposedPorts('registry.example.com/team/api:latest')).rejects.toThrow( + 'Registry host resolves to a private address' + ); + expect(requestMock).toHaveBeenCalledTimes(1); + expect(requestCalls[0].url.toString()).toBe( + 'https://registry.example.com/v2/team/api/manifests/latest' + ); + }); + + it('rejects DNS rebinding during the connection lookup', async () => { + lookupMock + .mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }]) + .mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }]) + .mockResolvedValueOnce([{ address: '10.0.0.8', family: 4 }]); + const requestMock = mockRegistryRequests({ body: { config: { digest: 'sha256:config' } } }); + + await expect(getImageExposedPorts('registry.example.com/team/api:latest')).rejects.toThrow( + 'Registry host resolves to a private address' + ); + expect(requestMock).toHaveBeenCalledTimes(1); + }); + + it('does not forward registry credentials to an untrusted challenge realm', async () => { + lookupMock.mockResolvedValue([{ address: '8.8.8.8', family: 4 }]); + const requestMock = mockRegistryRequests({ + status: 401, + body: '', + headers: { + 'www-authenticate': + 'Bearer realm="https://attacker.example/token",service="registry.example.com"' + } + }); + + await expect( + getImageExposedPorts('registry.example.com/team/api:latest', { + username: 'user', + password: 'password' + }) + ).rejects.toThrow('Registry auth realm is not trusted'); + expect(requestMock).toHaveBeenCalledTimes(1); + }); + + it('does not fall back to Docker Hub auth for private registry challenges', async () => { + lookupMock.mockResolvedValue([{ address: '8.8.8.8', family: 4 }]); + const requestMock = mockRegistryRequests({ + status: 401, + body: '', + headers: { + 'www-authenticate': 'Bearer' + } + }); + + await expect( + getImageExposedPorts('registry.example.com/team/api:latest', { + username: 'user', + password: 'password' + }) + ).rejects.toThrow('Registry auth challenge is invalid'); + expect(requestMock).toHaveBeenCalledTimes(1); + }); + + it('aborts oversized registry JSON responses', async () => { + lookupMock.mockResolvedValue([{ address: '8.8.8.8', family: 4 }]); + const largeManifest = { manifests: [{ digest: 'sha256:' + 'a'.repeat(1024 * 1024) }] }; + mockRegistryRequests({ body: largeManifest }); + + await expect(getImageExposedPorts('registry.example.com/team/api:latest')).rejects.toThrow( + 'Image manifest is too large' + ); + }); + + it('parses exposed ports after trusted registry manifest and config fetches', async () => { + lookupMock.mockResolvedValue([{ address: '8.8.8.8', family: 4 }]); + mockRegistryRequests( + { body: { config: { digest: 'sha256:config' } } }, + { + body: { + config: { + ExposedPorts: { + '8080/tcp': {}, + '8443/tcp': {} + } + } + } + } + ); + + await expect(getImageExposedPorts('registry.example.com/team/api:latest')).resolves.toEqual([ + { port: 8080, protocol: 'TCP' }, + { port: 8443, protocol: 'TCP' } + ]); + }); +}); diff --git a/frontend/providers/applaunchpad/__tests__/unit/utils/public-domain.test.ts b/frontend/providers/applaunchpad/__tests__/unit/utils/public-domain.test.ts new file mode 100644 index 000000000..ee17b77c7 --- /dev/null +++ b/frontend/providers/applaunchpad/__tests__/unit/utils/public-domain.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + isCustomPublicDomainPrefixEnabled, + isImagePortsEnabled +} from '@/utils/feature-gates'; +import { setPublicDomainReservedPrefixes, validatePublicDomainPrefix } from '@/utils/public-domain'; +import { + getPublicDomainConflictResponse, + isIngressPublicDomainConflictError +} from '@/services/backend/publicDomain'; + +describe('validatePublicDomainPrefix', () => { + afterEach(() => { + setPublicDomainReservedPrefixes([]); + }); + + it('normalizes and accepts dns-safe prefixes', () => { + expect(validatePublicDomainPrefix(' My-App1 ')).toEqual({ + valid: true, + value: 'my-app1' + }); + }); + + it('rejects reserved prefixes', () => { + setPublicDomainReservedPrefixes(['admin']); + + expect(validatePublicDomainPrefix('admin')).toEqual({ + valid: false, + value: 'admin', + reason: 'reserved' + }); + }); + + it('rejects invalid dns label shapes', () => { + expect(validatePublicDomainPrefix('-bad')).toMatchObject({ valid: false, reason: 'format' }); + expect(validatePublicDomainPrefix('bad-')).toMatchObject({ valid: false, reason: 'format' }); + expect(validatePublicDomainPrefix('ab')).toMatchObject({ valid: false, reason: 'format' }); + }); +}); + +describe('feature gates', () => { + it('defaults branch feature gates to disabled', () => { + expect(isImagePortsEnabled()).toBe(false); + expect(isCustomPublicDomainPrefixEnabled()).toBe(false); + }); + + it('reads branch feature gates from config', () => { + const config = { + launchpad: { + imagePorts: { + enabled: true + }, + publicDomain: { + customPrefixEnabled: true + } + } + }; + + expect(isImagePortsEnabled(config)).toBe(true); + expect(isCustomPublicDomainPrefixEnabled(config)).toBe(true); + }); +}); + +describe('isIngressPublicDomainConflictError', () => { + it('detects ingress admission owner conflicts', () => { + const error = { + body: { + message: + 'admission webhook "vingress.sealos.io" denied the request: 40301: ingress host demo.cloud.sealos.io is owned by other user, you can not create ingress with same host.' + } + }; + + expect(isIngressPublicDomainConflictError(error)).toBe(true); + expect(getPublicDomainConflictResponse(error)).toMatchObject({ + code: 'PUBLIC_DOMAIN_CONFLICT', + message: 'Public domain is already in use by another workspace.' + }); + }); + + it('detects ingress admission owner conflicts from Kubernetes Error body', () => { + const error = new Error('Forbidden'); + Object.assign(error, { + body: { + message: + 'admission webhook "vingress.sealos.io" denied the request: 40301: ingress host devbox.192.168.13.209.nip.io is owned by other user, you can not create ingress with same host.' + } + }); + + expect(isIngressPublicDomainConflictError(error)).toBe(true); + expect(getPublicDomainConflictResponse(error)).toMatchObject({ + code: 'PUBLIC_DOMAIN_CONFLICT', + details: + 'admission webhook "vingress.sealos.io" denied the request: 40301: ingress host devbox.192.168.13.209.nip.io is owned by other user, you can not create ingress with same host.' + }); + }); + + it('does not treat other admission failures as public domain conflicts', () => { + expect( + isIngressPublicDomainConflictError({ + body: { + message: + 'admission webhook "vingress.sealos.io" denied the request: 40300: can not verify ingress host' + } + }) + ).toBe(false); + }); +}); diff --git a/frontend/providers/applaunchpad/data/config.yaml b/frontend/providers/applaunchpad/data/config.yaml index b50cc7b96..5b6f5584f 100644 --- a/frontend/providers/applaunchpad/data/config.yaml +++ b/frontend/providers/applaunchpad/data/config.yaml @@ -25,6 +25,11 @@ launchpad: scripts: [] gtmId: null pvcStorageMax: 100 + imagePorts: + enabled: false + publicDomain: + customPrefixEnabled: false + reservedPrefixes: [] eventAnalyze: enabled: false fastGPTKey: '' diff --git a/frontend/providers/applaunchpad/deploy/charts/applaunchpad-frontend/applaunchpad-frontend-values.yaml b/frontend/providers/applaunchpad/deploy/charts/applaunchpad-frontend/applaunchpad-frontend-values.yaml index 5e1e4dd36..43c525734 100644 --- a/frontend/providers/applaunchpad/deploy/charts/applaunchpad-frontend/applaunchpad-frontend-values.yaml +++ b/frontend/providers/applaunchpad/deploy/charts/applaunchpad-frontend/applaunchpad-frontend-values.yaml @@ -10,3 +10,8 @@ resources: requests: cpu: 10m memory: 128Mi + +applaunchpadConfig: + imagePortsEnabled: false + customPublicDomainPrefixEnabled: false + publicDomainReservedPrefixes: [] diff --git a/frontend/providers/applaunchpad/deploy/charts/applaunchpad-frontend/templates/configmap.yaml b/frontend/providers/applaunchpad/deploy/charts/applaunchpad-frontend/templates/configmap.yaml index ec6db2149..414cccfc0 100644 --- a/frontend/providers/applaunchpad/deploy/charts/applaunchpad-frontend/templates/configmap.yaml +++ b/frontend/providers/applaunchpad/deploy/charts/applaunchpad-frontend/templates/configmap.yaml @@ -31,6 +31,11 @@ data: eventAnalyze: enabled: false fastGPTKey: "" + imagePorts: + enabled: {{ .Values.applaunchpadConfig.imagePortsEnabled }} + publicDomain: + customPrefixEnabled: {{ .Values.applaunchpadConfig.customPublicDomainPrefixEnabled }} + reservedPrefixes:{{ toYaml .Values.applaunchpadConfig.publicDomainReservedPrefixes | nindent 10 }} components: monitor: url: {{ .Values.applaunchpadConfig.monitorUrl }} diff --git a/frontend/providers/applaunchpad/deploy/charts/applaunchpad-frontend/values.yaml b/frontend/providers/applaunchpad/deploy/charts/applaunchpad-frontend/values.yaml index 3cf1aeb3a..a17df2819 100644 --- a/frontend/providers/applaunchpad/deploy/charts/applaunchpad-frontend/values.yaml +++ b/frontend/providers/applaunchpad/deploy/charts/applaunchpad-frontend/values.yaml @@ -49,6 +49,9 @@ applaunchpadConfig: billingUrl: "http://account-service.account-system.svc:2333" logUrl: "" tlsRejectUnauthorized: "1" + imagePortsEnabled: false + customPublicDomainPrefixEnabled: false + publicDomainReservedPrefixes: [] affinity: podAntiAffinity: diff --git a/frontend/providers/applaunchpad/public/locales/en/common.json b/frontend/providers/applaunchpad/public/locales/en/common.json index dba6d6233..a40c96efd 100644 --- a/frontend/providers/applaunchpad/public/locales/en/common.json +++ b/frontend/providers/applaunchpad/public/locales/en/common.json @@ -98,6 +98,10 @@ "Image Name": "Image Name", "Image Name (Private)": "Image Name (Private)", "Image name cannot be empty": "Image name is required.", + "image_ports_detection_failed": "Port detection failed", + "no_image_ports_detected": "No exposed ports detected", + "recognized_image_ports": "{{count}} ports detected", + "recognizing_image_ports": "Detecting ports", "Input your custom domain": "Enter your custom domain", "Intelligent Analysis": "Smart Insights", "Inventory": "Stock", @@ -251,6 +255,7 @@ "contains": "contains", "cpu": "CPU", "custom_domain_input_title": "Your Domain", + "bind_custom_domain": "Bind custom domain", "custom_domain_cname_required": "Point {{customDomain}} CNAME to {{publicDomain}} before submitting.", "day": "days", "delete_app_tip": "Are you sure you want to delete this application? All project data will be permanently lost if you proceed.", @@ -286,6 +291,14 @@ "domain_verification_refresh": "Refresh", "domain_verification_success": "Your domain has been successfully bound.", "domain_verified": "Verified", + "public_domain_prefix_conflict_error": "This public address prefix is already in use. Please choose another one.", + "public_domain_prefix_conflict_owner_error": "This public address prefix is already used by {{type}} \"{{name}}\" in this workspace. Please choose another one.", + "public_domain_prefix_duplicate_error": "This public address prefix is duplicated in this app. Please choose another one.", + "public_domain_prefix_edit_tooltip": "Edit the public address prefix", + "public_domain_prefix_format_error": "Use {{min}}-{{max}} lowercase letters, numbers, or hyphens. It cannot start or end with a hyphen.", + "public_domain_prefix_input_label": "Public address prefix", + "public_domain_prefix_placeholder": "Edit prefix", + "public_domain_prefix_reserved_error": "This public address prefix is reserved. Please choose another one.", "download": "Download", "driver": { "access_application": "Access Application", diff --git a/frontend/providers/applaunchpad/public/locales/zh/common.json b/frontend/providers/applaunchpad/public/locales/zh/common.json index 570ba7919..ab3f9cf27 100644 --- a/frontend/providers/applaunchpad/public/locales/zh/common.json +++ b/frontend/providers/applaunchpad/public/locales/zh/common.json @@ -98,6 +98,10 @@ "Image Name": "镜像名", "Image Name (Private)": "镜像名称(私有)", "Image name cannot be empty": "镜像名称不能为空。", + "image_ports_detection_failed": "端口识别失败", + "no_image_ports_detected": "未识别到暴露端口", + "recognized_image_ports": "已识别 {{count}} 个端口", + "recognizing_image_ports": "正在识别端口", "Input your custom domain": "输入您的自定义域名", "Intelligent Analysis": "智能分析", "Inventory": "库存", @@ -251,6 +255,7 @@ "contains": "包含", "cpu": "CPU", "custom_domain_input_title": "您的域名", + "bind_custom_domain": "绑定自定义域名", "custom_domain_cname_required": "请先将 {{customDomain}} 的 CNAME 解析到 {{publicDomain}},生效后再提交。", "day": "天", "delete_app_tip": "确定要删除此应用吗?如果继续,该项目的所有数据将被删除。", @@ -286,6 +291,14 @@ "domain_verification_refresh": "刷新", "domain_verification_success": "您的域名已成功绑定。", "domain_verified": "验证通过", + "public_domain_prefix_conflict_error": "该公网地址前缀已被占用,请换一个", + "public_domain_prefix_conflict_owner_error": "该公网地址前缀已被当前工作区的 {{type}}「{{name}}」占用,请换一个", + "public_domain_prefix_duplicate_error": "当前应用内已有端口使用该公网地址前缀,请换一个", + "public_domain_prefix_edit_tooltip": "可修改公网地址前缀", + "public_domain_prefix_format_error": "公网地址前缀需为 {{min}}-{{max}} 位小写字母、数字或连字符,且不能以连字符开头或结尾", + "public_domain_prefix_input_label": "公网地址前缀", + "public_domain_prefix_placeholder": "修改前缀", + "public_domain_prefix_reserved_error": "该公网地址前缀为系统保留,请换一个", "download": "下载", "driver": { "access_application": "访问应用", diff --git a/frontend/providers/applaunchpad/src/api/platform.ts b/frontend/providers/applaunchpad/src/api/platform.ts index 7c9db282c..f5837582e 100644 --- a/frontend/providers/applaunchpad/src/api/platform.ts +++ b/frontend/providers/applaunchpad/src/api/platform.ts @@ -3,6 +3,7 @@ import type { UserQuotaItemType, UserTask, userPriceType } from '@/types/user'; import { getUserSession } from '@/utils/user'; import { AuthCnamePrams, AuthDomainChallengeParams } from './params'; import type { EnvResponse } from '@/types'; +import type { PublicDomainConflictOwner } from '@/utils/public-domain'; export const getResourcePrice = () => GET('/api/platform/resourcePrice'); @@ -27,6 +28,29 @@ export const postAuthDomainChallenge = (data: AuthDomainChallengeParams) => }; }>('/api/platform/authDomainChallenge', data); +export const getImagePorts = (data: { + imageName: string; + imageRegistry?: { + username?: string; + password?: string; + serverAddress?: string; + }; +}) => + POST<{ + ports: { + port: number; + protocol: 'TCP' | 'UDP' | 'SCTP'; + }[]; + }>('/api/platform/getImagePorts', data); + +export const checkPublicDomain = (data: { prefix: string; domain: string; appName?: string }) => + POST<{ + available: boolean; + prefix?: string; + host?: string; + conflictOwner?: PublicDomainConflictOwner; + }>('/api/platform/checkPublicDomain', data); + export const getUserTasks = () => GET<{ needGuide: boolean; task: UserTask }>('/api/guide/getTasks', undefined, { headers: { diff --git a/frontend/providers/applaunchpad/src/instrumentation.ts b/frontend/providers/applaunchpad/src/instrumentation.ts index b22d27040..d680da5e9 100644 --- a/frontend/providers/applaunchpad/src/instrumentation.ts +++ b/frontend/providers/applaunchpad/src/instrumentation.ts @@ -6,6 +6,8 @@ export async function register() { const yaml = (await import('js-yaml')).default; const fs = (await import('node:fs')).default; const getGpuNode = (await import('./services/backend/gpu')).getGpuNode; + const setPublicDomainReservedPrefixes = (await import('./utils/public-domain')) + .setPublicDomainReservedPrefixes; async function loadConfig() { const defaultAppConfig: AppConfigType = { @@ -44,6 +46,13 @@ export async function register() { gtmId: null, currencySymbol: Coin.shellCoin, pvcStorageMax: 20, + imagePorts: { + enabled: false + }, + publicDomain: { + customPrefixEnabled: false, + reservedPrefixes: [] + }, eventAnalyze: { enabled: false, fastGPTKey: '' @@ -84,6 +93,7 @@ export async function register() { ...res }; global.AppConfig = config; + setPublicDomainReservedPrefixes(global.AppConfig.launchpad.publicDomain?.reservedPrefixes); const gpuNodes = await getGpuNode(); console.log(gpuNodes, 'gpuNodes'); global.AppConfig.common.gpuEnabled = gpuNodes.length > 0; diff --git a/frontend/providers/applaunchpad/src/pages/api/applyApp.ts b/frontend/providers/applaunchpad/src/pages/api/applyApp.ts index 26c92b4ad..a21375667 100644 --- a/frontend/providers/applaunchpad/src/pages/api/applyApp.ts +++ b/frontend/providers/applaunchpad/src/pages/api/applyApp.ts @@ -2,9 +2,70 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { ApiResp } from '@/services/kubernet'; import { authSession } from '@/services/backend/auth'; import { getK8s } from '@/services/backend/kubernetes'; -import { handleK8sError, jsonRes } from '@/services/backend/response'; +import { getPublicDomainErrorResponse, handleK8sError, jsonRes } from '@/services/backend/response'; import yaml from 'js-yaml'; import { generateOwnerReference, shouldHaveOwnerReference } from '@/utils/deployYaml2Json'; +import { appDeployKey } from '@/constants/app'; +import { + ensurePublicDomainTargetsAvailable, + PublicDomainError, + PublicDomainTarget +} from '@/services/backend/publicDomain'; + +type K8sResource = { + kind?: string; + metadata?: { + name?: string; + labels?: Record; + ownerReferences?: any[]; + }; + spec?: { + rules?: { + host?: string; + }[]; + }; +}; + +type WorkloadResource = K8sResource & { + kind: 'Deployment' | 'StatefulSet'; +}; + +function isWorkloadResource(resource: K8sResource): resource is WorkloadResource { + return resource.kind === 'Deployment' || resource.kind === 'StatefulSet'; +} + +function getManagedDomains() { + return [ + global.AppConfig?.cloud?.domain, + ...(global.AppConfig?.cloud?.userDomains || []).map((domain: { name: string }) => domain.name) + ].filter((domain): domain is string => Boolean(domain)); +} + +function getPublicDomainTargets(resources: K8sResource[]): PublicDomainTarget[] { + const workload = resources.find(isWorkloadResource); + const fallbackAppName = workload?.metadata?.name; + const managedDomains = getManagedDomains(); + + return resources + .filter((resource) => resource.kind === 'Ingress') + .flatMap((resource) => + (resource.spec?.rules || []) + .map((rule) => rule.host) + .filter((host): host is string => typeof host === 'string' && !host.startsWith('*.')) + .flatMap((host) => { + const domain = managedDomains.find((item) => host.endsWith(`.${item}`)); + if (!domain) return []; + return [ + { + prefix: host.slice(0, -domain.length - 1), + domain, + appName: resource.metadata?.labels?.[appDeployKey] || fallbackAppName, + networkName: resource.metadata?.name + } + ]; + }) + ); +} export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { yamlList, mode = 'create' }: { yamlList: string[]; mode?: 'create' | 'replace' } = @@ -17,19 +78,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< return; } try { - const { k8sApp, applyYamlList, namespace } = await getK8s({ + const { k8sApp, k8sNetworkingApp, applyYamlList, namespace } = await getK8s({ kubeconfig: await authSession(req.headers) }); - const allResources: any[] = []; + const allResources: K8sResource[] = []; yamlList.forEach((yamlStr) => { - const resources = yaml.loadAll(yamlStr).filter((item) => item); + const resources = yaml.loadAll(yamlStr).filter((item): item is K8sResource => Boolean(item)); allResources.push(...resources); }); - const mainWorkloadIndex = allResources.findIndex( - (resource) => resource.kind === 'Deployment' || resource.kind === 'StatefulSet' - ); + await ensurePublicDomainTargetsAvailable(getPublicDomainTargets(allResources), { + k8sNetworkingApp, + namespace + }); + + const mainWorkloadIndex = allResources.findIndex(isWorkloadResource); if (mainWorkloadIndex === -1) { const applyRes = await applyYamlList(yamlList, mode); @@ -37,7 +101,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< return; } - const mainWorkload = allResources[mainWorkloadIndex]; + const mainWorkload = allResources[mainWorkloadIndex] as WorkloadResource; + const mainWorkloadName = mainWorkload.metadata?.name; + if (!mainWorkloadName) { + throw new Error('Workload metadata.name is required'); + } const dependentResources = allResources.filter((_, index) => index !== mainWorkloadIndex); const mainWorkloadYaml = yaml.dump(mainWorkload); @@ -47,16 +115,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< let workloadUid: string; try { if (mainWorkload.kind === 'Deployment') { - const deployment = await k8sApp.readNamespacedDeployment( - mainWorkload.metadata.name, - namespace - ); + const deployment = await k8sApp.readNamespacedDeployment(mainWorkloadName, namespace); workloadUid = deployment.body.metadata?.uid || ''; } else { - const statefulSet = await k8sApp.readNamespacedStatefulSet( - mainWorkload.metadata.name, - namespace - ); + const statefulSet = await k8sApp.readNamespacedStatefulSet(mainWorkloadName, namespace); workloadUid = statefulSet.body.metadata?.uid || ''; } } catch (err) { @@ -69,13 +131,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< } const ownerReferences = generateOwnerReference( - mainWorkload.metadata.name, + mainWorkloadName, mainWorkload.kind, workloadUid ); dependentResources.forEach((resource) => { - if (shouldHaveOwnerReference(resource.kind)) { + if (resource.kind && shouldHaveOwnerReference(resource.kind)) { if (!resource.metadata) { resource.metadata = {}; } @@ -92,6 +154,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< jsonRes(res, { data: allKinds }); } catch (err: any) { console.log(err); + if (err instanceof PublicDomainError) { + return jsonRes(res, { + code: err.status, + message: err.message, + error: getPublicDomainErrorResponse(err) + }); + } jsonRes(res, handleK8sError(err)); } } diff --git a/frontend/providers/applaunchpad/src/pages/api/platform/checkPublicDomain.ts b/frontend/providers/applaunchpad/src/pages/api/platform/checkPublicDomain.ts new file mode 100644 index 000000000..2b3cf9549 --- /dev/null +++ b/frontend/providers/applaunchpad/src/pages/api/platform/checkPublicDomain.ts @@ -0,0 +1,100 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getPublicDomainErrorResponse, jsonRes } from '@/services/backend/response'; +import { createK8sContext } from '@/services/backend'; +import { + dryRunPublicDomainIngress, + ensurePublicDomainTargetsAvailable, + getPublicDomainConflictMessage, + getPublicDomainConflictResponse, + isIngressPublicDomainConflictError, + PublicDomainError +} from '@/services/backend/publicDomain'; +import { isCustomPublicDomainPrefixEnabled } from '@/utils/feature-gates'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + res.setHeader('Allow', ['POST']); + return jsonRes(res, { + code: 405, + error: `Method ${req.method} Not Allowed` + }); + } + + if (!isCustomPublicDomainPrefixEnabled()) { + return jsonRes(res, { + code: 404, + error: 'Custom public domain prefixes are disabled' + }); + } + + try { + const { prefix, domain, appName } = req.body as { + prefix?: string; + domain?: string; + appName?: string; + }; + + if (!prefix || !domain) { + return jsonRes(res, { + code: 400, + error: 'prefix and domain are required' + }); + } + + const k8s = await createK8sContext(req); + const target = { + prefix, + domain, + appName + }; + + await ensurePublicDomainTargetsAvailable([target], k8s); + const result = await dryRunPublicDomainIngress(target, k8s); + + return jsonRes(res, { + data: { + available: true, + ...result + } + }); + } catch (err: any) { + if (err === 'unAuthorization') { + return jsonRes(res, { + code: 401, + error: 'Unauthorized' + }); + } + + if (err instanceof PublicDomainError) { + const publicDomainError = getPublicDomainErrorResponse(err); + return jsonRes(res, { + code: err.status, + message: + err.code === 'PUBLIC_DOMAIN_CONFLICT' && !err.conflictOwner + ? getPublicDomainConflictMessage() + : err.message, + data: { + available: false + }, + error: publicDomainError + }); + } + + if (isIngressPublicDomainConflictError(err)) { + const conflict = getPublicDomainConflictResponse(err); + return jsonRes(res, { + code: 409, + message: conflict.message, + data: { + available: false + }, + error: conflict + }); + } + + return jsonRes(res, { + code: 500, + error: err?.body || err?.message || err + }); + } +} diff --git a/frontend/providers/applaunchpad/src/pages/api/platform/getImagePorts.ts b/frontend/providers/applaunchpad/src/pages/api/platform/getImagePorts.ts new file mode 100644 index 000000000..fc06e02c0 --- /dev/null +++ b/frontend/providers/applaunchpad/src/pages/api/platform/getImagePorts.ts @@ -0,0 +1,130 @@ +import { getImageExposedPorts } from '@/utils/image-exposed-ports'; +import { authSession } from '@/services/backend/auth'; +import { jsonRes } from '@/services/backend/response'; +import { isImagePortsEnabled } from '@/utils/feature-gates'; +import { createHash } from 'crypto'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +const RATE_LIMIT_WINDOW_MS = 60 * 1000; +const MAX_REQUESTS_PER_WINDOW = 20; +const MAX_CONCURRENT_REQUESTS = 2; + +const requestCounters = new Map(); + +function getRateLimitKey(kubeconfig: string) { + return createHash('sha256').update(kubeconfig).digest('hex'); +} + +function acquireRequestSlot(key: string) { + const now = Date.now(); + for (const [entryKey, entry] of requestCounters) { + if (entry.active === 0 && now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) { + requestCounters.delete(entryKey); + } + } + + const current = requestCounters.get(key); + const entry = + current && now - current.windowStart < RATE_LIMIT_WINDOW_MS + ? current + : { windowStart: now, count: 0, active: 0 }; + + if (entry.active >= MAX_CONCURRENT_REQUESTS) { + requestCounters.set(key, entry); + return { ok: false, code: 429, error: 'Too many concurrent image port requests' }; + } + + if (entry.count >= MAX_REQUESTS_PER_WINDOW) { + requestCounters.set(key, entry); + return { ok: false, code: 429, error: 'Too many image port requests' }; + } + + entry.count += 1; + entry.active += 1; + requestCounters.set(key, entry); + return { ok: true }; +} + +function releaseRequestSlot(key: string) { + const entry = requestCounters.get(key); + if (!entry) return; + + entry.active = Math.max(0, entry.active - 1); + if (entry.active === 0 && Date.now() - entry.windowStart >= RATE_LIMIT_WINDOW_MS) { + requestCounters.delete(key); + } +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + res.setHeader('Allow', ['POST']); + return jsonRes(res, { + code: 405, + error: `Method ${req.method} Not Allowed` + }); + } + + if (!isImagePortsEnabled()) { + return jsonRes(res, { + code: 404, + error: 'Image port detection is disabled' + }); + } + + try { + const kubeconfig = await authSession(req.headers); + const rateLimitKey = getRateLimitKey(kubeconfig); + const slot = acquireRequestSlot(rateLimitKey); + + if (!slot.ok) { + return jsonRes(res, { + code: slot.code, + error: slot.error + }); + } + + try { + const { imageName, imageRegistry } = req.body as { + imageName?: string; + imageRegistry?: { + username?: string; + password?: string; + serverAddress?: string; + }; + }; + + const normalizedImageName = imageName?.trim(); + + if (!normalizedImageName) { + return jsonRes(res, { + code: 400, + error: 'imageName is required' + }); + } + + if (normalizedImageName.length > 512) { + return jsonRes(res, { + code: 400, + error: 'imageName is too long' + }); + } + + const ports = await getImageExposedPorts(normalizedImageName, imageRegistry); + return jsonRes(res, { data: { ports } }); + } finally { + releaseRequestSlot(rateLimitKey); + } + } catch (error: any) { + if (error === 'unAuthorization') { + return jsonRes(res, { + code: 401, + error: 'Unauthorized' + }); + } + + return jsonRes(res, { + code: 400, + error: error?.message || error + }); + } +} diff --git a/frontend/providers/applaunchpad/src/pages/api/platform/getInitData.ts b/frontend/providers/applaunchpad/src/pages/api/platform/getInitData.ts index 1629ed2d2..2dc463336 100644 --- a/frontend/providers/applaunchpad/src/pages/api/platform/getInitData.ts +++ b/frontend/providers/applaunchpad/src/pages/api/platform/getInitData.ts @@ -1,6 +1,8 @@ import { Coin } from '@/constants/app'; import { jsonRes } from '@/services/backend/response'; import type { AppConfigType, EnvResponse } from '@/types'; +import { isCustomPublicDomainPrefixEnabled, isImagePortsEnabled } from '@/utils/feature-gates'; +import { normalizePublicDomainReservedPrefixes } from '@/utils/public-domain'; import type { NextApiRequest, NextApiResponse } from 'next'; process.on('unhandledRejection', (reason, promise) => { @@ -50,6 +52,11 @@ export const getServerEnv = (AppConfig: AppConfigType): EnvResponse => { PVC_STORAGE_MAX: AppConfig.launchpad.pvcStorageMax || 20, GPU_ENABLED: AppConfig.common.gpuEnabled, LOG_ENABLED: !!AppConfig?.launchpad?.components?.log?.url, - NETWORK_STORAGE_ENABLED: AppConfig.common.networkStorageEnabled + NETWORK_STORAGE_ENABLED: AppConfig.common.networkStorageEnabled, + IMAGE_PORTS_ENABLED: isImagePortsEnabled(AppConfig), + CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED: isCustomPublicDomainPrefixEnabled(AppConfig), + PUBLIC_DOMAIN_RESERVED_PREFIXES: normalizePublicDomainReservedPrefixes( + AppConfig.launchpad.publicDomain?.reservedPrefixes + ) }; }; diff --git a/frontend/providers/applaunchpad/src/pages/api/updateApp.ts b/frontend/providers/applaunchpad/src/pages/api/updateApp.ts index 6fd855551..b9a201949 100644 --- a/frontend/providers/applaunchpad/src/pages/api/updateApp.ts +++ b/frontend/providers/applaunchpad/src/pages/api/updateApp.ts @@ -1,6 +1,6 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { ApiResp } from '@/services/kubernet'; -import { jsonRes } from '@/services/backend/response'; +import { handleK8sError, jsonRes } from '@/services/backend/response'; import { YamlKindEnum } from '@/utils/adapt'; import yaml from 'js-yaml'; import type { CustomObjectsApi, V1StatefulSet } from '@kubernetes/client-node'; @@ -489,9 +489,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< return jsonRes(res); } catch (err: any) { - return jsonRes(res, { - code: 500, - error: err?.body - }); + return jsonRes(res, handleK8sError(err?.body || err)); } } diff --git a/frontend/providers/applaunchpad/src/pages/api/v1/app/[name]/index.ts b/frontend/providers/applaunchpad/src/pages/api/v1/app/[name]/index.ts index 056c59b73..6c44d1cc6 100644 --- a/frontend/providers/applaunchpad/src/pages/api/v1/app/[name]/index.ts +++ b/frontend/providers/applaunchpad/src/pages/api/v1/app/[name]/index.ts @@ -4,7 +4,7 @@ import { UpdateAppResourcesSchema, nanoid } from '@/types/request_schema'; -import { jsonRes } from '@/services/backend/response'; +import { getPublicDomainErrorResponse, jsonRes } from '@/services/backend/response'; import { ApiResp } from '@/services/kubernet'; import type { NextApiRequest, NextApiResponse } from 'next'; import { @@ -24,9 +24,19 @@ import { mountPathToConfigMapKey } from '@/utils/tools'; import { json2DeployCr, json2Service, json2Ingress } from '@/utils/deployYaml2Json'; import type { AppEditType } from '@/types/app'; import { appDeployKey } from '@/constants/app'; +import { + ensurePublicDomainPrefixesAvailable, + PublicDomainError +} from '@/services/backend/publicDomain'; +import { isCustomPublicDomainPrefixEnabled } from '@/utils/feature-gates'; +import { validatePublicDomainPrefix } from '@/utils/public-domain'; class PortError extends Error { - constructor(message: string, public code: number = 500, public details?: any) { + constructor( + message: string, + public code: number = 500, + public details?: any + ) { super(message); this.name = 'PortError'; } @@ -53,6 +63,29 @@ class PortValidationError extends PortError { } } +function normalizePublicDomainPrefixOrThrow(value: string) { + if (!isCustomPublicDomainPrefixEnabled()) { + throw new PortValidationError('Custom public domain prefixes are disabled', { + operation: 'UPDATE_PUBLIC_DOMAIN_PREFIX' + }); + } + + const result = validatePublicDomainPrefix(value); + if (!result.valid) { + throw new PortValidationError( + result.reason === 'reserved' + ? `Public domain prefix "${result.value}" is reserved` + : `Public domain prefix "${result.value}" is invalid`, + { + publicDomain: result.value, + reason: result.reason, + operation: 'VALIDATE_PUBLIC_DOMAIN_PREFIX' + } + ); + } + return result.value; +} + async function validateAppExists(name: string, k8s: any, res: NextApiResponse) { try { await k8s.getDeployApp(name); @@ -225,6 +258,8 @@ async function updateConfigMap( } async function updateServiceAndIngress(appEditData: AppEditType, applyYamlList: any, k8s: any) { + await ensurePublicDomainPrefixesAvailable(appEditData, k8s); + const yamlList: string[] = []; try { @@ -746,6 +781,11 @@ async function manageAppPorts( updatedNetwork.domain = ''; } else if (isApplicationProtocol && portConfig.exposesPublicDomain) { updatedNetwork.publicDomain = existingNetwork.publicDomain || nanoid(); + if (portConfig.publicDomain) { + updatedNetwork.publicDomain = normalizePublicDomainPrefixOrThrow( + portConfig.publicDomain + ); + } updatedNetwork.domain = existingNetwork.domain || global.AppConfig?.cloud?.domain || 'cloud.sealos.io'; } @@ -760,6 +800,11 @@ async function manageAppPorts( if (portConfig.exposesPublicDomain) { updatedNetwork.publicDomain = updatedNetwork.publicDomain || nanoid(); + if (portConfig.publicDomain) { + updatedNetwork.publicDomain = normalizePublicDomainPrefixOrThrow( + portConfig.publicDomain + ); + } updatedNetwork.domain = updatedNetwork.domain || global.AppConfig?.cloud?.domain || 'cloud.sealos.io'; @@ -790,6 +835,24 @@ async function manageAppPorts( } } + if (portConfig.publicDomain !== undefined) { + const finalAppProtocol = updatedNetwork.appProtocol; + const isApplicationProtocol = ['HTTP', 'GRPC', 'WS'].includes(finalAppProtocol || ''); + if (!isApplicationProtocol || !updatedNetwork.openPublicDomain) { + throw new PortValidationError( + 'Cannot set publicDomain for a port without public domain access', + { + currentAppProtocol: finalAppProtocol, + currentProtocol: updatedNetwork.protocol, + operation: 'UPDATE_PUBLIC_DOMAIN_PREFIX' + } + ); + } + updatedNetwork.publicDomain = normalizePublicDomainPrefixOrThrow(portConfig.publicDomain); + updatedNetwork.domain = + updatedNetwork.domain || global.AppConfig?.cloud?.domain || 'cloud.sealos.io'; + } + newNetworks.push(updatedNetwork); } else if (!portConfig.portName) { if (!portConfig.number) { @@ -819,6 +882,13 @@ async function manageAppPorts( } const isApplicationProtocol = ['HTTP', 'GRPC', 'WS'].includes(portConfig.protocol); + const openPublicDomain = + isApplicationProtocol && + (portConfig.exposesPublicDomain !== undefined ? portConfig.exposesPublicDomain : false); + const publicDomain = + openPublicDomain && portConfig.publicDomain + ? normalizePublicDomainPrefixOrThrow(portConfig.publicDomain) + : nanoid(); const newNetwork = { serviceName: `service-${nanoid()}`, networkName: `network-${nanoid()}`, @@ -826,10 +896,8 @@ async function manageAppPorts( port: portConfig.number, protocol: isApplicationProtocol ? 'TCP' : portConfig.protocol || 'TCP', appProtocol: isApplicationProtocol ? portConfig.protocol || 'HTTP' : undefined, - openPublicDomain: - isApplicationProtocol && - (portConfig.exposesPublicDomain !== undefined ? portConfig.exposesPublicDomain : false), - publicDomain: isApplicationProtocol ? nanoid() : '', + openPublicDomain, + publicDomain: openPublicDomain ? publicDomain : '', customDomain: '', domain: isApplicationProtocol ? global.AppConfig?.cloud?.domain || 'cloud.sealos.io' : '', nodePort: undefined, @@ -851,13 +919,14 @@ async function manageAppPorts( protocol: network.protocol })); - await updateAppPorts(app, appName, k8sApp, namespace, targetContainerPorts); - const updatedAppData: AppEditType = { ...latestAppData, networks: resultNetworks }; + await ensurePublicDomainPrefixesAvailable(updatedAppData, k8s); + await updateAppPorts(app, appName, k8sApp, namespace, targetContainerPorts); + await updateServiceAndIngress(updatedAppData, applyYamlList, k8s); return updatedAppData; @@ -974,6 +1043,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< try { currentAppData = await manageAppPorts(name, updateData.ports, currentAppData!, k8s); } catch (error: any) { + if (error instanceof PublicDomainError) { + return jsonRes(res, { + code: error.status, + error: { + type: 'PUBLIC_DOMAIN_ERROR', + ...getPublicDomainErrorResponse(error) + } + }); + } + if (error instanceof PortConflictError) { return jsonRes(res, { code: error.code, diff --git a/frontend/providers/applaunchpad/src/pages/api/v1/app/index.ts b/frontend/providers/applaunchpad/src/pages/api/v1/app/index.ts index e6b200feb..aedbf58c2 100644 --- a/frontend/providers/applaunchpad/src/pages/api/v1/app/index.ts +++ b/frontend/providers/applaunchpad/src/pages/api/v1/app/index.ts @@ -1,4 +1,4 @@ -import { jsonRes } from '@/services/backend/response'; +import { getPublicDomainErrorResponse, jsonRes } from '@/services/backend/response'; import { ApiResp } from '@/services/kubernet'; import type { NextApiRequest, NextApiResponse } from 'next'; import { @@ -11,6 +11,7 @@ import { adaptAppDetail } from '@/utils/adapt'; import { DeployKindsType, AppDetailType } from '@/types/app'; import { z } from 'zod'; import { LaunchpadApplicationSchema } from '@/types/schema'; +import { PublicDomainError } from '@/services/backend/publicDomain'; async function processAppResponse( response: PromiseSettledResult[] @@ -67,6 +68,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< }); } } catch (err: any) { + if (err instanceof PublicDomainError) { + return jsonRes(res, { + code: err.status, + error: getPublicDomainErrorResponse(err) + }); + } jsonRes(res, { code: 500, error: err diff --git a/frontend/providers/applaunchpad/src/pages/api/v2alpha/apps/[name]/index.ts b/frontend/providers/applaunchpad/src/pages/api/v2alpha/apps/[name]/index.ts index ed14c3365..11580b5a8 100644 --- a/frontend/providers/applaunchpad/src/pages/api/v2alpha/apps/[name]/index.ts +++ b/frontend/providers/applaunchpad/src/pages/api/v2alpha/apps/[name]/index.ts @@ -25,6 +25,7 @@ import { import { mountPathToConfigMapKey } from '@/utils/tools'; import { json2DeployCr, json2Service, json2Ingress } from '@/utils/deployYaml2Json'; import { appDeployKey } from '@/constants/app'; +import { getPublicDomainErrorResponse } from '@/services/backend/response'; import { sendError, @@ -38,6 +39,12 @@ import { sendK8sOperationError, sendInternalError } from '@/pages/api/v2alpha/k8sContext'; +import { + ensurePublicDomainPrefixesAvailable, + PublicDomainError +} from '@/services/backend/publicDomain'; +import { isCustomPublicDomainPrefixEnabled } from '@/utils/feature-gates'; +import { validatePublicDomainPrefix } from '@/utils/public-domain'; // Constants const DELAY_SHORT = 2000; @@ -51,7 +58,11 @@ const SIZE_UNITS = { // Custom Error Classes class PortError extends Error { - constructor(message: string, public code: number = 500, public details?: ApiErrorDetails) { + constructor( + message: string, + public code: number = 500, + public details?: ApiErrorDetails + ) { super(message); this.name = 'PortError'; } @@ -78,6 +89,29 @@ class PortValidationError extends PortError { } } +function normalizePublicDomainPrefixOrThrow(value: string) { + if (!isCustomPublicDomainPrefixEnabled()) { + throw new PortValidationError('Custom public domain prefixes are disabled', { + operation: 'UPDATE_PUBLIC_DOMAIN_PREFIX' + }); + } + + const result = validatePublicDomainPrefix(value); + if (!result.valid) { + throw new PortValidationError( + result.reason === 'reserved' + ? `Public domain prefix "${result.value}" is reserved` + : `Public domain prefix "${result.value}" is invalid`, + { + publicDomain: result.value, + reason: result.reason, + operation: 'VALIDATE_PUBLIC_DOMAIN_PREFIX' + } + ); + } + return result.value; +} + // Utility Functions function isApplicationProtocol(protocol?: string): boolean { if (!protocol) return false; @@ -372,6 +406,7 @@ async function updateServiceAndIngress( applyYamlList: any, k8s: any ): Promise { + await ensurePublicDomainPrefixesAvailable(appEditData, k8s); await deleteServiceAndIngress(k8s, appEditData.appName); const yamlList: string[] = []; @@ -656,6 +691,12 @@ async function updateAppPorts( function createNetworkConfig(appName: string, portConfig: any): any { const protocol = (portConfig.protocol || 'http').toUpperCase(); const isAppProtocol = isApplicationProtocol(protocol); + const openPublicDomain = + isAppProtocol && (portConfig.isPublic !== undefined ? portConfig.isPublic : false); + const publicDomain = + isAppProtocol && openPublicDomain && portConfig.publicDomain + ? normalizePublicDomainPrefixOrThrow(portConfig.publicDomain) + : nanoid(); return { serviceName: `${appName}-${portConfig.number}-${nanoid()}-service`, @@ -664,9 +705,8 @@ function createNetworkConfig(appName: string, portConfig: any): any { port: portConfig.number, protocol: isAppProtocol ? 'TCP' : protocol, appProtocol: isAppProtocol ? protocol : undefined, - openPublicDomain: - isAppProtocol && (portConfig.isPublic !== undefined ? portConfig.isPublic : false), - publicDomain: isAppProtocol ? nanoid() : '', + openPublicDomain, + publicDomain: openPublicDomain ? publicDomain : '', customDomain: '', domain: isAppProtocol ? global.AppConfig?.cloud?.domain || 'cloud.sealos.io' : '', nodePort: undefined, @@ -695,6 +735,9 @@ function updateNetworkConfig(existingNetwork: any, portConfig: any, appName: str updatedNetwork.domain = ''; } else if (portConfig.isPublic) { updatedNetwork.publicDomain = existingNetwork.publicDomain || nanoid(); + if (portConfig.publicDomain) { + updatedNetwork.publicDomain = normalizePublicDomainPrefixOrThrow(portConfig.publicDomain); + } updatedNetwork.domain = existingNetwork.domain || global.AppConfig?.cloud?.domain || 'cloud.sealos.io'; } @@ -709,6 +752,9 @@ function updateNetworkConfig(existingNetwork: any, portConfig: any, appName: str if (portConfig.isPublic) { updatedNetwork.publicDomain = updatedNetwork.publicDomain || nanoid(); + if (portConfig.publicDomain) { + updatedNetwork.publicDomain = normalizePublicDomainPrefixOrThrow(portConfig.publicDomain); + } updatedNetwork.domain = updatedNetwork.domain || global.AppConfig?.cloud?.domain || 'cloud.sealos.io'; @@ -739,6 +785,27 @@ function updateNetworkConfig(existingNetwork: any, portConfig: any, appName: str } } + if (portConfig.publicDomain !== undefined) { + const finalAppProtocol = updatedNetwork.appProtocol; + const isAppProtocol = isApplicationProtocol(finalAppProtocol); + + if (!isAppProtocol || !updatedNetwork.openPublicDomain) { + throw new PortValidationError( + 'Cannot set publicDomain for a port without public domain access', + { + currentAppProtocol: finalAppProtocol, + currentProtocol: updatedNetwork.protocol, + supportedProtocols: APPLICATION_PROTOCOLS, + operation: 'UPDATE_PUBLIC_DOMAIN_PREFIX' + } + ); + } + + updatedNetwork.publicDomain = normalizePublicDomainPrefixOrThrow(portConfig.publicDomain); + updatedNetwork.domain = + updatedNetwork.domain || global.AppConfig?.cloud?.domain || 'cloud.sealos.io'; + } + return updatedNetwork; } @@ -849,13 +916,14 @@ async function manageAppPorts(appName: string, requestPorts: any[], k8s: any): P protocol: network.protocol })); - await updateAppPorts(app, appName, k8sApp, namespace, targetContainerPorts); - const updatedAppData: AppEditType = { ...latestAppData, networks: resultNetworks }; + await ensurePublicDomainPrefixesAvailable(updatedAppData, k8s); + await updateAppPorts(app, appName, k8sApp, namespace, targetContainerPorts); + await updateServiceAndIngress(updatedAppData, applyYamlList, k8s); } @@ -1002,12 +1070,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) updateData.image.imageRegistry === null ? null : updateData.image.imageRegistry - ? { - username: updateData.image.imageRegistry.username, - password: updateData.image.imageRegistry.password, - serverAddress: updateData.image.imageRegistry.apiUrl - } - : undefined + ? { + username: updateData.image.imageRegistry.username, + password: updateData.image.imageRegistry.password, + serverAddress: updateData.image.imageRegistry.apiUrl + } + : undefined }) }; @@ -1028,6 +1096,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) try { await manageAppPorts(name, updateData.ports, k8s); } catch (error: any) { + if (error instanceof PublicDomainError) { + return sendError(res, { + status: error.status, + type: ErrorType.VALIDATION_ERROR, + code: ErrorCode.INVALID_VALUE, + message: error.message, + details: getPublicDomainErrorResponse(error) + }); + } + if (error instanceof PortError) { handlePortError(error, res); return; diff --git a/frontend/providers/applaunchpad/src/pages/api/v2alpha/apps/index.ts b/frontend/providers/applaunchpad/src/pages/api/v2alpha/apps/index.ts index 72522ebdc..401d2f3e7 100644 --- a/frontend/providers/applaunchpad/src/pages/api/v2alpha/apps/index.ts +++ b/frontend/providers/applaunchpad/src/pages/api/v2alpha/apps/index.ts @@ -1,4 +1,4 @@ -import { jsonRes } from '@/services/backend/response'; +import { getPublicDomainErrorResponse, jsonRes } from '@/services/backend/response'; import { ApiResp } from '@/services/kubernet'; import type { NextApiRequest, NextApiResponse } from 'next'; import { @@ -18,6 +18,7 @@ import { sendK8sOperationError, sendInternalError } from '@/pages/api/v2alpha/k8sContext'; +import { PublicDomainError } from '@/services/backend/publicDomain'; async function processAppResponse( response: PromiseSettledResult[], @@ -77,6 +78,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const appData = await processAppResponse(response, k8s.namespace); return res.status(201).json(appData); } catch (err) { + if (err instanceof PublicDomainError) { + return sendError(res, { + status: err.status, + type: ErrorType.VALIDATION_ERROR, + code: ErrorCode.INVALID_VALUE, + message: err.message, + details: getPublicDomainErrorResponse(err) + }); + } console.error('Kubernetes create application error:', err); return sendK8sOperationError( res, diff --git a/frontend/providers/applaunchpad/src/pages/api/v2alpha/k8sContext.ts b/frontend/providers/applaunchpad/src/pages/api/v2alpha/k8sContext.ts index 064d7fa87..20694fc71 100644 --- a/frontend/providers/applaunchpad/src/pages/api/v2alpha/k8sContext.ts +++ b/frontend/providers/applaunchpad/src/pages/api/v2alpha/k8sContext.ts @@ -2,6 +2,10 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { createK8sContext } from '@/services/backend'; import type { K8sContext } from '@/services/backend'; import { sendError, ErrorType, ErrorCode } from '@/types/v2alpha/error'; +import { + getPublicDomainConflictResponse, + isIngressPublicDomainConflictError +} from '@/services/backend/publicDomain'; /** * Auth/kubeconfig error identifiers from authSession and getK8s. @@ -134,6 +138,22 @@ export function sendK8sOperationError( const errStr = errMessage.toLowerCase(); const statusCode = getK8sStatusCode(err); + if (isIngressPublicDomainConflictError(err)) { + const conflict = getPublicDomainConflictResponse(err); + sendError(res, { + status: 409, + type: ErrorType.RESOURCE_ERROR, + code: ErrorCode.CONFLICT, + message: conflict.message, + details: { + code: conflict.code, + message: conflict.message, + rawMessage: conflict.details + } + }); + return; + } + // 403 Forbidden - K8s permission denied if (statusCode === 403 || errStr.includes('forbidden') || errStr.includes('permission denied')) { sendError(res, { diff --git a/frontend/providers/applaunchpad/src/pages/app/edit/components/Form.tsx b/frontend/providers/applaunchpad/src/pages/app/edit/components/Form.tsx index 3b86e271c..df172359a 100644 --- a/frontend/providers/applaunchpad/src/pages/app/edit/components/Form.tsx +++ b/frontend/providers/applaunchpad/src/pages/app/edit/components/Form.tsx @@ -1,12 +1,18 @@ import { obj2Query } from '@/api/tools'; import { getSharePVCs } from '@/api/app'; +import { getImagePorts } from '@/api/platform'; import MyIcon from '@/components/Icon'; import { MyRangeSlider, MySelect, MySlider, MyTooltip, RangeInput, Tabs, Tip } from '@sealos/ui'; import { defaultSliderKey, defaultGpuSliderKey } from '@/constants/app'; import { GpuAmountMarkList } from '@/constants/editApp'; import { useToast } from '@/hooks/useToast'; import { useGlobalStore } from '@/store/global'; -import { PVC_STORAGE_MAX, NETWORK_STORAGE_ENABLED } from '@/store/static'; +import { + PVC_STORAGE_MAX, + NETWORK_STORAGE_ENABLED, + SEALOS_DOMAIN, + IMAGE_PORTS_ENABLED +} from '@/store/static'; import { useUserStore } from '@/store/user'; import type { QueryType } from '@/types'; import { type AppEditType } from '@/types/app'; @@ -28,6 +34,7 @@ import { IconButton, Image, Input, + Spinner, Switch, useDisclosure, useTheme @@ -36,8 +43,9 @@ import { throttle } from 'lodash'; import { useTranslation } from 'next-i18next'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/router'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useFieldArray, UseFormReturn } from 'react-hook-form'; +import { customAlphabet } from 'nanoid'; import type { ConfigMapType } from './ConfigmapModal'; import PriceBox from './PriceBox'; import QuotaBox from './QuotaBox'; @@ -53,6 +61,40 @@ const NetworkStoreModal = dynamic(() => import('./NetworkStoreModal')); const EditEnvs = dynamic(() => import('./EditEnvs')); const labelWidth = 120; +const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 12); + +type ImagePortDetectionState = { + status: 'idle' | 'loading' | 'success' | 'empty' | 'error'; + count?: number; +}; + +function getNetworkSignature(networks: AppEditType['networks']) { + return networks + .map((network) => + [ + network.port, + network.protocol, + network.appProtocol || '', + network.openPublicDomain ? 'public' : 'private', + network.openNodePort ? 'nodeport' : 'cluster' + ].join(':') + ) + .join('|'); +} + +function canAutoReplaceNetworks(networks: AppEditType['networks'], lastAutoSignature: string) { + const networkSignature = getNetworkSignature(networks); + return ( + networks.length === 0 || + networkSignature === lastAutoSignature || + (networks.length === 1 && + Number(networks[0].port) === 80 && + networks[0].protocol === 'TCP' && + networks[0].appProtocol === 'HTTP' && + !networks[0].openPublicDomain && + !networks[0].openNodePort) + ); +} const Form = ({ formHook, @@ -163,6 +205,22 @@ const Form = ({ const [storeEdit, setStoreEdit] = useState(); const [networkStoreEdit, setNetworkStoreEdit] = useState(false); const { isOpen: isEditEnvs, onOpen: onOpenEditEnvs, onClose: onCloseEditEnvs } = useDisclosure(); + const imagePortRequestId = useRef(0); + const secretPasswordVersion = useRef(0); + const lastAutoPortKey = useRef(''); + const lastAutoNetworkSignature = useRef(''); + const [imagePortDetection, setImagePortDetection] = useState({ + status: 'idle' + }); + const watchedImageName = watch('imageName'); + const watchedSecretUse = watch('secret.use'); + const watchedSecretUsername = watch('secret.username'); + const watchedSecretPassword = watch('secret.password'); + const watchedSecretServerAddress = watch('secret.serverAddress'); + + useEffect(() => { + secretPasswordVersion.current += 1; + }, [watchedSecretPassword]); // For quota calculation in fields const { userQuota, loadUserQuota } = useUserStore(); @@ -230,6 +288,129 @@ const Form = ({ return () => subscription.unsubscribe(); }, [watch, setValue]); + useEffect(() => { + if (!IMAGE_PORTS_ENABLED || isEdit || !already) { + setImagePortDetection({ status: 'idle' }); + return; + } + + const imageName = getValues('imageName'); + if (!imageName) { + setImagePortDetection({ status: 'idle' }); + return; + } + const secret = getValues('secret'); + const autoPortKey = [ + imageName, + secret.use ? secret.username : '', + secret.use ? secret.serverAddress : '', + secret.use && secret.password ? `password-v${secretPasswordVersion.current}` : '' + ].join('|'); + if (lastAutoPortKey.current === autoPortKey) return; + + if (!canAutoReplaceNetworks(getValues('networks'), lastAutoNetworkSignature.current)) { + setImagePortDetection({ status: 'idle' }); + return; + } + + const requestId = ++imagePortRequestId.current; + setImagePortDetection({ status: 'loading' }); + const timer = setTimeout(async () => { + try { + const res = await getImagePorts({ + imageName, + imageRegistry: secret.use + ? { + username: secret.username, + password: secret.password, + serverAddress: secret.serverAddress + } + : undefined + }); + + if (requestId !== imagePortRequestId.current) { + return; + } + + if (!res.ports.length) { + setImagePortDetection({ status: 'empty' }); + return; + } + + if (!canAutoReplaceNetworks(getValues('networks'), lastAutoNetworkSignature.current)) { + setImagePortDetection({ status: 'idle' }); + return; + } + lastAutoPortKey.current = autoPortKey; + const nextNetworks: AppEditType['networks'] = res.ports.slice(0, 15).map((item, index) => ({ + networkName: '', + portName: nanoid(), + port: item.port, + protocol: item.protocol, + appProtocol: item.protocol === 'TCP' && index === 0 ? 'HTTP' : undefined, + openPublicDomain: false, + publicDomain: '', + customDomain: '', + domain: SEALOS_DOMAIN, + openNodePort: false, + nodePort: undefined + })); + lastAutoNetworkSignature.current = getNetworkSignature(nextNetworks); + + setValue('networks', nextNetworks); + setImagePortDetection({ status: 'success', count: nextNetworks.length }); + } catch (error) { + if (requestId === imagePortRequestId.current) { + setImagePortDetection({ status: 'error' }); + } + } + }, 700); + + return () => clearTimeout(timer); + }, [ + already, + getValues, + isEdit, + refresh, + setValue, + watchedImageName, + watchedSecretPassword, + watchedSecretServerAddress, + watchedSecretUse, + watchedSecretUsername + ]); + + const imagePortDetectionView = useMemo(() => { + switch (imagePortDetection.status) { + case 'loading': + return { + color: 'brightBlue.600', + text: t('recognizing_image_ports'), + showSpinner: true + }; + case 'success': + return { + color: 'green.600', + text: t('recognized_image_ports', { count: imagePortDetection.count || 0 }), + showSpinner: false + }; + case 'empty': + return { + color: 'grayModern.600', + text: t('no_image_ports_detected'), + showSpinner: false + }; + case 'error': + return { + color: 'grayModern.600', + text: t('image_ports_detection_failed'), + showSpinner: false + }; + default: + return null; + } + }, [imagePortDetection, t]); + // common form label const Label = ({ children, @@ -583,22 +764,42 @@ const Form = ({ /> - + {t('Image Name')} - + + + + {imagePortDetectionView?.showSpinner ? ( + + ) : null} + + {imagePortDetectionView?.text} + + + {getValues('secret.use') ? ( <> diff --git a/frontend/providers/applaunchpad/src/pages/app/edit/components/NetworkSection.tsx b/frontend/providers/applaunchpad/src/pages/app/edit/components/NetworkSection.tsx index d98f64503..05a3225bd 100644 --- a/frontend/providers/applaunchpad/src/pages/app/edit/components/NetworkSection.tsx +++ b/frontend/providers/applaunchpad/src/pages/app/edit/components/NetworkSection.tsx @@ -1,11 +1,29 @@ import MyIcon from '@/components/Icon'; +import { checkPublicDomain } from '@/api/platform'; import { MySelect } from '@sealos/ui'; import { APPLICATION_PROTOCOLS, ProtocolList } from '@/constants/app'; -import { DISABLE_HTTPS, DOMAIN_PORT, HTTP_PORT, SEALOS_DOMAIN } from '@/store/static'; +import { + CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED, + DISABLE_HTTPS, + DOMAIN_PORT, + HTTP_PORT, + SEALOS_DOMAIN +} from '@/store/static'; import { useTranslation } from 'next-i18next'; import { customAlphabet } from 'nanoid'; import { UseFormReturn, useFieldArray, useWatch } from 'react-hook-form'; -import { Box, Button, Flex, IconButton, Input, Switch, Tooltip, useTheme } from '@chakra-ui/react'; +import { + Box, + Button, + Flex, + FormControl, + FormErrorMessage, + IconButton, + Input, + Switch, + Tooltip, + useTheme +} from '@chakra-ui/react'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { AppEditType } from '@/types/app'; import RouteRulesModal from './RouteRulesModal'; @@ -14,11 +32,199 @@ import { buildExternalUrl, getExternalProtocol } from '@/utils/network-url'; import { syncDefaultRouteServicePort } from '@/utils/network-routes'; import type { CustomAccessModalParams } from './CustomAccessModal'; import dynamic from 'next/dynamic'; +import { + PUBLIC_DOMAIN_PREFIX_MAX_LENGTH, + PUBLIC_DOMAIN_PREFIX_MIN_LENGTH, + PublicDomainConflictOwner, + getDuplicateManagedPublicDomainHosts, + normalizePublicDomainPrefix, + validatePublicDomainPrefix +} from '@/utils/public-domain'; const CustomAccessModal = dynamic(() => import('./CustomAccessModal')); const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 12); +const getPublicDomainPrefixErrorMessage = ( + t: ReturnType['t'], + reason: 'format' | 'reserved' | 'conflict' | 'duplicate', + conflictOwner?: PublicDomainConflictOwner +) => { + if (reason === 'duplicate') { + return ( + t('public_domain_prefix_duplicate_error') || + 'This public address prefix is duplicated in this app. Please choose another one.' + ); + } + + if (reason === 'conflict') { + if (conflictOwner) { + return ( + t('public_domain_prefix_conflict_owner_error', { + type: conflictOwner.displayType, + name: conflictOwner.displayName + }) || + `This public address prefix is already used by ${conflictOwner.displayType} "${conflictOwner.displayName}" in this workspace.` + ); + } + + return ( + t('public_domain_prefix_conflict_error') || + 'This public address prefix is already in use. Please choose another one.' + ); + } + + if (reason === 'reserved') { + return ( + t('public_domain_prefix_reserved_error') || + 'This public address prefix is reserved. Please choose another one.' + ); + } + + return ( + t('public_domain_prefix_format_error', { + min: PUBLIC_DOMAIN_PREFIX_MIN_LENGTH, + max: PUBLIC_DOMAIN_PREFIX_MAX_LENGTH + }) || + `Use ${PUBLIC_DOMAIN_PREFIX_MIN_LENGTH}-${PUBLIC_DOMAIN_PREFIX_MAX_LENGTH} lowercase letters, numbers, or hyphens. It cannot start or end with a hyphen.` + ); +}; + +const getConflictOwnerFromError = (error: any): PublicDomainConflictOwner | undefined => { + return error?.error?.conflictOwner; +}; + +function PublicDomainPrefixInput({ + value, + errorMessage, + onDraftChange, + onStartEdit, + onCommit +}: { + value: string; + errorMessage?: string; + onDraftChange: (value: string) => void; + onStartEdit: () => void; + onCommit: (value: string) => Promise; +}) { + const { t } = useTranslation(); + const [draft, setDraft] = useState(value); + const [isFocused, setIsFocused] = useState(false); + const lastCommittedValueRef = useRef(value); + const skipNextBlurCommitRef = useRef(false); + + useEffect(() => { + if (!isFocused) { + setDraft(value); + lastCommittedValueRef.current = value; + } + }, [isFocused, value]); + + const commitDraft = useCallback(async () => { + const committedValue = await onCommit(draft); + if (committedValue) { + lastCommittedValueRef.current = committedValue; + } + return committedValue; + }, [draft, onCommit]); + + return ( + + + { + setIsFocused(true); + onStartEdit(); + }} + onBlur={() => { + setIsFocused(false); + if (skipNextBlurCommitRef.current) { + skipNextBlurCommitRef.current = false; + return; + } + void commitDraft(); + }} + onChange={(e) => { + const nextValue = e.target.value; + setDraft(nextValue); + onDraftChange(nextValue); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + skipNextBlurCommitRef.current = true; + void commitDraft(); + e.currentTarget.blur(); + } + if (e.key === 'Escape') { + e.preventDefault(); + setDraft(lastCommittedValueRef.current); + onDraftChange(lastCommittedValueRef.current); + skipNextBlurCommitRef.current = true; + e.currentTarget.blur(); + } + }} + /> + + + + ); +} + type NetworkAction = | { type: 'ADD_PORT'; payload: AppEditType['networks'][0] } | { type: 'REMOVE_PORT'; payload: { index: number } } @@ -112,10 +318,26 @@ export function NetworkSection({ const { copyData } = useCopyData(); const [routeRulesIndex, setRouteRulesIndex] = useState(); const [customAccessModalData, setCustomAccessModalData] = useState(); + const publicDomainCheckSeqRef = useRef>({}); + const publicDomainDraftCheckTimerRef = useRef>>({}); - const { register, control, getValues, setValue } = formHook; + const { + register, + control, + getValues, + setValue, + setError, + clearErrors, + formState: { errors } + } = formHook; const watchedNetworks = useWatch({ control, name: 'networks' }); const previousNetworkPortsRef = useRef>({}); + const clearPublicDomainErrorByIndex = useCallback( + (index: number) => { + clearErrors(`networks.${index}.publicDomain`); + }, + [clearErrors] + ); const { fields: networks, @@ -180,6 +402,7 @@ export function NetworkSection({ case 'REMOVE_PORT': { const { index } = action.payload; if (currentNetworks.length > 1 && index >= 0 && index < currentNetworks.length) { + clearPublicDomainErrorByIndex(index); removeNetworks(index); } break; @@ -209,6 +432,7 @@ export function NetworkSection({ case 'DISABLE_EXTERNAL_ACCESS': { const { index } = action.payload; + clearPublicDomainErrorByIndex(index); updateNetworks(index, { ...currentNetworks[index], serviceName: '', @@ -241,6 +465,7 @@ export function NetworkSection({ }) ); } else { + clearPublicDomainErrorByIndex(index); updateNetworks( index, withDefaultRoutes({ @@ -262,6 +487,9 @@ export function NetworkSection({ case 'UPDATE_CUSTOM_DOMAIN': { const { index, customDomain } = action.payload; + if (customDomain) { + clearPublicDomainErrorByIndex(index); + } updateNetworks(index, { ...currentNetworks[index], customDomain @@ -282,7 +510,267 @@ export function NetworkSection({ break; } }, - [getValues, appendNetworks, removeNetworks, updateNetworks] + [getValues, appendNetworks, removeNetworks, updateNetworks, clearPublicDomainErrorByIndex] + ); + + const getPublicDomainFieldName = useCallback( + (index: number) => `networks.${index}.publicDomain` as const, + [] + ); + + const setPublicDomainValidationError = useCallback( + ( + index: number, + reason: 'format' | 'reserved' | 'conflict' | 'duplicate', + conflictOwner?: PublicDomainConflictOwner + ) => { + setError(getPublicDomainFieldName(index), { + type: reason, + message: getPublicDomainPrefixErrorMessage(t, reason, conflictOwner) + }); + }, + [getPublicDomainFieldName, setError, t] + ); + + const clearPublicDomainValidationError = useCallback( + (index: number) => { + clearErrors(getPublicDomainFieldName(index)); + }, + [clearErrors, getPublicDomainFieldName] + ); + + const getPublicDomainValidationError = useCallback( + (index: number) => { + const message = (errors.networks as any)?.[index]?.publicDomain?.message; + return typeof message === 'string' ? message : undefined; + }, + [errors.networks] + ); + + const getPublicDomainValidationErrorType = useCallback( + (index: number) => { + const type = (errors.networks as any)?.[index]?.publicDomain?.type; + return typeof type === 'string' ? type : undefined; + }, + [errors.networks] + ); + + const hasManagedPublicDomainHostDuplicate = useCallback( + (index: number, publicDomain: string, domain: string) => { + const networks = getValues('networks').map((network, networkIndex) => + networkIndex === index + ? { + ...network, + publicDomain, + domain + } + : network + ); + + if (!CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED) return false; + + return getDuplicateManagedPublicDomainHosts(networks, SEALOS_DOMAIN).some(({ indexes }) => + indexes.includes(index) + ); + }, + [getValues] + ); + + const syncManagedPublicDomainHostDuplicateErrors = useCallback( + (nextNetworks: AppEditType['networks']) => { + if (!CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED) return new Set(); + + const duplicateIndexes = new Set( + getDuplicateManagedPublicDomainHosts(nextNetworks, SEALOS_DOMAIN).flatMap( + ({ indexes }) => indexes + ) + ); + const message = getPublicDomainPrefixErrorMessage(t, 'duplicate'); + + nextNetworks.forEach((_, index) => { + const currentErrorType = getPublicDomainValidationErrorType(index); + + if (duplicateIndexes.has(index)) { + if (currentErrorType !== 'duplicate') { + setError(getPublicDomainFieldName(index), { + type: 'duplicate', + message + }); + } + return; + } + + if (currentErrorType === 'duplicate') { + clearErrors(getPublicDomainFieldName(index)); + } + }); + + return duplicateIndexes; + }, + [clearErrors, getPublicDomainFieldName, getPublicDomainValidationErrorType, setError, t] + ); + + useEffect(() => { + if (watchedNetworks) { + syncManagedPublicDomainHostDuplicateErrors(watchedNetworks); + } + }, [syncManagedPublicDomainHostDuplicateErrors, watchedNetworks]); + + const clearPublicDomainDraftCheckTimer = useCallback((index?: number) => { + if (typeof index === 'number') { + if (publicDomainDraftCheckTimerRef.current[index]) { + clearTimeout(publicDomainDraftCheckTimerRef.current[index]); + delete publicDomainDraftCheckTimerRef.current[index]; + } + return; + } + + Object.values(publicDomainDraftCheckTimerRef.current).forEach(clearTimeout); + publicDomainDraftCheckTimerRef.current = {}; + }, []); + + useEffect(() => clearPublicDomainDraftCheckTimer, [clearPublicDomainDraftCheckTimer]); + + const isPublicDomainCheckCurrent = useCallback( + (index: number, prefix: string, domain: string, checkSeq: number) => { + const network = getValues(`networks.${index}`); + + return ( + checkSeq === publicDomainCheckSeqRef.current[index] && + !!network?.openPublicDomain && + !network.openNodePort && + !network.customDomain && + normalizePublicDomainPrefix(network.publicDomain) === prefix && + (network.domain || SEALOS_DOMAIN) === domain + ); + }, + [getValues] + ); + + const commitPublicDomainDraft = useCallback( + async (index: number, value: string, options: { commitValue?: boolean } = {}) => { + const { commitValue = true } = options; + clearPublicDomainDraftCheckTimer(index); + + if (!CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED) { + const network = getValues(`networks.${index}`); + const publicDomain = network?.publicDomain || nanoid(); + if (!network?.publicDomain) { + setValue(getPublicDomainFieldName(index), publicDomain, { + shouldDirty: true, + shouldValidate: false + }); + } + clearPublicDomainValidationError(index); + return publicDomain; + } + + const result = validatePublicDomainPrefix(value); + + if (!result.valid) { + setValue(getPublicDomainFieldName(index), value, { + shouldDirty: true, + shouldValidate: false + }); + setPublicDomainValidationError(index, result.reason); + return null; + } + + const network = getValues(`networks.${index}`); + const domain = network.domain || SEALOS_DOMAIN; + if (hasManagedPublicDomainHostDuplicate(index, result.value, domain)) { + setValue(getPublicDomainFieldName(index), commitValue ? result.value : value, { + shouldDirty: true, + shouldValidate: false + }); + setPublicDomainValidationError(index, 'duplicate'); + return null; + } + + const checkSeq = (publicDomainCheckSeqRef.current[index] || 0) + 1; + publicDomainCheckSeqRef.current[index] = checkSeq; + + try { + await checkPublicDomain({ + prefix: result.value, + domain, + appName: getValues('appName') + }); + } catch (error: any) { + if (!isPublicDomainCheckCurrent(index, result.value, domain, checkSeq)) return null; + + if (error?.error?.code === 'PUBLIC_DOMAIN_CONFLICT') { + setPublicDomainValidationError(index, 'conflict', getConflictOwnerFromError(error)); + return null; + } + + return result.value; + } + + if (!isPublicDomainCheckCurrent(index, result.value, domain, checkSeq)) return null; + + clearPublicDomainValidationError(index); + if (commitValue) { + setValue(getPublicDomainFieldName(index), result.value, { + shouldDirty: true, + shouldValidate: false + }); + } + return result.value; + }, + [ + clearPublicDomainDraftCheckTimer, + clearPublicDomainValidationError, + getPublicDomainFieldName, + getValues, + hasManagedPublicDomainHostDuplicate, + isPublicDomainCheckCurrent, + setPublicDomainValidationError, + setValue + ] + ); + + const schedulePublicDomainDraftCheck = useCallback( + (index: number, value: string) => { + if (!CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED) return; + + clearPublicDomainDraftCheckTimer(index); + publicDomainCheckSeqRef.current[index] = (publicDomainCheckSeqRef.current[index] || 0) + 1; + publicDomainDraftCheckTimerRef.current[index] = setTimeout(() => { + void commitPublicDomainDraft(index, value, { commitValue: false }); + }, 600); + }, + [clearPublicDomainDraftCheckTimer, commitPublicDomainDraft] + ); + + const updatePublicDomainDraft = useCallback( + (index: number, value: string) => { + if (!CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED) return; + + setValue(getPublicDomainFieldName(index), value, { + shouldDirty: true, + shouldValidate: false + }); + clearPublicDomainValidationError(index); + const nextNetworks = getValues('networks').map((network, networkIndex) => + networkIndex === index + ? { + ...network, + publicDomain: value + } + : network + ); + syncManagedPublicDomainHostDuplicateErrors(nextNetworks); + schedulePublicDomainDraftCheck(index, value); + }, + [ + clearPublicDomainValidationError, + getPublicDomainFieldName, + getValues, + schedulePublicDomainDraftCheck, + syncManagedPublicDomainHostDuplicateErrors, + setValue + ] ); const getServiceOptions = useCallback(() => { @@ -362,13 +850,22 @@ export function NetworkSection({ {t('Network Configuration')} - {networks.map((network, i) => { + {networks.map((field, i) => { + const network = watchedNetworks?.[i] || field; const isExternalAccess = !!network.openPublicDomain || !!network.openNodePort; const canConfigureRouteRules = !!network.openPublicDomain && !network.openNodePort; + const isPublicDomainPrefixVisible = + CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED && + network.openPublicDomain && + !network.openNodePort && + !network.customDomain; + const publicDomainErrorMessage = isPublicDomainPrefixVisible + ? getPublicDomainValidationError(i) + : undefined; return ( {t('Public Access')} - - { - if (e.target.checked) { - dispatch({ - type: 'ENABLE_EXTERNAL_ACCESS', - payload: { index: i } - }); - } else { - dispatch({ - type: 'DISABLE_EXTERNAL_ACCESS', - payload: { index: i } - }); - } - }} - /> + + + { + if (e.target.checked) { + dispatch({ + type: 'ENABLE_EXTERNAL_ACCESS', + payload: { index: i } + }); + } else { + dispatch({ + type: 'DISABLE_EXTERNAL_ACCESS', + payload: { index: i } + }); + } + }} + /> - {isExternalAccess && ( - <> - - { - dispatch({ - type: 'UPDATE_PROTOCOL', - payload: { - index: i, - protocol: val - } - }); - }} - /> - - - + + { + dispatch({ + type: 'UPDATE_PROTOCOL', + payload: { + index: i, + protocol: val + } + }); + }} + /> + + {isPublicDomainPrefixVisible ? ( + <> + updatePublicDomainDraft(i, value)} + onStartEdit={() => clearPublicDomainValidationError(i)} + onCommit={(value) => commitPublicDomainDraft(i, value)} + /> + + { + copyData(getDomainDisplay(network)); + }} + > + .{network.domain} + + + + ) : ( + + { + copyData(getDomainDisplay(network)); + }} + > + {getDomainDisplay(network)} + + + )} + + {network.openPublicDomain && !network.openNodePort && ( + { + const publicDomain = network.customDomain + ? network.publicDomain + : await commitPublicDomainDraft(i, network.publicDomain); + if (!publicDomain) return; + setCustomAccessModalData({ + publicDomain, + currentCustomDomain: network.customDomain, + domain: network.domain + }); + }} + > + {t('bind_custom_domain')} + + )} + {/* keep a hidden field registered so customDomain remains part of form state */} + { - copyData(getDomainDisplay(network)); - }} - > - {getDomainDisplay(network)} - - - {network.openPublicDomain && !network.openNodePort && ( - - setCustomAccessModalData({ - publicDomain: network.publicDomain, - currentCustomDomain: network.customDomain, - domain: network.domain - }) - } - > - {t('Custom Domain')} - - )} - {/* keep a hidden field registered so customDomain remains part of form state */} - + {...register(`networks.${i}.customDomain`)} + /> + - - {canConfigureRouteRules && ( - - )} - {networks.length > 1 && ( - } - onClick={() => dispatch({ type: 'REMOVE_PORT', payload: { index: i } })} - /> - )} - - )} - + {canConfigureRouteRules && ( + + )} + {networks.length > 1 && ( + } + onClick={() => + dispatch({ type: 'REMOVE_PORT', payload: { index: i } }) + } + /> + )} + + )} + + + {publicDomainErrorMessage} + + @@ -639,7 +1179,7 @@ export function NetworkSection({ {...customAccessModalData} onClose={() => setCustomAccessModalData(undefined)} onSuccess={(customDomain) => { - const index = networks.findIndex( + const index = getValues('networks').findIndex( (network) => network.publicDomain === customAccessModalData.publicDomain ); if (index === -1) return; diff --git a/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx b/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx index aa490286b..fdcb7e196 100644 --- a/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx +++ b/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx @@ -1,11 +1,17 @@ import { postDeployApp, putApp } from '@/api/app'; -import { checkPermission, postAuthCname, postAuthDomainChallenge } from '@/api/platform'; +import { + checkPermission, + checkPublicDomain, + postAuthCname, + postAuthDomainChallenge +} from '@/api/platform'; import { defaultSliderKey } from '@/constants/app'; import { defaultEditVal, editModeMap } from '@/constants/editApp'; import { useConfirm } from '@/hooks/useConfirm'; import { useLoading } from '@/hooks/useLoading'; import { useAppStore } from '@/store/app'; import { useGlobalStore } from '@/store/global'; +import { CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED, SEALOS_DOMAIN } from '@/store/static'; import { useUserStore } from '@/store/user'; import type { YamlItemType } from '@/types'; import type { AppEditSyncedFields, AppEditType, DeployKindsType } from '@/types/app'; @@ -37,13 +43,154 @@ import { customAlphabet } from 'nanoid'; import { ResponseCode } from '@/types/response'; import { useGuideStore } from '@/store/guide'; import { track } from '@sealos/gtm'; -import { SEALOS_DOMAIN } from '@/store/static'; +import { + PUBLIC_DOMAIN_PREFIX_MAX_LENGTH, + PUBLIC_DOMAIN_PREFIX_MIN_LENGTH, + PublicDomainConflictOwner, + getDuplicateManagedPublicDomainHosts, + validatePublicDomainPrefix +} from '@/utils/public-domain'; import { getCustomDomainBindings } from '@/utils/custom-domain'; const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 12); const ErrorModal = dynamic(() => import('./components/ErrorModal')); +const EDIT_PAGE_MIN_PADDING = 20; +const EDIT_PAGE_NAV_WIDTH = 220; +const EDIT_PAGE_COLUMN_GAP = 20; +const EDIT_PAGE_CONTENT_TARGET_WIDTH = 1100; +const EDIT_PAGE_TARGET_WIDTH = + EDIT_PAGE_NAV_WIDTH + EDIT_PAGE_COLUMN_GAP + EDIT_PAGE_CONTENT_TARGET_WIDTH; + +const getPublicDomainPrefixErrorMessage = ( + t: ReturnType['t'], + reason: 'format' | 'reserved' | 'conflict' | 'duplicate', + conflictOwner?: PublicDomainConflictOwner +) => { + if (reason === 'duplicate') { + return ( + t('public_domain_prefix_duplicate_error') || + 'This public address prefix is duplicated in this app. Please choose another one.' + ); + } + + if (reason === 'conflict') { + if (conflictOwner) { + return ( + t('public_domain_prefix_conflict_owner_error', { + type: conflictOwner.displayType, + name: conflictOwner.displayName + }) || + `This public address prefix is already used by ${conflictOwner.displayType} "${conflictOwner.displayName}" in this workspace.` + ); + } + + return ( + t('public_domain_prefix_conflict_error') || + 'This public address prefix is already in use. Please choose another one.' + ); + } + + if (reason === 'reserved') { + return ( + t('public_domain_prefix_reserved_error') || + 'This public address prefix is reserved. Please choose another one.' + ); + } + + return ( + t('public_domain_prefix_format_error', { + min: PUBLIC_DOMAIN_PREFIX_MIN_LENGTH, + max: PUBLIC_DOMAIN_PREFIX_MAX_LENGTH + }) || + `Use ${PUBLIC_DOMAIN_PREFIX_MIN_LENGTH}-${PUBLIC_DOMAIN_PREFIX_MAX_LENGTH} lowercase letters, numbers, or hyphens. It cannot start or end with a hyphen.` + ); +}; + +const getConflictOwnerFromError = (error: any): PublicDomainConflictOwner | undefined => { + return error?.error?.conflictOwner; +}; + +function validatePublicDomainPrefixBeforeSubmit( + data: AppEditType, + t: ReturnType['t'], + setFieldError: (index: number, message: string) => void +) { + if (!CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED) return ''; + + for (const [index, network] of data.networks.entries()) { + if (!network.openPublicDomain || network.openNodePort || network.customDomain) { + continue; + } + + const result = validatePublicDomainPrefix(network.publicDomain); + if (result.valid) { + network.publicDomain = result.value; + continue; + } + + const message = getPublicDomainPrefixErrorMessage(t, result.reason); + setFieldError(index, message); + return message; + } + + return ''; +} + +function validateManagedPublicDomainHostDuplicatesBeforeSubmit( + data: AppEditType, + t: ReturnType['t'], + setFieldError: (index: number, message: string) => void +) { + if (!CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED) return ''; + + const duplicatedHosts = getDuplicateManagedPublicDomainHosts(data.networks, SEALOS_DOMAIN); + if (duplicatedHosts.length === 0) return ''; + + const message = getPublicDomainPrefixErrorMessage(t, 'duplicate'); + duplicatedHosts.forEach(({ indexes }) => { + indexes.forEach((index) => setFieldError(index, message)); + }); + return message; +} + +async function validatePublicDomainAvailabilityBeforeSubmit( + data: AppEditType, + t: ReturnType['t'], + setFieldError: (index: number, message: string) => void +) { + if (!CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED) return ''; + + for (const [index, network] of data.networks.entries()) { + if (!network.openPublicDomain || network.openNodePort || network.customDomain) { + continue; + } + + try { + await checkPublicDomain({ + prefix: network.publicDomain, + domain: network.domain, + appName: data.appName + }); + } catch (error: any) { + if (error?.error?.code !== 'PUBLIC_DOMAIN_CONFLICT') { + throw error; + } + + const message = getPublicDomainPrefixErrorMessage( + t, + 'conflict', + getConflictOwnerFromError(error) + ); + setFieldError(index, message); + return message; + } + } + + return ''; +} + export const formData2Yamls = ( data: AppEditType // handleType: 'edit' | 'create' = 'create', @@ -125,11 +272,7 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) => content: applyMessage }); const pxVal = useMemo(() => { - const val = Math.floor((screenWidth - 1050) / 2); - if (val < 20) { - return 20; - } - return val; + return Math.max(EDIT_PAGE_MIN_PADDING, Math.floor((screenWidth - EDIT_PAGE_TARGET_WIDTH) / 2)); }, [screenWidth]); const { createCompleted } = useGuideStore(); @@ -539,6 +682,54 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) => formHook.handleSubmit(async (data) => { console.log('data', data); + const publicDomainErrorMessage = validatePublicDomainPrefixBeforeSubmit( + data, + t, + (index, message) => { + formHook.setError(`networks.${index}.publicDomain`, { + type: 'validate', + message + }); + } + ); + + if (publicDomainErrorMessage) { + return toast({ + status: 'warning', + title: publicDomainErrorMessage + }); + } + + const publicDomainDuplicateErrorMessage = + validateManagedPublicDomainHostDuplicatesBeforeSubmit(data, t, (index, message) => { + formHook.setError(`networks.${index}.publicDomain`, { + type: 'duplicate', + message + }); + }); + + if (publicDomainDuplicateErrorMessage) { + return toast({ + status: 'warning', + title: publicDomainDuplicateErrorMessage + }); + } + + const publicDomainAvailabilityErrorMessage = + await validatePublicDomainAvailabilityBeforeSubmit(data, t, (index, message) => { + formHook.setError(`networks.${index}.publicDomain`, { + type: 'validate', + message + }); + }); + + if (publicDomainAvailabilityErrorMessage) { + return toast({ + status: 'warning', + title: publicDomainAvailabilityErrorMessage + }); + } + const parseYamls = formData2Yamls(data); setYamlList(parseYamls); @@ -623,8 +814,8 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) => data.hpa.target === 'cpu' ? 'CPU' : data.hpa.target === 'gpu' - ? 'GPU' - : 'RAM', + ? 'GPU' + : 'RAM', value: data.hpa.value } : undefined diff --git a/frontend/providers/applaunchpad/src/services/backend/appService.ts b/frontend/providers/applaunchpad/src/services/backend/appService.ts index 713ba9b38..a8c7f8d59 100644 --- a/frontend/providers/applaunchpad/src/services/backend/appService.ts +++ b/frontend/providers/applaunchpad/src/services/backend/appService.ts @@ -28,6 +28,7 @@ import { KubernetesObject, User } from '@kubernetes/client-node'; +import { ensurePublicDomainPrefixesAvailable } from './publicDomain'; export interface K8sContext { kc: KubeConfig; @@ -117,6 +118,7 @@ export async function createApp(appForm: AppEditType, k8s: K8sContext) { ...network, domain: global.AppConfig.cloud.domain })); + await ensurePublicDomainPrefixesAvailable(appForm, k8s); const parseYamls = formData2Yamls(appForm); const yamls = parseYamls.map((item) => item.value); diff --git a/frontend/providers/applaunchpad/src/services/backend/publicDomain.ts b/frontend/providers/applaunchpad/src/services/backend/publicDomain.ts new file mode 100644 index 000000000..81742b18d --- /dev/null +++ b/frontend/providers/applaunchpad/src/services/backend/publicDomain.ts @@ -0,0 +1,360 @@ +import { appDeployKey, publicDomainKey } from '@/constants/app'; +import type { KubernetesObjectApi, NetworkingV1Api, V1Ingress } from '@kubernetes/client-node'; +import type { K8sContext } from '@/services/backend/appService'; +import type { AppEditType } from '@/types/app'; +import { isCustomPublicDomainPrefixEnabled } from '@/utils/feature-gates'; +import { PublicDomainConflictOwner, validatePublicDomainPrefix } from '@/utils/public-domain'; + +const INGRESS_OWNER_CONFLICT_CODE = '40301'; +const INGRESS_OWNER_CONFLICT_MESSAGE = 'owned by other user'; +const APP_LABEL_KEYS = [ + appDeployKey, + publicDomainKey, + 'cloud.sealos.io/app-deploy-manager-port', + 'app.kubernetes.io/name', + 'app.kubernetes.io/instance', + 'app.kubernetes.io/component', + 'app.kubernetes.io/part-of', + 'app.kubernetes.io/managed-by', + 'helm.sh/chart' +]; + +export class PublicDomainError extends Error { + constructor( + message: string, + public code: 'INVALID_PUBLIC_DOMAIN' | 'RESERVED_PUBLIC_DOMAIN' | 'PUBLIC_DOMAIN_CONFLICT', + public status = 400, + public conflictOwner?: PublicDomainConflictOwner + ) { + super(message); + this.name = 'PublicDomainError'; + } +} + +export type PublicDomainTarget = { + prefix: string; + domain: string; + appName?: string; + networkName?: string; +}; + +type PublicDomainDryRunContext = { + apiClient: KubernetesObjectApi; + namespace: string; +}; + +function getDryRunIngressName(prefix: string) { + const suffix = Date.now().toString(36); + const maxPrefixLength = 63 - 'public-domain-check--'.length - suffix.length; + return `public-domain-check-${prefix.slice(0, maxPrefixLength)}-${suffix}`.replace(/-+$/, ''); +} + +export function getPublicDomainErrorMessage(error: unknown) { + if (typeof error === 'string') return error; + + const anyError = error as { + body?: { + message?: unknown; + }; + message?: unknown; + }; + + if (typeof anyError?.body?.message === 'string') return anyError.body.message; + if (error instanceof Error) return error.message; + if (typeof anyError?.message === 'string') return anyError.message; + return String(error); +} + +export function isIngressPublicDomainConflictError(error: unknown) { + const message = getPublicDomainErrorMessage(error).toLowerCase(); + return ( + message.includes(`admission webhook "vingress.sealos.io" denied the request`) && + message.includes(`${INGRESS_OWNER_CONFLICT_CODE}:`) && + message.includes(INGRESS_OWNER_CONFLICT_MESSAGE) + ); +} + +export function getPublicDomainConflictResponse(error: unknown) { + const details = getPublicDomainErrorMessage(error); + + return { + code: 'PUBLIC_DOMAIN_CONFLICT' as const, + message: getPublicDomainConflictMessage(), + details + }; +} + +export function getPublicDomainConflictMessage() { + return 'Public domain is already in use by another workspace.'; +} + +export function getSameWorkspacePublicDomainConflictMessage(owner: PublicDomainConflictOwner) { + return `Public domain "${owner.host}" is already used by ${owner.displayType} "${owner.displayName}" in this workspace.`; +} + +function pickSafeLabels(labels?: Record) { + if (!labels) return undefined; + + const safeLabels = APP_LABEL_KEYS.reduce>((result, key) => { + const value = labels[key]; + if (typeof value === 'string' && value) { + result[key] = value; + } + return result; + }, {}); + + return Object.keys(safeLabels).length > 0 ? safeLabels : undefined; +} + +function getRecommendedDisplayName(labels: Record | undefined, fallback: string) { + return ( + labels?.['app.kubernetes.io/instance'] || + labels?.['app.kubernetes.io/name'] || + labels?.['app.kubernetes.io/component'] || + fallback + ); +} + +function getPublicDomainConflictOwner(ingress: V1Ingress, host: string): PublicDomainConflictOwner { + const labels = ingress.metadata?.labels || {}; + const ingressName = ingress.metadata?.name || 'unknown-ingress'; + const namespace = ingress.metadata?.namespace || ''; + const launchpadAppName = labels[appDeployKey]; + const publicDomainPrefix = labels[publicDomainKey]; + + if (launchpadAppName) { + return { + scope: 'same_workspace', + resourceKind: 'Ingress', + component: 'app_launchpad', + displayType: 'App Launchpad app', + displayName: launchpadAppName, + host, + namespace, + ingressName, + publicDomainPrefix, + labels: pickSafeLabels(labels), + matchedBy: 'cloud.sealos.io/app-deploy-manager', + confidence: 'high' + }; + } + + if ( + labels['app.kubernetes.io/part-of'] === 'devbox' || + labels['app.kubernetes.io/name'] === 'devbox' + ) { + return { + scope: 'same_workspace', + resourceKind: 'Ingress', + component: 'devbox', + displayType: 'Devbox', + displayName: getRecommendedDisplayName(labels, ingressName), + host, + namespace, + ingressName, + publicDomainPrefix, + labels: pickSafeLabels(labels), + matchedBy: 'app.kubernetes.io/part-of=devbox', + confidence: labels['app.kubernetes.io/part-of'] === 'devbox' ? 'high' : 'medium' + }; + } + + if ( + labels['app.kubernetes.io/name'] || + labels['app.kubernetes.io/instance'] || + labels['app.kubernetes.io/component'] || + labels['app.kubernetes.io/managed-by'] + ) { + return { + scope: 'same_workspace', + resourceKind: 'Ingress', + component: 'workspace_component', + displayType: 'workspace component', + displayName: getRecommendedDisplayName(labels, ingressName), + host, + namespace, + ingressName, + publicDomainPrefix, + labels: pickSafeLabels(labels), + matchedBy: 'kubernetes-recommended-labels', + confidence: 'medium' + }; + } + + return { + scope: 'same_workspace', + resourceKind: 'Ingress', + component: 'ingress', + displayType: 'Ingress', + displayName: ingressName, + host, + namespace, + ingressName, + publicDomainPrefix, + labels: pickSafeLabels(labels), + matchedBy: 'unlabeled-ingress', + confidence: 'low' + }; +} + +type PublicDomainK8sContext = { + k8sNetworkingApp: NetworkingV1Api; + namespace: string; +}; + +export function normalizeAndValidatePublicDomainPrefix(value: string) { + if (!isCustomPublicDomainPrefixEnabled()) { + throw new PublicDomainError( + 'Custom public domain prefixes are disabled', + 'INVALID_PUBLIC_DOMAIN' + ); + } + + const result = validatePublicDomainPrefix(value); + + if (!result.valid) { + throw new PublicDomainError( + result.reason === 'reserved' + ? `Public domain prefix "${result.value}" is reserved` + : `Public domain prefix "${result.value}" is invalid`, + result.reason === 'reserved' ? 'RESERVED_PUBLIC_DOMAIN' : 'INVALID_PUBLIC_DOMAIN' + ); + } + + return result.value; +} + +export async function dryRunPublicDomainIngress( + target: PublicDomainTarget, + k8s: PublicDomainDryRunContext +) { + const prefix = normalizeAndValidatePublicDomainPrefix(target.prefix); + const host = `${prefix}.${target.domain}`; + const appName = target.appName || 'public-domain-check'; + const networkName = target.networkName || getDryRunIngressName(prefix); + + await k8s.apiClient.create( + { + apiVersion: 'networking.k8s.io/v1', + kind: 'Ingress', + metadata: { + name: networkName, + namespace: k8s.namespace, + labels: { + [appDeployKey]: appName, + [publicDomainKey]: prefix + }, + annotations: { + 'kubernetes.io/ingress.class': 'nginx' + } + }, + spec: { + rules: [ + { + host, + http: { + paths: [ + { + path: '/', + pathType: 'Prefix', + backend: { + service: { + name: appName, + port: { + number: 80 + } + } + } + } + ] + } + } + ] + } + }, + undefined, + 'All', + 'applaunchpad-public-domain-check' + ); + + return { + prefix, + host + }; +} + +export async function ensurePublicDomainTargetsAvailable( + targets: PublicDomainTarget[], + k8s: PublicDomainK8sContext +) { + if (!isCustomPublicDomainPrefixEnabled()) return; + if (targets.length === 0) return; + + const seenHosts = new Set(); + const appsByHost = new Map>(); + + for (const target of targets) { + const prefix = normalizeAndValidatePublicDomainPrefix(target.prefix); + const host = `${prefix}.${target.domain}`; + if (seenHosts.has(host)) { + throw new PublicDomainError( + `Public domain "${host}" is duplicated in this application`, + 'PUBLIC_DOMAIN_CONFLICT', + 409 + ); + } + target.prefix = prefix; + seenHosts.add(host); + if (target.appName) { + const apps = appsByHost.get(host) || new Set(); + apps.add(target.appName); + appsByHost.set(host, apps); + } + } + + const { body } = await k8s.k8sNetworkingApp.listNamespacedIngress(k8s.namespace); + + for (const item of body.items || []) { + const ownerAppName = item.metadata?.labels?.[appDeployKey]; + const hosts = + item.spec?.rules + ?.map((rule) => rule.host) + .filter((host): host is string => typeof host === 'string') || []; + const conflictingHost = hosts.find((host) => { + if (!seenHosts.has(host)) return false; + return !ownerAppName || !appsByHost.get(host)?.has(ownerAppName); + }); + + if (conflictingHost) { + const conflictOwner = getPublicDomainConflictOwner(item, conflictingHost); + throw new PublicDomainError( + getSameWorkspacePublicDomainConflictMessage(conflictOwner), + 'PUBLIC_DOMAIN_CONFLICT', + 409, + conflictOwner + ); + } + } +} + +export function validateAppPublicDomainPrefixes(app: AppEditType) { + return app.networks + .filter((network) => network.openPublicDomain && !network.openNodePort && !network.customDomain) + .map((network) => { + const prefix = normalizeAndValidatePublicDomainPrefix(network.publicDomain); + network.publicDomain = prefix; + return { + prefix, + domain: network.domain || global.AppConfig?.cloud?.domain || 'cloud.sealos.io', + appName: app.appName, + networkName: network.networkName + }; + }); +} + +export async function ensurePublicDomainPrefixesAvailable(app: AppEditType, k8s: K8sContext) { + if (!isCustomPublicDomainPrefixEnabled()) return; + + const targets = validateAppPublicDomainPrefixes(app); + await ensurePublicDomainTargetsAvailable(targets, k8s); +} diff --git a/frontend/providers/applaunchpad/src/services/backend/response.ts b/frontend/providers/applaunchpad/src/services/backend/response.ts index cbcd8c7c9..2161e8dee 100644 --- a/frontend/providers/applaunchpad/src/services/backend/response.ts +++ b/frontend/providers/applaunchpad/src/services/backend/response.ts @@ -1,6 +1,11 @@ import { NextApiResponse } from 'next'; import { ApiResponse, ResponseCode, ResponseMessages } from '@/types/response'; import { V1Status } from '@kubernetes/client-node'; +import { + getPublicDomainConflictResponse, + isIngressPublicDomainConflictError, + PublicDomainError +} from './publicDomain'; export const jsonRes = (res: NextApiResponse, options: Partial> = {}) => { const { code = ResponseCode.SUCCESS, message, data, error } = options; @@ -16,7 +21,24 @@ export const jsonRes = (res: NextApiResponse, options: Partial => { + if (isIngressPublicDomainConflictError(err)) { + const conflict = getPublicDomainConflictResponse(err); + return { + code: 409, + message: conflict.message, + error: conflict + }; + } + if (err?.kind === 'Status' && err?.apiVersion === 'v1' && err?.status) { const k8sApiErr = err as V1Status; if (k8sApiErr.code === 403) { diff --git a/frontend/providers/applaunchpad/src/store/static.ts b/frontend/providers/applaunchpad/src/store/static.ts index b77e01158..0d9d8fb6b 100644 --- a/frontend/providers/applaunchpad/src/store/static.ts +++ b/frontend/providers/applaunchpad/src/store/static.ts @@ -1,5 +1,6 @@ import { getInitData } from '@/api/platform'; import { Coin } from '@/constants/app'; +import { setPublicDomainReservedPrefixes } from '@/utils/public-domain'; export let SEALOS_DOMAIN = 'cloud.sealos.io'; export let SEALOS_USER_DOMAINS = [{ name: 'cloud.sealos.io', secretName: 'wildcard-cert' }]; @@ -19,6 +20,9 @@ export let PVC_STORAGE_MAX = 20; export let GPU_ENABLED = false; export let LOG_ENABLED = false; export let NETWORK_STORAGE_ENABLED = false; +export let IMAGE_PORTS_ENABLED = false; +export let CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED = false; +export let PUBLIC_DOMAIN_RESERVED_PREFIXES: string[] = []; export const loadInitData = async () => { try { @@ -42,6 +46,10 @@ export const loadInitData = async () => { GPU_ENABLED = res.GPU_ENABLED; LOG_ENABLED = res.LOG_ENABLED; NETWORK_STORAGE_ENABLED = res.NETWORK_STORAGE_ENABLED; + IMAGE_PORTS_ENABLED = res.IMAGE_PORTS_ENABLED; + CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED = res.CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED; + PUBLIC_DOMAIN_RESERVED_PREFIXES = res.PUBLIC_DOMAIN_RESERVED_PREFIXES || []; + setPublicDomainReservedPrefixes(PUBLIC_DOMAIN_RESERVED_PREFIXES); return { SEALOS_DOMAIN, @@ -51,7 +59,10 @@ export const loadInitData = async () => { CURRENCY, FORM_SLIDER_LIST_CONFIG: res.FORM_SLIDER_LIST_CONFIG, DESKTOP_DOMAIN: res.DESKTOP_DOMAIN, - GPU_ENABLED + GPU_ENABLED, + IMAGE_PORTS_ENABLED, + CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED, + PUBLIC_DOMAIN_RESERVED_PREFIXES }; } catch (error) {} @@ -69,5 +80,11 @@ export const serverLoadInitData = () => { DISABLE_HTTPS = !!global.AppConfig.cloud.disableHttps; SHOW_EVENT_ANALYZE = global.AppConfig.launchpad.eventAnalyze.enabled; SEALOS_USER_DOMAINS = global.AppConfig.cloud.userDomains; + IMAGE_PORTS_ENABLED = !!global.AppConfig.launchpad.imagePorts?.enabled; + CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED = + !!global.AppConfig.launchpad.publicDomain?.customPrefixEnabled; + PUBLIC_DOMAIN_RESERVED_PREFIXES = + global.AppConfig.launchpad.publicDomain?.reservedPrefixes || []; + setPublicDomainReservedPrefixes(PUBLIC_DOMAIN_RESERVED_PREFIXES); } catch (error) {} }; diff --git a/frontend/providers/applaunchpad/src/types/index.d.ts b/frontend/providers/applaunchpad/src/types/index.d.ts index de1262827..75aedec3c 100644 --- a/frontend/providers/applaunchpad/src/types/index.d.ts +++ b/frontend/providers/applaunchpad/src/types/index.d.ts @@ -62,6 +62,13 @@ export type AppConfigType = { gtmId: string | null; currencySymbol: Coin; pvcStorageMax: number; + imagePorts?: { + enabled?: boolean; + }; + publicDomain?: { + customPrefixEnabled?: boolean; + reservedPrefixes?: string[]; + }; eventAnalyze: { enabled: boolean; fastGPTKey?: string; @@ -112,4 +119,7 @@ export type EnvResponse = { GPU_ENABLED: boolean; LOG_ENABLED: boolean; NETWORK_STORAGE_ENABLED: boolean; + IMAGE_PORTS_ENABLED: boolean; + CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED: boolean; + PUBLIC_DOMAIN_RESERVED_PREFIXES: string[]; }; diff --git a/frontend/providers/applaunchpad/src/types/request_schema.ts b/frontend/providers/applaunchpad/src/types/request_schema.ts index 71e5ad191..5c61f1864 100644 --- a/frontend/providers/applaunchpad/src/types/request_schema.ts +++ b/frontend/providers/applaunchpad/src/types/request_schema.ts @@ -41,15 +41,80 @@ import { imageRegistrySchema, resourceConverters } from './schema'; +import { isCustomPublicDomainPrefixEnabled } from '@/utils/feature-gates'; +import { validatePublicDomainPrefix } from '@/utils/public-domain'; export const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 12); +function includePublicDomainPrefix() { + return isCustomPublicDomainPrefixEnabled(); +} + +const PublicDomainPrefixSchema = z.string().superRefine((value, ctx) => { + if (!isCustomPublicDomainPrefixEnabled()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Custom public domain prefixes are disabled' + }); + return; + } + + const result = validatePublicDomainPrefix(value); + if (!result.valid) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + result.reason === 'reserved' + ? `Public domain prefix "${result.value}" is reserved` + : `Public domain prefix "${result.value}" is invalid` + }); + } +}); + +function getPublicDomainPrefixOrRandom(value?: string) { + if (!value) return nanoid(); + if (!isCustomPublicDomainPrefixEnabled()) { + throw new Error('Custom public domain prefixes are disabled'); + } + + const result = validatePublicDomainPrefix(value); + if (!result.valid) { + throw new Error( + result.reason === 'reserved' + ? `Public domain prefix "${result.value}" is reserved` + : `Public domain prefix "${result.value}" is invalid` + ); + } + return result.value; +} + export const GetAppByAppNameQuerySchema = z.object({ name: z.string().min(1, { message: 'name cannot be empty' }) }); export const GetAppByAppNameResponseSchema = z.array(z.any()).nullable(); +const CreatePortConfigSchema = PortConfigSchema.pick({ + number: true, + protocol: true, + exposesPublicDomain: true, + publicDomain: true +}) + .extend({ + publicDomain: PublicDomainPrefixSchema.optional() + }) + .superRefine((data, ctx) => { + if (data.publicDomain === undefined) return; + const isApplicationProtocol = ['HTTP', 'GRPC', 'WS'].includes(data.protocol); + if (!isApplicationProtocol || data.exposesPublicDomain === false) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['publicDomain'], + message: 'publicDomain can only be set for HTTP/GRPC/WS ports with public domain access' + }); + } + }); + export const DeleteAppByNameQuerySchema = z.object({ name: z.string().min(1, { message: 'name cannot be empty' }) }); @@ -84,6 +149,9 @@ export const PortUpdateSchema = z description: 'Whether to expose this port via public domain (only effective for HTTP/GRPC/WS protocols)' }), + publicDomain: PublicDomainPrefixSchema.optional().openapi({ + description: 'Custom public subdomain prefix. Omit to keep or auto-generate.' + }), portName: z.string().optional().openapi({ description: 'Port name (include this to update existing port, omit to create new port)' }), @@ -97,6 +165,18 @@ export const PortUpdateSchema = z .openapi({ description: 'Port configuration. Include portName to update existing port. Omit portName to create new port. Ports not included in the array will be deleted.' + }) + .superRefine((data, ctx) => { + if (data.publicDomain === undefined) return; + const isKnownNonApplicationProtocol = + data.protocol !== undefined && !['HTTP', 'GRPC', 'WS'].includes(data.protocol); + if (isKnownNonApplicationProtocol || data.exposesPublicDomain === false) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['publicDomain'], + message: 'publicDomain can only be set for HTTP/GRPC/WS ports with public domain access' + }); + } }); export const UpdateImageSchema = z @@ -316,13 +396,7 @@ export const CreateLaunchpadRequestSchema = z resource: ResourceSchema, ports: z - .array( - PortConfigSchema.pick({ - number: true, - protocol: true, - exposesPublicDomain: true - }) - ) + .array(CreatePortConfigSchema) .default([ { number: 80, @@ -363,6 +437,11 @@ export function transformToLegacySchema( const networks = standardRequest.ports?.map((port) => { const isApplicationProtocol = ['HTTP', 'GRPC', 'WS'].includes(port.protocol); + const publicDomain = + isApplicationProtocol && port.exposesPublicDomain + ? getPublicDomainPrefixOrRandom(port.publicDomain) + : ''; + return { serviceName: `service-${nanoid()}`, networkName: `network-${nanoid()}`, @@ -371,7 +450,7 @@ export function transformToLegacySchema( protocol: (isApplicationProtocol ? 'TCP' : port.protocol) as TransportProtocolType, appProtocol: (isApplicationProtocol ? port.protocol : 'HTTP') as ApplicationProtocolType, openPublicDomain: isApplicationProtocol ? port.exposesPublicDomain : false, - publicDomain: isApplicationProtocol ? nanoid() : '', + publicDomain, customDomain: '', domain: '', nodePort: undefined, @@ -542,7 +621,10 @@ export function transformFromLegacySchema( exposesPublicDomain: exposesPublicDomain, networkName: network.networkName, portName: network.portName, - publicDomain: network.publicDomain, + ...(includePublicDomainPrefix() && + network.publicDomain && { + publicDomain: network.publicDomain + }), domain: network.domain, customDomain: network.customDomain, nodePort: network.nodePort diff --git a/frontend/providers/applaunchpad/src/types/schema.ts b/frontend/providers/applaunchpad/src/types/schema.ts index f00ae3906..518ea4b66 100644 --- a/frontend/providers/applaunchpad/src/types/schema.ts +++ b/frontend/providers/applaunchpad/src/types/schema.ts @@ -266,7 +266,7 @@ export const PortConfigSchema = z.object({ }), networkName: z.string().default(() => `network-${nanoid()}`), portName: z.string().default(() => nanoid()), - publicDomain: z.string().default(() => nanoid()), + publicDomain: z.string().optional(), domain: z.string().default(''), customDomain: z.string().optional().openapi({ description: 'Custom domain' diff --git a/frontend/providers/applaunchpad/src/types/v2alpha/request_schema.ts b/frontend/providers/applaunchpad/src/types/v2alpha/request_schema.ts index 37a58438e..fdc6c6128 100644 --- a/frontend/providers/applaunchpad/src/types/v2alpha/request_schema.ts +++ b/frontend/providers/applaunchpad/src/types/v2alpha/request_schema.ts @@ -19,9 +19,53 @@ import { resourceConverters } from './schema'; import { buildExternalUrl } from '@/utils/network-url'; +import { isCustomPublicDomainPrefixEnabled } from '@/utils/feature-gates'; +import { validatePublicDomainPrefix } from '@/utils/public-domain'; export const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 12); +function includePublicDomainPrefix() { + return isCustomPublicDomainPrefixEnabled(); +} + +const PublicDomainPrefixSchema = z.string().superRefine((value, ctx) => { + if (!isCustomPublicDomainPrefixEnabled()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Custom public domain prefixes are disabled' + }); + return; + } + + const result = validatePublicDomainPrefix(value); + if (!result.valid) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + result.reason === 'reserved' + ? `Public domain prefix "${result.value}" is reserved` + : `Public domain prefix "${result.value}" is invalid` + }); + } +}); + +function getPublicDomainPrefixOrRandom(value?: string) { + if (!value) return nanoid(); + if (!isCustomPublicDomainPrefixEnabled()) { + throw new Error('Custom public domain prefixes are disabled'); + } + + const result = validatePublicDomainPrefix(value); + if (!result.valid) { + throw new Error( + result.reason === 'reserved' + ? `Public domain prefix "${result.value}" is reserved` + : `Public domain prefix "${result.value}" is invalid` + ); + } + return result.value; +} + function parseCreateTimeToDate(createTime: string): Date | null { // Common formats in this repo: 'YYYY/MM/DD HH:mm' or 'YYYY-MM-DD HH:mm' (sometimes with seconds). const m = createTime.match(/^(\d{4})[/-](\d{2})[/-](\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/); @@ -100,8 +144,22 @@ export const CreatePortConfigSchema = z isPublic: z.boolean().default(true).openapi({ description: 'Whether to expose this port via public domain (only effective for http/grpc/ws protocols)' + }), + publicDomain: PublicDomainPrefixSchema.optional().openapi({ + description: 'Custom public subdomain prefix. Omit to auto-generate.' }) }) + .superRefine((data, ctx) => { + if (data.publicDomain === undefined) return; + const isApplicationProtocol = ['http', 'grpc', 'ws'].includes(data.protocol); + if (!isApplicationProtocol || data.isPublic === false) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['publicDomain'], + message: 'publicDomain can only be set for http/grpc/ws ports with public domain access' + }); + } + }) .openapi({ description: 'Port configuration for creating applications' }); @@ -119,6 +177,9 @@ export const PortUpdateSchema = z description: 'Whether to expose this port via public domain (only effective for http/grpc/ws protocols)' }), + publicDomain: PublicDomainPrefixSchema.optional().openapi({ + description: 'Custom public subdomain prefix. Omit to keep or auto-generate.' + }), portName: z.string().optional().openapi({ description: 'Port name (include this to update existing port, omit to create new port)' }) @@ -126,6 +187,18 @@ export const PortUpdateSchema = z .openapi({ description: 'Port configuration. Include portName to update existing port. Omit portName to create new port. Ports not included in the array will be deleted.' + }) + .superRefine((data, ctx) => { + if (data.publicDomain === undefined) return; + const isKnownNonApplicationProtocol = + data.protocol !== undefined && !['http', 'grpc', 'ws'].includes(data.protocol); + if (isKnownNonApplicationProtocol || data.isPublic === false) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['publicDomain'], + message: 'publicDomain can only be set for http/grpc/ws ports with public domain access' + }); + } }); export const UpdateImageSchema = z @@ -369,6 +442,11 @@ export function transformToLegacySchema( const protocolLower = (port.protocol || 'http').toLowerCase(); const isApplicationProtocol = ['http', 'grpc', 'ws'].includes(protocolLower); const protocolUpper = protocolLower.toUpperCase(); + const openPublicDomain = isApplicationProtocol ? (port.isPublic ?? true) : false; + const publicDomain = + isApplicationProtocol && openPublicDomain + ? getPublicDomainPrefixOrRandom(port.publicDomain) + : ''; return { serviceName: `${standardRequest.name}-${port.number}-${nanoid()}-service`, networkName: `${standardRequest.name}-${port.number}-${nanoid()}-network`, @@ -378,8 +456,8 @@ export function transformToLegacySchema( appProtocol: (isApplicationProtocol ? protocolUpper : 'HTTP') as ApplicationProtocolType, - openPublicDomain: isApplicationProtocol ? port.isPublic ?? true : false, - publicDomain: isApplicationProtocol ? nanoid() : '', + openPublicDomain, + publicDomain, customDomain: '', domain: '', nodePort: undefined, @@ -587,6 +665,8 @@ export function transformFromLegacySchema( number: network.port, portName: network.portName, protocol: protocolLower, + ...(includePublicDomainPrefix() && + network.publicDomain && { publicDomain: network.publicDomain }), ...(privateAddress && { privateAddress }), ...(publicAddress && { publicAddress }), ...(network.customDomain && { customDomain: network.customDomain }) diff --git a/frontend/providers/applaunchpad/src/types/v2alpha/schema.ts b/frontend/providers/applaunchpad/src/types/v2alpha/schema.ts index 3d4363b66..052503e36 100644 --- a/frontend/providers/applaunchpad/src/types/v2alpha/schema.ts +++ b/frontend/providers/applaunchpad/src/types/v2alpha/schema.ts @@ -233,6 +233,10 @@ export const PortConfigSchema = z description: 'Public access address', example: 'https://xyz789.cloud.sealos.io' }), + publicDomain: z.string().optional().openapi({ + description: 'Public subdomain prefix', + example: 'xyz789' + }), customDomain: z.string().optional().openapi({ description: 'Custom domain (if configured)', example: 'api.example.com' diff --git a/frontend/providers/applaunchpad/src/utils/adapt.ts b/frontend/providers/applaunchpad/src/utils/adapt.ts index 60d68e1ec..eced94ca9 100644 --- a/frontend/providers/applaunchpad/src/utils/adapt.ts +++ b/frontend/providers/applaunchpad/src/utils/adapt.ts @@ -578,8 +578,8 @@ export const adaptAppDetail = async ( domain: isCustomDomain ? SEALOS_DOMAIN : item?.nodePort - ? domain - : domain.split('.').slice(1).join('.') || SEALOS_DOMAIN, + ? domain + : domain.split('.').slice(1).join('.') || SEALOS_DOMAIN, routes: ingressPaths.length ? ingressPaths.map((path) => ({ path: path.path || '/', @@ -714,8 +714,8 @@ export const sliderNumber2MarkList = ({ ? `${item / 1024} G` : `${item} M` : type === 'ephemeralStorage' - ? `${item}` - : `${item / 1000}`, + ? `${item}` + : `${item / 1000}`, value: item })); }; diff --git a/frontend/providers/applaunchpad/src/utils/deployYaml2Json.ts b/frontend/providers/applaunchpad/src/utils/deployYaml2Json.ts index c8b8619dd..53ba73d85 100644 --- a/frontend/providers/applaunchpad/src/utils/deployYaml2Json.ts +++ b/frontend/providers/applaunchpad/src/utils/deployYaml2Json.ts @@ -104,10 +104,13 @@ export const json2DeployCr = (data: AppEditType, type: 'deployment' | 'statefuls ? { [`${data.gpu.manufacturers}.com/use-gputype`]: data.gpu.type } - : supportedGpuManufacturers.reduce((acc, manufacturer) => { - acc[`${manufacturer}.com/use-gputype`] = null; - return acc; - }, {} as Record); + : supportedGpuManufacturers.reduce( + (acc, manufacturer) => { + acc[`${manufacturer}.com/use-gputype`] = null; + return acc; + }, + {} as Record + ); const metadata = { name: data.appName, diff --git a/frontend/providers/applaunchpad/src/utils/feature-gates.ts b/frontend/providers/applaunchpad/src/utils/feature-gates.ts new file mode 100644 index 000000000..2302c9f34 --- /dev/null +++ b/frontend/providers/applaunchpad/src/utils/feature-gates.ts @@ -0,0 +1,11 @@ +function getAppConfig(config?: unknown) { + return (config || (globalThis as any).AppConfig) as any; +} + +export function isCustomPublicDomainPrefixEnabled(config?: unknown) { + return !!getAppConfig(config)?.launchpad?.publicDomain?.customPrefixEnabled; +} + +export function isImagePortsEnabled(config?: unknown) { + return !!getAppConfig(config)?.launchpad?.imagePorts?.enabled; +} diff --git a/frontend/providers/applaunchpad/src/utils/image-exposed-ports.ts b/frontend/providers/applaunchpad/src/utils/image-exposed-ports.ts new file mode 100644 index 000000000..2b2de05be --- /dev/null +++ b/frontend/providers/applaunchpad/src/utils/image-exposed-ports.ts @@ -0,0 +1,643 @@ +import { lookup } from 'dns/promises'; +import { request as httpRequest } from 'http'; +import type { IncomingHttpHeaders, IncomingMessage, RequestOptions } from 'http'; +import { request as httpsRequest } from 'https'; +import { isIP } from 'net'; +import type { LookupFunction } from 'net'; + +export type ImageRegistryAuth = { + username?: string; + password?: string; + serverAddress?: string; +}; + +export type ImageExposedPort = { + port: number; + protocol: 'TCP' | 'UDP' | 'SCTP'; +}; + +type ImageRef = { + registry: string; + repository: string; + reference: string; +}; + +type RegistryManifest = { + mediaType?: string; + config?: { + digest?: string; + }; + manifests?: { + digest?: string; + platform?: { + architecture?: string; + os?: string; + variant?: string; + }; + }[]; +}; + +type ChallengeParams = { + realm?: string; + service?: string; + scope?: string; +}; + +type RegistryResponse = { + ok: boolean; + status: number; + headers: { + get: (name: string) => string | null; + }; + json: () => Promise; +}; + +const DEFAULT_REGISTRY = 'registry-1.docker.io'; +const DOCKER_HUB_AUTH_SERVICE = 'registry.docker.io'; +const DOCKER_HUB_AUTH_HOST = 'auth.docker.io'; +const DOCKER_HUB_REGISTRY_ALIASES = new Set([ + 'docker.io', + 'index.docker.io', + 'registry.hub.docker.com', + DEFAULT_REGISTRY +]); +const ACCEPT_HEADER = [ + 'application/vnd.oci.image.index.v1+json', + 'application/vnd.docker.distribution.manifest.list.v2+json', + 'application/vnd.oci.image.manifest.v1+json', + 'application/vnd.docker.distribution.manifest.v2+json', + 'application/vnd.docker.distribution.manifest.v1+json' +].join(', '); +const REGISTRY_REQUEST_TIMEOUT = 10000; +const MAX_REGISTRY_RESPONSE_BYTES = 1024 * 1024; +const MAX_REGISTRY_REDIRECTS = 3; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const JSON_CONTENT_TYPES = new Set([ + 'application/json', + 'application/vnd.oci.image.index.v1+json', + 'application/vnd.docker.distribution.manifest.list.v2+json', + 'application/vnd.oci.image.manifest.v1+json', + 'application/vnd.docker.distribution.manifest.v2+json', + 'application/vnd.docker.distribution.manifest.v1+json', + 'application/vnd.oci.image.config.v1+json', + 'application/vnd.docker.container.image.v1+json' +]); +const OCTET_STREAM_CONTENT_TYPE = 'application/octet-stream'; + +function isBlockedIPv4(address: string) { + const parts = address.split('.').map((part) => Number(part)); + if ( + parts.length !== 4 || + parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) + ) { + return true; + } + + const [a, b] = parts; + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19)) || + (a === 100 && b >= 64 && b <= 127) || + (a === 192 && b === 0 && parts[2] === 0) || + (a === 192 && b === 0 && parts[2] === 2) || + (a === 198 && b === 51 && parts[2] === 100) || + (a === 203 && b === 0 && parts[2] === 113) || + a >= 224 + ); +} + +function isBlockedIPv6(address: string) { + const normalized = address.toLowerCase(); + const ipv4Mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (ipv4Mapped) { + return isBlockedIPv4(ipv4Mapped[1]); + } + + return ( + normalized === '::' || + normalized === '::1' || + normalized.startsWith('2001:db8:') || + normalized.startsWith('fc') || + normalized.startsWith('fd') || + /^fe[89ab][0-9a-f]:/.test(normalized) || + normalized.startsWith('ff') + ); +} + +function isBlockedAddress(address: string) { + const ipVersion = isIP(address); + if (ipVersion === 4) return isBlockedIPv4(address); + if (ipVersion === 6) return isBlockedIPv6(address); + return true; +} + +function getUrlHost(value: string) { + const url = new URL( + value.startsWith('http://') || value.startsWith('https://') ? value : `https://${value}` + ); + return url.hostname.replace(/^\[|\]$/g, ''); +} + +async function assertPublicRegistryHost(registry: string) { + const host = getUrlHost(registry); + await assertPublicHost(host, 'Registry host'); +} + +async function assertPublicHost(host: string, context: string) { + const hostIsIp = isIP(host) !== 0; + if (host === 'localhost' || host.endsWith('.localhost') || (hostIsIp && isBlockedAddress(host))) { + throw new Error(`${context} is not allowed`); + } + + if (hostIsIp) { + return; + } + + const addresses = await lookup(host, { all: true }); + if (!addresses.length || addresses.some((item) => isBlockedAddress(item.address))) { + throw new Error(`${context} resolves to a private address`); + } +} + +function assertRegistryUrl(url: URL) { + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error('Registry request protocol is not allowed'); + } +} + +const publicLookup: LookupFunction = (hostname, options, callback) => { + const lookupOptions = { + family: options.family, + hints: options.hints, + verbatim: options.verbatim + }; + + const hostIsIp = isIP(hostname) !== 0; + if (hostIsIp) { + if (isBlockedAddress(hostname)) { + callback( + Object.assign(new Error('Registry host is not allowed'), { code: 'EHOSTDENIED' }), + '', + 0 + ); + return; + } + + callback(null, hostname, isIP(hostname)); + return; + } + + lookup(hostname, { ...lookupOptions, all: true }) + .then((addresses) => { + if (!addresses.length || addresses.some((item) => isBlockedAddress(item.address))) { + callback( + Object.assign(new Error('Registry host resolves to a private address'), { + code: 'EHOSTDENIED' + }), + '', + 0 + ); + return; + } + + const preferredFamily = + lookupOptions.family === 4 || lookupOptions.family === 6 ? lookupOptions.family : undefined; + const selected = preferredFamily + ? addresses.find((item) => item.family === preferredFamily) + : addresses[0]; + + if (!selected) { + callback( + Object.assign(new Error('Registry host address family is unavailable'), { + code: 'ENOTFOUND' + }), + '', + 0 + ); + return; + } + + callback(null, selected.address, selected.family); + }) + .catch((error) => { + callback(error, '', 0); + }); +}; + +function getHeader(headers: IncomingHttpHeaders, name: string) { + const value = headers[name.toLowerCase()]; + if (Array.isArray(value)) return value.join(', '); + return value ?? null; +} + +function createRegistryResponse(message: IncomingMessage, body: Buffer): RegistryResponse { + const status = message.statusCode || 0; + return { + ok: status >= 200 && status < 300, + status, + headers: { + get: (name) => getHeader(message.headers, name) + }, + json: async () => JSON.parse(body.toString('utf8')) + }; +} + +async function readRegistryResponseBody(message: IncomingMessage, context: string) { + return await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + let settled = false; + + const fail = (error: Error) => { + if (settled) return; + settled = true; + reject(error); + }; + + message.on('data', (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buffer.byteLength; + if (total > MAX_REGISTRY_RESPONSE_BYTES) { + message.destroy(); + fail(new Error(`${context} is too large`)); + return; + } + chunks.push(buffer); + }); + message.on('error', fail); + message.on('end', () => { + if (settled) return; + settled = true; + resolve(Buffer.concat(chunks)); + }); + }); +} + +async function requestRegistryUrl( + url: URL, + options: { headers?: Record; signal?: AbortSignal; context: string } +) { + assertRegistryUrl(url); + await assertPublicHost(url.hostname, 'Registry host'); + + const request = url.protocol === 'https:' ? httpsRequest : httpRequest; + const requestOptions: RequestOptions = { + method: 'GET', + headers: options.headers, + lookup: publicLookup, + signal: options.signal + }; + + return await new Promise((resolve, reject) => { + const req = request(url, requestOptions, async (message) => { + try { + const body = await readRegistryResponseBody(message, options.context); + resolve(createRegistryResponse(message, body)); + } catch (error) { + reject(error); + } + }); + + req.on('error', reject); + req.end(); + }); +} + +function stripRedirectHeaders(headers: Record | undefined, from: URL, to: URL) { + if (from.origin === to.origin) return headers; + if (!headers) return headers; + + const nextHeaders = { ...headers }; + delete nextHeaders.Authorization; + delete nextHeaders.authorization; + return nextHeaders; +} + +async function registryHttpRequest( + url: string, + options: { headers?: Record; signal?: AbortSignal; context: string }, + redirectCount = 0 +): Promise { + const currentUrl = new URL(url); + const response = await requestRegistryUrl(currentUrl, options); + + if (!REDIRECT_STATUSES.has(response.status)) { + return response; + } + + if (redirectCount >= MAX_REGISTRY_REDIRECTS) { + throw new Error('Registry request redirected too many times'); + } + + const location = response.headers.get('location'); + if (!location) { + throw new Error('Registry request redirect is invalid'); + } + + const redirectUrl = new URL(location, currentUrl); + assertRegistryUrl(redirectUrl); + if (currentUrl.protocol === 'https:' && redirectUrl.protocol !== 'https:') { + throw new Error('Registry request redirect is not trusted'); + } + + await assertPublicHost(redirectUrl.hostname, 'Registry host'); + + return await registryHttpRequest( + redirectUrl.toString(), + { + ...options, + headers: stripRedirectHeaders(options.headers, currentUrl, redirectUrl) + }, + redirectCount + 1 + ); +} + +function assertTrustedRealm(challenge: ChallengeParams, image: ImageRef) { + if (!challenge.realm) return; + + let realmUrl: URL; + try { + realmUrl = new URL(challenge.realm); + } catch { + throw new Error('Registry auth realm is invalid'); + } + + if (realmUrl.protocol !== 'https:') { + throw new Error('Registry auth realm is not trusted'); + } + + const registryHost = getUrlHost(image.registry); + const allowedHosts = + image.registry === DEFAULT_REGISTRY ? new Set([DOCKER_HUB_AUTH_HOST]) : new Set([registryHost]); + + if (!allowedHosts.has(realmUrl.hostname)) { + throw new Error('Registry auth realm is not trusted'); + } +} + +async function readJsonResponse( + response: RegistryResponse, + context: string, + options?: { allowOctetStream?: boolean } +): Promise { + const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase(); + const contentTypeAllowed = + contentType && + (JSON_CONTENT_TYPES.has(contentType) || + (options?.allowOctetStream && contentType === OCTET_STREAM_CONTENT_TYPE)); + if (contentType && !contentTypeAllowed) { + throw new Error(`${context} returned unsupported content type`); + } + + return (await response.json()) as T; +} + +function normalizeRegistry(registry?: string) { + const normalized = registry?.replace(/^https?:\/\//, '').replace(/\/$/, ''); + if (!normalized) return DEFAULT_REGISTRY; + return DOCKER_HUB_REGISTRY_ALIASES.has(normalized) ? DEFAULT_REGISTRY : normalized; +} + +export function parseImageRef(imageName: string, registryOverride?: string): ImageRef { + const trimmed = imageName.trim(); + const [namePart, digest] = trimmed.split('@'); + const tagIndex = namePart.lastIndexOf(':'); + const slashIndex = namePart.indexOf('/'); + const hasExplicitTag = tagIndex > slashIndex; + const reference = digest || (hasExplicitTag ? namePart.slice(tagIndex + 1) : 'latest'); + const withoutTag = hasExplicitTag ? namePart.slice(0, tagIndex) : namePart; + const segments = withoutTag.split('/').filter(Boolean); + const first = segments[0] || ''; + const hasRegistry = first.includes('.') || first.includes(':') || first === 'localhost'; + const registry = registryOverride + ? normalizeRegistry(registryOverride) + : hasRegistry + ? normalizeRegistry(first) + : DEFAULT_REGISTRY; + let repositorySegments = hasRegistry ? segments.slice(1) : segments; + if (registryOverride && !hasRegistry && segments.length > 1) { + repositorySegments = segments; + } + const repository = + registry === DEFAULT_REGISTRY && repositorySegments.length === 1 + ? `library/${repositorySegments[0]}` + : repositorySegments.join('/'); + + if (!repository) { + throw new Error('Invalid image name'); + } + + return { registry, repository, reference }; +} + +function toRegistryUrl(registry: string, path: string) { + const base = + registry.startsWith('http://') || registry.startsWith('https://') + ? registry + : `https://${registry}`; + return `${base.replace(/\/$/, '')}${path}`; +} + +function parseAuthenticateHeader(header: string) { + const params: Record = {}; + const challenge = header.replace(/^Bearer\s+/i, ''); + challenge.replace(/(\w+)="([^"]*)"/g, (_, key, value) => { + params[key] = value; + return ''; + }); + return params; +} + +async function getBearerToken(image: ImageRef, auth?: ImageRegistryAuth) { + if (image.registry !== DEFAULT_REGISTRY) { + throw new Error('Registry auth challenge is invalid'); + } + + const service = image.registry === DEFAULT_REGISTRY ? DOCKER_HUB_AUTH_SERVICE : image.registry; + const realm = 'https://auth.docker.io/token'; + const url = `${realm}?service=${encodeURIComponent(service)}&scope=${encodeURIComponent( + `repository:${image.repository}:pull` + )}`; + const headers: Record = {}; + + if (auth?.username && auth.password) { + headers.Authorization = `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString( + 'base64' + )}`; + } + + const response = await registryHttpRequest(url, { + headers, + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT), + context: 'Registry auth response' + }); + if (!response.ok) { + throw new Error(`Registry auth failed: ${response.status}`); + } + + const data = await readJsonResponse<{ token?: string; access_token?: string }>( + response, + 'Registry auth response' + ); + return data.token || data.access_token; +} + +async function registryFetch( + url: string, + image: ImageRef, + auth?: ImageRegistryAuth, + context = 'Registry response' +) { + const headers: Record = { + Accept: ACCEPT_HEADER + }; + + if (auth?.username && auth.password && image.registry !== DEFAULT_REGISTRY) { + headers.Authorization = `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString( + 'base64' + )}`; + } + + let response = await registryHttpRequest(url, { + headers, + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT), + context + }); + + if (response.status === 401) { + const authenticate = response.headers.get('www-authenticate') || ''; + const challenge = parseAuthenticateHeader(authenticate); + assertTrustedRealm(challenge, image); + const token = + challenge.realm && challenge.service + ? await registryHttpRequest( + `${challenge.realm}?service=${encodeURIComponent( + challenge.service + )}&scope=${encodeURIComponent( + challenge.scope || `repository:${image.repository}:pull` + )}`, + { + headers: + auth?.username && auth.password + ? { + Authorization: `Basic ${Buffer.from( + `${auth.username}:${auth.password}` + ).toString('base64')}` + } + : undefined, + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT), + context: 'Registry auth response' + } + ) + .then((res) => { + if (!res.ok) throw new Error(`Registry auth failed: ${res.status}`); + return readJsonResponse<{ token?: string; access_token?: string }>( + res, + 'Registry auth response' + ); + }) + .then((data) => data.token || data.access_token) + : await getBearerToken(image, auth); + + response = await registryHttpRequest(url, { + headers: { + ...headers, + Authorization: `Bearer ${token}` + }, + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT), + context + }); + } + + return response; +} + +async function fetchManifest(image: ImageRef, reference: string, auth?: ImageRegistryAuth) { + const manifestUrl = toRegistryUrl( + image.registry, + `/v2/${image.repository}/manifests/${reference}` + ); + const manifestResponse = await registryFetch(manifestUrl, image, auth, 'Image manifest'); + + if (!manifestResponse.ok) { + throw new Error(`Failed to fetch image manifest: ${manifestResponse.status}`); + } + + return await readJsonResponse(manifestResponse, 'Image manifest'); +} + +function selectManifestDigest(manifest: RegistryManifest) { + const manifests = manifest.manifests || []; + const linuxAmd64 = manifests.find( + (item) => item.platform?.os === 'linux' && item.platform?.architecture === 'amd64' + ); + const linux = manifests.find((item) => item.platform?.os === 'linux'); + return linuxAmd64?.digest || linux?.digest || manifests[0]?.digest; +} + +export function parseExposedPorts(exposedPorts?: Record): ImageExposedPort[] { + if (!exposedPorts) return []; + + const ports = Object.keys(exposedPorts) + .map((key) => { + const [port, protocol = 'tcp'] = key.split('/'); + const parsedPort = Number(port); + const parsedProtocol = protocol.toUpperCase(); + + if ( + !Number.isInteger(parsedPort) || + parsedPort < 1 || + parsedPort > 65535 || + !['TCP', 'UDP', 'SCTP'].includes(parsedProtocol) + ) { + return null; + } + + return { + port: parsedPort, + protocol: parsedProtocol as ImageExposedPort['protocol'] + }; + }) + .filter((item): item is ImageExposedPort => item !== null); + + return [...new Map(ports.map((item) => [`${item.port}/${item.protocol}`, item])).values()].sort( + (a, b) => a.port - b.port || a.protocol.localeCompare(b.protocol) + ); +} + +export async function getImageExposedPorts( + imageName: string, + auth?: ImageRegistryAuth +): Promise { + const image = parseImageRef(imageName, auth?.serverAddress); + await assertPublicRegistryHost(image.registry); + let manifest = await fetchManifest(image, image.reference, auth); + const platformManifestDigest = selectManifestDigest(manifest); + if (!manifest.config?.digest && platformManifestDigest) { + manifest = await fetchManifest(image, platformManifestDigest, auth); + } + const configDigest = manifest.config?.digest; + + if (!configDigest) { + return []; + } + + const configUrl = toRegistryUrl(image.registry, `/v2/${image.repository}/blobs/${configDigest}`); + const configResponse = await registryFetch(configUrl, image, auth, 'Image config'); + + if (!configResponse.ok) { + throw new Error(`Failed to fetch image config: ${configResponse.status}`); + } + + const config = await readJsonResponse<{ config?: { ExposedPorts?: Record } }>( + configResponse, + 'Image config', + { allowOctetStream: true } + ); + return parseExposedPorts(config?.config?.ExposedPorts); +} diff --git a/frontend/providers/applaunchpad/src/utils/network-routes.ts b/frontend/providers/applaunchpad/src/utils/network-routes.ts index 8cdf91c29..f37ccbef7 100644 --- a/frontend/providers/applaunchpad/src/utils/network-routes.ts +++ b/frontend/providers/applaunchpad/src/utils/network-routes.ts @@ -20,12 +20,7 @@ export const syncDefaultRouteServicePort = ({ const newPort = Number(nextPort); const defaultPort = Number(defaultServicePort); - if ( - !routes?.length || - !oldPort || - !newPort || - (oldPort === newPort && defaultPort === oldPort) - ) { + if (!routes?.length || !oldPort || !newPort || (oldPort === newPort && defaultPort === oldPort)) { return routes; } diff --git a/frontend/providers/applaunchpad/src/utils/public-domain.ts b/frontend/providers/applaunchpad/src/utils/public-domain.ts new file mode 100644 index 000000000..0447ca632 --- /dev/null +++ b/frontend/providers/applaunchpad/src/utils/public-domain.ts @@ -0,0 +1,107 @@ +export const PUBLIC_DOMAIN_PREFIX_MAX_LENGTH = 32; +export const PUBLIC_DOMAIN_PREFIX_MIN_LENGTH = 3; + +export type PublicDomainConflictOwnerComponent = + | 'app_launchpad' + | 'devbox' + | 'workspace_component' + | 'ingress'; + +export type PublicDomainConflictOwner = { + scope: 'same_workspace'; + resourceKind: 'Ingress'; + component: PublicDomainConflictOwnerComponent; + displayType: string; + displayName: string; + host: string; + namespace: string; + ingressName: string; + publicDomainPrefix?: string; + labels?: Record; + matchedBy: + | 'cloud.sealos.io/app-deploy-manager' + | 'app.kubernetes.io/part-of=devbox' + | 'kubernetes-recommended-labels' + | 'unlabeled-ingress'; + confidence: 'high' | 'medium' | 'low'; +}; + +let reservedPublicDomainPrefixes = new Set(); + +export type PublicDomainPrefixValidationResult = + | { valid: true; value: string } + | { valid: false; value: string; reason: 'format' | 'reserved' }; + +export type ManagedPublicDomainNetwork = { + openPublicDomain?: boolean; + openNodePort?: boolean; + customDomain?: string; + publicDomain?: string; + domain?: string; +}; + +export type DuplicateManagedPublicDomainHost = { + host: string; + indexes: number[]; +}; + +export function normalizePublicDomainPrefix(value: string) { + return value.trim().toLowerCase(); +} + +export function normalizePublicDomainReservedPrefixes(prefixes?: unknown) { + if (!Array.isArray(prefixes)) return []; + + return prefixes + .filter((prefix): prefix is string => typeof prefix === 'string') + .map(normalizePublicDomainPrefix); +} + +export function setPublicDomainReservedPrefixes(prefixes?: unknown) { + reservedPublicDomainPrefixes = new Set(normalizePublicDomainReservedPrefixes(prefixes)); +} + +export function getPublicDomainReservedPrefixes() { + return Array.from(reservedPublicDomainPrefixes); +} + +export function validatePublicDomainPrefix(value: string): PublicDomainPrefixValidationResult { + const normalized = normalizePublicDomainPrefix(value); + const pattern = new RegExp( + `^[a-z0-9](?:[a-z0-9-]{${PUBLIC_DOMAIN_PREFIX_MIN_LENGTH - 2},${ + PUBLIC_DOMAIN_PREFIX_MAX_LENGTH - 2 + }}[a-z0-9])$` + ); + + if (!pattern.test(normalized)) { + return { valid: false, value: normalized, reason: 'format' }; + } + + if (reservedPublicDomainPrefixes.has(normalized)) { + return { valid: false, value: normalized, reason: 'reserved' }; + } + + return { valid: true, value: normalized }; +} + +export function getDuplicateManagedPublicDomainHosts( + networks: ManagedPublicDomainNetwork[], + defaultDomain: string +) { + const indexesByHost = new Map(); + + networks.forEach((network, index) => { + if (!network.openPublicDomain || network.openNodePort || network.customDomain) return; + + const prefixResult = validatePublicDomainPrefix(network.publicDomain || ''); + const domain = network.domain || defaultDomain; + if (!prefixResult.valid || !domain) return; + + const host = `${prefixResult.value}.${domain}`; + indexesByHost.set(host, [...(indexesByHost.get(host) || []), index]); + }); + + return Array.from(indexesByHost.entries()) + .filter(([, indexes]) => indexes.length > 1) + .map(([host, indexes]): DuplicateManagedPublicDomainHost => ({ host, indexes })); +} diff --git a/frontend/providers/applaunchpad/vitest.config.ts b/frontend/providers/applaunchpad/vitest.config.ts new file mode 100644 index 000000000..f0b5c1ca0 --- /dev/null +++ b/frontend/providers/applaunchpad/vitest.config.ts @@ -0,0 +1,12 @@ +import { resolve } from 'path'; + +export default { + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + test: { + globals: false + } +};