From a45661eadca77739e037ab55f034a32fe2566877 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Thu, 1 Oct 2026 00:31:31 +0100 Subject: [PATCH 1/4] Add editable pills to IP address inputs --- .../Project/Settings/ProjectSettings.tsx | 6 + .../pages/Project/Settings/tabs/Shields.tsx | 15 +- web/app/ui/TagInput.tsx | 226 ++++++++++++++++++ web/public/locales/en.json | 1 + 4 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 web/app/ui/TagInput.tsx diff --git a/web/app/pages/Project/Settings/ProjectSettings.tsx b/web/app/pages/Project/Settings/ProjectSettings.tsx index de41740b6..cd4cf9dcb 100644 --- a/web/app/pages/Project/Settings/ProjectSettings.tsx +++ b/web/app/pages/Project/Settings/ProjectSettings.tsx @@ -1057,6 +1057,12 @@ const ProjectSettings = () => { beenSubmitted={beenSubmitted} handleInput={handleInput} handleBlur={handleInputBlur} + onIpListChange={(name, value) => + handleFieldAutosave( + { [name]: value }, + PROJECT_TEXT_AUTOSAVE_TOASTS[name], + ) + } botsProtectionLevels={botsProtectionLevels} setBotsLevel={(name) => handleFieldAutosave( diff --git a/web/app/pages/Project/Settings/tabs/Shields.tsx b/web/app/pages/Project/Settings/tabs/Shields.tsx index 68c0cba61..eaf1be744 100644 --- a/web/app/pages/Project/Settings/tabs/Shields.tsx +++ b/web/app/pages/Project/Settings/tabs/Shields.tsx @@ -8,6 +8,7 @@ import Flag from '~/ui/Flag' import Input from '~/ui/Input' import MultiSelect from '~/ui/MultiSelect' import Select from '~/ui/Select' +import TagInput from '~/ui/TagInput' import { Text } from '~/ui/Text' import countries from '~/utils/isoCountries' @@ -33,6 +34,7 @@ interface ShieldsProps { beenSubmitted: boolean handleInput: (e: React.ChangeEvent) => void handleBlur: (e: React.FocusEvent) => void + onIpListChange: (name: 'ipBlacklist' | 'ipWhitelist', value: string) => void botsProtectionLevels: readonly BotsProtectionLevelOption[] setBotsLevel: (name: string) => void countryBlacklist: string[] @@ -45,6 +47,7 @@ const Shields = ({ beenSubmitted, handleInput, handleBlur, + onIpListChange, botsProtectionLevels, setBotsLevel, countryBlacklist, @@ -126,15 +129,15 @@ const Shields = ({ onBlur={handleBlur} error={beenSubmitted ? errors.origins : null} /> - onIpListChange('ipBlacklist', value)} + instructions={t('project.settings.ipListInstructions')} error={beenSubmitted ? errors.ipBlacklist : null} />
@@ -187,15 +190,15 @@ const Shields = ({ )} />
- onIpListChange('ipWhitelist', value)} + instructions={t('project.settings.ipListInstructions')} error={beenSubmitted ? errors.ipWhitelist : null} /> diff --git a/web/app/ui/TagInput.tsx b/web/app/ui/TagInput.tsx new file mode 100644 index 000000000..bfdcbb53e --- /dev/null +++ b/web/app/ui/TagInput.tsx @@ -0,0 +1,226 @@ +import { Description, Field, Input, Label } from '@headlessui/react' +import { XIcon } from '@phosphor-icons/react' +import cx from 'clsx' +import React, { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { Text } from './Text' + +interface TagInputProps { + name: string + value: string + onChange: (value: string) => void + label: string + hint?: string + placeholder?: string + instructions?: string + error?: string | null + className?: string +} + +const splitValues = (value: string) => + value + .split(/[,\r\n]+/) + .map((item) => item.trim()) + .filter(Boolean) + +const TagInput = ({ + name, + value, + onChange, + label, + hint, + placeholder, + instructions, + error, + className, +}: TagInputProps) => { + const { t } = useTranslation('common') + const inputRef = useRef(null) + const [draft, setDraft] = useState('') + const [editingIndex, setEditingIndex] = useState(null) + const values = splitValues(value) + + useEffect(() => { + if (editingIndex !== null) { + inputRef.current?.focus() + inputRef.current?.select() + } + }, [editingIndex]) + + const commit = (text = draft) => { + const additions = splitValues(text) + const next = [...values] + + if (editingIndex === null) { + next.push(...additions) + } else { + next.splice(editingIndex, 1, ...additions) + } + + const nextValue = next.join(', ') + if (nextValue !== value) onChange(nextValue) + setDraft('') + setEditingIndex(null) + return next + } + + const edit = (index: number) => { + const selected = values[index] + const next = commit() + const nextIndex = + editingIndex !== null && editingIndex < index + ? index + next.length - values.length + : index + + setDraft(selected) + setEditingIndex(nextIndex) + } + + const remove = (index: number) => { + onChange(values.filter((_, itemIndex) => itemIndex !== index).join(', ')) + if (editingIndex !== null && index < editingIndex) { + setEditingIndex(editingIndex - 1) + } + inputRef.current?.focus() + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.nativeEvent.isComposing) return + + if (event.key === ',' || event.key === 'Enter') { + event.preventDefault() + commit() + } else if ( + event.key === 'Backspace' && + !draft && + editingIndex === null && + values.length + ) { + event.preventDefault() + edit(values.length - 1) + } else if (event.key === 'Escape') { + event.preventDefault() + setDraft('') + setEditingIndex(null) + } + } + + return ( + + + {hint ? ( + + + {hint} + + + ) : null} +
{ + if (!event.currentTarget.contains(event.relatedTarget)) commit() + }} + > + {values.map((item, index) => + index === editingIndex ? null : ( + + + + + ), + )} + { + const text = event.target.value + if ( + text.includes(',') && + !(event.nativeEvent as InputEvent).isComposing + ) { + const lastComma = text.lastIndexOf(',') + commit(text.slice(0, lastComma)) + setDraft(text.slice(lastComma + 1).trimStart()) + } else { + setDraft(text) + } + }} + onPaste={(event) => { + const pasted = event.clipboardData.getData('text') + if (!/[,\r\n]/.test(pasted)) return + event.preventDefault() + const { selectionStart, selectionEnd } = event.currentTarget + commit( + draft.slice(0, selectionStart ?? draft.length) + + pasted + + draft.slice(selectionEnd ?? draft.length), + ) + }} + onKeyDown={handleKeyDown} + /> + +
+ {instructions ? ( + + {instructions} + + ) : null} + {error ? ( + + + {error} + + + ) : null} +
+ ) +} + +export default TagInput diff --git a/web/public/locales/en.json b/web/public/locales/en.json index 8aa7a1d69..b32ce7f26 100644 --- a/web/public/locales/en.json +++ b/web/public/locales/en.json @@ -2655,6 +2655,7 @@ "ipWhitelist": "Trusted IP addresses", "ipWhitelistHint": "Traffic from these IP addresses will never be flagged by bot protection. Separate multiple IP addresses or CIDR ranges with commas. Useful for server-side event tracking, as requests from your backend may otherwise be dropped as datacenter traffic. Example: 172.126.10.16, 192.168.0.1/32, ::1.", "ipWhitelistPlaceholder": "No trusted IP addresses by default", + "ipListInstructions": "Press comma or Enter to add. Click an address to edit, or × to remove.", "countryBlacklist": "Blocked countries", "countryBlacklistHint": "Choose the countries you don't want to track. Traffic from these countries will be ignored in your analytics.", "countryBlacklistPlaceholder": "Not blocking any countries by default", From 95fa7c1050be2adf41bc1c47946873772d223ad1 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Thu, 1 Oct 2026 00:55:33 +0100 Subject: [PATCH 2/4] feat: Configure managed proxy with Cloudflare --- backend/.env.example | 4 + .../apps/cloud/src/project/project.module.ts | 2 + .../proxy-domain-connect-controller.spec.ts | 76 +++++++++++ .../project/proxy-domain-connect.service.ts | 103 +++++++++++++++ .../src/project/proxy-domain-connect.spec.ts | 118 ++++++++++++++++++ .../src/project/proxy-domain.controller.ts | 41 +++++- backend/domain-connect/README.md | 36 ++++++ .../swetrix.com.managed-proxy.json | 21 ++++ backend/jest.proxy.config.js | 7 ++ .../Settings/components/ProxyDomainsTab.tsx | 75 ++++++++++- web/app/routes/projects.settings.$id.tsx | 42 ++++++- web/public/locales/en.json | 3 + 12 files changed, 522 insertions(+), 6 deletions(-) create mode 100644 backend/apps/cloud/src/project/proxy-domain-connect-controller.spec.ts create mode 100644 backend/apps/cloud/src/project/proxy-domain-connect.service.ts create mode 100644 backend/apps/cloud/src/project/proxy-domain-connect.spec.ts create mode 100644 backend/domain-connect/README.md create mode 100644 backend/domain-connect/swetrix.com.managed-proxy.json create mode 100644 backend/jest.proxy.config.js diff --git a/backend/.env.example b/backend/.env.example index 36df96d93..5d2bfc0c7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -170,6 +170,10 @@ INDEXNOW_KEY= # fail closed (404) when this is unset. MANAGED_PROXY_EDGE_API_KEY= +MANAGED_PROXY_DOMAIN_CONNECT_ENABLED=false +MANAGED_PROXY_DOMAIN_CONNECT_KEY_ID=_dcpubkeyv1 +MANAGED_PROXY_DOMAIN_CONNECT_PRIVATE_KEY= + # Public demo project synthetic data generator DEMO_DATA_ENABLED=false # S3 prefix containing demo replay folders, for example demo-replays/1/*.json.gz. diff --git a/backend/apps/cloud/src/project/project.module.ts b/backend/apps/cloud/src/project/project.module.ts index 988558fab..a1088faab 100644 --- a/backend/apps/cloud/src/project/project.module.ts +++ b/backend/apps/cloud/src/project/project.module.ts @@ -19,6 +19,7 @@ import { ProxyDomain, } from './entity' import { ProxyDomainService } from './proxy-domain.service' +import { ProxyDomainConnectService } from './proxy-domain-connect.service' import { ProxyDomainController, ProxyDomainEdgeController, @@ -54,6 +55,7 @@ import { PendingInvitationModule } from '../pending-invitation/pending-invitatio ProjectsViewsRepository, GSCService, ProxyDomainService, + ProxyDomainConnectService, ], exports: [ ProjectService, diff --git a/backend/apps/cloud/src/project/proxy-domain-connect-controller.spec.ts b/backend/apps/cloud/src/project/proxy-domain-connect-controller.spec.ts new file mode 100644 index 000000000..5cc1986c9 --- /dev/null +++ b/backend/apps/cloud/src/project/proxy-domain-connect-controller.spec.ts @@ -0,0 +1,76 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common' +import { ProxyDomainController } from './proxy-domain.controller' +import { ProxyDomainConnectService } from './proxy-domain-connect.service' +import { ProxyDomainService } from './proxy-domain.service' +import { ProjectService } from './project.service' + +jest.mock('./project.service', () => ({ ProjectService: class {} })) +jest.mock('./proxy-domain.service', () => ({ + ProxyDomainService: class {}, + PROXY_BASE_DOMAIN: 'proxy.swetrix.org', +})) +jest.mock('../common/constants', () => ({ + isValidPID: (pid: string) => pid === 'project-id', +})) +jest.mock('../auth/decorators', () => ({ + Auth: () => () => {}, + Public: () => () => {}, +})) + +const project = { id: 'project-id' } +const storedDomain = { + hostname: 't.example.com', + proxyTargetId: 'stored-target', +} + +function setup() { + const domains = { findById: jest.fn().mockResolvedValue(storedDomain) } + const projects = { + getFullProject: jest.fn().mockResolvedValue(project), + allowedToManage: jest.fn(), + } + const connect = { + createUrl: jest.fn().mockReturnValue('https://dash.cloudflare.com/signed'), + } + const controller = new ProxyDomainController( + domains as unknown as ProxyDomainService, + projects as unknown as ProjectService, + connect as unknown as ProxyDomainConnectService, + ) + return { controller, domains, projects, connect } +} + +it('checks management access and uses only the proxy saved in the requested project', async () => { + const { controller, domains, projects, connect } = setup() + await expect( + controller.configureCloudflare('project-id', 'domain-id', 'user-id'), + ).resolves.toEqual({ url: 'https://dash.cloudflare.com/signed' }) + expect(projects.allowedToManage).toHaveBeenCalledWith(project, 'user-id') + expect(domains.findById).toHaveBeenCalledWith('project-id', 'domain-id') + expect(connect.createUrl).toHaveBeenCalledWith( + 'project-id', + storedDomain.hostname, + storedDomain.proxyTargetId, + ) +}) + +it('does not issue a signed URL to a user without management access', async () => { + const { controller, domains, projects, connect } = setup() + projects.allowedToManage.mockImplementation(() => { + throw new ForbiddenException() + }) + await expect( + controller.configureCloudflare('project-id', 'domain-id', 'viewer-id'), + ).rejects.toThrow(ForbiddenException) + expect(domains.findById).not.toHaveBeenCalled() + expect(connect.createUrl).not.toHaveBeenCalled() +}) + +it('does not issue a signed URL for a proxy outside the project', async () => { + const { controller, domains, connect } = setup() + domains.findById.mockRejectedValue(new NotFoundException()) + await expect( + controller.configureCloudflare('project-id', 'other-domain-id', 'user-id'), + ).rejects.toThrow(NotFoundException) + expect(connect.createUrl).not.toHaveBeenCalled() +}) diff --git a/backend/apps/cloud/src/project/proxy-domain-connect.service.ts b/backend/apps/cloud/src/project/proxy-domain-connect.service.ts new file mode 100644 index 000000000..aa4bc67d3 --- /dev/null +++ b/backend/apps/cloud/src/project/proxy-domain-connect.service.ts @@ -0,0 +1,103 @@ +import { + BadRequestException, + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common' +import { createPrivateKey, KeyObject, sign } from 'crypto' +import { parse } from 'tldts' + +@Injectable() +export class ProxyDomainConnectService { + private readonly privateKey?: KeyObject + private readonly keyId?: string + private readonly clientOrigin?: string + + constructor() { + if (process.env.MANAGED_PROXY_DOMAIN_CONNECT_ENABLED !== 'true') return + + try { + const keyId = process.env.MANAGED_PROXY_DOMAIN_CONNECT_KEY_ID + if (!keyId || !/^_[a-z0-9_-]+$/i.test(keyId)) { + throw new Error('Invalid Domain Connect key ID') + } + if ( + process.env.MANAGED_PROXY_BASE_DOMAIN && + process.env.MANAGED_PROXY_BASE_DOMAIN !== 'proxy.swetrix.org' + ) { + throw new Error('Domain Connect template requires proxy.swetrix.org') + } + const origin = new URL(process.env.CLIENT_URL || 'https://swetrix.com') + if (origin.protocol !== 'https:') { + throw new Error('Domain Connect requires an HTTPS client URL') + } + const key = createPrivateKey( + (process.env.MANAGED_PROXY_DOMAIN_CONNECT_PRIVATE_KEY || '').replace( + /\\n/g, + '\n', + ), + ) + if ( + key.asymmetricKeyType !== 'rsa' || + (key.asymmetricKeyDetails?.modulusLength || 0) < 2048 + ) { + throw new Error( + 'Domain Connect requires an RSA key of at least 2048 bits', + ) + } + this.keyId = keyId + this.clientOrigin = origin.origin + this.privateKey = key + } catch { + new Logger(ProxyDomainConnectService.name).warn( + 'Cloudflare DNS setup is disabled: check the Domain Connect configuration', + ) + } + } + + get enabled(): boolean { + return !!this.privateKey + } + + createUrl( + projectId: string, + hostname: string, + proxyTargetId: string, + ): string { + if (!this.privateKey) { + throw new ServiceUnavailableException( + 'Cloudflare DNS setup is unavailable', + ) + } + + const { domain, subdomain } = parse(hostname) + if (!domain || !subdomain || !/^[a-f0-9]{32}$/.test(proxyTargetId)) { + throw new BadRequestException('Invalid managed proxy domain') + } + + const redirect = new URL( + `/projects/settings/${encodeURIComponent(projectId)}`, + this.clientOrigin, + ) + redirect.searchParams.set('tab', 'proxy') + redirect.searchParams.set('cloudflare', hostname) + + const query = new URLSearchParams({ + domain, + host: subdomain, + proxyTargetId, + redirect_uri: redirect.toString(), + }).toString() + const signature = sign( + 'RSA-SHA256', + Buffer.from(query), + this.privateKey, + ).toString('base64') + + return ( + 'https://dash.cloudflare.com/domainconnect/v2/domainTemplates/providers/' + + 'swetrix.com/services/managed-proxy/apply?' + + `${query}&key=${encodeURIComponent(this.keyId)}&sig=${encodeURIComponent(signature)}` + ) + } +} diff --git a/backend/apps/cloud/src/project/proxy-domain-connect.spec.ts b/backend/apps/cloud/src/project/proxy-domain-connect.spec.ts new file mode 100644 index 000000000..102e4cb11 --- /dev/null +++ b/backend/apps/cloud/src/project/proxy-domain-connect.spec.ts @@ -0,0 +1,118 @@ +import { Logger } from '@nestjs/common' +import { generateKeyPairSync, verify } from 'crypto' +import { ProxyDomainConnectService } from './proxy-domain-connect.service' +import template from '../../../../domain-connect/swetrix.com.managed-proxy.json' + +const keys = generateKeyPairSync('rsa', { modulusLength: 2048 }) +const pem = keys.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString() +const targetId = '0123456789abcdef0123456789abcdef' +const originalEnv = process.env + +beforeEach(() => { + process.env = { + ...originalEnv, + CLIENT_URL: 'https://swetrix.com', + MANAGED_PROXY_BASE_DOMAIN: 'proxy.swetrix.org', + MANAGED_PROXY_DOMAIN_CONNECT_ENABLED: 'true', + MANAGED_PROXY_DOMAIN_CONNECT_KEY_ID: '_dcpubkeyv1', + MANAGED_PROXY_DOMAIN_CONNECT_PRIVATE_KEY: pem, + } + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + process.env = originalEnv + jest.restoreAllMocks() +}) + +it('signs only the encoded query, excluding key and sig, and rejects tampering', () => { + const service = new ProxyDomainConnectService() + const url = new URL( + service.createUrl('project-id', 't.example.com', targetId), + ) + expect(service.enabled).toBe(true) + expect(url.origin).toBe('https://dash.cloudflare.com') + expect(url.pathname).toBe( + `/domainconnect/v2/domainTemplates/providers/${template.providerId}/services/${template.serviceId}/apply`, + ) + const query = url.search.slice(1).split('&key=')[0] + const signature = Buffer.from(url.searchParams.get('sig'), 'base64') + expect( + verify('RSA-SHA256', Buffer.from(query), keys.publicKey, signature), + ).toBe(true) + expect( + verify( + 'RSA-SHA256', + Buffer.from(query.replace('host=t&', 'host=www&')), + keys.publicKey, + signature, + ), + ).toBe(false) + expect([...url.searchParams.keys()].slice(-2)).toEqual(['key', 'sig']) + expect(url.searchParams.get('proxyTargetId')).toBe(targetId) + expect( + template.records[0].pointsTo.replace('%proxyTargetId%', targetId), + ).toBe(`${targetId}.proxy.swetrix.org`) + const redirect = new URL(url.searchParams.get('redirect_uri')) + expect(redirect.origin).toBe('https://swetrix.com') + expect(redirect.pathname).toBe('/projects/settings/project-id') + expect(redirect.searchParams.get('tab')).toBe('proxy') + expect(redirect.searchParams.get('cloudflare')).toBe('t.example.com') +}) + +it('keeps nested hosts intact under multi-label public suffixes', () => { + const url = new URL( + new ProxyDomainConnectService().createUrl( + 'pid', + 't.shop.example.co.uk', + targetId, + ), + ) + expect(url.searchParams.get('domain')).toBe('example.co.uk') + expect(url.searchParams.get('host')).toBe('t.shop') +}) + +it('accepts escaped newlines in the private key', () => { + process.env.MANAGED_PROXY_DOMAIN_CONNECT_PRIVATE_KEY = pem.replace( + /\n/g, + '\\n', + ) + expect(new ProxyDomainConnectService().enabled).toBe(true) +}) + +it.each([ + ['MANAGED_PROXY_DOMAIN_CONNECT_ENABLED', 'false'], + ['MANAGED_PROXY_DOMAIN_CONNECT_PRIVATE_KEY', ''], + ['MANAGED_PROXY_DOMAIN_CONNECT_PRIVATE_KEY', 'invalid'], + ['MANAGED_PROXY_DOMAIN_CONNECT_KEY_ID', ''], + ['MANAGED_PROXY_DOMAIN_CONNECT_KEY_ID', '../invalid'], + ['MANAGED_PROXY_BASE_DOMAIN', 'other.example.com'], + ['CLIENT_URL', 'http://swetrix.com'], +])('disables setup for invalid configuration: %s', (name, value) => { + process.env[name] = value + const service = new ProxyDomainConnectService() + expect(service.enabled).toBe(false) + expect(() => service.createUrl('pid', 't.example.com', targetId)).toThrow( + 'Cloudflare DNS setup is unavailable', + ) +}) + +it('rejects non-RSA keys', () => { + process.env.MANAGED_PROXY_DOMAIN_CONNECT_PRIVATE_KEY = generateKeyPairSync( + 'ec', + { namedCurve: 'P-256' }, + ) + .privateKey.export({ type: 'pkcs8', format: 'pem' }) + .toString() + expect(new ProxyDomainConnectService().enabled).toBe(false) +}) + +it('rejects apex domains and target substitution', () => { + const service = new ProxyDomainConnectService() + expect(() => service.createUrl('pid', 'example.co.uk', targetId)).toThrow( + 'Invalid managed proxy domain', + ) + expect(() => + service.createUrl('pid', 't.example.com', 'evil.example.com'), + ).toThrow('Invalid managed proxy domain') +}) diff --git a/backend/apps/cloud/src/project/proxy-domain.controller.ts b/backend/apps/cloud/src/project/proxy-domain.controller.ts index ac9614e26..3c68aa7e8 100644 --- a/backend/apps/cloud/src/project/proxy-domain.controller.ts +++ b/backend/apps/cloud/src/project/proxy-domain.controller.ts @@ -21,6 +21,7 @@ import { ProjectService } from './project.service' import { ProxyDomainService, PROXY_BASE_DOMAIN } from './proxy-domain.service' import { ProxyDomainCreateDTO } from './dto' import { ProxyDomainEdgeGuard } from './proxy-domain-edge.guard' +import { ProxyDomainConnectService } from './proxy-domain-connect.service' @ApiTags('Project - Managed Reverse Proxy') @Controller(['project', 'v1/project']) @@ -28,6 +29,7 @@ export class ProxyDomainController { constructor( private readonly proxyDomainService: ProxyDomainService, private readonly projectService: ProjectService, + private readonly domainConnectService: ProxyDomainConnectService, ) {} @ApiBearerAuth() @@ -46,7 +48,44 @@ export class ProxyDomainController { this.projectService.allowedToManage(project, uid) const domains = await this.proxyDomainService.listForProject(pid) - return { domains, proxyBaseDomain: PROXY_BASE_DOMAIN } + return { + domains, + proxyBaseDomain: PROXY_BASE_DOMAIN, + cloudflareSetupAvailable: this.domainConnectService.enabled, + } + } + + @ApiBearerAuth() + @Post(':pid/proxy-domains/:id/cloudflare') + @Auth() + @HttpCode(200) + @Header('Cache-Control', 'no-store') + @ApiOperation({ + summary: 'Authorize managed proxy DNS setup with Cloudflare', + }) + async configureCloudflare( + @Param('pid') pid: string, + @Param('id') id: string, + @CurrentUserId() uid: string, + ) { + if (!isValidPID(pid)) { + throw new BadRequestException('The provided project ID is incorrect') + } + + const project = await this.projectService.getFullProject(pid) + if (!project) { + throw new NotFoundException('Project not found') + } + this.projectService.allowedToManage(project, uid) + + const domain = await this.proxyDomainService.findById(pid, id) + return { + url: this.domainConnectService.createUrl( + pid, + domain.hostname, + domain.proxyTargetId, + ), + } } @ApiBearerAuth() diff --git a/backend/domain-connect/README.md b/backend/domain-connect/README.md new file mode 100644 index 000000000..ad0934db2 --- /dev/null +++ b/backend/domain-connect/README.md @@ -0,0 +1,36 @@ +# Cloudflare DNS setup for managed proxies + +The **Configure with Cloudflare** button uses Cloudflare's synchronous Domain Connect flow. It is disabled by default. The API signs a URL for a saved proxy domain after checking project management access; users review and approve the CNAME on Cloudflare. The return to Swetrix triggers the existing DNS/TLS verifier and does not establish domain ownership by itself. + +## Onboarding + +1. Submit `swetrix.com.managed-proxy.json` to the [Domain Connect template repository](https://github.com/Domain-Connect/Templates). Run its template linter with `-cloudflare` before submission. +2. Generate a dedicated RSA private key of at least 2048 bits and keep it in the backend's secret configuration. Do not commit the private key. Export the public key as DER-encoded SPKI, then base64-encode it. +3. Publish that public key at `_dcpubkeyv1.domainconnect.swetrix.com` in TXT records using Domain Connect's `p=1,a=RS256,d=`, `p=2,a=RS256,d=`, etc. format. Split the base64 value into chunks small enough to keep each record under 255 characters. See the [signed-request specification](https://github.com/Domain-Connect/spec/blob/master/Domain%20Connect%20Spec%20Draft.adoc#digitally-sign-requests). +4. After the template is merged, email `domain-connect@cloudflare.com` with its GitHub link, the public-key TXT hostname, a Swetrix SVG logo, and a request for **DNS-only (`proxied: false`)** as the template's default proxy status. This setting is agreed with Cloudflare during onboarding, not encoded in the standard template. Optionally include a Cloudflare account ID to test privately before public activation. +5. Once Cloudflare confirms onboarding, configure the backend: + + ```dotenv + MANAGED_PROXY_DOMAIN_CONNECT_ENABLED=true + MANAGED_PROXY_DOMAIN_CONNECT_KEY_ID=_dcpubkeyv1 + MANAGED_PROXY_DOMAIN_CONNECT_PRIVATE_KEY="" + CLIENT_URL=https://swetrix.com + ``` + +6. Restart the backend through the normal deployment process. Verify with a test domain that approval creates only the displayed CNAME, with DNS-only status, and returns to **Settings → Managed proxy**. Also test cancellation, an existing conflicting DNS record, and an account without access to the selected zone. Enable publicly only after these checks pass. + +Cloudflare onboarding and DNS publication are external rollout steps; adding this code does not perform them. Follow [Cloudflare's onboarding documentation](https://developers.cloudflare.com/dns/reference/domain-connect/) for current requirements. + +## Scope and verification + +The button is offered as an explicit Cloudflare option; it does not automatically identify DNS providers. Root domains come from the public suffix list, preserving nested subdomains and domains such as `example.co.uk`. Separately delegated subdomain zones should use manual setup. The template is restricted to `.proxy.swetrix.org`; a custom `MANAGED_PROXY_BASE_DOMAIN` disables this integration. + +Missing or invalid signing configuration hides the button and leaves manual DNS setup available. No Cloudflare API tokens are collected or stored. The signature covers the encoded query before `key` and `sig`, as required by Domain Connect; `sig` is the final parameter. + +Run the local signing/configuration tests from `backend`: + +```sh +npx jest --config jest.proxy.config.js --runInBand +``` + +These tests do not establish that Cloudflare has onboarded the template or that the live consent flow works. diff --git a/backend/domain-connect/swetrix.com.managed-proxy.json b/backend/domain-connect/swetrix.com.managed-proxy.json new file mode 100644 index 000000000..530b77494 --- /dev/null +++ b/backend/domain-connect/swetrix.com.managed-proxy.json @@ -0,0 +1,21 @@ +{ + "providerId": "swetrix.com", + "providerName": "Swetrix", + "serviceId": "managed-proxy", + "serviceName": "Managed reverse proxy", + "version": 1, + "logoUrl": "https://swetrix.com/assets/logo/dark.svg", + "description": "Connect a subdomain to your Swetrix managed reverse proxy.", + "syncBlock": false, + "syncPubKeyDomain": "domainconnect.swetrix.com", + "syncRedirectDomain": "swetrix.com", + "hostRequired": true, + "records": [ + { + "type": "CNAME", + "host": "@", + "pointsTo": "%proxyTargetId%.proxy.swetrix.org", + "ttl": 3600 + } + ] +} diff --git a/backend/jest.proxy.config.js b/backend/jest.proxy.config.js new file mode 100644 index 000000000..09338ebb6 --- /dev/null +++ b/backend/jest.proxy.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + watchman: false, + roots: ['/apps/cloud/src/project'], + testMatch: ['**/proxy-domain-connect*.spec.ts'], +} diff --git a/web/app/pages/Project/Settings/components/ProxyDomainsTab.tsx b/web/app/pages/Project/Settings/components/ProxyDomainsTab.tsx index 0468c57e6..12e4aa037 100644 --- a/web/app/pages/Project/Settings/components/ProxyDomainsTab.tsx +++ b/web/app/pages/Project/Settings/components/ProxyDomainsTab.tsx @@ -10,11 +10,12 @@ import { SpinnerIcon, PlusIcon, CaretDownIcon, + ArrowUpRightIcon, } from '@phosphor-icons/react' import cx from 'clsx' import dayjs from 'dayjs' import { Trans, useTranslation } from 'react-i18next' -import { useFetcher } from 'react-router' +import { useFetcher, useSearchParams } from 'react-router' import { toast } from 'sonner' import type { ProxyDomain, ProxyDomainStatus } from '~/lib/models/Project' @@ -296,16 +297,20 @@ interface ProxyDomainsTabProps { export default function ProxyDomainsTab({ projectId }: ProxyDomainsTabProps) { const { t, i18n } = useTranslation('common') + const [searchParams, setSearchParams] = useSearchParams() const listFetcher = useFetcher() const addFetcher = useFetcher() const deleteFetcher = useFetcher() const verifyFetcher = useFetcher() + const cloudflareFetcher = useFetcher() const settingsAction = `/projects/settings/${projectId}` const [domains, setDomains] = useState([]) const [loading, setLoading] = useState(true) + const [cloudflareSetupAvailable, setCloudflareSetupAvailable] = + useState(false) const [showAddModal, setShowAddModal] = useState(false) const [hostnameInput, setHostnameInput] = useState('') const [hostnameError, setHostnameError] = useState(null) @@ -315,6 +320,7 @@ export default function ProxyDomainsTab({ projectId }: ProxyDomainsTabProps) { const pollRef = useRef | null>(null) const fetchDomainsRef = useRef<() => void>(() => {}) const verifyingIdRef = useRef(null) + const cloudflareReturnHandledRef = useRef(false) const [verifyingId, setVerifyingId] = useState(null) const statusLabels: Record = { @@ -346,6 +352,7 @@ export default function ProxyDomainsTab({ projectId }: ProxyDomainsTabProps) { toast.error(listFetcher.data.error) } else if (listFetcher.data.proxyDomains) { setDomains(listFetcher.data.proxyDomains) + setCloudflareSetupAvailable(!!listFetcher.data.cloudflareSetupAvailable) } setLoading(false) // eslint-disable-next-line react-hooks/exhaustive-deps @@ -439,6 +446,50 @@ export default function ProxyDomainsTab({ projectId }: ProxyDomainsTabProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [verifyFetcher.state, verifyFetcher.data]) + useEffect(() => { + if (cloudflareFetcher.state !== 'idle' || !cloudflareFetcher.data) return + if (cloudflareFetcher.data.error) { + toast.error(cloudflareFetcher.data.error) + } else if (cloudflareFetcher.data.cloudflareSetupUrl) { + window.location.assign(cloudflareFetcher.data.cloudflareSetupUrl) + } + }, [cloudflareFetcher.state, cloudflareFetcher.data]) + + useEffect(() => { + const hostname = searchParams.get('cloudflare') + if (!hostname || loading || cloudflareReturnHandledRef.current) return + cloudflareReturnHandledRef.current = true + + const nextParams = new URLSearchParams(searchParams) + nextParams.delete('cloudflare') + nextParams.delete('error') + nextParams.delete('error_description') + setSearchParams(nextParams, { replace: true }) + + if (searchParams.has('error')) { + toast.error(t('project.settings.proxy.cloudflareNotCompleted')) + return + } + + const domain = domains.find((item) => item.hostname === hostname) + if (!domain) return + setExpandedIds((prev) => new Set([...prev, domain.id])) + verifyingIdRef.current = domain.id + setVerifyingId(domain.id) + verifyFetcher.submit( + { intent: 'verify-proxy-domain', id: domain.id }, + { method: 'POST', action: settingsAction }, + ) + }, [ + loading, + domains, + searchParams, + setSearchParams, + settingsAction, + t, + verifyFetcher, + ]) + const submitAddDomain = () => { const validated = validateHostnameClient(hostnameInput) if (!validated) { @@ -538,6 +589,28 @@ export default function ProxyDomainsTab({ projectId }: ProxyDomainsTabProps) { + {cloudflareSetupAvailable ? ( +
+ + + {t('project.settings.proxy.cloudflareSetupDescription')} + +
+ ) : null} + {domain.status === 'error' && domain.errorMessage ? ( diff --git a/web/app/routes/projects.settings.$id.tsx b/web/app/routes/projects.settings.$id.tsx index 8b731f1fe..7f6245fec 100644 --- a/web/app/routes/projects.settings.$id.tsx +++ b/web/app/routes/projects.settings.$id.tsx @@ -100,6 +100,8 @@ export interface ProjectSettingsActionData { proxyDomains?: ProxyDomain[] proxyDomain?: ProxyDomain proxyDomainKeywordWarning?: boolean + cloudflareSetupAvailable?: boolean + cloudflareSetupUrl?: string shareId?: string role?: string revenueCurrencyRequestId?: string @@ -958,10 +960,10 @@ export async function action({ request, params }: ActionFunctionArgs) { } case 'list-proxy-domains': { - const result = await serverFetch<{ domains: ProxyDomain[] }>( - request, - `project/${id}/proxy-domains`, - ) + const result = await serverFetch<{ + domains: ProxyDomain[] + cloudflareSetupAvailable?: boolean + }>(request, `project/${id}/proxy-domains`) if (result.error) { return data( @@ -975,11 +977,43 @@ export async function action({ request, params }: ActionFunctionArgs) { intent, success: true, proxyDomains: (result.data?.domains as ProxyDomain[]) || [], + cloudflareSetupAvailable: !!result.data?.cloudflareSetupAvailable, }, { headers: createHeadersWithCookies(result.cookies) }, ) } + case 'configure-proxy-cloudflare': { + const proxyDomainId = formData.get('id')?.toString() + if (!proxyDomainId) { + return data( + { intent, error: 'Proxy domain ID is required' }, + { status: 400 }, + ) + } + + const result = await serverFetch<{ url: string }>( + request, + `project/${id}/proxy-domains/${encodeURIComponent(proxyDomainId)}/cloudflare`, + { method: 'POST' }, + ) + + const headers = createHeadersWithCookies(result.cookies) + headers.set('Cache-Control', 'no-store') + + return data( + { + intent, + error: result.error as string | undefined, + cloudflareSetupUrl: result.data?.url, + }, + { + status: result.error ? result.status || 400 : 200, + headers, + }, + ) + } + case 'add-proxy-domain': { const hostname = formData.get('hostname')?.toString() diff --git a/web/public/locales/en.json b/web/public/locales/en.json index b32ce7f26..d230d2e05 100644 --- a/web/public/locales/en.json +++ b/web/public/locales/en.json @@ -2476,6 +2476,9 @@ "dnsRecordTTL": "TTL", "ttlAuto": "Auto", "cloudflareTipTitle": "Using Cloudflare?", + "configureCloudflare": "Configure with Cloudflare", + "cloudflareSetupDescription": "Using Cloudflare? Sign in to approve this DNS record.", + "cloudflareNotCompleted": "Cloudflare setup wasn't completed. Try again or add the DNS record manually.", "cloudflareTipBody": "Make sure the record is set to <1>DNS only, not <3>Proxied. Cloudflare's proxy interferes with our certificate provisioning.", "cloudflareProxied": "Proxied", "cloudflareDnsOnly": "DNS only", From 02e8d0ded0270260c6d9ec2ae9fa10bd9d65ec8b Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Thu, 1 Oct 2026 00:58:11 +0100 Subject: [PATCH 3/4] Cloudflare Autoconfigure feature docs --- docs/content/docs/adblockers/managed-proxy.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/content/docs/adblockers/managed-proxy.mdx b/docs/content/docs/adblockers/managed-proxy.mdx index 38b82b619..c8df2a6ce 100644 --- a/docs/content/docs/adblockers/managed-proxy.mdx +++ b/docs/content/docs/adblockers/managed-proxy.mdx @@ -72,6 +72,10 @@ You'll see the domain appear in the list with a `Waiting` status and a **CNAME t

Add the CNAME record

+If your domain uses Cloudflare and **Configure with Cloudflare** is available, click it and sign in to Cloudflare to approve the DNS record. This is a one-time authorization. You'll return to Swetrix, where we'll check the record and certificate. + +You can also configure DNS manually: + In your DNS provider, create a `CNAME` record: | Name | Type | Value | TTL | From 010886f25853ec0c667c6e15207b51853b98f5ff Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sun, 4 Oct 2026 21:53:59 +0100 Subject: [PATCH 4/4] Wait for a successful domain-list response before handling the Cloudflare return --- .../pages/Project/Settings/components/ProxyDomainsTab.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web/app/pages/Project/Settings/components/ProxyDomainsTab.tsx b/web/app/pages/Project/Settings/components/ProxyDomainsTab.tsx index 12e4aa037..acf47b12c 100644 --- a/web/app/pages/Project/Settings/components/ProxyDomainsTab.tsx +++ b/web/app/pages/Project/Settings/components/ProxyDomainsTab.tsx @@ -309,6 +309,7 @@ export default function ProxyDomainsTab({ projectId }: ProxyDomainsTabProps) { const [domains, setDomains] = useState([]) const [loading, setLoading] = useState(true) + const [domainsLoaded, setDomainsLoaded] = useState(false) const [cloudflareSetupAvailable, setCloudflareSetupAvailable] = useState(false) const [showAddModal, setShowAddModal] = useState(false) @@ -352,6 +353,7 @@ export default function ProxyDomainsTab({ projectId }: ProxyDomainsTabProps) { toast.error(listFetcher.data.error) } else if (listFetcher.data.proxyDomains) { setDomains(listFetcher.data.proxyDomains) + setDomainsLoaded(true) setCloudflareSetupAvailable(!!listFetcher.data.cloudflareSetupAvailable) } setLoading(false) @@ -457,7 +459,8 @@ export default function ProxyDomainsTab({ projectId }: ProxyDomainsTabProps) { useEffect(() => { const hostname = searchParams.get('cloudflare') - if (!hostname || loading || cloudflareReturnHandledRef.current) return + if (!hostname || !domainsLoaded || cloudflareReturnHandledRef.current) + return cloudflareReturnHandledRef.current = true const nextParams = new URLSearchParams(searchParams) @@ -481,7 +484,7 @@ export default function ProxyDomainsTab({ projectId }: ProxyDomainsTabProps) { { method: 'POST', action: settingsAction }, ) }, [ - loading, + domainsLoaded, domains, searchParams, setSearchParams,