@takosjp/yurucommu-core 3.2.1 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/migrations/0020_call_sessions.sql +23 -0
- package/package.json +2 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/index.ts +1 -0
- package/packages/api/src/lib/rtc-client.ts +542 -0
- package/packages/api/src/types/call.ts +306 -0
- package/packages/api/src/types/index.ts +3 -0
- package/src/backend/index.ts +21 -1
- package/src/backend/lib/rtc/call-store.ts +101 -0
- package/src/backend/lib/rtc/provider.ts +135 -0
- package/src/backend/lib/rtc/signal-transport.ts +125 -0
- package/src/backend/public.ts +3 -0
- package/src/backend/routes/activitypub.ts +5 -1
- package/src/backend/routes/auth.ts +8 -2
- package/src/backend/routes/rtc/index.ts +147 -0
- package/src/backend/runtime/call-hub-core.ts +470 -0
- package/src/backend/runtime/call-hub-port.ts +78 -0
- package/src/backend/runtime/call-signaling-do.ts +242 -0
- package/src/backend/runtime/signaling-hub.ts +187 -0
- package/src/backend/types.ts +28 -0
- package/src/db/schema/calls.ts +46 -0
- package/src/db/schema/index.ts +1 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-instance call signaling transport (server-to-server).
|
|
3
|
+
*
|
|
4
|
+
* Signaling for a call between two self-hosted instances travels as an
|
|
5
|
+
* HTTP-Signature-signed POST to the peer instance's `/ap/rtc/signal` endpoint.
|
|
6
|
+
* This deliberately does NOT use the queued federation delivery path
|
|
7
|
+
* (`enqueueDeliveryToActor`, with its circuit-breaker + retry/backoff): an SDP
|
|
8
|
+
* offer or ICE candidate is ephemeral and latency-sensitive, so a stale retry is
|
|
9
|
+
* useless. We reuse the same low-level signing (`signRequest`) + SSRF-guarded
|
|
10
|
+
* fetch (`fetchWithTimeout`) primitives the delivery worker uses, but send
|
|
11
|
+
* synchronously and directly. It also does NOT go through the inbox activity
|
|
12
|
+
* pipeline (`claimActivityForDispatch` / `parseActivity`), which would both strip
|
|
13
|
+
* the SDP/ICE fields and pollute the `activities` ledger with ephemeral frames.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { eq } from "drizzle-orm";
|
|
17
|
+
import type { Database } from "../../../db/index.ts";
|
|
18
|
+
import { actorCache } from "../../../db/index.ts";
|
|
19
|
+
import type { RtcSignalEnvelopeV1 } from "../../../../packages/api/src/types/call.ts";
|
|
20
|
+
import { signRequest } from "../ap-signing.ts";
|
|
21
|
+
import { fetchWithTimeout } from "../federation-fetch.ts";
|
|
22
|
+
import { fetchAndUpsertActorCache } from "../activitypub-actor-cache.ts";
|
|
23
|
+
import { isSafeRemoteUrl } from "../ssrf.ts";
|
|
24
|
+
import { logger } from "../logger.ts";
|
|
25
|
+
|
|
26
|
+
const log = logger.child({ component: "rtc.signal-transport" });
|
|
27
|
+
|
|
28
|
+
const SIGNAL_PATH = "/ap/rtc/signal";
|
|
29
|
+
const SIGNAL_TIMEOUT_MS = 8000;
|
|
30
|
+
|
|
31
|
+
export interface CallSigner {
|
|
32
|
+
apId: string;
|
|
33
|
+
privateKeyPem: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Derive a peer's signaling endpoint from a cached actor row. */
|
|
37
|
+
function endpointFromActorRow(row: {
|
|
38
|
+
inbox: string;
|
|
39
|
+
rawJson: string;
|
|
40
|
+
}): string | null {
|
|
41
|
+
// Prefer an explicitly advertised endpoint (actor `endpoints.rtcSignal`).
|
|
42
|
+
try {
|
|
43
|
+
const doc = JSON.parse(row.rawJson) as {
|
|
44
|
+
endpoints?: { rtcSignal?: unknown };
|
|
45
|
+
};
|
|
46
|
+
const advertised = doc.endpoints?.rtcSignal;
|
|
47
|
+
if (typeof advertised === "string" && isSafeRemoteUrl(advertised)) {
|
|
48
|
+
return advertised;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// fall through to inbox-origin derivation
|
|
52
|
+
}
|
|
53
|
+
// Fall back to `<inbox-origin>/ap/rtc/signal` (every yurucommu instance
|
|
54
|
+
// serves this path). Peers that are not yurucommu simply won't answer.
|
|
55
|
+
try {
|
|
56
|
+
const origin = new URL(row.inbox).origin;
|
|
57
|
+
const endpoint = `${origin}${SIGNAL_PATH}`;
|
|
58
|
+
return isSafeRemoteUrl(endpoint) ? endpoint : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve (and cache) the peer instance's signaling endpoint URL. */
|
|
65
|
+
export async function resolvePeerSignalEndpoint(
|
|
66
|
+
db: Database,
|
|
67
|
+
peerApId: string,
|
|
68
|
+
): Promise<string | null> {
|
|
69
|
+
let row = await db.query.actorCache.findFirst({
|
|
70
|
+
where: eq(actorCache.apId, peerApId),
|
|
71
|
+
columns: { inbox: true, rawJson: true },
|
|
72
|
+
});
|
|
73
|
+
if (!row) {
|
|
74
|
+
const result = await fetchAndUpsertActorCache(db, peerApId, {});
|
|
75
|
+
if (result.ok) {
|
|
76
|
+
row = { inbox: result.row.inbox, rawJson: result.row.rawJson };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!row) return null;
|
|
80
|
+
return endpointFromActorRow(row);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Sign + POST a signaling envelope to the peer instance. Throws on any
|
|
85
|
+
* unreachable / non-2xx outcome so the caller can fail the call fast.
|
|
86
|
+
*/
|
|
87
|
+
export async function sendCallSignal(
|
|
88
|
+
db: Database,
|
|
89
|
+
signer: CallSigner,
|
|
90
|
+
envelope: RtcSignalEnvelopeV1,
|
|
91
|
+
peerSignalEndpoint?: string,
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
const endpoint =
|
|
94
|
+
peerSignalEndpoint ?? (await resolvePeerSignalEndpoint(db, envelope.to));
|
|
95
|
+
if (!endpoint) {
|
|
96
|
+
throw new Error(`no signaling endpoint for ${envelope.to}`);
|
|
97
|
+
}
|
|
98
|
+
const body = JSON.stringify(envelope);
|
|
99
|
+
const keyId = `${signer.apId}#main-key`;
|
|
100
|
+
const signed = await signRequest(
|
|
101
|
+
signer.privateKeyPem,
|
|
102
|
+
keyId,
|
|
103
|
+
"POST",
|
|
104
|
+
endpoint,
|
|
105
|
+
body,
|
|
106
|
+
);
|
|
107
|
+
const res = await fetchWithTimeout(endpoint, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: {
|
|
110
|
+
...signed,
|
|
111
|
+
"Content-Type": "application/activity+json",
|
|
112
|
+
Accept: "application/json",
|
|
113
|
+
},
|
|
114
|
+
body,
|
|
115
|
+
timeout: SIGNAL_TIMEOUT_MS,
|
|
116
|
+
});
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
log.warn("Signaling POST rejected", {
|
|
119
|
+
callId: envelope.callId,
|
|
120
|
+
type: envelope.type,
|
|
121
|
+
status: res.status,
|
|
122
|
+
});
|
|
123
|
+
throw new Error(`signal POST ${res.status}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/backend/public.ts
CHANGED
|
@@ -13,6 +13,9 @@ export { default } from "./index.ts";
|
|
|
13
13
|
export { default as app } from "./index.ts";
|
|
14
14
|
export { type Database, getDb, getDbSQLite } from "../db/index.ts";
|
|
15
15
|
export { wrapCloudflareBindings } from "./runtime/cloudflare.ts";
|
|
16
|
+
// Call feature: the signaling Durable Object class each product's generated
|
|
17
|
+
// worker entry must re-export so Wrangler can bind CALL_SIGNALING to it.
|
|
18
|
+
export { CallSignalingDurableObject } from "./runtime/call-signaling-do.ts";
|
|
16
19
|
export type { Env, EnvVars } from "./types.ts";
|
|
17
20
|
export type {
|
|
18
21
|
DeliveryDlqMessageV1,
|
|
@@ -440,9 +440,13 @@ ap.get(
|
|
|
440
440
|
following: showCollections ? actor.followingUrl : undefined,
|
|
441
441
|
// Advertise sharedInbox so remote servers can deduplicate fan-out
|
|
442
442
|
// delivery (Mastodon convention). The endpoint accepts signed
|
|
443
|
-
// activities just like the per-actor inbox.
|
|
443
|
+
// activities just like the per-actor inbox. `rtcSignal` advertises the
|
|
444
|
+
// call-signaling ingest so a caller's instance can reach this user
|
|
445
|
+
// directly (yurucommu extension; peers that don't recognize it just fall
|
|
446
|
+
// back to deriving it from the inbox origin).
|
|
444
447
|
endpoints: {
|
|
445
448
|
sharedInbox: `${baseUrl}/ap/inbox`,
|
|
449
|
+
rtcSignal: `${baseUrl}/ap/rtc/signal`,
|
|
446
450
|
},
|
|
447
451
|
publicKey: buildPublicKey(actor.apId, actor.publicKeyPem),
|
|
448
452
|
discoverable: !actor.isPrivate,
|
|
@@ -63,6 +63,12 @@ const auth = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
|
63
63
|
// abuse even from the legitimate owner session (#23).
|
|
64
64
|
const MAX_SUB_ACCOUNTS = 20;
|
|
65
65
|
|
|
66
|
+
function parsePassword(value: unknown): string | null {
|
|
67
|
+
// Passwords are opaque credentials. Trimming changes a valid secret and can
|
|
68
|
+
// make the native and browser login behavior diverge from the stored hash.
|
|
69
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
66
72
|
// 認証設定取得
|
|
67
73
|
auth.get("/providers", async (c) => {
|
|
68
74
|
const config = getAuthConfig(c.env);
|
|
@@ -151,7 +157,7 @@ auth.post("/login", async (c) => {
|
|
|
151
157
|
return c.json({ error: "Invalid request body", code: "BAD_REQUEST" }, 400);
|
|
152
158
|
}
|
|
153
159
|
|
|
154
|
-
const password =
|
|
160
|
+
const password = parsePassword(body.password);
|
|
155
161
|
if (!password) {
|
|
156
162
|
return c.json({ error: "password is required", code: "BAD_REQUEST" }, 400);
|
|
157
163
|
}
|
|
@@ -233,7 +239,7 @@ auth.post("/mobile/login", async (c) => {
|
|
|
233
239
|
}
|
|
234
240
|
|
|
235
241
|
const body = await parseJsonObject(c);
|
|
236
|
-
const password = body ?
|
|
242
|
+
const password = body ? parsePassword(body.password) : null;
|
|
237
243
|
if (!password) {
|
|
238
244
|
return c.json({ error: "password is required", code: "BAD_REQUEST" }, 400);
|
|
239
245
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call feature routes (WebRTC voice + video).
|
|
3
|
+
*
|
|
4
|
+
* POST /ap/rtc/signal server-to-server signaling ingest (HTTP-Signature)
|
|
5
|
+
* GET /api/rtc/socket browser WebSocket upgrade -> per-user signaling hub
|
|
6
|
+
* GET /api/rtc/ice mint short-lived ICE (STUN/TURN) servers
|
|
7
|
+
* POST /api/rtc/calls start a call (block-list gate + callId + ICE)
|
|
8
|
+
* GET /api/rtc/calls call history (missed / recent)
|
|
9
|
+
* GET /api/rtc/calls/:id current state of one call
|
|
10
|
+
*
|
|
11
|
+
* Signaling is intentionally OUTSIDE the ActivityPub inbox pipeline: the
|
|
12
|
+
* `/ap/rtc/signal` endpoint bypasses `claimActivityForDispatch` /
|
|
13
|
+
* `parseActivity` (which would strip SDP/ICE and persist ephemeral frames to the
|
|
14
|
+
* `activities` ledger). It reuses the same HTTP-Signature auth every inbox uses.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Hono } from "hono";
|
|
18
|
+
import { eq } from "drizzle-orm";
|
|
19
|
+
import type { Env, Variables } from "../../types.ts";
|
|
20
|
+
import { actors } from "../../../db/index.ts";
|
|
21
|
+
import { verifyHttpSignature } from "../../lib/ap-verify.ts";
|
|
22
|
+
import {
|
|
23
|
+
isActorMismatch,
|
|
24
|
+
signingActorFromKeyId,
|
|
25
|
+
} from "../activitypub/inbox.ts";
|
|
26
|
+
import { isActorBlocked } from "../../lib/blocklist.ts";
|
|
27
|
+
import {
|
|
28
|
+
getSignalingHub,
|
|
29
|
+
isSignalingAvailable,
|
|
30
|
+
} from "../../runtime/signaling-hub.ts";
|
|
31
|
+
import { createRtcProvider } from "../../lib/rtc/provider.ts";
|
|
32
|
+
import { getCallSession, listCallSessions } from "../../lib/rtc/call-store.ts";
|
|
33
|
+
import type {
|
|
34
|
+
CallMediaKind,
|
|
35
|
+
StartCallRequest,
|
|
36
|
+
} from "../../../../packages/api/src/types/call.ts";
|
|
37
|
+
import { parseRtcSignalEnvelope } from "../../../../packages/api/src/types/call.ts";
|
|
38
|
+
|
|
39
|
+
const rtc = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
40
|
+
|
|
41
|
+
function normalizeMedia(input: unknown): CallMediaKind {
|
|
42
|
+
if (input && typeof input === "object") {
|
|
43
|
+
const m = input as Partial<CallMediaKind>;
|
|
44
|
+
return { audio: m.audio !== false, video: Boolean(m.video) };
|
|
45
|
+
}
|
|
46
|
+
return { audio: true, video: false };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Server-to-server signaling ingest -------------------------------------
|
|
50
|
+
rtc.post("/ap/rtc/signal", async (c) => {
|
|
51
|
+
const db = c.get("db");
|
|
52
|
+
const body = await c.req.text();
|
|
53
|
+
const sig = await verifyHttpSignature(c.req.raw, db, body);
|
|
54
|
+
if (!sig.valid) return c.json({ error: "invalid_signature" }, 401);
|
|
55
|
+
|
|
56
|
+
let parsed: unknown;
|
|
57
|
+
try {
|
|
58
|
+
parsed = JSON.parse(body);
|
|
59
|
+
} catch {
|
|
60
|
+
return c.json({ error: "bad_json" }, 400);
|
|
61
|
+
}
|
|
62
|
+
const envelope = parseRtcSignalEnvelope(parsed);
|
|
63
|
+
if (!envelope) return c.json({ error: "bad_envelope" }, 400);
|
|
64
|
+
|
|
65
|
+
// The HTTP-Signature signer must own the claimed `from` actor.
|
|
66
|
+
if (isActorMismatch(signingActorFromKeyId(sig.keyId), envelope.from)) {
|
|
67
|
+
return c.json({ error: "signer_mismatch" }, 403);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The recipient must be a local actor served by this instance.
|
|
71
|
+
const local = await db.query.actors.findFirst({
|
|
72
|
+
where: eq(actors.apId, envelope.to),
|
|
73
|
+
columns: { apId: true },
|
|
74
|
+
});
|
|
75
|
+
if (!local) return c.json({ error: "unknown_recipient" }, 404);
|
|
76
|
+
|
|
77
|
+
// Never ring for a sender the local owner has blocked; drop silently.
|
|
78
|
+
if (await isActorBlocked(db, envelope.from)) return c.body(null, 204);
|
|
79
|
+
|
|
80
|
+
await getSignalingHub(c.env).deliver(envelope.to, envelope);
|
|
81
|
+
return c.body(null, 204);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// --- Browser WebSocket upgrade ---------------------------------------------
|
|
85
|
+
rtc.get("/api/rtc/socket", async (c) => {
|
|
86
|
+
const actor = c.get("actor");
|
|
87
|
+
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
88
|
+
if (!isSignalingAvailable(c.env)) {
|
|
89
|
+
return c.json({ error: "signaling_unavailable" }, 503);
|
|
90
|
+
}
|
|
91
|
+
return getSignalingHub(c.env).upgrade(c.req.raw, actor.ap_id);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// --- ICE servers ------------------------------------------------------------
|
|
95
|
+
rtc.get("/api/rtc/ice", async (c) => {
|
|
96
|
+
const actor = c.get("actor");
|
|
97
|
+
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
98
|
+
const iceServers = await createRtcProvider(c.env).getIceServers();
|
|
99
|
+
return c.json({ iceServers });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// --- Start a call -----------------------------------------------------------
|
|
103
|
+
rtc.post("/api/rtc/calls", async (c) => {
|
|
104
|
+
const actor = c.get("actor");
|
|
105
|
+
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
106
|
+
if (!isSignalingAvailable(c.env)) {
|
|
107
|
+
return c.json({ error: "signaling_unavailable" }, 503);
|
|
108
|
+
}
|
|
109
|
+
const db = c.get("db");
|
|
110
|
+
let payload: Partial<StartCallRequest>;
|
|
111
|
+
try {
|
|
112
|
+
payload = (await c.req.json()) as Partial<StartCallRequest>;
|
|
113
|
+
} catch {
|
|
114
|
+
return c.json({ error: "bad_json" }, 400);
|
|
115
|
+
}
|
|
116
|
+
const to = typeof payload.to === "string" ? payload.to.trim() : "";
|
|
117
|
+
if (!to || to === actor.ap_id) return c.json({ error: "bad_target" }, 400);
|
|
118
|
+
const media = normalizeMedia(payload.media);
|
|
119
|
+
|
|
120
|
+
// Do not let the local owner place a call to a contact they have blocked.
|
|
121
|
+
if (await isActorBlocked(db, to)) return c.json({ error: "blocked" }, 403);
|
|
122
|
+
|
|
123
|
+
const provider = createRtcProvider(c.env);
|
|
124
|
+
const [iceServers, sfuFocus] = await Promise.all([
|
|
125
|
+
provider.getIceServers(),
|
|
126
|
+
provider.getSfuFocus(media),
|
|
127
|
+
]);
|
|
128
|
+
return c.json({ callId: crypto.randomUUID(), iceServers, sfuFocus });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// --- Call history + state ---------------------------------------------------
|
|
132
|
+
rtc.get("/api/rtc/calls", async (c) => {
|
|
133
|
+
const actor = c.get("actor");
|
|
134
|
+
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
135
|
+
const calls = await listCallSessions(c.get("db"), actor.ap_id);
|
|
136
|
+
return c.json({ calls });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
rtc.get("/api/rtc/calls/:id", async (c) => {
|
|
140
|
+
const actor = c.get("actor");
|
|
141
|
+
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
142
|
+
const call = await getCallSession(c.get("db"), actor.ap_id, c.req.param("id"));
|
|
143
|
+
if (!call) return c.json({ error: "not_found" }, 404);
|
|
144
|
+
return c.json({ call });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
export default rtc;
|