Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions deploy/relay-v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,44 @@ WantedBy=timers.target
is cheap: hourly traffic moves past the window in seconds, versus 455 seconds
for the first pass over 2.17 M rows. Pruning and retiring are separate steps —
pruning only buys time until the version reaches the retirement switch above.

## Version-by-query endpoint and the Page origin

Releases used to be separated by a proxy location (`/v/<version>/`). A client
now targets `https://remote.openbitfun.com/relay?v=<service-version>`: the
version travels as a query parameter, one proxy location serves every build,
and the bare origin is the account controller. `/v/1.0.2/` stays live on the
same process and database for builds that predate the parameter, so both
aliases share credentials and device registration.

```nginx
location = /relay { return 308 /relay/; }
location ^~ /relay/ {
proxy_pass http://127.0.0.1:19702/; # strip the prefix
proxy_http_version 1.1;
proxy_set_header Host $host; # bare host: the Relay compares it verbatim
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
client_max_body_size 65m;
}
location = / { proxy_pass http://127.0.0.1:19702/; } # the controller SPA
location = /index.html { proxy_pass http://127.0.0.1:19702/index.html; }
location ^~ /assets/ { proxy_pass http://127.0.0.1:19702; }
location ^~ /brand/ { proxy_pass http://127.0.0.1:19702; }
location / { return 410; } # keep unversioned paths closed
```

Published Pages must not share a browser origin with the controller: Page
documents execute user-authored JavaScript, and the controller keeps its device
key and Relay credential in that origin's IndexedDB. Serve `/p/` and
`/api/page-auth/` from a separate host (`RELAY_PAGE_PUBLIC_BASE_URL`) and keep
`RELAY_PAGE_AUTH_BASE_URL` on a third origin. Redirect the old path instead of
serving content there:

```nginx
location ^~ /v/1.0.2/p/ { rewrite ^/v/1\.0\.2(/p/.*)$ https://pages.example.com$1 redirect; }
location ^~ /relay/p/ { rewrite ^/relay(/p/.*)$ https://pages.example.com$1 redirect; }
```
6 changes: 4 additions & 2 deletions src/apps/cli/src/account_guidance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,10 @@ mod tests {

#[test]
fn the_endpoint_variant_prefers_a_retired_official_endpoint() {
let older = DEFAULT_RELAY_URL.replace("/v/1.0.2", "/v/1.0.1");
let retired = account_failure_guidance_for_endpoint("some generic error", &older);
// `/v/1.0.2` and `/relay` are live aliases of one deployment; any other
// `/v/<release>` path belongs to a retired deployment.
let older = "https://remote.openbitfun.com/v/1.0.1";
let retired = account_failure_guidance_for_endpoint("some generic error", older);
assert_eq!(retired.0, UPDATE_SENTENCE);
assert_eq!(retired.1, Some(UPDATE_HINT));

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { GitHubAccountProfile, GitHubProfileLoader } from '../../services/GitHubAccountProfile';
import { normalizeAccountRelayUrl, OFFICIAL_RELAY_URL } from '../../services/AccountDeviceLink';
import { normalizeAccountRelayUrl, OFFICIAL_RELAY_URL, sameRelayDeployment } from '../../services/AccountDeviceLink';
import { RemoteI18n } from '../../i18n/RemoteI18n';
import { stampRemoteSessionsDevice } from '../../services/RemoteSessionIdentity';
import {
Expand Down Expand Up @@ -173,7 +173,7 @@ export class SettingsController {
selectAccountRelay(relayUrl: string): void {
const endpoint = normalizeAccountRelayUrl(relayUrl);
if (!endpoint) throw new Error('Invalid Relay URL');
if (this.cloudRelayUrl !== endpoint) {
if (!sameRelayDeployment(this.cloudRelayUrl, endpoint)) {
this.teardownCloudAccountProjection(this.requireCloud());
this.selectedRelayUrl = endpoint;
}
Expand Down Expand Up @@ -784,7 +784,8 @@ export class SettingsController {
if (!persisted) {
return;
}
if (/^https:\/\/remote\.openbitfun\.com\/v\/[^/]+\/?$/.test(persisted.relayUrl) && persisted.relayUrl.replace(/\/+$/, '') !== OFFICIAL_RELAY_URL) {
if (/^https:\/\/remote\.openbitfun\.com\/v\/[^/]+\/?$/.test(persisted.relayUrl)
&& !sameRelayDeployment(persisted.relayUrl, OFFICIAL_RELAY_URL)) {
// Relay credentials are database-scoped; never replay an older credential at a new endpoint.
this.selectedRelayUrl = OFFICIAL_RELAY_URL;
this.cloudRelayUrl = OFFICIAL_RELAY_URL;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,57 @@
export const OFFICIAL_RELAY_URL: string = 'https://remote.openbitfun.com/v/1.0.2';
/** Hosted Relay service version announced by the official endpoint. */
export const RELAY_SERVICE_VERSION: string = '1.0.9';

const OFFICIAL_RELAY_ORIGIN: string = 'https://remote.openbitfun.com';

/** Paths served by the same deployed Relay and the same account database.
* `?v=` replaced the path segment as the client-facing version, but `/v/1.0.2`
* stays live for builds that predate the change: a token, master key, or device
* registration obtained through either path is interchangeable, because both
* reach one process and one database. Any other `/v/<version>` path is a
* different deployment. */
const LIVE_RELAY_ENDPOINT_PATHS: string[] = ['/relay', '/v/1.0.2'];

const VERSION_PATTERN: RegExp = /^[A-Za-z0-9.]{1,32}$/;

/** Identity of the official Relay: no version query, no fragment, no trailing
* slash. Every path join and every deployment comparison uses this value. */
export const OFFICIAL_RELAY_URL: string = `${OFFICIAL_RELAY_ORIGIN}/relay`;

/** The same deployment as a user sees and shares it: the service version
* travels as a GET parameter instead of a path segment, so a release no longer
* needs a proxy location of its own. */
export const OFFICIAL_RELAY_ENDPOINT: string = `${OFFICIAL_RELAY_URL}?v=${RELAY_SERVICE_VERSION}`;

export interface AccountDeviceLink { relayUrl: string; deviceId: string; }

/** Strip the version parameter and any trailing slash, or explain why not.
* Exactly one `v=<version>` parameter is accepted; every other query parameter
* is rejected so the query can never carry per-request data, and a fragment
* means the value is a page rather than an endpoint. */
function endpointBase(value: string): string | undefined {
const text = value.trim();
if (text === '' || text.indexOf('#') >= 0) return undefined;
const mark = text.indexOf('?');
const base = (mark >= 0 ? text.substring(0, mark) : text).replace(/\/+$/, '');
if (mark >= 0) {
const pairs = text.substring(mark + 1).split('&');
if (pairs.length !== 1 || pairs[0].substring(0, 2) !== 'v=' ||
!VERSION_PATTERN.test(pairs[0].substring(2))) return undefined;
}
return base;
}

function officialPath(value: string): string {
return value.startsWith(OFFICIAL_RELAY_ORIGIN) ? value.substring(OFFICIAL_RELAY_ORIGIN.length) : '';
}

export function normalizeAccountRelayUrl(value: string): string {
const normalized = value.trim().replace(/\/+$/, '');
if (normalized === OFFICIAL_RELAY_URL) return normalized;
const match = /^(https?:\/\/)(localhost|\[[0-9a-fA-F:]+\]|[0-9.]+)(?::([0-9]{1,5}))?$/.exec(normalized);
const base = endpointBase(value);
if (!base) return '';
// Both live aliases of the official host answer as one deployment, so an
// endpoint stored by an older build still resolves to today's identity.
if (LIVE_RELAY_ENDPOINT_PATHS.indexOf(officialPath(base)) >= 0) return OFFICIAL_RELAY_URL;
const match = /^(https?:\/\/)(localhost|\[[0-9a-fA-F:]+\]|[0-9.]+)(?::([0-9]{1,5}))?$/.exec(base);
if (!match) return '';
if (match[3] && (Number(match[3]) < 1 || Number(match[3]) > 65535)) return '';
const host = match[2].toLowerCase();
Expand All @@ -14,7 +61,33 @@ export function normalizeAccountRelayUrl(value: string): string {
(octets.length === 4 && octets.every((part: number): boolean => Number.isInteger(part) && part >= 0 && part <= 255) &&
(octets[0] === 10 || octets[0] === 127 || (octets[0] === 192 && octets[1] === 168) ||
(octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 169 && octets[1] === 254)));
return local ? normalized : '';
return local ? base : '';
}

interface RelayEndpointIdentity { origin: string; path: string; }

function endpointIdentity(value: string): RelayEndpointIdentity | undefined {
const base = endpointBase(value);
if (!base) return undefined;
const match = /^(https?):\/\/(\[[0-9a-fA-F:]+\]|[^/?#:@]+)(?::([0-9]{1,5}))?([^?#]*)$/.exec(base);
if (!match) return undefined;
const scheme = match[1].toLowerCase();
const port = match[3] ? Number(match[3]) : (scheme === 'https' ? 443 : 80);
if (!Number.isInteger(port) || port < 1 || port > 65535) return undefined;
return { origin: `${scheme}://${match[2].toLowerCase()}:${port}`, path: match[4] };
}

/** Whether two endpoints resolve to the same deployed Relay.
* This is the comparison behind reusing a stored token or master key: both live
* aliases of one host count as one deployment, so a build that moves from
* `/v/1.0.2` to `/relay?v=` keeps its credential, while any other versioned
* path is a different or retired deployment. */
export function sameRelayDeployment(left: string, right: string): boolean {
const a = endpointIdentity(left);
const b = endpointIdentity(right);
if (!a || !b || a.origin !== b.origin) return false;
return a.path === b.path ||
(LIVE_RELAY_ENDPOINT_PATHS.indexOf(a.path) >= 0 && LIVE_RELAY_ENDPOINT_PATHS.indexOf(b.path) >= 0);
}

/** Endpoint and target are hints; the account directory is the authority. */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { ReadFileChunkResult } from '../main/ets/model/RemoteModels';
import { GitHubAccountProfile, GitHubProfileLoader, normalizeGitHubProfile, readCachedGitHubProfile } from '../main/ets/services/GitHubAccountProfile';
import { accountDeviceLink } from '../main/ets/services/AccountDeviceLink';
import {
accountDeviceLink,
normalizeAccountRelayUrl,
OFFICIAL_RELAY_ENDPOINT,
OFFICIAL_RELAY_URL,
RELAY_SERVICE_VERSION,
sameRelayDeployment
} from '../main/ets/services/AccountDeviceLink';
import { describe, it, expect } from '@ohos/hypium';
import { RemoteI18n } from '../main/ets/i18n/RemoteI18n';
import { ChatSessionController, ChatSessionSnapshot } from '../main/ets/services/ChatSessionController';
Expand Down Expand Up @@ -606,18 +613,46 @@ export default function transportAndGeneralChatUnitTest() {
});

it('uses the same device selector for official and LAN endpoints', 0, () => {
for (const endpoint of ['https://remote.openbitfun.com/v/1.0.2', 'http://192.168.1.10:9700']) {
for (const endpoint of [OFFICIAL_RELAY_URL, 'http://192.168.1.10:9700']) {
const parsed = accountDeviceLink(`${endpoint}/#/pair?did=desktop-1`);
expect(parsed?.deviceId).assertEqual('desktop-1');
expect(parsed?.relayUrl).assertEqual(endpoint);
}
});
it('resolves a versioned link to the endpoint base', 0, () => {
// A link the user opens carries the service version as a GET parameter
// before the fragment; the endpoint it resolves to is the base, so no join
// can ever produce `...?v=1.0.9/api/...`.
expect(OFFICIAL_RELAY_ENDPOINT).assertEqual(`${OFFICIAL_RELAY_URL}?v=${RELAY_SERVICE_VERSION}`);
const versioned = accountDeviceLink(`${OFFICIAL_RELAY_ENDPOINT}/#/pair?did=desktop-1`);
expect(versioned?.deviceId).assertEqual('desktop-1');
expect(versioned?.relayUrl).assertEqual(OFFICIAL_RELAY_URL);
expect(normalizeAccountRelayUrl(OFFICIAL_RELAY_ENDPOINT)).assertEqual(OFFICIAL_RELAY_URL);
// Exactly one `v` may ride along: every other parameter is not an endpoint.
expect(normalizeAccountRelayUrl(`${OFFICIAL_RELAY_URL}?v=1.0.9&relay=evil`)).assertEqual('');
expect(normalizeAccountRelayUrl(`${OFFICIAL_RELAY_URL}?version=1.0.9`)).assertEqual('');
expect(normalizeAccountRelayUrl(`${OFFICIAL_RELAY_URL}?v=1.0.9#/pair?did=desktop-1`)).assertEqual('');
});
it('keeps the older official path interchangeable with the current one', 0, () => {
const legacy = accountDeviceLink('https://remote.openbitfun.com/v/1.0.2/#/pair?did=desktop-1');
expect(legacy?.deviceId).assertEqual('desktop-1');
expect(legacy?.relayUrl).assertEqual(OFFICIAL_RELAY_URL);
// Both paths reach one process and one account database, so a stored token
// or master key from either stays usable.
expect(sameRelayDeployment('https://remote.openbitfun.com/v/1.0.2', OFFICIAL_RELAY_URL)).assertTrue();
expect(sameRelayDeployment(OFFICIAL_RELAY_ENDPOINT, OFFICIAL_RELAY_URL)).assertTrue();
expect(sameRelayDeployment('https://remote.openbitfun.com/v/1.0.0', OFFICIAL_RELAY_URL)).assertFalse();
expect(sameRelayDeployment('https://remote.openbitfun.com/v/1.0.0', 'https://remote.openbitfun.com/v/1.0.2')).assertFalse();
expect(sameRelayDeployment('http://remote.openbitfun.com/relay', OFFICIAL_RELAY_URL)).assertFalse();
expect(sameRelayDeployment('http://192.168.1.10:9700/', 'http://192.168.1.10:9700')).assertTrue();
});
it('rejects credentials, legacy rooms, duplicate targets and public lookalikes', 0, () => {
const prefix = 'https://remote.openbitfun.com/v/1.0.2/#/pair?';
const prefix = `${OFFICIAL_RELAY_ENDPOINT}/#/pair?`;
for (const suffix of ['did=desktop-1&pk=untrusted', 'did=desktop-1&relay=evil',
'did=desktop-1&did=desktop-2', 'did=%00', 'did=%ZZ', 'room=old&pk=old']) {
expect(accountDeviceLink(prefix + suffix)).assertUndefined();
}
expect(accountDeviceLink('https://evil.test/relay/#/pair?did=desktop-1')).assertUndefined();
expect(accountDeviceLink('https://evil.test/v/1.0.0/#/pair?did=desktop-1')).assertUndefined();
expect(accountDeviceLink('https://remote.openbitfun.com@evil.test/v/1.0.0/#/pair?did=desktop-1')).assertUndefined();
});
Expand Down Expand Up @@ -656,7 +691,9 @@ export default function transportAndGeneralChatUnitTest() {
describe('RemoteSessionManager account connection', () => {
it('loads account device sessions through either relay endpoint', 0, async () => {
const manager = new RemoteSessionManager();
for (const endpoint of ['https://remote.openbitfun.com/v/1.0.2', 'http://192.168.1.10:9700']) {
// The versioned official endpoint and the older live path both address the
// one deployment, next to a LAN relay that never carries a version.
for (const endpoint of [OFFICIAL_RELAY_ENDPOINT, 'https://remote.openbitfun.com/v/1.0.2', 'http://192.168.1.10:9700']) {
const result = await manager.connectAccountDevice(new RecordingAccountDeviceClient(), endpoint,
{ token: 'token', userId: 'account-user', masterKey: new Uint8Array(32) }, 'desktop-device');
expect(result.workspace.path).assertEqual('/account-workspace');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.openbitfun.mobile.core.feature.account

import com.openbitfun.mobile.core.transport.accountDeviceLink
import com.openbitfun.mobile.core.transport.sameRelayDeployment

public enum class AccountDeviceLinkStatus { INVALID, SIGN_IN_REQUIRED, UNAVAILABLE, READY }

Expand All @@ -13,7 +14,7 @@ public fun resolveAccountDeviceLink(url: String, state: AccountUiState): Account
val link = accountDeviceLink(url)
?: return AccountDeviceLinkResult(AccountDeviceLinkStatus.INVALID, null)
val id = link.deviceId
val ready = (state as? AccountUiState.Ready)?.takeIf { it.relayUrl == link.relayUrl }
val ready = (state as? AccountUiState.Ready)?.takeIf { sameRelayDeployment(it.relayUrl, link.relayUrl) }
?: return AccountDeviceLinkResult(AccountDeviceLinkStatus.SIGN_IN_REQUIRED, id, link.relayUrl)
// A scanned target must never resolve to a device the Relay confirmed is
// incompatible; the link reads as unavailable, the same as offline.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,20 +254,32 @@ public class AccountStore internal constructor(
)
return@launch
}
if (restored.relayUrl.trimEnd('/').startsWith("https://remote.openbitfun.com/v/") &&
restored.relayUrl.trimEnd('/') != AccountDefaults.CLOUD_RELAY_URL) {
// Relay tokens belong to their issuing database. Keep the old encrypted
// record/cache, but require a fresh login before using the new endpoint.
selectedRelayUrl = AccountDefaults.CLOUD_RELAY_URL
val storedRelay = restored.relayUrl.trimEnd('/')
val currentRelay = AccountDefaults.CLOUD_RELAY_URL
// Relay tokens belong to their issuing database. `/relay` and the older
// `/v/1.0.2` are live aliases of one deployment and one database, so a
// credential saved against either keeps working; only some other
// versioned path means a relay this build must sign in against again.
if (storedRelay.startsWith("https://remote.openbitfun.com/v/") &&
!com.openbitfun.mobile.core.transport.sameRelayDeployment(storedRelay, currentRelay)) {
// Keep the old encrypted record/cache, but require a fresh login
// before using the new endpoint.
selectedRelayUrl = currentRelay
expireSession(AccountFailureReason.AUTHENTICATION, AccountFailureStage.AUTHENTICATION)
return@launch
}
session = restored
selectedRelayUrl = restored.relayUrl
// Store today's endpoint identity: a session saved against a live alias
// or against the versioned official endpoint is joined and compared on
// the bare base, so the query can never reach a path.
val current = if (com.openbitfun.mobile.core.transport.sameRelayDeployment(storedRelay, currentRelay)) {
restored.copy(relayUrl = currentRelay)
} else restored
session = current
selectedRelayUrl = current.relayUrl
// Local credential restoration and remote directory availability are
// separate facts. Publish identity before any network wait, but grant
// no target authority until an authenticated membership snapshot arrives.
publishReady(restored, emptyList(), selectionConfirmed = false)
publishReady(current, emptyList(), selectionConfirmed = false)
refreshDevices()
}
}
Expand Down
Loading
Loading