From de80f3e6ab22b820a6ca75a1899cd71413d110e6 Mon Sep 17 00:00:00 2001 From: Wanjohi <71614375+wanjohiryan@users.noreply.github.com> Date: Mon, 7 Apr 2025 23:23:53 +0300 Subject: [PATCH] =?UTF-8?q?=E2=AD=90=20feat(maitred):=20Update=20maitred?= =?UTF-8?q?=20-=20hookup=20to=20the=20API=20(#198)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description We are attempting to hookup maitred to the API Maitred duties will be: - [ ] Hookup to the API - [ ] Wait for signal (from the API) to start Steam - [ ] Stop signal to stop the gaming session, clean up Steam... and maybe do the backup ## Summary by CodeRabbit - **New Features** - Introduced Docker-based deployment configurations for both the main and relay applications. - Added new API endpoints enabling real-time machine messaging and enhanced IoT operations. - Expanded database schema and actor types to support improved machine tracking. - **Improvements** - Enhanced real-time communication and relay management with streamlined room handling. - Upgraded dependencies, logging, and error handling for greater stability and performance. --------- Co-authored-by: DatCaptainHorse Co-authored-by: Kristian Ollikainen <14197772+DatCaptainHorse@users.noreply.github.com> --- containers/maitred.Containerfile | 12 + containers/relay.Containerfile | 2 + infra/api.ts | 6 + infra/realtime.ts | 9 + .../core/migrations/0002_tiny_toad_men.sql | 13 + .../core/migrations/meta/0002_snapshot.json | 379 ++++++ packages/core/migrations/meta/_journal.json | 7 + packages/core/package.json | 1 + packages/core/src/actor.ts | 18 +- packages/core/src/examples.ts | 9 + packages/core/src/machine/index.ts | 138 ++ packages/core/src/machine/machine.sql.ts | 37 + packages/core/src/member/index.ts | 16 +- packages/core/src/realtime/index.ts | 24 + packages/core/src/task/task.sql.todo | 20 + packages/core/src/team/index.ts | 13 +- packages/core/src/user/index.ts | 6 +- packages/core/src/utils/id.ts | 4 +- packages/functions/src/api/auth.ts | 32 +- packages/functions/src/api/index.ts | 4 +- packages/functions/src/api/machine.ts | 224 ++++ packages/functions/src/api/messages.ts | 54 + packages/functions/src/auth.ts | 72 +- packages/functions/src/party/authorizer.ts | 38 - packages/functions/src/realtime/authorizer.ts | 40 + .../src/{party => realtime}/create.ts | 0 .../src/{party => realtime}/subscriber.ts | 0 packages/functions/src/subjects.ts | 9 +- packages/functions/sst-env.d.ts | 5 + packages/input/src/webrtc-stream.ts | 62 +- packages/maitred/go.mod | 42 +- packages/maitred/go.sum | 153 ++- packages/maitred/internal/auth/auth.go | 45 + .../maitred/internal/containers/containers.go | 38 + .../maitred/internal/containers/docker.go | 299 +++++ packages/maitred/internal/flags.go | 70 + packages/maitred/internal/handler.go | 48 + packages/maitred/internal/realtime/managed.go | 366 ++++++ .../maitred/internal/realtime/messages.go | 52 + .../maitred/internal/realtime/realtime.go | 182 +++ packages/maitred/internal/realtime/utils.go | 17 + .../{pkg => internal}/resource/resource.go | 16 +- packages/maitred/internal/system/gpu.go | 184 +++ packages/maitred/internal/system/gpu_intel.go | 290 ++++ .../maitred/internal/system/gpu_intel_i915.go | 86 ++ .../maitred/internal/system/gpu_intel_xe.go | 84 ++ .../maitred/internal/system/gpu_nvidia.go | 57 + packages/maitred/internal/system/id.go | 24 + packages/maitred/internal/system/resources.go | 405 ++++++ packages/maitred/main.go | 125 +- packages/maitred/package.json | 11 + packages/maitred/pkg/auth/auth.go | 58 - packages/maitred/pkg/party/logger.go | 27 - packages/maitred/pkg/party/party.go | 129 -- packages/maitred/pkg/party/utils.go | 31 - packages/maitred/sst-env.d.ts | 9 + packages/relay/go.mod | 102 +- packages/relay/go.sum | 523 +++++++- .../relay/internal/{ => common}/common.go | 44 +- packages/relay/internal/common/crypto.go | 19 + packages/relay/internal/common/extensions.go | 11 + packages/relay/internal/{ => common}/flags.go | 50 +- .../relay/internal/{ => common}/latency.go | 2 +- packages/relay/internal/common/loghandler.go | 48 + packages/relay/internal/common/map.go | 101 ++ .../internal/{ => connections}/datachannel.go | 6 +- .../internal/{ => connections}/messages.go | 10 +- .../internal/connections/messages_mesh.go | 119 ++ .../internal/{ => connections}/websocket.go | 82 +- packages/relay/internal/egress.go | 157 ++- packages/relay/internal/http.go | 134 +- packages/relay/internal/ingest.go | 193 ++- packages/relay/internal/participant.go | 38 +- packages/relay/internal/peer.go.txt | 295 ----- .../internal/proto/latency_tracker.pb.go | 35 +- packages/relay/internal/proto/mesh.pb.go | 1170 +++++++++++++++++ packages/relay/internal/proto/messages.pb.go | 33 +- packages/relay/internal/proto/state.pb.go | 151 +++ packages/relay/internal/proto/types.pb.go | 103 +- packages/relay/internal/proto/webrtc.pb.go | 154 +++ packages/relay/internal/relay.go | 702 ++++++++++ packages/relay/internal/room.go | 259 ++-- packages/relay/main.go | 40 +- sst-env.d.ts | 5 + 84 files changed, 7357 insertions(+), 1331 deletions(-) create mode 100644 containers/maitred.Containerfile create mode 100644 infra/realtime.ts create mode 100644 packages/core/migrations/0002_tiny_toad_men.sql create mode 100644 packages/core/migrations/meta/0002_snapshot.json create mode 100644 packages/core/src/machine/index.ts create mode 100644 packages/core/src/machine/machine.sql.ts create mode 100644 packages/core/src/realtime/index.ts create mode 100644 packages/core/src/task/task.sql.todo create mode 100644 packages/functions/src/api/machine.ts create mode 100644 packages/functions/src/api/messages.ts delete mode 100644 packages/functions/src/party/authorizer.ts create mode 100644 packages/functions/src/realtime/authorizer.ts rename packages/functions/src/{party => realtime}/create.ts (100%) rename packages/functions/src/{party => realtime}/subscriber.ts (100%) create mode 100644 packages/maitred/internal/auth/auth.go create mode 100644 packages/maitred/internal/containers/containers.go create mode 100644 packages/maitred/internal/containers/docker.go create mode 100644 packages/maitred/internal/flags.go create mode 100644 packages/maitred/internal/handler.go create mode 100644 packages/maitred/internal/realtime/managed.go create mode 100644 packages/maitred/internal/realtime/messages.go create mode 100644 packages/maitred/internal/realtime/realtime.go create mode 100644 packages/maitred/internal/realtime/utils.go rename packages/maitred/{pkg => internal}/resource/resource.go (68%) create mode 100644 packages/maitred/internal/system/gpu.go create mode 100644 packages/maitred/internal/system/gpu_intel.go create mode 100644 packages/maitred/internal/system/gpu_intel_i915.go create mode 100644 packages/maitred/internal/system/gpu_intel_xe.go create mode 100644 packages/maitred/internal/system/gpu_nvidia.go create mode 100644 packages/maitred/internal/system/id.go create mode 100644 packages/maitred/internal/system/resources.go create mode 100644 packages/maitred/package.json delete mode 100644 packages/maitred/pkg/auth/auth.go delete mode 100644 packages/maitred/pkg/party/logger.go delete mode 100644 packages/maitred/pkg/party/party.go delete mode 100644 packages/maitred/pkg/party/utils.go create mode 100644 packages/maitred/sst-env.d.ts rename packages/relay/internal/{ => common}/common.go (72%) create mode 100644 packages/relay/internal/common/crypto.go create mode 100644 packages/relay/internal/common/extensions.go rename packages/relay/internal/{ => common}/flags.go (74%) rename packages/relay/internal/{ => common}/latency.go (99%) create mode 100644 packages/relay/internal/common/loghandler.go create mode 100644 packages/relay/internal/common/map.go rename packages/relay/internal/{ => connections}/datachannel.go (94%) rename packages/relay/internal/{ => connections}/messages.go (94%) create mode 100644 packages/relay/internal/connections/messages_mesh.go rename packages/relay/internal/{ => connections}/websocket.go (53%) delete mode 100644 packages/relay/internal/peer.go.txt create mode 100644 packages/relay/internal/proto/mesh.pb.go create mode 100644 packages/relay/internal/proto/state.pb.go create mode 100644 packages/relay/internal/proto/webrtc.pb.go create mode 100644 packages/relay/internal/relay.go diff --git a/containers/maitred.Containerfile b/containers/maitred.Containerfile new file mode 100644 index 00000000..ddbaf355 --- /dev/null +++ b/containers/maitred.Containerfile @@ -0,0 +1,12 @@ +FROM docker.io/golang:1.24-bookworm AS go-build +WORKDIR /builder +COPY packages/maitred/ /builder/ +RUN go build + +FROM docker.io/golang:1.24-bookworm +COPY --from=go-build /builder/maitred /maitred/maitred +WORKDIR /maitred + +RUN apt update && apt install -y --no-install-recommends pciutils + +ENTRYPOINT ["/maitred/maitred"] \ No newline at end of file diff --git a/containers/relay.Containerfile b/containers/relay.Containerfile index e9823d27..4c583bb9 100644 --- a/containers/relay.Containerfile +++ b/containers/relay.Containerfile @@ -13,6 +13,7 @@ WORKDIR /relay ENV VERBOSE=false ENV DEBUG=false ENV ENDPOINT_PORT=8088 +ENV MESH_PORT=8089 ENV WEBRTC_UDP_START=10000 ENV WEBRTC_UDP_END=20000 ENV STUN_SERVER="stun.l.google.com:19302" @@ -23,6 +24,7 @@ ENV TLS_CERT="" ENV TLS_KEY="" EXPOSE $ENDPOINT_PORT +EXPOSE $MESH_PORT EXPOSE $WEBRTC_UDP_START-$WEBRTC_UDP_END/udp EXPOSE $WEBRTC_UDP_MUX/udp diff --git a/infra/api.ts b/infra/api.ts index d22ee773..fac9d185 100644 --- a/infra/api.ts +++ b/infra/api.ts @@ -21,6 +21,12 @@ export const urls = new sst.Linkable("Urls", { export const apiFunction = new sst.aws.Function("ApiFn", { vpc, handler: "packages/functions/src/api/index.handler", + permissions: [ + { + actions: ["iot:*"], + resources: ["*"], + }, + ], link: [ bus, urls, diff --git a/infra/realtime.ts b/infra/realtime.ts new file mode 100644 index 00000000..731423b2 --- /dev/null +++ b/infra/realtime.ts @@ -0,0 +1,9 @@ +import { urls } from "./api"; +import { postgres } from "./postgres"; + +export const device = new sst.aws.Realtime("Realtime", { + authorizer: { + link: [urls, postgres], + handler: "./packages/functions/src/realtime/authorizer.handler" + } +}) \ No newline at end of file diff --git a/packages/core/migrations/0002_tiny_toad_men.sql b/packages/core/migrations/0002_tiny_toad_men.sql new file mode 100644 index 00000000..3e0ab3a3 --- /dev/null +++ b/packages/core/migrations/0002_tiny_toad_men.sql @@ -0,0 +1,13 @@ +CREATE TABLE "machine" ( + "id" char(30) PRIMARY KEY NOT NULL, + "time_created" timestamp with time zone DEFAULT now() NOT NULL, + "time_updated" timestamp with time zone DEFAULT now() NOT NULL, + "time_deleted" timestamp with time zone, + "country" text NOT NULL, + "timezone" text NOT NULL, + "location" "point" NOT NULL, + "fingerprint" varchar(32) NOT NULL, + "country_code" varchar(2) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "machine_fingerprint" ON "machine" USING btree ("fingerprint"); \ No newline at end of file diff --git a/packages/core/migrations/meta/0002_snapshot.json b/packages/core/migrations/meta/0002_snapshot.json new file mode 100644 index 00000000..a6040745 --- /dev/null +++ b/packages/core/migrations/meta/0002_snapshot.json @@ -0,0 +1,379 @@ +{ + "id": "aa60489b-b4e2-4a69-aee7-16e050d02ef9", + "prevId": "6f428226-b5d8-4182-a676-d04f842f9ded", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.machine": { + "name": "machine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "point", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "country_code": { + "name": "country_code", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "machine_fingerprint": { + "name": "machine_fingerprint", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "email_global": { + "name": "email_global", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_email": { + "name": "member_email", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "member_team_id_id_pk": { + "name": "member_team_id_id_pk", + "columns": [ + "team_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plan_type": { + "name": "plan_type", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "discriminator": { + "name": "discriminator", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "polar_customer_id": { + "name": "polar_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "flags": { + "name": "flags", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'::json" + } + }, + "indexes": { + "user_email": { + "name": "user_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_polar_customer_id_unique": { + "name": "user_polar_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "polar_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/migrations/meta/_journal.json b/packages/core/migrations/meta/_journal.json index 3b894840..787b0138 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1741955636085, "tag": "0001_nifty_sauron", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1743028682022, + "tag": "0002_tiny_toad_men", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json index a537624c..42588258 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,6 +25,7 @@ "zod-openapi": "^4.2.2" }, "dependencies": { + "@aws-sdk/client-iot-data-plane": "^3.758.0", "@aws-sdk/client-rds-data": "^3.758.0", "@aws-sdk/client-sesv2": "^3.753.0", "@instantdb/admin": "^0.17.7", diff --git a/packages/core/src/actor.ts b/packages/core/src/actor.ts index f706f65a..5a9f5995 100644 --- a/packages/core/src/actor.ts +++ b/packages/core/src/actor.ts @@ -37,15 +37,25 @@ export const SystemActor = z.object({ }); export type SystemActor = z.infer; +export const MachineActor = z.object({ + type: z.literal("machine"), + properties: z.object({ + fingerprint: z.string(), + machineID: z.string(), + }), +}); +export type MachineActor = z.infer; + export const Actor = z.discriminatedUnion("type", [ MemberActor, UserActor, PublicActor, SystemActor, + MachineActor ]); export type Actor = z.infer; -const ActorContext = createContext("actor"); +export const ActorContext = createContext("actor"); export const useActor = ActorContext.use; export const withActor = ActorContext.with; @@ -74,6 +84,12 @@ export function useTeam() { throw new Error(`Expected actor to have teamID`); } +export function useMachine() { + const actor = useActor(); + if ("machineID" in actor.properties) return actor.properties.fingerprint; + throw new Error(`Expected actor to have fingerprint`); +} + export async function assertUserFlag(flag: keyof UserFlags) { return useTransaction((tx) => tx diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index 9a4942a7..4c1ece47 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -31,4 +31,13 @@ export module Examples { timeSeen: new Date("2025-02-23T13:39:52.249Z"), } + export const Machine = { + id: Id("machine"), + country: "Kenya", + countryCode: "KE", + timezone: "Africa/Nairobi", + location: { latitude: 36.81550, longitude: -1.28410 }, + fingerprint: "fc27f428f9ca47d4b41b707ae0c62090", + } + } \ No newline at end of file diff --git a/packages/core/src/machine/index.ts b/packages/core/src/machine/index.ts new file mode 100644 index 00000000..682f1b9c --- /dev/null +++ b/packages/core/src/machine/index.ts @@ -0,0 +1,138 @@ +import { z } from "zod"; +import { Common } from "../common"; +import { createID, fn } from "../utils"; +import { Examples } from "../examples"; +import { machineTable } from "./machine.sql"; +import { getTableColumns, eq, sql, and, isNull } from "../drizzle"; +import { createTransaction, useTransaction } from "../drizzle/transaction"; + +export module Machine { + export const Info = z + .object({ + id: z.string().openapi({ + description: Common.IdDescription, + example: Examples.Machine.id, + }), + country: z.string().openapi({ + description: "The fullname of the country this machine is running in", + example: Examples.Machine.country + }), + fingerprint: z.string().openapi({ + description: "The fingerprint of this machine, deduced from the host machine's machine id - /etc/machine-id", + example: Examples.Machine.fingerprint + }), + location: z.object({ longitude: z.number(), latitude: z.number() }).openapi({ + description: "This is the 2d location of this machine, they might not be accurate", + example: Examples.Machine.location + }), + countryCode: z.string().openapi({ + description: "This is the 2 character country code of the country this machine [ISO 3166-1 alpha-2] ", + example: Examples.Machine.countryCode + }), + timezone: z.string().openapi({ + description: "The IANA timezone formatted string of the timezone of the location where the machine is running", + example: Examples.Machine.timezone + }) + }) + .openapi({ + ref: "Machine", + description: "Represents a hosted or BYOG machine connected to Nestri", + example: Examples.Machine, + }); + + export type Info = z.infer; + + export const create = fn(Info.partial({ id: true }), async (input) => + createTransaction(async (tx) => { + const id = input.id ?? createID("machine"); + await tx.insert(machineTable).values({ + id, + country: input.country, + timezone: input.timezone, + fingerprint: input.fingerprint, + countryCode: input.countryCode, + location: { x: input.location.longitude, y: input.location.latitude }, + }) + + // await afterTx(() => + // bus.publish(Resource.Bus, Events.Created, { + // teamID: id, + // }), + // ); + return id; + }) + ) + + export const list = fn(z.void(), async () => + useTransaction(async (tx) => + tx + .select() + .from(machineTable) + .where(isNull(machineTable.timeDeleted)) + .then((rows) => rows.map(serialize)) + ) + ) + + export const fromID = fn(Info.shape.id, async (id) => + useTransaction(async (tx) => + tx + .select() + .from(machineTable) + .where(and(eq(machineTable.id, id), isNull(machineTable.timeDeleted))) + .then((rows) => rows.map(serialize).at(0)) + ) + ) + + export const fromFingerprint = fn(Info.shape.fingerprint, async (fingerprint) => + useTransaction(async (tx) => + tx + .select() + .from(machineTable) + .where(and(eq(machineTable.fingerprint, fingerprint), isNull(machineTable.timeDeleted))) + .execute() + .then((rows) => rows.map(serialize).at(0)) + ) + ) + + export const remove = fn(Info.shape.id, (id) => + useTransaction(async (tx) => { + await tx + .update(machineTable) + .set({ + timeDeleted: sql`now()`, + }) + .where(and(eq(machineTable.id, id))) + .execute(); + return id; + }), + ); + + export const fromLocation = fn(Info.shape.location, async (location) => + useTransaction(async (tx) => { + const sqlDistance = sql`location <-> point(${location.longitude}, ${location.latitude})`; + return tx + .select({ + ...getTableColumns(machineTable), + distance: sql`round((${sqlDistance})::numeric, 2)` + }) + .from(machineTable) + .where(isNull(machineTable.timeDeleted)) //Should have a status update + .orderBy(sqlDistance) + .limit(3) + .then((rows) => rows.map(serialize)) + }) + ) + + export function serialize( + input: typeof machineTable.$inferSelect, + ): z.infer { + return { + id: input.id, + country: input.country, + timezone: input.timezone, + fingerprint: input.fingerprint, + countryCode: input.countryCode, + location: { latitude: input.location.y, longitude: input.location.x }, + }; + } +} \ No newline at end of file diff --git a/packages/core/src/machine/machine.sql.ts b/packages/core/src/machine/machine.sql.ts new file mode 100644 index 00000000..c295ff01 --- /dev/null +++ b/packages/core/src/machine/machine.sql.ts @@ -0,0 +1,37 @@ +import { } from "drizzle-orm/postgres-js"; +import { timestamps, id } from "../drizzle/types"; +import { + text, + varchar, + pgTable, + uniqueIndex, + point, +} from "drizzle-orm/pg-core"; + +export const machineTable = pgTable( + "machine", + { + ...id, + ...timestamps, + country: text('country').notNull(), + timezone: text('timezone').notNull(), + location: point('location', { mode: 'xy' }).notNull(), + fingerprint: varchar('fingerprint', { length: 32 }).notNull(), + countryCode: varchar('country_code', { length: 2 }).notNull(), + // provider: text("provider").notNull(), + // gpuType: text("gpu_type").notNull(), + // storage: numeric("storage").notNull(), + // ipaddress: text("ipaddress").notNull(), + // gpuNumber: integer("gpu_number").notNull(), + // computePrice: numeric("compute_price").notNull(), + // driverVersion: integer("driver_version").notNull(), + // operatingSystem: text("operating_system").notNull(), + // fingerprint: varchar("fingerprint", { length: 32 }).notNull(), + // externalID: varchar("external_id", { length: 255 }).notNull(), + // cudaVersion: numeric("cuda_version", { precision: 4, scale: 2 }).notNull(), + }, + (table) => [ + // uniqueIndex("external_id").on(table.externalID), + uniqueIndex("machine_fingerprint").on(table.fingerprint) + ], +); \ No newline at end of file diff --git a/packages/core/src/member/index.ts b/packages/core/src/member/index.ts index 905625fd..7bad1e05 100644 --- a/packages/core/src/member/index.ts +++ b/packages/core/src/member/index.ts @@ -78,16 +78,16 @@ export module Member { }), ); - export const remove = fn(Info.shape.id, (input) => + export const remove = fn(Info.shape.id, (id) => useTransaction(async (tx) => { await tx .update(memberTable) .set({ timeDeleted: sql`now()`, }) - .where(and(eq(memberTable.id, input), eq(memberTable.teamID, useTeam()))) + .where(and(eq(memberTable.id, id), eq(memberTable.teamID, useTeam()))) .execute(); - return input; + return id; }), ); @@ -98,11 +98,10 @@ export module Member { .from(memberTable) .where(and(eq(memberTable.email, email), isNull(memberTable.timeDeleted))) .orderBy(asc(memberTable.timeCreated)) - .then((rows) => rows.map(serialize)) - .then((rows) => rows.at(0)) - ), + .then((rows) => rows.map(serialize).at(0)) + ) ) - + export const fromID = fn(z.string(), async (id) => useTransaction(async (tx) => tx @@ -110,8 +109,7 @@ export module Member { .from(memberTable) .where(and(eq(memberTable.id, id), isNull(memberTable.timeDeleted))) .orderBy(asc(memberTable.timeCreated)) - .then((rows) => rows.map(serialize)) - .then((rows) => rows.at(0)) + .then((rows) => rows.map(serialize).at(0)) ), ) diff --git a/packages/core/src/realtime/index.ts b/packages/core/src/realtime/index.ts new file mode 100644 index 00000000..25771731 --- /dev/null +++ b/packages/core/src/realtime/index.ts @@ -0,0 +1,24 @@ +import { + IoTDataPlaneClient, + PublishCommand, +} from "@aws-sdk/client-iot-data-plane"; +import {useMachine} from "../actor"; +import {Resource} from "sst"; + +export module Realtime { + const client = new IoTDataPlaneClient({}); + + export async function publish(message: any, subTopic?: string) { + const fingerprint = useMachine(); + let topic = `${Resource.App.name}/${Resource.App.stage}/${fingerprint}/`; + if (subTopic) + topic = `${topic}${subTopic}`; + + await client.send( + new PublishCommand({ + payload: Buffer.from(JSON.stringify(message)), + topic: topic, + }) + ); + } +} \ No newline at end of file diff --git a/packages/core/src/task/task.sql.todo b/packages/core/src/task/task.sql.todo new file mode 100644 index 00000000..7454c89d --- /dev/null +++ b/packages/core/src/task/task.sql.todo @@ -0,0 +1,20 @@ +import { id, timestamps } from "../drizzle/types"; +import { pgTable, uniqueIndex, varchar } from "drizzle-orm/pg-core"; + +//This represents a task created on a machine for running a game +//Add billing info here? +//Add who owns the task here +// Add the session ID here +//Add which machine owns this task + +export const taskTable = pgTable( + "task", + { + ...id, + ...timestamps, + fingerprint: varchar('fingerprint', { length: 32 }).notNull(), + }, + (table) => [ + uniqueIndex("task_fingerprint").on(table.fingerprint), + ], +); \ No newline at end of file diff --git a/packages/core/src/team/index.ts b/packages/core/src/team/index.ts index 52c6a09d..30751156 100644 --- a/packages/core/src/team/index.ts +++ b/packages/core/src/team/index.ts @@ -5,7 +5,7 @@ import { Common } from "../common"; import { Examples } from "../examples"; import { createEvent } from "../event"; import { createID, fn } from "../utils"; -import { and, eq, sql } from "../drizzle"; +import { and, eq, sql, isNull } from "../drizzle"; import { PlanType, teamTable } from "./team.sql"; import { assertActor, withActor } from "../actor"; import { memberTable } from "../member/member.sql"; @@ -111,6 +111,7 @@ export module Team { tx .select() .from(teamTable) + .where(isNull(teamTable.timeDeleted)) .execute() .then((rows) => rows.map(serialize)), ), @@ -121,10 +122,9 @@ export module Team { return tx .select() .from(teamTable) - .where(eq(teamTable.id, id)) + .where(and(eq(teamTable.id, id), isNull(teamTable.timeDeleted))) .execute() - .then((rows) => rows.map(serialize)) - .then((rows) => rows.at(0)); + .then((rows) => rows.map(serialize).at(0)) }), ); @@ -133,10 +133,9 @@ export module Team { return tx .select() .from(teamTable) - .where(eq(teamTable.slug, input)) + .where(and(eq(teamTable.slug, input), isNull(teamTable.timeDeleted))) .execute() - .then((rows) => rows.map(serialize)) - .then((rows) => rows.at(0)); + .then((rows) => rows.map(serialize).at(0)) }), ); diff --git a/packages/core/src/user/index.ts b/packages/core/src/user/index.ts index 2f83fb56..9c942e93 100644 --- a/packages/core/src/user/index.ts +++ b/packages/core/src/user/index.ts @@ -183,16 +183,16 @@ export module User { }; } - export const remove = fn(Info.shape.id, (input) => + export const remove = fn(Info.shape.id, (id) => useTransaction(async (tx) => { await tx .update(userTable) .set({ timeDeleted: sql`now()`, }) - .where(and(eq(userTable.id, input))) + .where(and(eq(userTable.id, id))) .execute(); - return input; + return id; }), ); diff --git a/packages/core/src/utils/id.ts b/packages/core/src/utils/id.ts index d79d8bc1..8b1ff651 100644 --- a/packages/core/src/utils/id.ts +++ b/packages/core/src/utils/id.ts @@ -3,7 +3,9 @@ import { ulid } from "ulid"; export const prefixes = { user: "usr", team: "tea", - member: "mbr" + task: "tsk", + machine: "mch", + member: "mbr", } as const; export function createID(prefix: keyof typeof prefixes): string { diff --git a/packages/functions/src/api/auth.ts b/packages/functions/src/api/auth.ts index 290cf6b9..2621436f 100644 --- a/packages/functions/src/api/auth.ts +++ b/packages/functions/src/api/auth.ts @@ -1,15 +1,20 @@ import { Resource } from "sst"; import { subjects } from "../subjects"; import { type MiddlewareHandler } from "hono"; +import { VisibleError } from "@nestri/core/error"; +import { ActorContext } from "@nestri/core/actor"; +import { HTTPException } from "hono/http-exception"; import { useActor, withActor } from "@nestri/core/actor"; import { createClient } from "@openauthjs/openauth/client"; import { ErrorCodes, VisibleError } from "@nestri/core/error"; const client = createClient({ - issuer: Resource.Urls.auth, clientID: "api", + issuer: Resource.Urls.auth }); + + export const notPublic: MiddlewareHandler = async (c, next) => { const actor = useActor(); if (actor.type === "public") @@ -42,16 +47,22 @@ export const auth: MiddlewareHandler = async (c, next) => { "Invalid bearer token", ); } + + if (result.subject.type === "machine") { + console.log("machine detected") + return withActor(result.subject, next); + } - if (result.subject.type === "user") { - const teamID = c.req.header("x-nestri-team") //|| c.req.query("teamID"); - if (!teamID) return withActor(result.subject, next); - // const email = result.subject.properties.email; - return withActor( - { - type: "system", - properties: { - teamID, + if (result.subject.type === "user") { + const teamID = c.req.header("x-nestri-team") //|| c.req.query("teamID"); + if (!teamID) return withActor(result.subject, next); + // const email = result.subject.properties.email; + return withActor( + { + type: "system", + properties: { + teamID, + }, }, }, next @@ -71,4 +82,5 @@ export const auth: MiddlewareHandler = async (c, next) => { // }, ); } + return ActorContext.with({ type: "public", properties: {} }, next); }; \ No newline at end of file diff --git a/packages/functions/src/api/index.ts b/packages/functions/src/api/index.ts index 0229b439..24cd47d9 100644 --- a/packages/functions/src/api/index.ts +++ b/packages/functions/src/api/index.ts @@ -4,6 +4,7 @@ import { auth } from "./auth"; import { TeamApi } from "./team"; import { logger } from "hono/logger"; import { AccountApi } from "./account"; +import { MachineApi } from "./machine"; import { openAPISpecs } from "hono-openapi"; import { HTTPException } from "hono/http-exception"; import { handle, streamHandle } from "hono/aws-lambda"; @@ -22,6 +23,7 @@ const routes = app .get("/", (c) => c.text("Hello World!")) .route("/team", TeamApi.route) .route("/account", AccountApi.route) + .route("/machine", MachineApi.route) .onError((error, c) => { console.warn(error); if (error instanceof VisibleError) { @@ -38,7 +40,7 @@ const routes = app code: ErrorCodes.Validation.INVALID_PARAMETER, message: "Invalid request", }, - 400, + error.status, ); } console.error("unhandled error:", error); diff --git a/packages/functions/src/api/machine.ts b/packages/functions/src/api/machine.ts new file mode 100644 index 00000000..5c095cc8 --- /dev/null +++ b/packages/functions/src/api/machine.ts @@ -0,0 +1,224 @@ +import {z} from "zod" +import {Hono} from "hono"; +import {notPublic} from "./auth"; +import {Result} from "../common"; +import {describeRoute} from "hono-openapi"; +import {assertActor} from "@nestri/core/actor"; +import {Realtime} from "@nestri/core/realtime/index"; +import {validator} from "hono-openapi/zod"; +import {CreateMessageSchema, StartMessageSchema, StopMessageSchema} from "./messages.ts"; + +export module MachineApi { + export const route = new Hono() + .use(notPublic) + .post("/", + describeRoute({ + tags: ["Machine"], + summary: "Send messages to the machine", + description: "Send messages directly to the machine", + responses: { + 200: { + content: { + "application/json": { + schema: Result( + z.literal("ok") + ), + }, + }, + description: "Successfully sent the message to Maitred" + }, + // 404: { + // content: { + // "application/json": { + // schema: resolver(z.object({ error: z.string() })), + // }, + // }, + // description: "This account does not exist", + // }, + } + }), + validator( + "json", + z.any() + ), + async (c) => { + const actor = assertActor("machine"); + console.log("actor.id", actor.properties.machineID) + + await Realtime.publish(c.req.valid("json")) + + return c.json({ + data: "ok" + }, 200); + }, + ) + .post("/:machineID/create", + describeRoute({ + tags: ["Machine"], + summary: "Request to create a container for a specific machine", + description: "Publishes a message to create a container via MQTT for the given machine ID", + responses: { + 200: { + content: { + "application/json": { + schema: Result( + z.object({ + message: z.literal("create request sent"), + }) + ), + }, + }, + description: "Create request successfully sent to MQTT", + }, + 400: { + content: { + "application/json": { + schema: Result( + z.object({error: z.string()}) + ), + }, + }, + description: "Failed to publish create request", + }, + }, + }), + validator("json", CreateMessageSchema.shape.payload.optional()), // No payload required for create + async (c) => { + const actor = assertActor("machine"); + const body = c.req.valid("json"); + + const message = { + type: "create" as const, + payload: body || {}, // Empty payload if none provided + }; + + try { + await Realtime.publish(message, "create"); + console.log("Published create request to"); + } catch (error) { + console.error("Failed to publish to MQTT:", error); + return c.json({error: "Failed to send create request"}, 400); + } + + return c.json({ + data: { + message: "create request sent", + }, + }, 200); + } + ) + .post("/:machineID/start", + describeRoute({ + tags: ["Machine"], + summary: "Request to start a container for a specific machine", + description: "Publishes a message to start a container via MQTT for the given machine ID", + responses: { + 200: { + content: { + "application/json": { + schema: Result( + z.object({ + message: z.literal("start request sent"), + }) + ), + }, + }, + description: "Start request successfully sent to MQTT", + }, + 400: { + content: { + "application/json": { + schema: Result( + z.object({error: z.string()}) + ), + }, + }, + description: "Failed to publish start request", + }, + }, + }), + validator("json", StartMessageSchema.shape.payload), // Use the payload schema + async (c) => { + const actor = assertActor("machine"); + const body = c.req.valid("json"); + + const message = { + type: "start" as const, + payload: { + container_id: body.container_id, + }, + }; + + try { + await Realtime.publish(message, "start"); + console.log("Published start request"); + } catch (error) { + console.error("Failed to publish to MQTT:", error); + return c.json({error: "Failed to send start request"}, 400); + } + + return c.json({ + data: { + message: "start request sent", + }, + }, 200); + } + ) + .post("/:machineID/stop", + describeRoute({ + tags: ["Machine"], + summary: "Request to stop a container for a specific machine", + description: "Publishes a message to stop a container via MQTT for the given machine ID", + responses: { + 200: { + content: { + "application/json": { + schema: Result( + z.object({ + message: z.literal("stop request sent"), + }) + ), + }, + }, + description: "Stop request successfully sent to MQTT", + }, + 400: { + content: { + "application/json": { + schema: Result( + z.object({error: z.string()}) + ), + }, + }, + description: "Failed to publish start request", + }, + }, + }), + validator("json", StopMessageSchema.shape.payload), // Use the payload schema + async (c) => { + const actor = assertActor("machine"); + const body = c.req.valid("json"); + + const message = { + type: "stop" as const, + payload: { + container_id: body.container_id, + }, + }; + + try { + await Realtime.publish(message, "stop"); + console.log("Published stop request"); + } catch (error) { + console.error("Failed to publish to MQTT:", error); + return c.json({error: "Failed to send stop request"}, 400); + } + + return c.json({ + data: { + message: "stop request sent", + }, + }, 200); + } + ) +} \ No newline at end of file diff --git a/packages/functions/src/api/messages.ts b/packages/functions/src/api/messages.ts new file mode 100644 index 00000000..992cd9a9 --- /dev/null +++ b/packages/functions/src/api/messages.ts @@ -0,0 +1,54 @@ +import { z } from "zod" + +// Base message interface +export interface BaseMessage { + type: string; // e.g., "start", "stop", "status" + payload: Record; // Generic payload, refined by specific types +} + +// Specific message types +export interface StartMessage extends BaseMessage { + type: "start"; + payload: { + container_id: string; + [key: string]: any; // Allow additional fields for future expansion + }; +} + +// Example future message type +export interface StopMessage extends BaseMessage { + type: "stop"; + payload: { + container_id: string; + [key: string]: any; + }; +} + +// Union type for all possible messages (expandable) +export type MachineMessage = StartMessage | StopMessage; // Add more types as needed + +// Zod schema for validation +export const BaseMessageSchema = z.object({ + type: z.string(), + payload: z.record(z.any()), +}); + +export const CreateMessageSchema = BaseMessageSchema.extend({ + type: z.literal("create"), +}); + +export const StartMessageSchema = BaseMessageSchema.extend({ + type: z.literal("start"), + payload: z.object({ + container_id: z.string(), + }).passthrough(), +}); + +export const StopMessageSchema = BaseMessageSchema.extend({ + type: z.literal("stop"), + payload: z.object({ + container_id: z.string(), + }).passthrough(), +}); + +export const MachineMessageSchema = z.union([StartMessageSchema, StopMessageSchema]); \ No newline at end of file diff --git a/packages/functions/src/auth.ts b/packages/functions/src/auth.ts index e36f8350..9b1e9895 100644 --- a/packages/functions/src/auth.ts +++ b/packages/functions/src/auth.ts @@ -9,6 +9,7 @@ import { User } from "@nestri/core/user/index" import { Email } from "@nestri/core/email/index"; import { handleDiscord, handleGithub } from "./utils"; import { GithubAdapter } from "./ui/adapters/github"; +import { Machine } from "@nestri/core/machine/index" import { DiscordAdapter } from "./ui/adapters/discord"; import { PasswordAdapter } from "./ui/adapters/password" import { type Provider } from "@openauthjs/openauth/provider/provider" @@ -22,10 +23,11 @@ type OauthUser = { avatar: any; username: any; } + const app = issuer({ select: Select({ providers: { - device: { + machine: { hide: true, }, }, @@ -73,29 +75,24 @@ const app = issuer({ }, }), ), - device: { - type: "device", + machine: { + type: "machine", async client(input) { if (input.clientSecret !== Resource.AuthFingerprintKey.value) { throw new Error("Invalid authorization token"); } - const teamSlug = input.params.team; - if (!teamSlug) { - throw new Error("Team slug is required"); - } - const hostname = input.params.hostname; - if (!hostname) { + const fingerprint = input.params.fingerprint; + if (!fingerprint) { throw new Error("Hostname is required"); } return { - hostname, - teamSlug + fingerprint, }; }, init() { } - } as Provider<{ teamSlug: string; hostname: string; }>, + } as Provider<{ fingerprint: string; }>, }, allow: async (input) => { const url = new URL(input.redirectURI); @@ -104,20 +101,45 @@ const app = issuer({ if (hostname === "localhost") return true; return false; }, - success: async (ctx, value) => { - // if (value.provider === "device") { - // const team = await Teams.fromSlug(value.teamSlug) - // console.log("team", team) - // console.log("teamSlug", value.teamSlug) - // if (team) { - // await Instances.create({ hostname: value.hostname, teamID: team.id }) + success: async (ctx, value, req) => { + if (value.provider === "machine") { + const countryCode = req.headers.get('CloudFront-Viewer-Country') || 'Unknown' + const country = req.headers.get('CloudFront-Viewer-Country-Name') || 'Unknown' + const latitude = Number(req.headers.get('CloudFront-Viewer-Latitude')) || 0 + const longitude = Number(req.headers.get('CloudFront-Viewer-Longitude')) || 0 + const timezone = req.headers.get('CloudFront-Viewer-Time-Zone') || 'Unknown' + const fingerprint = value.fingerprint - // return await ctx.subject("device", { - // teamSlug: value.teamSlug, - // hostname: value.hostname, - // }) - // } - // } + const existing = await Machine.fromFingerprint(fingerprint) + if (!existing) { + const machineID = await Machine.create({ + countryCode, + country, + fingerprint, + timezone, + location: { + latitude, + longitude + } + }) + return ctx.subject("machine", { + machineID, + fingerprint + }); + } + + return ctx.subject("machine", { + machineID: existing.id, + fingerprint + }); + } + + //TODO: This works, so use this while registering the task + // console.log("country_code", req.headers.get('CloudFront-Viewer-Country')) + // console.log("country_name", req.headers.get('CloudFront-Viewer-Country-Name')) + // console.log("latitude", req.headers.get('CloudFront-Viewer-Latitude')) + // console.log("longitude", req.headers.get('CloudFront-Viewer-Longitude')) + // console.log("timezone", req.headers.get('CloudFront-Viewer-Time-Zone')) if (value.provider === "password") { const email = value.email diff --git a/packages/functions/src/party/authorizer.ts b/packages/functions/src/party/authorizer.ts deleted file mode 100644 index 1d566343..00000000 --- a/packages/functions/src/party/authorizer.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Resource } from "sst"; -import { subjects } from "../subjects"; -import { realtime } from "sst/aws/realtime"; -import { createClient } from "@openauthjs/openauth/client"; - -export const handler = realtime.authorizer(async (token) => { - //TODO: Use the following criteria for a topic - team-slug/container-id (container ids are not unique globally) - //TODO: Allow the authorizer to subscriber/publisher to listen on - team-slug topics only (as the container will listen on the team-slug/container-id topic to be specific) - // Return the topics to subscribe and publish - - const client = createClient({ - clientID: "api", - issuer: Resource.Urls.auth - }); - - const result = await client.verify(subjects, token); - - if (result.err) { - console.log("error", result.err) - return { - subscribe: [], - publish: [], - }; - } - - if (result.subject.type != "device") { - return { - subscribe: [], - publish: [], - }; - } - - return { - //It can publish and listen to other instances under this team - subscribe: [`${Resource.App.name}/${Resource.App.stage}/${result.subject.properties.teamSlug}/*`], - publish: [`${Resource.App.name}/${Resource.App.stage}/${result.subject.properties.teamSlug}/*`], - }; -}); \ No newline at end of file diff --git a/packages/functions/src/realtime/authorizer.ts b/packages/functions/src/realtime/authorizer.ts new file mode 100644 index 00000000..ba6a8e62 --- /dev/null +++ b/packages/functions/src/realtime/authorizer.ts @@ -0,0 +1,40 @@ +import { Resource } from "sst"; +import { subjects } from "../subjects"; +import { realtime } from "sst/aws/realtime"; +import { createClient } from "@openauthjs/openauth/client"; + +const client = createClient({ + clientID: "realtime", + issuer: Resource.Urls.auth +}); + +export const handler = realtime.authorizer(async (token) => { + + console.log("token", token) + + const result = await client.verify(subjects, token); + + if (result.err) { + console.log("error", result.err) + return { + subscribe: [], + publish: [], + }; + } + + if (result.subject.type == "machine") { + console.log("machineID", result.subject.properties.machineID) + console.log("fingerprint", result.subject.properties.fingerprint) + + return { + //It can publish and listen to other instances under this machineID + publish: [`${Resource.App.name}/${Resource.App.stage}/${result.subject.properties.fingerprint}/*`], + subscribe: [`${Resource.App.name}/${Resource.App.stage}/${result.subject.properties.fingerprint}/*`], + }; + } + + return { + publish: [], + subscribe: [], + }; +}); \ No newline at end of file diff --git a/packages/functions/src/party/create.ts b/packages/functions/src/realtime/create.ts similarity index 100% rename from packages/functions/src/party/create.ts rename to packages/functions/src/realtime/create.ts diff --git a/packages/functions/src/party/subscriber.ts b/packages/functions/src/realtime/subscriber.ts similarity index 100% rename from packages/functions/src/party/subscriber.ts rename to packages/functions/src/realtime/subscriber.ts diff --git a/packages/functions/src/subjects.ts b/packages/functions/src/subjects.ts index 3ec2ddaf..4c6e9c88 100644 --- a/packages/functions/src/subjects.ts +++ b/packages/functions/src/subjects.ts @@ -1,5 +1,4 @@ import * as v from "valibot" -import { Subscription } from "./type" import { createSubjects } from "@openauthjs/openauth/subject" export const subjects = createSubjects({ @@ -7,8 +6,8 @@ export const subjects = createSubjects({ email: v.string(), userID: v.string(), }), - // device: v.object({ - // teamSlug: v.string(), - // hostname: v.string(), - // }) + machine: v.object({ + fingerprint: v.string(), + machineID: v.string(), + }) }) \ No newline at end of file diff --git a/packages/functions/sst-env.d.ts b/packages/functions/sst-env.d.ts index c1463f67..4f8d03e1 100644 --- a/packages/functions/sst-env.d.ts +++ b/packages/functions/sst-env.d.ts @@ -68,6 +68,11 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "Realtime": { + "authorizer": string + "endpoint": string + "type": "sst.aws.Realtime" + } "Steam": { "service": string "type": "sst.aws.Service" diff --git a/packages/input/src/webrtc-stream.ts b/packages/input/src/webrtc-stream.ts index 333ab759..406bee30 100644 --- a/packages/input/src/webrtc-stream.ts +++ b/packages/input/src/webrtc-stream.ts @@ -14,10 +14,10 @@ import { export class WebRTCStream { private _ws: WebSocket | undefined = undefined; private _pc: RTCPeerConnection | undefined = undefined; - private _mediaStream: MediaStream | undefined = undefined; + private _audioTrack: MediaStreamTrack | undefined = undefined; + private _videoTrack: MediaStreamTrack | undefined = undefined; private _dataChannel: RTCDataChannel | undefined = undefined; private _onConnected: ((stream: MediaStream | null) => void) | undefined = undefined; - private _connectionTimeout: number = 7000; private _connectionTimer: NodeJS.Timeout | NodeJS.Timer | undefined = undefined; private _serverURL: string | undefined = undefined; private _roomName: string | undefined = undefined; @@ -163,12 +163,13 @@ export class WebRTCStream { ], }); - // Start connection timeout - this._startConnectionTimer(); - this._pc.ontrack = (e) => { console.log("Track received: ", e.track); - this._mediaStream = e.streams[e.streams.length - 1]; + if (e.track.kind === "audio") + this._audioTrack = e.track; + else if (e.track.kind === "video") + this._videoTrack = e.track; + this._checkConnectionState(); }; @@ -184,6 +185,7 @@ export class WebRTCStream { this._pc.onicegatheringstatechange = () => { console.log("ICE gathering state changed to: ", this._pc!.iceGatheringState); + this._checkConnectionState(); }; this._pc.onicecandidate = (e) => { @@ -208,16 +210,31 @@ export class WebRTCStream { console.log("Checking connection state:", { connectionState: this._pc.connectionState, iceConnectionState: this._pc.iceConnectionState, - hasMediaStream: !!this._mediaStream, + hasAudioTrack: !!this._audioTrack, + hasVideoTrack: !!this._videoTrack, isConnected: this._isConnected }); - if (this._pc.connectionState === "connected" && this._mediaStream) { + if (this._pc.connectionState === "connected" && this._audioTrack !== undefined && this._videoTrack !== undefined) { this._clearConnectionTimer(); - if (!this._isConnected) { // Only trigger callback if not already connected + if (!this._isConnected) { + // Only trigger callback if not already connected this._isConnected = true; - if (this._onConnected) { - this._onConnected(this._mediaStream); + if (this._onConnected !== undefined) { + this._onConnected(new MediaStream([this._audioTrack, this._videoTrack])); + + // Continuously set low-latency target + this._pc.getReceivers().forEach((receiver: RTCRtpReceiver) => { + let intervalLoop = setInterval(async () => { + if (receiver.track.readyState !== "live" || receiver.transport.state !== "connected") { + clearInterval(intervalLoop); + return; + } else { + // @ts-ignore + receiver.jitterBufferTarget = receiver.jitterBufferDelayHint = receiver.playoutDelayHint = 0; + } + }, 15); + }); } } @@ -247,14 +264,6 @@ export class WebRTCStream { } } - private _startConnectionTimer() { - this._clearConnectionTimer(); - this._connectionTimer = setTimeout(() => { - console.log("Connection timeout reached"); - this._handleConnectionFailure(); - }, this._connectionTimeout); - } - private _cleanupPeerConnection() { if (this._pc) { try { @@ -265,13 +274,17 @@ export class WebRTCStream { this._pc = undefined; } - if (this._mediaStream) { + if (this._audioTrack || this._videoTrack) { try { - this._mediaStream.getTracks().forEach(track => track.stop()); + if (this._audioTrack) + this._audioTrack.stop(); + if (this._videoTrack) + this._videoTrack.stop(); } catch (err) { console.error("Error stopping media tracks:", err); } - this._mediaStream = undefined; + this._audioTrack = undefined; + this._videoTrack = undefined; } if (this._dataChannel) { @@ -301,11 +314,10 @@ export class WebRTCStream { } private _gatherFrameRate() { - if (this._pc === undefined || this._mediaStream === undefined) + if (this._pc === undefined || this._videoTrack === undefined) return; const videoInfoPromise = new Promise<{ fps: number}>((resolve) => { - const track = this._mediaStream!.getVideoTracks()[0]; // Keep trying to get fps until it's found const interval = setInterval(async () => { if (this._pc === undefined) { @@ -313,7 +325,7 @@ export class WebRTCStream { return; } - const stats = await this._pc!.getStats(track); + const stats = await this._pc!.getStats(this._videoTrack); stats.forEach((report) => { if (report.type === "inbound-rtp") { clearInterval(interval); diff --git a/packages/maitred/go.mod b/packages/maitred/go.mod index 3c278974..16c097c9 100644 --- a/packages/maitred/go.mod +++ b/packages/maitred/go.mod @@ -1,25 +1,39 @@ module nestri/maitred -go 1.23.3 +go 1.24 require ( - github.com/charmbracelet/log v0.4.0 + github.com/docker/docker v28.0.1+incompatible github.com/eclipse/paho.golang v0.22.0 github.com/oklog/ulid/v2 v2.1.0 ) require ( - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/lipgloss v0.10.0 // indirect - github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/Microsoft/go-winio v0.4.14 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/gorilla/websocket v1.5.3 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.18 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.15.2 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect - golang.org/x/net v0.33.0 // indirect - golang.org/x/sys v0.28.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect + go.opentelemetry.io/otel v1.34.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.34.0 // indirect + go.opentelemetry.io/otel/sdk v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.34.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/time v0.10.0 // indirect + gotest.tools/v3 v3.5.2 // indirect ) diff --git a/packages/maitred/go.sum b/packages/maitred/go.sum index 72a6e9b3..7721452f 100644 --- a/packages/maitred/go.sum +++ b/packages/maitred/go.sum @@ -1,49 +1,134 @@ -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= -github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= -github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= -github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= +github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0= +github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/eclipse/paho.golang v0.22.0 h1:JhhUngr8TBlyUZDZw/L6WVayPi9qmSmdWeki48i5AVE= github.com/eclipse/paho.golang v0.22.0/go.mod h1:9ZiYJ93iEfGRJri8tErNeStPKLXIGBHiqbHV74t5pqI= -github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= -github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 h1:BEj3SPM81McUZHYjRS5pEgNgnmzGJ5tRpU5krWnV8Bs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0/go.mod h1:9cKLGBDzI/F3NoHLQGm4ZrYdIHsvGt6ej6hUowxY0J4= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA= +google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:Ic02D47M+zbarjYYUlK57y316f2MoN0gjAwI3f2S95o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= +google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= +google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= +google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/packages/maitred/internal/auth/auth.go b/packages/maitred/internal/auth/auth.go new file mode 100644 index 00000000..d0027ddc --- /dev/null +++ b/packages/maitred/internal/auth/auth.go @@ -0,0 +1,45 @@ +package auth + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "nestri/maitred/internal/resource" + "net/http" + "net/url" +) + +type UserCredentials struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +func FetchUserToken(machineID string, resource *resource.Resource) (*UserCredentials, error) { + data := url.Values{} + data.Set("grant_type", "client_credentials") + data.Set("client_id", "maitred") + data.Set("client_secret", resource.AuthFingerprintKey.Value) + data.Set("fingerprint", machineID) + data.Set("provider", "machine") + resp, err := http.PostForm(resource.Auth.Url+"/token", data) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + err = Body.Close() + if err != nil { + slog.Error("Error closing body", "err", err) + } + }(resp.Body) + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to auth: " + string(body)) + } + credentials := UserCredentials{} + err = json.NewDecoder(resp.Body).Decode(&credentials) + if err != nil { + return nil, err + } + return &credentials, nil +} diff --git a/packages/maitred/internal/containers/containers.go b/packages/maitred/internal/containers/containers.go new file mode 100644 index 00000000..83a35567 --- /dev/null +++ b/packages/maitred/internal/containers/containers.go @@ -0,0 +1,38 @@ +package containers + +import ( + "context" + "fmt" +) + +// Container represents a container instance +type Container struct { + ID string + Name string + State string + Image string +} + +// ContainerEngine defines the common interface for differing container engines +type ContainerEngine interface { + Close() error + ListContainers(ctx context.Context) ([]Container, error) + ListContainersByImage(ctx context.Context, img string) ([]Container, error) + NewContainer(ctx context.Context, img string, envs []string) (string, error) + StartContainer(ctx context.Context, id string) error + StopContainer(ctx context.Context, id string) error + RemoveContainer(ctx context.Context, id string) error + InspectContainer(ctx context.Context, id string) (*Container, error) + PullImage(ctx context.Context, img string) error + Info(ctx context.Context) (string, error) + LogsContainer(ctx context.Context, id string) (string, error) +} + +func NewContainerEngine() (ContainerEngine, error) { + dockerEngine, err := NewDockerEngine() + if err == nil { + return dockerEngine, nil + } + + return nil, fmt.Errorf("failed to create container engine: %w", err) +} diff --git a/packages/maitred/internal/containers/docker.go b/packages/maitred/internal/containers/docker.go new file mode 100644 index 00000000..3ede30bd --- /dev/null +++ b/packages/maitred/internal/containers/docker.go @@ -0,0 +1,299 @@ +package containers + +import ( + "context" + "encoding/json" + "fmt" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/client" + "io" + "log/slog" + "strings" + "time" +) + +// DockerEngine implements the ContainerEngine interface for Docker / Docker compatible engines +type DockerEngine struct { + cli *client.Client +} + +func NewDockerEngine() (*DockerEngine, error) { + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return nil, fmt.Errorf("failed to create Docker client: %w", err) + } + return &DockerEngine{cli: cli}, nil +} + +func (d *DockerEngine) Close() error { + return d.cli.Close() +} + +func (d *DockerEngine) ListContainers(ctx context.Context) ([]Container, error) { + containerList, err := d.cli.ContainerList(ctx, container.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list containers: %w", err) + } + + var result []Container + for _, c := range containerList { + result = append(result, Container{ + ID: c.ID, + Name: strings.TrimPrefix(strings.Join(c.Names, ","), "/"), + State: c.State, + Image: c.Image, + }) + } + return result, nil +} + +func (d *DockerEngine) ListContainersByImage(ctx context.Context, img string) ([]Container, error) { + if len(img) <= 0 { + return nil, fmt.Errorf("image name cannot be empty") + } + + containerList, err := d.cli.ContainerList(ctx, container.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list containers: %w", err) + } + + var result []Container + for _, c := range containerList { + if c.Image == img { + result = append(result, Container{ + ID: c.ID, + Name: strings.TrimPrefix(strings.Join(c.Names, ","), "/"), + State: c.State, + Image: c.Image, + }) + } + } + return result, nil +} + +func (d *DockerEngine) NewContainer(ctx context.Context, img string, envs []string) (string, error) { + // Create a new container with the given image and environment variables + resp, err := d.cli.ContainerCreate(ctx, &container.Config{ + Image: img, + Env: envs, + }, &container.HostConfig{ + NetworkMode: "host", + }, nil, nil, "") + if err != nil { + return "", fmt.Errorf("failed to create container: %w", err) + } + + if len(resp.ID) <= 0 { + return "", fmt.Errorf("failed to create container, no ID returned") + } + + return resp.ID, nil +} + +func (d *DockerEngine) StartContainer(ctx context.Context, id string) error { + err := d.cli.ContainerStart(ctx, id, container.StartOptions{}) + if err != nil { + return fmt.Errorf("failed to start container: %w", err) + } + + // Wait for the container to start + if err = d.waitForContainer(ctx, id, "running"); err != nil { + return fmt.Errorf("container failed to reach running state: %w", err) + } + + return nil +} + +func (d *DockerEngine) StopContainer(ctx context.Context, id string) error { + // Waiter for the container to stop + respChan, errChan := d.cli.ContainerWait(ctx, id, container.WaitConditionNotRunning) + + // Stop the container + err := d.cli.ContainerStop(ctx, id, container.StopOptions{}) + if err != nil { + return fmt.Errorf("failed to stop container: %w", err) + } + + select { + case <-respChan: + // Container stopped successfully + break + case err = <-errChan: + if err != nil { + return fmt.Errorf("failed to wait for container to stop: %w", err) + } + case <-ctx.Done(): + return fmt.Errorf("context canceled while waiting for container to stop") + } + return nil +} + +func (d *DockerEngine) RemoveContainer(ctx context.Context, id string) error { + // Waiter for the container to be removed + respChan, errChan := d.cli.ContainerWait(ctx, id, container.WaitConditionRemoved) + + err := d.cli.ContainerRemove(ctx, id, container.RemoveOptions{}) + if err != nil { + return fmt.Errorf("failed to remove container: %w", err) + } + + select { + case <-respChan: + // Container removed successfully + break + case err = <-errChan: + if err != nil { + return fmt.Errorf("failed to wait for container to be removed: %w", err) + } + case <-ctx.Done(): + return fmt.Errorf("context canceled while waiting for container to stop") + } + return nil +} + +func (d *DockerEngine) InspectContainer(ctx context.Context, id string) (*Container, error) { + info, err := d.cli.ContainerInspect(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to inspect container: %w", err) + } + + return &Container{ + ID: info.ID, + Name: info.Name, + State: info.State.Status, + Image: info.Config.Image, + }, nil +} + +func (d *DockerEngine) PullImage(ctx context.Context, img string) error { + if len(img) <= 0 { + return fmt.Errorf("image name cannot be empty") + } + + slog.Info("Starting image pull", "image", img) + + reader, err := d.cli.ImagePull(ctx, img, image.PullOptions{}) + if err != nil { + return fmt.Errorf("failed to start image pull for %s: %w", img, err) + } + defer func(reader io.ReadCloser) { + err = reader.Close() + if err != nil { + slog.Warn("Failed to close reader", "err", err) + } + }(reader) + + // Parse the JSON stream for progress + decoder := json.NewDecoder(reader) + lastDownloadPercent := 0 + downloadTotals := make(map[string]int64) + downloadCurrents := make(map[string]int64) + + var msg struct { + ID string `json:"id"` + Status string `json:"status"` + ProgressDetail struct { + Current int64 `json:"current"` + Total int64 `json:"total"` + } `json:"progressDetail"` + } + + for { + err = decoder.Decode(&msg) + if err == io.EOF { + break // Pull completed + } + if err != nil { + return fmt.Errorf("error decoding pull response for %s: %w", img, err) + } + + // Skip if no progress details or ID + if msg.ID == "" || msg.ProgressDetail.Total == 0 { + continue + } + + if strings.Contains(strings.ToLower(msg.Status), "downloading") { + downloadTotals[msg.ID] = msg.ProgressDetail.Total + downloadCurrents[msg.ID] = msg.ProgressDetail.Current + var total, current int64 + for _, t := range downloadTotals { + total += t + } + for _, c := range downloadCurrents { + current += c + } + percent := int((float64(current) / float64(total)) * 100) + if percent >= lastDownloadPercent+10 && percent <= 100 { + slog.Info("Download progress", "image", img, "percent", percent) + lastDownloadPercent = percent - (percent % 10) + } + } + } + + slog.Info("Pulled image", "image", img) + + return nil +} + +func (d *DockerEngine) Info(ctx context.Context) (string, error) { + info, err := d.cli.Info(ctx) + if err != nil { + return "", fmt.Errorf("failed to get Docker info: %w", err) + } + + return fmt.Sprintf("Docker Engine Version: %s", info.ServerVersion), nil +} + +func (d *DockerEngine) LogsContainer(ctx context.Context, id string) (string, error) { + reader, err := d.cli.ContainerLogs(ctx, id, container.LogsOptions{ShowStdout: true, ShowStderr: true}) + if err != nil { + return "", fmt.Errorf("failed to get container logs: %w", err) + } + defer func(reader io.ReadCloser) { + err = reader.Close() + if err != nil { + slog.Warn("Failed to close reader", "err", err) + } + }(reader) + + logs, err := io.ReadAll(reader) + if err != nil { + return "", fmt.Errorf("failed to read container logs: %w", err) + } + + return string(logs), nil +} + +func (d *DockerEngine) waitForContainer(ctx context.Context, id, desiredState string) error { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + for { + // Inspect the container to get its current state + inspection, err := d.cli.ContainerInspect(ctx, id) + if err != nil { + return fmt.Errorf("failed to inspect container: %w", err) + } + + // Check the container's state + currentState := strings.ToLower(inspection.State.Status) + switch currentState { + case desiredState: + // Container is in the desired state (e.g., "running") + return nil + case "exited", "dead", "removing": + // Container failed or stopped unexpectedly, get logs and return error + logs, _ := d.LogsContainer(ctx, id) + return fmt.Errorf("container failed to reach %s state, logs: %s", desiredState, logs) + } + + // Wait before polling again + select { + case <-ctx.Done(): + return fmt.Errorf("timed out after 10s waiting for container to reach %s state", desiredState) + case <-time.After(1 * time.Second): + // Continue polling + } + } +} diff --git a/packages/maitred/internal/flags.go b/packages/maitred/internal/flags.go new file mode 100644 index 00000000..e206ece8 --- /dev/null +++ b/packages/maitred/internal/flags.go @@ -0,0 +1,70 @@ +package internal + +import ( + "flag" + "log/slog" + "os" + "strconv" +) + +var globalFlags *Flags + +type Flags struct { + Verbose bool // Log everything to console + Debug bool // Enable debug mode, implies Verbose - disables SST and MQTT connections + NoMonitor bool // Disable system monitoring +} + +func (flags *Flags) DebugLog() { + slog.Info("Maitred flags", + "verbose", flags.Verbose, + "debug", flags.Debug, + "no-monitor", flags.NoMonitor, + ) +} + +func getEnvAsInt(name string, defaultVal int) int { + valueStr := os.Getenv(name) + if value, err := strconv.Atoi(valueStr); err != nil { + return defaultVal + } else { + return value + } +} + +func getEnvAsBool(name string, defaultVal bool) bool { + valueStr := os.Getenv(name) + val, err := strconv.ParseBool(valueStr) + if err != nil { + return defaultVal + } + return val +} + +func getEnvAsString(name string, defaultVal string) string { + valueStr := os.Getenv(name) + if len(valueStr) == 0 { + return defaultVal + } + return valueStr +} + +func InitFlags() { + // Create Flags struct + globalFlags = &Flags{} + // Get flags + flag.BoolVar(&globalFlags.Verbose, "verbose", getEnvAsBool("VERBOSE", false), "Verbose mode") + flag.BoolVar(&globalFlags.Debug, "debug", getEnvAsBool("DEBUG", false), "Debug mode") + flag.BoolVar(&globalFlags.NoMonitor, "no-monitor", getEnvAsBool("NO_MONITOR", false), "Disable system monitoring") + // Parse flags + flag.Parse() + + // If debug is enabled, verbose is also enabled + if globalFlags.Debug { + globalFlags.Verbose = true + } +} + +func GetFlags() *Flags { + return globalFlags +} diff --git a/packages/maitred/internal/handler.go b/packages/maitred/internal/handler.go new file mode 100644 index 00000000..214a490d --- /dev/null +++ b/packages/maitred/internal/handler.go @@ -0,0 +1,48 @@ +package internal + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" +) + +type CustomHandler struct { + Handler slog.Handler +} + +func (h *CustomHandler) Enabled(_ context.Context, level slog.Level) bool { + return h.Handler.Enabled(nil, level) +} + +func (h *CustomHandler) Handle(_ context.Context, r slog.Record) error { + // Format the timestamp as "2006/01/02 15:04:05" + timestamp := r.Time.Format("2006/01/02 15:04:05") + // Convert level to uppercase string (e.g., "INFO") + level := strings.ToUpper(r.Level.String()) + // Build the message + msg := fmt.Sprintf("%s %s %s", timestamp, level, r.Message) + + // Handle additional attributes if they exist + var attrs []string + r.Attrs(func(a slog.Attr) bool { + attrs = append(attrs, fmt.Sprintf("%s=%v", a.Key, a.Value)) + return true + }) + if len(attrs) > 0 { + msg += " " + strings.Join(attrs, " ") + } + + // Write the formatted message to stdout + _, err := fmt.Fprintln(os.Stdout, msg) + return err +} + +func (h *CustomHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &CustomHandler{Handler: h.Handler.WithAttrs(attrs)} +} + +func (h *CustomHandler) WithGroup(name string) slog.Handler { + return &CustomHandler{Handler: h.Handler.WithGroup(name)} +} diff --git a/packages/maitred/internal/realtime/managed.go b/packages/maitred/internal/realtime/managed.go new file mode 100644 index 00000000..68bbf868 --- /dev/null +++ b/packages/maitred/internal/realtime/managed.go @@ -0,0 +1,366 @@ +package realtime + +import ( + "context" + "fmt" + "log/slog" + "nestri/maitred/internal" + "nestri/maitred/internal/containers" + "strings" + "sync" + "time" +) + +var ( + nestriRunnerImage = "ghcr.io/nestrilabs/nestri/runner:nightly" + nestriRelayImage = "ghcr.io/nestrilabs/nestri/relay:nightly" +) + +type ManagedContainerType int + +const ( + // Runner is the nestri runner container + Runner ManagedContainerType = iota + // Relay is the nestri relay container + Relay +) + +// ManagedContainer type with extra information fields +type ManagedContainer struct { + containers.Container + Type ManagedContainerType +} + +// managedContainers is a map of containers that are managed by us (maitred) +var ( + managedContainers = make(map[string]ManagedContainer) + managedContainersMutex sync.RWMutex +) + +// InitializeManager handles the initialization of the managed containers and pulls their latest images +func InitializeManager(ctx context.Context, ctrEngine containers.ContainerEngine) error { + // If debug, override the images + if internal.GetFlags().Debug { + nestriRunnerImage = "ghcr.io/datcaptainhorse/nestri-cachyos:latest-v3" + nestriRelayImage = "ghcr.io/datcaptainhorse/nestri-relay:latest" + } + + // Look for existing stopped runner containers and remove them + slog.Info("Checking and removing old runner containers") + oldRunners, err := ctrEngine.ListContainersByImage(ctx, nestriRunnerImage) + if err != nil { + return err + } + for _, c := range oldRunners { + // If running, stop first + if strings.Contains(strings.ToLower(c.State), "running") { + slog.Info("Stopping old runner container", "id", c.ID) + if err = ctrEngine.StopContainer(ctx, c.ID); err != nil { + return err + } + } + slog.Info("Removing old runner container", "id", c.ID) + if err = ctrEngine.RemoveContainer(ctx, c.ID); err != nil { + return err + } + } + + // Pull the runner image if not in debug mode + if !internal.GetFlags().Debug { + slog.Info("Pulling runner image", "image", nestriRunnerImage) + if err := ctrEngine.PullImage(ctx, nestriRunnerImage); err != nil { + return fmt.Errorf("failed to pull runner image: %w", err) + } + } + + // Look for existing stopped relay containers and remove them + slog.Info("Checking and removing old relay containers") + oldRelays, err := ctrEngine.ListContainersByImage(ctx, nestriRelayImage) + if err != nil { + return err + } + for _, c := range oldRelays { + // If running, stop first + if strings.Contains(strings.ToLower(c.State), "running") { + slog.Info("Stopping old relay container", "id", c.ID) + if err = ctrEngine.StopContainer(ctx, c.ID); err != nil { + return err + } + } + slog.Info("Removing old relay container", "id", c.ID) + if err = ctrEngine.RemoveContainer(ctx, c.ID); err != nil { + return err + } + } + + // Pull the relay image if not in debug mode + if !internal.GetFlags().Debug { + slog.Info("Pulling relay image", "image", nestriRelayImage) + if err := ctrEngine.PullImage(ctx, nestriRelayImage); err != nil { + return fmt.Errorf("failed to pull relay image: %w", err) + } + } + + return nil +} + +// CreateRunner creates a new runner image container +func CreateRunner(ctx context.Context, ctrEngine containers.ContainerEngine) (string, error) { + // For safety, limit to 4 runners + if CountRunners() >= 4 { + return "", fmt.Errorf("maximum number of runners reached") + } + + // Create the container + containerID, err := ctrEngine.NewContainer(ctx, nestriRunnerImage, nil) + if err != nil { + return "", err + } + + // Add the container to the managed list + managedContainersMutex.Lock() + defer managedContainersMutex.Unlock() + managedContainers[containerID] = ManagedContainer{ + Container: containers.Container{ + ID: containerID, + }, + Type: Runner, + } + + return containerID, nil +} + +// StartRunner starts a runner container, keeping track of it's state +func StartRunner(ctx context.Context, ctrEngine containers.ContainerEngine, id string) error { + // Verify the container is part of the managed list + managedContainersMutex.RLock() + if _, ok := managedContainers[id]; !ok { + managedContainersMutex.RUnlock() + return fmt.Errorf("container %s is not managed", id) + } + managedContainersMutex.RUnlock() + + // Start the container + if err := ctrEngine.StartContainer(ctx, id); err != nil { + return err + } + + // Check container status in background at 10 second intervals, if it exits print it's logs + go func() { + err := monitorContainer(ctx, ctrEngine, id) + if err != nil { + slog.Error("failure while monitoring runner container", "id", id, "err", err) + return + } + }() + + return nil +} + +// RemoveRunner removes a runner container +func RemoveRunner(ctx context.Context, ctrEngine containers.ContainerEngine, id string) error { + // Stop the container if it's running + if strings.Contains(strings.ToLower(managedContainers[id].State), "running") { + if err := ctrEngine.StopContainer(ctx, id); err != nil { + return err + } + } + + // Remove the container + if err := ctrEngine.RemoveContainer(ctx, id); err != nil { + return err + } + + // Remove the container from the managed list + managedContainersMutex.Lock() + defer managedContainersMutex.Unlock() + delete(managedContainers, id) + + return nil +} + +// ListRunners returns a list of all runner containers +func ListRunners() []ManagedContainer { + managedContainersMutex.Lock() + defer managedContainersMutex.Unlock() + var runners []ManagedContainer + for _, v := range managedContainers { + if v.Type == Runner { + runners = append(runners, v) + } + } + return runners +} + +// CountRunners returns the number of runner containers +func CountRunners() int { + return len(ListRunners()) +} + +// CreateRelay creates a new relay image container +func CreateRelay(ctx context.Context, ctrEngine containers.ContainerEngine) (string, error) { + // Limit to 1 relay + if CountRelays() >= 1 { + return "", fmt.Errorf("maximum number of relays reached") + } + + // TODO: Placeholder for control secret, should be generated at runtime + secretEnv := fmt.Sprintf("CONTROL_SECRET=%s", "1234") + + // Create the container + containerID, err := ctrEngine.NewContainer(ctx, nestriRelayImage, []string{secretEnv}) + if err != nil { + return "", err + } + + // Add the container to the managed list + managedContainersMutex.Lock() + defer managedContainersMutex.Unlock() + managedContainers[containerID] = ManagedContainer{ + Container: containers.Container{ + ID: containerID, + }, + Type: Relay, + } + + return containerID, nil +} + +// StartRelay starts a relay container, keeping track of it's state +func StartRelay(ctx context.Context, ctrEngine containers.ContainerEngine, id string) error { + // Verify the container is part of the managed list + managedContainersMutex.RLock() + if _, ok := managedContainers[id]; !ok { + managedContainersMutex.RUnlock() + return fmt.Errorf("container %s is not managed", id) + } + managedContainersMutex.RUnlock() + + // Start the container + if err := ctrEngine.StartContainer(ctx, id); err != nil { + return err + } + + // Check container status in background at 10 second intervals, if it exits print it's logs + go func() { + err := monitorContainer(ctx, ctrEngine, id) + if err != nil { + slog.Error("failure while monitoring relay container", "id", id, "err", err) + return + } + }() + + return nil +} + +// RemoveRelay removes a relay container +func RemoveRelay(ctx context.Context, ctrEngine containers.ContainerEngine, id string) error { + // Stop the container if it's running + if strings.Contains(strings.ToLower(managedContainers[id].State), "running") { + if err := ctrEngine.StopContainer(ctx, id); err != nil { + return err + } + } + + // Remove the container + if err := ctrEngine.RemoveContainer(ctx, id); err != nil { + return err + } + + // Remove the container from the managed list + managedContainersMutex.Lock() + defer managedContainersMutex.Unlock() + delete(managedContainers, id) + + return nil +} + +// ListRelays returns a list of all relay containers +func ListRelays() []ManagedContainer { + managedContainersMutex.Lock() + defer managedContainersMutex.Unlock() + var relays []ManagedContainer + for _, v := range managedContainers { + if v.Type == Relay { + relays = append(relays, v) + } + } + return relays +} + +// CountRelays returns the number of relay containers +func CountRelays() int { + return len(ListRelays()) +} + +// CleanupManaged stops and removes all managed containers +func CleanupManaged(ctx context.Context, ctrEngine containers.ContainerEngine) error { + if len(managedContainers) <= 0 { + return nil + } + + slog.Info("Cleaning up managed containers") + managedContainersMutex.Lock() + defer managedContainersMutex.Unlock() + for id := range managedContainers { + // If running, stop first + if strings.Contains(strings.ToLower(managedContainers[id].State), "running") { + slog.Info("Stopping managed container", "id", id) + if err := ctrEngine.StopContainer(ctx, id); err != nil { + return err + } + } + + // Remove the container + slog.Info("Removing managed container", "id", id) + if err := ctrEngine.RemoveContainer(ctx, id); err != nil { + return err + } + // Remove from the managed list + delete(managedContainers, id) + } + return nil +} + +func monitorContainer(ctx context.Context, ctrEngine containers.ContainerEngine, id string) error { + for { + select { + case <-ctx.Done(): + return nil + default: + // Check the container status + ctr, err := ctrEngine.InspectContainer(ctx, id) + if err != nil { + return fmt.Errorf("failed to inspect container: %w", err) + } + + // Update the container state in the managed list + managedContainersMutex.Lock() + managedContainers[id] = ManagedContainer{ + Container: containers.Container{ + ID: ctr.ID, + Name: ctr.Name, + State: ctr.State, + Image: ctr.Image, + }, + Type: Relay, + } + managedContainersMutex.Unlock() + + if !strings.Contains(strings.ToLower(ctr.State), "running") { + // Container is not running, print logs + logs, err := ctrEngine.LogsContainer(ctx, id) + if err != nil { + return fmt.Errorf("failed to get container logs: %w", err) + } + return fmt.Errorf("container %s stopped running: %s", id, logs) + } + } + // Sleep for 10 seconds + select { + case <-ctx.Done(): + return nil + case <-time.After(10 * time.Second): + } + } +} diff --git a/packages/maitred/internal/realtime/messages.go b/packages/maitred/internal/realtime/messages.go new file mode 100644 index 00000000..72433608 --- /dev/null +++ b/packages/maitred/internal/realtime/messages.go @@ -0,0 +1,52 @@ +package realtime + +import ( + "encoding/json" +) + +// BaseMessage is the generic top-level message structure +type BaseMessage struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` +} + +type CreatePayload struct{} + +type StartPayload struct { + ContainerID string `json:"container_id"` +} + +type StopPayload struct { + ContainerID string `json:"container_id"` +} + +// ParseMessage parses a BaseMessage and returns the specific payload +func ParseMessage(data []byte) (BaseMessage, interface{}, error) { + var base BaseMessage + if err := json.Unmarshal(data, &base); err != nil { + return base, nil, err + } + + switch base.Type { + case "create": + var payload CreatePayload + if err := json.Unmarshal(base.Payload, &payload); err != nil { + return base, nil, err + } + return base, payload, nil + case "start": + var payload StartPayload + if err := json.Unmarshal(base.Payload, &payload); err != nil { + return base, nil, err + } + return base, payload, nil + case "stop": + var payload StopPayload + if err := json.Unmarshal(base.Payload, &payload); err != nil { + return base, nil, err + } + return base, payload, nil + default: + return base, base.Payload, nil + } +} diff --git a/packages/maitred/internal/realtime/realtime.go b/packages/maitred/internal/realtime/realtime.go new file mode 100644 index 00000000..bf314e38 --- /dev/null +++ b/packages/maitred/internal/realtime/realtime.go @@ -0,0 +1,182 @@ +package realtime + +import ( + "context" + "fmt" + "github.com/eclipse/paho.golang/autopaho" + "github.com/eclipse/paho.golang/paho" + "log/slog" + "nestri/maitred/internal/auth" + "nestri/maitred/internal/containers" + "nestri/maitred/internal/resource" + "net/url" + "os" + "time" +) + +func Run(ctx context.Context, machineID string, containerEngine containers.ContainerEngine, resource *resource.Resource) error { + var clientID = generateClientID() + var topic = fmt.Sprintf("%s/%s/%s", resource.App.Name, resource.App.Stage, machineID) + var serverURL = fmt.Sprintf("wss://%s/mqtt?x-amz-customauthorizer-name=%s", resource.Realtime.Endpoint, resource.Realtime.Authorizer) + + slog.Info("Realtime", "topic", topic) + + userTokens, err := auth.FetchUserToken(machineID, resource) + if err != nil { + return err + } + + slog.Info("Realtime", "token", userTokens.AccessToken) + + u, err := url.Parse(serverURL) + if err != nil { + return err + } + + router := paho.NewStandardRouter() + router.DefaultHandler(func(p *paho.Publish) { + slog.Debug("DefaultHandler", "topic", p.Topic, "message", fmt.Sprintf("default handler received message: %s - with topic: %s", p.Payload, p.Topic)) + }) + + createTopic := fmt.Sprintf("%s/create", topic) + slog.Debug("Registering handler", "topic", createTopic) + router.RegisterHandler(createTopic, func(p *paho.Publish) { + slog.Debug("Router", "message", "received create message with payload", fmt.Sprintf("%s", p.Payload)) + + base, _, err := ParseMessage(p.Payload) + if err != nil { + slog.Error("Router", "err", fmt.Sprintf("failed to parse message: %s", err)) + return + } + + if base.Type != "create" { + slog.Error("Router", "err", "unexpected message type") + return + } + + // Create runner container + containerID, err := CreateRunner(ctx, containerEngine) + if err != nil { + slog.Error("Router", "err", fmt.Sprintf("failed to create runner container: %s", err)) + return + } + + slog.Info("Router", "info", fmt.Sprintf("created runner container: %s", containerID)) + }) + + startTopic := fmt.Sprintf("%s/start", topic) + slog.Debug("Registering handler", "topic", startTopic) + router.RegisterHandler(startTopic, func(p *paho.Publish) { + slog.Debug("Router", "message", "received start message with payload", fmt.Sprintf("%s", p.Payload)) + + base, payload, err := ParseMessage(p.Payload) + if err != nil { + slog.Error("Router", "err", fmt.Sprintf("failed to parse message: %s", err)) + return + } + + if base.Type != "start" { + slog.Error("Router", "err", "unexpected message type") + return + } + + // Get container ID + startPayload, ok := payload.(StartPayload) + if !ok { + slog.Error("Router", "err", "failed to get payload") + return + } + + // Start runner container + if err = containerEngine.StartContainer(ctx, startPayload.ContainerID); err != nil { + slog.Error("Router", "err", fmt.Sprintf("failed to start runner container: %s", err)) + return + } + + slog.Info("Router", "info", fmt.Sprintf("started runner container: %s", startPayload.ContainerID)) + }) + + stopTopic := fmt.Sprintf("%s/stop", topic) + slog.Debug("Registering handler", "topic", stopTopic) + router.RegisterHandler(stopTopic, func(p *paho.Publish) { + slog.Debug("Router", "message", "received stop message with payload", fmt.Sprintf("%s", p.Payload)) + + base, payload, err := ParseMessage(p.Payload) + if err != nil { + slog.Error("Router", "err", fmt.Sprintf("failed to parse message: %s", err)) + return + } + + if base.Type != "stop" { + slog.Error("Router", "err", "unexpected message type") + return + } + + // Get container ID + stopPayload, ok := payload.(StopPayload) + if !ok { + slog.Error("Router", "err", "failed to get payload") + return + } + + // Stop runner container + if err = containerEngine.StopContainer(ctx, stopPayload.ContainerID); err != nil { + slog.Error("Router", "err", fmt.Sprintf("failed to stop runner container: %s", err)) + return + } + + slog.Info("Router", "info", fmt.Sprintf("stopped runner container: %s", stopPayload.ContainerID)) + }) + + legacyLogger := slog.NewLogLogger(slog.NewTextHandler(os.Stdout, nil), slog.LevelError) + cliCfg := autopaho.ClientConfig{ + ServerUrls: []*url.URL{u}, + ConnectUsername: "", + ConnectPassword: []byte(userTokens.AccessToken), + KeepAlive: 20, + CleanStartOnInitialConnection: true, + SessionExpiryInterval: 60, + ReconnectBackoff: autopaho.NewConstantBackoff(time.Second), + OnConnectionUp: func(cm *autopaho.ConnectionManager, connAck *paho.Connack) { + slog.Info("Router", "info", "MQTT connection is up and running") + if _, err = cm.Subscribe(context.Background(), &paho.Subscribe{ + Subscriptions: []paho.SubscribeOptions{ + {Topic: fmt.Sprintf("%s/#", topic), QoS: 1}, + }, + }); err != nil { + slog.Error("Router", "err", fmt.Sprint("failed to subscribe, likely no messages will be received: ", err)) + } + }, + Errors: legacyLogger, + OnConnectError: func(err error) { + slog.Error("Router", "err", fmt.Sprintf("error whilst attempting connection: %s", err)) + }, + ClientConfig: paho.ClientConfig{ + ClientID: clientID, + OnPublishReceived: []func(paho.PublishReceived) (bool, error){ + func(pr paho.PublishReceived) (bool, error) { + router.Route(pr.Packet.Packet()) + return true, nil + }}, + OnClientError: func(err error) { slog.Error("Router", "err", fmt.Sprintf("client error: %s", err)) }, + OnServerDisconnect: func(d *paho.Disconnect) { + if d.Properties != nil { + slog.Info("Router", "info", fmt.Sprintf("server requested disconnect: %s", d.Properties.ReasonString)) + } else { + slog.Info("Router", "info", fmt.Sprintf("server requested disconnect; reason code: %d", d.ReasonCode)) + } + }, + }, + } + + c, err := autopaho.NewConnection(ctx, cliCfg) + if err != nil { + return err + } + + if err = c.AwaitConnection(ctx); err != nil { + return err + } + + return nil +} diff --git a/packages/maitred/internal/realtime/utils.go b/packages/maitred/internal/realtime/utils.go new file mode 100644 index 00000000..d22f8e9d --- /dev/null +++ b/packages/maitred/internal/realtime/utils.go @@ -0,0 +1,17 @@ +package realtime + +import ( + "crypto/rand" + "fmt" + "github.com/oklog/ulid/v2" + "time" +) + +func generateClientID() string { + // Create a source of entropy (cryptographically secure) + entropy := ulid.Monotonic(rand.Reader, 0) + // Generate a new ULID + id := ulid.MustNew(ulid.Timestamp(time.Now()), entropy) + // Create the client ID string + return fmt.Sprintf("mch_%s", id.String()) +} diff --git a/packages/maitred/pkg/resource/resource.go b/packages/maitred/internal/resource/resource.go similarity index 68% rename from packages/maitred/pkg/resource/resource.go rename to packages/maitred/internal/resource/resource.go index c10e2f65..02c6841d 100644 --- a/packages/maitred/pkg/resource/resource.go +++ b/packages/maitred/internal/resource/resource.go @@ -7,7 +7,7 @@ import ( "reflect" ) -type resource struct { +type Resource struct { Api struct { Url string `json:"url"` } @@ -17,7 +17,7 @@ type resource struct { AuthFingerprintKey struct { Value string `json:"value"` } - Party struct { + Realtime struct { Endpoint string `json:"endpoint"` Authorizer string `json:"authorizer"` } @@ -27,20 +27,20 @@ type resource struct { } } -var Resource resource - -func init() { - val := reflect.ValueOf(&Resource).Elem() +func NewResource() (*Resource, error) { + resource := Resource{} + val := reflect.ValueOf(&resource).Elem() for i := 0; i < val.NumField(); i++ { field := val.Field(i) typeField := val.Type().Field(i) envVarName := fmt.Sprintf("SST_RESOURCE_%s", typeField.Name) envValue, exists := os.LookupEnv(envVarName) if !exists { - panic(fmt.Sprintf("Environment variable %s is required", envVarName)) + return nil, fmt.Errorf("missing environment variable %s", envVarName) } if err := json.Unmarshal([]byte(envValue), field.Addr().Interface()); err != nil { - panic(err) + return nil, fmt.Errorf("error unmarshalling %s: %w", envVarName, err) } } + return &resource, nil } diff --git a/packages/maitred/internal/system/gpu.go b/packages/maitred/internal/system/gpu.go new file mode 100644 index 00000000..ba38f1f5 --- /dev/null +++ b/packages/maitred/internal/system/gpu.go @@ -0,0 +1,184 @@ +package system + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +const ( + pciClassVGA = 0x0300 // VGA compatible controller + pciClass3D = 0x0302 // 3D controller + pciClassDisplay = 0x0380 // Display controller + pciClassCoProcessor = 0x0b40 // Co-processor (e.g., NVIDIA Tesla) +) + +type infoPair struct { + Name string + ID int +} + +type PCIInfo struct { + Slot string + Class infoPair + Vendor infoPair + Device infoPair + SVendor infoPair + SDevice infoPair + Rev string + ProgIf string + Driver string + Modules []string + IOMMUGroup string +} + +const ( + VendorIntel = 0x8086 + VendorNVIDIA = 0x10de + VendorAMD = 0x1002 +) + +func GetAllGPUInfo() ([]PCIInfo, error) { + var gpus []PCIInfo + + cmd := exec.Command("lspci", "-mmvvvnnkD") + output, err := cmd.Output() + if err != nil { + return nil, err + } + + sections := bytes.Split(output, []byte("\n\n")) + for _, section := range sections { + var info PCIInfo + + lines := bytes.Split(section, []byte("\n")) + for _, line := range lines { + parts := bytes.SplitN(line, []byte(":"), 2) + if len(parts) < 2 { + continue + } + + key := strings.TrimSpace(string(parts[0])) + value := strings.TrimSpace(string(parts[1])) + + switch key { + case "Slot": + info.Slot = value + case "Class": + info.Class, err = parseInfoPair(value) + case "Vendor": + info.Vendor, err = parseInfoPair(value) + case "Device": + info.Device, err = parseInfoPair(value) + case "SVendor": + info.SVendor, err = parseInfoPair(value) + case "SDevice": + info.SDevice, err = parseInfoPair(value) + case "Rev": + info.Rev = value + case "ProgIf": + info.ProgIf = value + case "Driver": + info.Driver = value + case "Module": + info.Modules = append(info.Modules, value) + case "IOMMUGroup": + info.IOMMUGroup = value + } + + if err != nil { + return nil, err + } + } + + // Check if this is a GPU device + if isGPUClass(info.Class.ID) { + gpus = append(gpus, info) + } + } + + return gpus, nil +} + +// gets infoPair from "SomeName [SomeID]" +// example: "DG2 [Arc A770] [56a0]" -> Name: "DG2 [Arc A770]", ID: "56a0" +func parseInfoPair(pair string) (infoPair, error) { + parts := strings.Split(pair, "[") + if len(parts) < 2 { + return infoPair{}, errors.New("invalid info pair") + } + + id := strings.TrimSuffix(parts[len(parts)-1], "]") + name := strings.TrimSuffix(pair, "["+id) + name = strings.TrimSpace(name) + id = strings.TrimSpace(id) + + // Remove ID including square brackets from name + name = strings.ReplaceAll(name, "["+id+"]", "") + name = strings.TrimSpace(name) + + idHex, err := parseHexID(id) + if err != nil { + return infoPair{}, err + } + + return infoPair{ + Name: name, + ID: idHex, + }, nil +} + +func parseHexID(id string) (int, error) { + if strings.HasPrefix(id, "0x") { + id = id[2:] + } + parsed, err := strconv.ParseInt(id, 16, 32) + if err != nil { + return 0, err + } + return int(parsed), nil +} + +func isGPUClass(class int) bool { + return class == pciClassVGA || class == pciClass3D || class == pciClassDisplay || class == pciClassCoProcessor +} + +// GetCardDevices returns the /dev/dri/cardX and /dev/dri/renderDXXX device +func (info PCIInfo) GetCardDevices() (cardPath, renderPath string, err error) { + busID := strings.ToLower(info.Slot) + if !strings.HasPrefix(busID, "0000:") || len(busID) != 12 || busID[4] != ':' || busID[7] != ':' || busID[10] != '.' { + return "", "", fmt.Errorf("invalid PCI Bus ID format: %s (expected 0000:XX:YY.Z)", busID) + } + + byPathDir := "/dev/dri/by-path/" + entries, err := os.ReadDir(byPathDir) + if err != nil { + return "", "", fmt.Errorf("failed to read %s: %v", byPathDir, err) + } + + for _, entry := range entries { + name := entry.Name() + if strings.HasPrefix(name, "pci-"+busID+"-card") { + cardPath, err = filepath.EvalSymlinks(filepath.Join(byPathDir, name)) + if err != nil { + return "", "", fmt.Errorf("failed to resolve card symlink %s: %v", name, err) + } + } + if strings.HasPrefix(name, "pci-"+busID+"-render") { + renderPath, err = filepath.EvalSymlinks(filepath.Join(byPathDir, name)) + if err != nil { + return "", "", fmt.Errorf("failed to resolve render symlink %s: %v", name, err) + } + } + } + + if cardPath == "" && renderPath == "" { + return "", "", fmt.Errorf("no DRM devices found for PCI Bus ID: %s", busID) + } + return cardPath, renderPath, nil +} diff --git a/packages/maitred/internal/system/gpu_intel.go b/packages/maitred/internal/system/gpu_intel.go new file mode 100644 index 00000000..906df21f --- /dev/null +++ b/packages/maitred/internal/system/gpu_intel.go @@ -0,0 +1,290 @@ +package system + +import ( + "bufio" + "fmt" + "log/slog" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + "unsafe" +) + +// FDInfo holds parsed fdinfo data +type FDInfo struct { + ClientID string + EngineTime uint64 // i915: "drm-engine-render" in ns + Cycles uint64 // Xe: "drm-cycles-rcs" + TotalCycles uint64 // Xe: "drm-total-cycles-rcs" + MemoryVRAM uint64 // i915: "drm-memory-vram", Xe: "drm-total-vram0" in bytes +} + +// findCardX maps PCI slot to /dev/dri/cardX +func findCardX(pciSlot string) (string, error) { + driPath := "/sys/class/drm" + entries, err := os.ReadDir(driPath) + if err != nil { + return "", fmt.Errorf("failed to read /sys/class/drm: %v", err) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "card") { + deviceLink := filepath.Join(driPath, entry.Name(), "device") + target, err := os.Readlink(deviceLink) + if err != nil { + continue + } + if strings.Contains(target, pciSlot) { + return entry.Name(), nil + } + } + } + return "", fmt.Errorf("no cardX found for PCI slot %s", pciSlot) +} + +// getDriver retrieves the driver name +func getDriver(cardX string) (string, error) { + driverLink := filepath.Join("/sys/class/drm", cardX, "device", "driver") + target, err := os.Readlink(driverLink) + if err != nil { + return "", fmt.Errorf("failed to read driver link for %s: %v", cardX, err) + } + return filepath.Base(target), nil +} + +// collectFDInfo gathers fdinfo data +func collectFDInfo(cardX string) ([]FDInfo, error) { + var fdInfos []FDInfo + clientIDs := make(map[string]struct{}) + + procDirs, err := os.ReadDir("/proc") + if err != nil { + return nil, fmt.Errorf("failed to read /proc: %v", err) + } + + for _, procDir := range procDirs { + if !procDir.IsDir() { + continue + } + pid := procDir.Name() + if _, err := strconv.Atoi(pid); err != nil { + continue + } + fdDir := filepath.Join("/proc", pid, "fd") + fdEntries, err := os.ReadDir(fdDir) + if err != nil { + continue + } + for _, fdEntry := range fdEntries { + fdPath := filepath.Join(fdDir, fdEntry.Name()) + target, err := os.Readlink(fdPath) + if err != nil { + continue + } + if target == "/dev/dri/"+cardX { + fdinfoPath := filepath.Join("/proc", pid, "fdinfo", fdEntry.Name()) + file, err := os.Open(fdinfoPath) + if err != nil { + continue + } + + scanner := bufio.NewScanner(file) + var clientID, engineTime, cycles, totalCycles, memoryVRAM string + for scanner.Scan() { + line := scanner.Text() + parts := strings.SplitN(line, ":", 2) + if len(parts) < 2 { + continue + } + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + switch key { + case "drm-client-id": + clientID = value + case "drm-engine-render": + engineTime = value + case "drm-cycles-rcs": + cycles = value + case "drm-total-cycles-rcs": + totalCycles = value + case "drm-memory-vram", "drm-total-vram0": // i915 and Xe keys + memoryVRAM = value + } + } + if clientID == "" || clientID == "0" { + continue + } + if _, exists := clientIDs[clientID]; exists { + continue + } + clientIDs[clientID] = struct{}{} + + fdInfo := FDInfo{ClientID: clientID} + if engineTime != "" { + fdInfo.EngineTime, _ = strconv.ParseUint(engineTime, 10, 64) + } + if cycles != "" { + fdInfo.Cycles, _ = strconv.ParseUint(cycles, 10, 64) + } + if totalCycles != "" { + fdInfo.TotalCycles, _ = strconv.ParseUint(totalCycles, 10, 64) + } + if memoryVRAM != "" { + if strings.HasSuffix(memoryVRAM, " kB") || strings.HasSuffix(memoryVRAM, " KiB") { + memKB := strings.TrimSuffix(strings.TrimSuffix(memoryVRAM, " kB"), " KiB") + if mem, err := strconv.ParseUint(memKB, 10, 64); err == nil { + fdInfo.MemoryVRAM = mem * 1024 // Convert kB to bytes + } + } else { + fdInfo.MemoryVRAM, _ = strconv.ParseUint(memoryVRAM, 10, 64) // Assume bytes if no unit + } + } + fdInfos = append(fdInfos, fdInfo) + _ = file.Close() + } + } + } + return fdInfos, nil +} + +// drmIoctl wraps the syscall.Syscall for ioctl +func drmIoctl(fd int, request uintptr, data unsafe.Pointer) error { + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), request, uintptr(data)) + if errno != 0 { + return fmt.Errorf("ioctl failed: %v", errno) + } + return nil +} + +func monitorIntelGPU(device PCIInfo) GPUUsage { + // Map PCI slot to cardX + cardX, err := findCardX(device.Slot) + if err != nil { + slog.Warn("failed to find cardX for Intel GPU", "slot", device.Slot, "error", err) + return GPUUsage{} + } + + // Determine driver + driver, err := getDriver(cardX) + if err != nil { + slog.Warn("failed to get driver", "card", cardX, "error", err) + return GPUUsage{} + } + if driver != "i915" && driver != "xe" { + slog.Warn("unsupported Intel driver", "driver", driver, "card", cardX) + return GPUUsage{} + } + + // PCIInfo also has the driver, let's warn if they don't match + if device.Driver != driver { + slog.Warn("driver mismatch", "card", cardX, "lspci driver", device.Driver, "sysfs driver", driver) + } + + // Open DRM device + cardPath := "/dev/dri/" + cardX + fd, err := syscall.Open(cardPath, syscall.O_RDWR, 0) + if err != nil { + slog.Error("failed to open DRM device", "path", cardPath, "error", err) + return GPUUsage{} + } + defer func(fd int) { + _ = syscall.Close(fd) + }(fd) + + // Get total and used VRAM via ioctl + var totalVRAM, usedVRAMFromIOCTL uint64 + if driver == "i915" { + totalVRAM, usedVRAMFromIOCTL, err = getMemoryRegionsI915(fd) + } else { // xe + totalVRAM, usedVRAMFromIOCTL, err = queryMemoryRegionsXE(fd) + } + if err != nil { + //slog.Debug("failed to get memory regions", "card", cardX, "error", err) + // Proceed with totalVRAM = 0 if ioctl fails + } + + // Collect samples for usage percentage + firstFDInfos, err := collectFDInfo(cardX) + if err != nil { + slog.Warn("failed to collect first FDInfo", "card", cardX, "error", err) + return GPUUsage{} + } + time.Sleep(1 * time.Second) + secondFDInfos, err := collectFDInfo(cardX) + if err != nil { + slog.Warn("failed to collect second FDInfo", "card", cardX, "error", err) + return GPUUsage{} + } + + // Calculate usage percentage + var usagePercent float64 + if driver == "i915" { + var totalDeltaTime uint64 + for _, second := range secondFDInfos { + for _, first := range firstFDInfos { + if second.ClientID == first.ClientID { + totalDeltaTime += second.EngineTime - first.EngineTime + break + } + } + } + if totalDeltaTime > 0 { + usagePercent = float64(totalDeltaTime) / 1e9 * 100 // ns to percent + } + } else { // xe + var totalDeltaCycles, deltaTotalCycles uint64 + for i, second := range secondFDInfos { + for _, first := range firstFDInfos { + if second.ClientID == first.ClientID { + deltaCycles := second.Cycles - first.Cycles + totalDeltaCycles += deltaCycles + if i == 0 { + deltaTotalCycles = second.TotalCycles - first.TotalCycles + } + break + } + } + } + if deltaTotalCycles > 0 { + usagePercent = float64(totalDeltaCycles) / float64(deltaTotalCycles) * 100 + } + } + if usagePercent > 100 { + usagePercent = 100 + } + + // Sum per-process VRAM usage as fallback + var usedVRAM uint64 + for _, fdInfo := range secondFDInfos { + usedVRAM += fdInfo.MemoryVRAM + } + + // Prefer ioctl used VRAM if available and non-zero + if usedVRAMFromIOCTL != 0 { + usedVRAM = usedVRAMFromIOCTL + } + + // Compute VRAM metrics + var freeVRAM uint64 + var usedPercent float64 + if totalVRAM > 0 { + if usedVRAM > totalVRAM { + usedVRAM = totalVRAM + } + freeVRAM = totalVRAM - usedVRAM + usedPercent = float64(usedVRAM) / float64(totalVRAM) * 100 + } + + return GPUUsage{ + Info: device, + UsagePercent: usagePercent, + VRAM: VRAMUsage{ + Total: totalVRAM, + Used: usedVRAM, + Free: freeVRAM, + UsedPercent: usedPercent, + }, + } +} diff --git a/packages/maitred/internal/system/gpu_intel_i915.go b/packages/maitred/internal/system/gpu_intel_i915.go new file mode 100644 index 00000000..2ffcf605 --- /dev/null +++ b/packages/maitred/internal/system/gpu_intel_i915.go @@ -0,0 +1,86 @@ +package system + +import ( + "fmt" + "unsafe" +) + +// Constants for i915 +const ( + DRM_COMMAND_BASE = 0x40 + DRM_I915_QUERY = 0x39 + DRM_IOCTL_I915_QUERY = 0x80106479 // _IOWR('d', 0x79, 16) + DRM_I915_QUERY_MEMORY_REGIONS = 4 + I915_MEMORY_CLASS_DEVICE = 1 +) + +// drmI915QueryItem mirrors struct drm_i915_query_item +type drmI915QueryItem struct { + QueryID uintptr + Length int32 + Flags uint32 + DataPtr uintptr +} + +// drmI915Query mirrors struct drm_i915_query +type drmI915Query struct { + NumItems uint32 + Flags uint32 + ItemsPtr uintptr +} + +// drmI915MemoryRegionInfo mirrors struct drm_i915_memory_region_info +type drmI915MemoryRegionInfo struct { + Region struct { + MemoryClass uint16 + MemoryInstance uint16 + } + Rsvd0 uint32 + ProbedSize uint64 + UnallocatedSize uint64 + Rsvd1 [8]uint64 +} + +func getMemoryRegionsI915(fd int) (totalVRAM, usedVRAM uint64, err error) { + // Step 1: Get the required buffer size + item := drmI915QueryItem{ + QueryID: DRM_I915_QUERY_MEMORY_REGIONS, + Length: 0, + } + query := drmI915Query{ + NumItems: 1, + ItemsPtr: uintptr(unsafe.Pointer(&item)), + } + if err = drmIoctl(fd, DRM_IOCTL_I915_QUERY, unsafe.Pointer(&query)); err != nil { + return 0, 0, fmt.Errorf("initial i915 query failed: %v", err) + } + if item.Length <= 0 { + return 0, 0, fmt.Errorf("i915 query returned invalid length: %d", item.Length) + } + + // Step 2: Allocate buffer and perform the query + data := make([]byte, item.Length) + item.DataPtr = uintptr(unsafe.Pointer(&data[0])) + if err = drmIoctl(fd, DRM_IOCTL_I915_QUERY, unsafe.Pointer(&query)); err != nil { + return 0, 0, fmt.Errorf("second i915 query failed: %v", err) + } + + // Step 3: Parse the memory regions + numRegions := *(*uint32)(unsafe.Pointer(&data[0])) + headerSize := uint32(16) // num_regions (4) + rsvd[3] (12) = 16 bytes + regionSize := uint32(88) // Size of drm_i915_memory_region_info (calculated: 4+4+8+8+64) + + for i := uint32(0); i < numRegions; i++ { + offset := headerSize + i*regionSize + if offset+regionSize > uint32(len(data)) { + return 0, 0, fmt.Errorf("data buffer too small for i915 region %d", i) + } + mr := (*drmI915MemoryRegionInfo)(unsafe.Pointer(&data[offset])) + if mr.Region.MemoryClass == I915_MEMORY_CLASS_DEVICE { + totalVRAM += mr.ProbedSize + usedVRAM += mr.ProbedSize - mr.UnallocatedSize + } + } + + return totalVRAM, usedVRAM, nil +} diff --git a/packages/maitred/internal/system/gpu_intel_xe.go b/packages/maitred/internal/system/gpu_intel_xe.go new file mode 100644 index 00000000..878f7a84 --- /dev/null +++ b/packages/maitred/internal/system/gpu_intel_xe.go @@ -0,0 +1,84 @@ +package system + +import ( + "fmt" + "unsafe" +) + +// Constants from xe_drm.h +const ( + DRM_XE_DEVICE_QUERY_MEM_REGIONS = 1 + DRM_XE_MEM_REGION_CLASS_VRAM = 1 + DRM_XE_DEVICE_QUERY = 0x00 + DRM_IOCTL_XE_DEVICE_QUERY uintptr = 0xC0286440 // Precomputed as above +) + +// drmXEDeviceQuery mirrors struct drm_xe_device_query +type drmXEDeviceQuery struct { + Extensions uint64 + Query uint32 + Size uint32 + Data uint64 + Reserved [2]uint64 +} + +// drmXEQueryMemRegions mirrors struct drm_xe_query_mem_regions header +type drmXEQueryMemRegions struct { + NumMemRegions uint32 + Pad uint32 + // mem_regions[] follows +} + +// drmXEMemRegion mirrors struct drm_xe_mem_region +type drmXEMemRegion struct { + MemClass uint16 + Instance uint16 + MinPageSize uint32 + TotalSize uint64 + Used uint64 + CPUVisibleSize uint64 + CPUVisibleUsed uint64 + Reserved [6]uint64 +} + +func queryMemoryRegionsXE(fd int) (totalVRAM, usedVRAM uint64, err error) { + // Step 1: Get the required size + query := drmXEDeviceQuery{ + Query: DRM_XE_DEVICE_QUERY_MEM_REGIONS, + Size: 0, + } + if err = drmIoctl(fd, DRM_IOCTL_XE_DEVICE_QUERY, unsafe.Pointer(&query)); err != nil { + return 0, 0, fmt.Errorf("initial xe query failed: %v", err) + } + if query.Size == 0 { + return 0, 0, fmt.Errorf("xe query returned zero size") + } + + // Step 2: Allocate buffer and perform the query + data := make([]byte, query.Size) + query.Data = uint64(uintptr(unsafe.Pointer(&data[0]))) + query.Size = uint32(len(data)) + if err = drmIoctl(fd, DRM_IOCTL_XE_DEVICE_QUERY, unsafe.Pointer(&query)); err != nil { + return 0, 0, fmt.Errorf("second xe query failed: %v", err) + } + + // Step 3: Parse the memory regions + header := (*drmXEQueryMemRegions)(unsafe.Pointer(&data[0])) + numRegions := header.NumMemRegions + headerSize := unsafe.Sizeof(drmXEQueryMemRegions{}) + regionSize := unsafe.Sizeof(drmXEMemRegion{}) + + for i := uint32(0); i < numRegions; i++ { + offset := headerSize + uintptr(i)*regionSize + if offset+regionSize > uintptr(len(data)) { + return 0, 0, fmt.Errorf("data buffer too small for xe region %d", i) + } + mr := (*drmXEMemRegion)(unsafe.Pointer(&data[offset])) + if mr.MemClass == DRM_XE_MEM_REGION_CLASS_VRAM { + totalVRAM += mr.TotalSize + usedVRAM += mr.Used + } + } + + return totalVRAM, usedVRAM, nil +} diff --git a/packages/maitred/internal/system/gpu_nvidia.go b/packages/maitred/internal/system/gpu_nvidia.go new file mode 100644 index 00000000..e5c39ac0 --- /dev/null +++ b/packages/maitred/internal/system/gpu_nvidia.go @@ -0,0 +1,57 @@ +package system + +import ( + "log/slog" + "os/exec" + "strconv" + "strings" +) + +// monitorNVIDIAGPU monitors an NVIDIA GPU using nvidia-smi +func monitorNVIDIAGPU(device PCIInfo) GPUUsage { + // Query nvidia-smi for GPU metrics + cmd := exec.Command("nvidia-smi", "--query-gpu=pci.bus_id,utilization.gpu,memory.total,memory.used,memory.free", "--format=csv,noheader,nounits") + output, err := cmd.Output() + if err != nil { + slog.Warn("failed to run nvidia-smi", "error", err) + return GPUUsage{} + } + + // Parse output and find matching GPU + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, line := range lines { + fields := strings.Split(line, ", ") + if len(fields) != 5 { + continue + } + busID := fields[0] // e.g., "0000:01:00.0" + if strings.Contains(busID, device.Slot) || strings.Contains(device.Slot, busID) { + usagePercent, _ := strconv.ParseFloat(fields[1], 64) + totalMiB, _ := strconv.ParseUint(fields[2], 10, 64) + usedMiB, _ := strconv.ParseUint(fields[3], 10, 64) + freeMiB, _ := strconv.ParseUint(fields[4], 10, 64) + + // Convert MiB to bytes + total := totalMiB * 1024 * 1024 + used := usedMiB * 1024 * 1024 + free := freeMiB * 1024 * 1024 + usedPercent := float64(0) + if total > 0 { + usedPercent = float64(used) / float64(total) * 100 + } + + return GPUUsage{ + Info: device, + UsagePercent: usagePercent, + VRAM: VRAMUsage{ + Total: total, + Used: used, + Free: free, + UsedPercent: usedPercent, + }, + } + } + } + slog.Warn("No NVIDIA GPU found matching PCI slot", "slot", device.Slot) + return GPUUsage{} +} diff --git a/packages/maitred/internal/system/id.go b/packages/maitred/internal/system/id.go new file mode 100644 index 00000000..4d95cd58 --- /dev/null +++ b/packages/maitred/internal/system/id.go @@ -0,0 +1,24 @@ +package system + +import ( + "os" + "strings" +) + +const ( + dbusPath = "/var/lib/dbus/machine-id" + dbusPathEtc = "/etc/machine-id" +) + +// GetID returns the machine ID specified at `/var/lib/dbus/machine-id` or `/etc/machine-id`. +// If there is an error reading the files an empty string is returned. +func GetID() (string, error) { + id, err := os.ReadFile(dbusPath) + if err != nil { + id, err = os.ReadFile(dbusPathEtc) + } + if err != nil { + return "", err + } + return strings.Trim(string(id), " \n"), nil +} diff --git a/packages/maitred/internal/system/resources.go b/packages/maitred/internal/system/resources.go new file mode 100644 index 00000000..53199a20 --- /dev/null +++ b/packages/maitred/internal/system/resources.go @@ -0,0 +1,405 @@ +package system + +import ( + "bufio" + "bytes" + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "time" +) + +// CPUInfo contains CPU model information +type CPUInfo struct { + Vendor string `json:"vendor"` // CPU vendor (e.g., "AMD", "Intel") + Model string `json:"model"` // CPU model name +} + +// CPUUsage contains CPU usage metrics +type CPUUsage struct { + Info CPUInfo `json:"info"` // CPU vendor and model information + Total float64 `json:"total"` // Total CPU usage in percentage (0-100) + PerCore []float64 `json:"per_core"` // CPU usage per core in percentage (0-100) +} + +// MemoryUsage contains memory usage metrics +type MemoryUsage struct { + Total uint64 `json:"total"` // Total memory in bytes + Used uint64 `json:"used"` // Used memory in bytes + Available uint64 `json:"available"` // Available memory in bytes + Free uint64 `json:"free"` // Free memory in bytes + UsedPercent float64 `json:"used_percent"` // Used memory in percentage (0-100) +} + +// FilesystemUsage contains usage metrics for a filesystem path +type FilesystemUsage struct { + Path string `json:"path"` // Filesystem path + Total uint64 `json:"total"` // Total disk space in bytes + Used uint64 `json:"used"` // Used disk space in bytes + Free uint64 `json:"free"` // Free disk space in bytes + UsedPercent float64 `json:"used_percent"` // Used disk space in percentage (0-100) +} + +// GPUUsage contains GPU usage metrics +type GPUUsage struct { + Info PCIInfo `json:"pci_info"` // GPU PCI information + UsagePercent float64 `json:"usage_percent"` // GPU usage in percentage (0-100) + VRAM VRAMUsage `json:"vram"` // GPU memory usage metrics +} + +// VRAMUsage contains GPU memory usage metrics +type VRAMUsage struct { + Total uint64 `json:"total"` // Total VRAM in bytes + Used uint64 `json:"used"` // Used VRAM in bytes + Free uint64 `json:"free"` // Free VRAM in bytes + UsedPercent float64 `json:"used_percent"` // Used VRAM in percentage (0-100) +} + +// ResourceUsage contains resource usage metrics +type ResourceUsage struct { + CPU CPUUsage `json:"cpu"` // CPU usage metrics + Memory MemoryUsage `json:"memory"` // Memory usage metrics + Disk FilesystemUsage `json:"disk"` // Disk usage metrics + GPUs []GPUUsage `json:"gpus"` // Per-GPU usage metrics +} + +var ( + lastUsage ResourceUsage + lastUsageMutex sync.RWMutex +) + +// GetSystemUsage returns last known system resource usage metrics +func GetSystemUsage() ResourceUsage { + lastUsageMutex.RLock() + defer lastUsageMutex.RUnlock() + return lastUsage +} + +// StartMonitoring begins periodic system usage monitoring with the given interval +func StartMonitoring(ctx context.Context, interval time.Duration) { + slog.Info("Starting system monitoring") + go func() { + // Initial sample immediately + updateUsage() + + // Ticker for periodic updates + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + slog.Info("Stopping system monitoring") + return + case <-ticker.C: + updateUsage() + } + } + }() +} + +// updateUsage collects and updates the lastUsage variable +func updateUsage() { + // Collect CPU usage + cpu := GetCPUUsage() + + // Collect memory usage + memory := GetMemoryUsage() + + // Collect root filesystem usage + rootfs, err := GetFilesystemUsage("/") + if err != nil { + slog.Warn("Failed to get root filesystem usage", "error", err) + } + + // Collect GPU usage + gpus := GetGPUUsage() + + // Update shared variable safely + lastUsageMutex.Lock() + lastUsage = ResourceUsage{ + CPU: cpu, + Memory: memory, + Disk: rootfs, + GPUs: gpus, + } + lastUsageMutex.Unlock() +} + +// PrettyString returns resource usage metrics in a human-readable format string +func (r ResourceUsage) PrettyString() string { + res := "Resource Usage:\n" + res += fmt.Sprintf(" CPU:\n") + res += fmt.Sprintf(" Vendor: %s\n", r.CPU.Info.Vendor) + res += fmt.Sprintf(" Model: %s\n", r.CPU.Info.Model) + res += fmt.Sprintf(" Total Usage: %.2f%%\n", r.CPU.Total) + res += fmt.Sprintf(" Per-Core Usage:\n") + res += fmt.Sprintf(" [") + for i, coreUsage := range r.CPU.PerCore { + res += fmt.Sprintf("%.2f%%", coreUsage) + if i < len(r.CPU.PerCore)-1 { + res += ", " + } + } + res += "]\n" + + res += fmt.Sprintf(" Memory:\n") + res += fmt.Sprintf(" Total: %d bytes\n", r.Memory.Total) + res += fmt.Sprintf(" Used: %d bytes\n", r.Memory.Used) + res += fmt.Sprintf(" Available: %d bytes\n", r.Memory.Available) + res += fmt.Sprintf(" Free: %d bytes\n", r.Memory.Free) + res += fmt.Sprintf(" Used Percent: %.2f%%\n", r.Memory.UsedPercent) + + res += fmt.Sprintf(" Filesystem:\n") + res += fmt.Sprintf(" Path: %s\n", r.Disk.Path) + res += fmt.Sprintf(" Total: %d bytes\n", r.Disk.Total) + res += fmt.Sprintf(" Used: %d bytes\n", r.Disk.Used) + res += fmt.Sprintf(" Free: %d bytes\n", r.Disk.Free) + res += fmt.Sprintf(" Used Percent: %.2f%%\n", r.Disk.UsedPercent) + + res += fmt.Sprintf(" GPUs:\n") + for i, gpu := range r.GPUs { + cardDev, renderDev, err := gpu.Info.GetCardDevices() + if err != nil { + slog.Warn("Failed to get card and render devices", "error", err) + } + + res += fmt.Sprintf(" GPU %d:\n", i) + res += fmt.Sprintf(" Vendor: %s\n", gpu.Info.Vendor.Name) + res += fmt.Sprintf(" Model: %s\n", gpu.Info.Device.Name) + res += fmt.Sprintf(" Driver: %s\n", gpu.Info.Driver) + res += fmt.Sprintf(" Card Device: %s\n", cardDev) + res += fmt.Sprintf(" Render Device: %s\n", renderDev) + res += fmt.Sprintf(" Usage Percent: %.2f%%\n", gpu.UsagePercent) + res += fmt.Sprintf(" VRAM:\n") + res += fmt.Sprintf(" Total: %d bytes\n", gpu.VRAM.Total) + res += fmt.Sprintf(" Used: %d bytes\n", gpu.VRAM.Used) + res += fmt.Sprintf(" Free: %d bytes\n", gpu.VRAM.Free) + res += fmt.Sprintf(" Used Percent: %.2f%%\n", gpu.VRAM.UsedPercent) + } + + return res +} + +// GetCPUUsage gathers CPU usage +func GetCPUUsage() CPUUsage { + // Helper to read /proc/stat + readStat := func() (uint64, uint64, []uint64, []uint64) { + statBytes, err := os.ReadFile("/proc/stat") + if err != nil { + slog.Warn("Failed to read /proc/stat", "error", err) + return 0, 0, nil, nil + } + statScanner := bufio.NewScanner(bytes.NewReader(statBytes)) + statScanner.Scan() // Total CPU line + fields := strings.Fields(statScanner.Text())[1:] + var total, idle uint64 + for i, field := range fields { + val, _ := strconv.ParseUint(field, 10, 64) + total += val + if i == 3 { // Idle time + idle = val + } + } + + var perCoreTotals, perCoreIdles []uint64 + for statScanner.Scan() { + line := statScanner.Text() + if !strings.HasPrefix(line, "cpu") { + break + } + coreFields := strings.Fields(line)[1:] + var coreTotal, coreIdle uint64 + for i, field := range coreFields { + val, _ := strconv.ParseUint(field, 10, 64) + coreTotal += val + if i == 3 { // Idle time + coreIdle = val + } + } + perCoreTotals = append(perCoreTotals, coreTotal) + perCoreIdles = append(perCoreIdles, coreIdle) + } + return total, idle, perCoreTotals, perCoreIdles + } + + // First sample + prevTotal, prevIdle, prevPerCoreTotals, prevPerCoreIdles := readStat() + time.Sleep(1 * time.Second) // Delay for accurate delta + // Second sample + currTotal, currIdle, currPerCoreTotals, currPerCoreIdles := readStat() + + // Calculate total CPU usage + totalDiff := float64(currTotal - prevTotal) + idleDiff := float64(currIdle - prevIdle) + var totalUsage float64 + if totalDiff > 0 { + totalUsage = ((totalDiff - idleDiff) / totalDiff) * 100 + } + + // Calculate per-core usage + var perCore []float64 + for i := range currPerCoreTotals { + coreTotalDiff := float64(currPerCoreTotals[i] - prevPerCoreTotals[i]) + coreIdleDiff := float64(currPerCoreIdles[i] - prevPerCoreIdles[i]) + if coreTotalDiff > 0 { + perCoreUsage := ((coreTotalDiff - coreIdleDiff) / coreTotalDiff) * 100 + perCore = append(perCore, perCoreUsage) + } else { + perCore = append(perCore, 0) + } + } + + // Get CPU info + cpuInfoBytes, err := os.ReadFile("/proc/cpuinfo") + if err != nil { + slog.Warn("Failed to read /proc/cpuinfo", "error", err) + return CPUUsage{} + } + cpuInfo := string(cpuInfoBytes) + scanner := bufio.NewScanner(strings.NewReader(cpuInfo)) + var vendor, model string + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "vendor_id") { + vendor = strings.TrimSpace(strings.Split(line, ":")[1]) + } else if strings.HasPrefix(line, "model name") { + model = strings.TrimSpace(strings.Split(line, ":")[1]) + } + if vendor != "" && model != "" { + break + } + } + + return CPUUsage{ + Info: CPUInfo{ + Vendor: vendor, + Model: model, + }, + Total: totalUsage, + PerCore: perCore, + } +} + +// GetMemoryUsage gathers memory usage from /proc/meminfo +func GetMemoryUsage() MemoryUsage { + data, err := os.ReadFile("/proc/meminfo") + if err != nil { + panic(err) + } + + scanner := bufio.NewScanner(bytes.NewReader(data)) + var total, free, available uint64 + + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "MemTotal:") { + total = parseMemInfoLine(line) + } else if strings.HasPrefix(line, "MemFree:") { + free = parseMemInfoLine(line) + } else if strings.HasPrefix(line, "MemAvailable:") { + available = parseMemInfoLine(line) + } + } + + used := total - available + usedPercent := (float64(used) / float64(total)) * 100 + + return MemoryUsage{ + Total: total * 1024, // Convert from KB to bytes + Used: used * 1024, + Available: available * 1024, + Free: free * 1024, + UsedPercent: usedPercent, + } +} + +// parseMemInfoLine parses a line from /proc/meminfo +func parseMemInfoLine(line string) uint64 { + fields := strings.Fields(line) + val, _ := strconv.ParseUint(fields[1], 10, 64) + return val +} + +// GetFilesystemUsage gathers usage statistics for the specified path +func GetFilesystemUsage(path string) (FilesystemUsage, error) { + cmd := exec.Command("df", path) + output, err := cmd.Output() + if err != nil { + return FilesystemUsage{}, err + } + + lines := strings.Split(string(output), "\n") + if len(lines) < 2 { + return FilesystemUsage{}, fmt.Errorf("unexpected `df` output format for path: %s", path) + } + + fields := strings.Fields(lines[1]) + if len(fields) < 5 { + return FilesystemUsage{}, fmt.Errorf("insufficient fields in `df` output for path: %s", path) + } + + total, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + return FilesystemUsage{}, fmt.Errorf("failed to parse total space: %v", err) + } + + used, err := strconv.ParseUint(fields[2], 10, 64) + if err != nil { + return FilesystemUsage{}, fmt.Errorf("failed to parse used space: %v", err) + } + + free, err := strconv.ParseUint(fields[3], 10, 64) + if err != nil { + return FilesystemUsage{}, fmt.Errorf("failed to parse free space: %v", err) + } + + usedPercent, err := strconv.ParseFloat(strings.TrimSuffix(fields[4], "%"), 64) + if err != nil { + return FilesystemUsage{}, fmt.Errorf("failed to parse used percentage: %v", err) + } + + return FilesystemUsage{ + Path: path, + Total: total * 1024, + Used: used * 1024, + Free: free * 1024, + UsedPercent: usedPercent, + }, nil +} + +// GetGPUUsage gathers GPU usage for all detected GPUs +func GetGPUUsage() []GPUUsage { + var gpus []GPUUsage + + // Detect all GPUs + pciInfos, err := GetAllGPUInfo() + if err != nil { + slog.Warn("Failed to get GPU info", "error", err) + return nil + } + + // Monitor each GPU + for _, gpu := range pciInfos { + var gpuUsage GPUUsage + switch gpu.Vendor.ID { + case VendorIntel: + gpuUsage = monitorIntelGPU(gpu) + case VendorNVIDIA: + gpuUsage = monitorNVIDIAGPU(gpu) + case VendorAMD: + // TODO: Implement if needed + continue + default: + continue + } + gpus = append(gpus, gpuUsage) + } + + return gpus +} diff --git a/packages/maitred/main.go b/packages/maitred/main.go index 8934789d..25cefe12 100644 --- a/packages/maitred/main.go +++ b/packages/maitred/main.go @@ -1,21 +1,126 @@ package main import ( - "nestri/maitred/pkg/party" + "context" + "log/slog" + "nestri/maitred/internal" + "nestri/maitred/internal/containers" + "nestri/maitred/internal/realtime" + "nestri/maitred/internal/resource" + "nestri/maitred/internal/system" "os" - - "github.com/charmbracelet/log" + "os/signal" + "syscall" + "time" ) func main() { - var teamSlug string //FIXME: Switch to team-slug as they are more memorable but still unique + // Setup main context and stopper + mainCtx, mainStop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - if len(os.Args) > 1 { - teamSlug = os.Args[1] - } else { - log.Fatal("Nestri needs a team slug to register this container to") + // Get flags and log them + internal.InitFlags() + internal.GetFlags().DebugLog() + + logLevel := slog.LevelInfo + if internal.GetFlags().Verbose { + logLevel = slog.LevelDebug } - party.Run(teamSlug) - //TODO: On stop here, set the API as the instance is not running (stopped) + // Create the base handler with debug level + baseHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: logLevel, + }) + customHandler := &internal.CustomHandler{Handler: baseHandler} + logger := slog.New(customHandler) + slog.SetDefault(logger) + + if !internal.GetFlags().NoMonitor { + // Start system monitoring, fetch every 5 seconds + system.StartMonitoring(mainCtx, 5*time.Second) + } + + // Get machine ID + machineID, err := system.GetID() + if err != nil { + slog.Error("failed getting machine id", "err", machineID) + } + + slog.Info("Machine ID", "id", machineID) + + // Initialize container engine + ctrEngine, err := containers.NewContainerEngine() + if err != nil { + slog.Error("failed initializing container engine", "err", err) + mainStop() + return + } + defer func(ctrEngine containers.ContainerEngine) { + // Stop our managed containers first, with a 30 second timeout + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + err = realtime.CleanupManaged(cleanupCtx, ctrEngine) + if err != nil { + slog.Error("failed cleaning up managed containers", "err", err) + } + + err = ctrEngine.Close() + if err != nil { + slog.Error("failed closing container engine", "err", err) + } + }(ctrEngine) + + // Print engine info + info, err := ctrEngine.Info(mainCtx) + if err != nil { + slog.Error("failed getting engine info", "err", err) + mainStop() + return + } + slog.Info("Container engine", "info", info) + + if err = realtime.InitializeManager(mainCtx, ctrEngine); err != nil { + slog.Error("failed initializing container manager", "err", err) + mainStop() + return + } + + // If in debug mode, skip running SST - MQTT connections + if !internal.GetFlags().Debug { + // Initialize SST resource + res, err := resource.NewResource() + if err != nil { + slog.Error("failed getting resource", "err", err) + mainStop() + return + } + + // Run realtime + err = realtime.Run(mainCtx, machineID, ctrEngine, res) + if err != nil { + slog.Error("failed running realtime", "err", err) + mainStop() + return + } + } + + // Create relay container + slog.Info("Creating default relay container") + relayID, err := realtime.CreateRelay(mainCtx, ctrEngine) + if err != nil { + slog.Error("failed creating relay container", "err", err) + mainStop() + return + } + // Start relay container + slog.Info("Starting default relay container", "id", relayID) + if err = realtime.StartRelay(mainCtx, ctrEngine, relayID); err != nil { + slog.Error("failed starting relay container", "err", err) + mainStop() + return + } + + // Wait for signal + <-mainCtx.Done() + slog.Info("Shutting down gracefully by signal..") } diff --git a/packages/maitred/package.json b/packages/maitred/package.json new file mode 100644 index 00000000..c22c0005 --- /dev/null +++ b/packages/maitred/package.json @@ -0,0 +1,11 @@ +{ + "name": "@nestri/maitred", + "version": "0.1.0", + "type": "module", + "sideEffects": false, + "scripts": { + "dev": "sst shell go run main.go" + }, + "devDependencies": {}, + "dependencies": {} + } \ No newline at end of file diff --git a/packages/maitred/pkg/auth/auth.go b/packages/maitred/pkg/auth/auth.go deleted file mode 100644 index 9deba86e..00000000 --- a/packages/maitred/pkg/auth/auth.go +++ /dev/null @@ -1,58 +0,0 @@ -package auth - -import ( - "encoding/json" - "fmt" - "io" - "nestri/maitred/pkg/resource" - "net/http" - "net/url" - "os" - "os/exec" - - "github.com/charmbracelet/log" -) - -type UserCredentials struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` -} - -func FetchUserToken(teamSlug string) (*UserCredentials, error) { - hostname, err := os.Hostname() - if err != nil { - log.Fatal("Could not get the hostname") - } - data := url.Values{} - data.Set("grant_type", "client_credentials") - data.Set("client_id", "device") - data.Set("client_secret", resource.Resource.AuthFingerprintKey.Value) - data.Set("team", teamSlug) - data.Set("hostname", hostname) - data.Set("provider", "device") - resp, err := http.PostForm(resource.Resource.Auth.Url+"/token", data) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - body, _ := io.ReadAll(resp.Body) - fmt.Println(string(body)) - return nil, fmt.Errorf("failed to auth: " + string(body)) - } - credentials := UserCredentials{} - err = json.NewDecoder(resp.Body).Decode(&credentials) - if err != nil { - return nil, err - } - return &credentials, nil -} - -func GetHostname() string { - cmd, err := exec.Command("cat", "/etc/hostname").Output() - if err != nil { - log.Error("error getting container hostname", "err", err) - } - output := string(cmd) - return output -} diff --git a/packages/maitred/pkg/party/logger.go b/packages/maitred/pkg/party/logger.go deleted file mode 100644 index 010e2928..00000000 --- a/packages/maitred/pkg/party/logger.go +++ /dev/null @@ -1,27 +0,0 @@ -package party - -import ( - "github.com/charmbracelet/log" -) - -// logger implements the paho.Logger interface -type logger struct { - prefix string -} - -// Println is the library provided NOOPLogger's -// implementation of the required interface function() -func (l logger) Println(v ...interface{}) { - // fmt.Println(append([]interface{}{l.prefix + ":"}, v...)...) - log.Info(l.prefix, "info", v) -} - -// Printf is the library provided NOOPLogger's -// implementation of the required interface function(){} -func (l logger) Printf(format string, v ...interface{}) { - // if len(format) > 0 && format[len(format)-1] != '\n' { - // format = format + "\n" // some log calls in paho do not add \n - // } - // fmt.Printf(l.prefix+":"+format, v...) - log.Info(l.prefix, "info", v) -} diff --git a/packages/maitred/pkg/party/party.go b/packages/maitred/pkg/party/party.go deleted file mode 100644 index 4ab8a83b..00000000 --- a/packages/maitred/pkg/party/party.go +++ /dev/null @@ -1,129 +0,0 @@ -package party - -import ( - "context" - "fmt" - "nestri/maitred/pkg/auth" - "nestri/maitred/pkg/resource" - "net/url" - "os" - "os/signal" - "syscall" - "time" - - "github.com/charmbracelet/log" - "github.com/eclipse/paho.golang/autopaho" - "github.com/eclipse/paho.golang/paho" -) - -func Run(teamSlug string) { - var topic = fmt.Sprintf("%s/%s/%s", resource.Resource.App.Name, resource.Resource.App.Stage, teamSlug) - var serverURL = fmt.Sprintf("wss://%s/mqtt?x-amz-customauthorizer-name=%s", resource.Resource.Party.Endpoint, resource.Resource.Party.Authorizer) - var clientID = generateClientID() - hostname, err := os.Hostname() - if err != nil { - log.Fatal(" Could not get the hostname") - } - - // App will run until cancelled by user (e.g. ctrl-c) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - userTokens, err := auth.FetchUserToken(teamSlug) - if err != nil { - log.Error("Error trying to request for credentials", "err", err) - stop() - } - - // We will connect to the Eclipse test server (note that you may see messages that other users publish) - u, err := url.Parse(serverURL) - if err != nil { - panic(err) - } - - router := paho.NewStandardRouter() - router.DefaultHandler(func(p *paho.Publish) { - infoLogger.Info("Router", "info", fmt.Sprintf("default handler received message with topic: %s\n", p.Topic)) - }) - - cliCfg := autopaho.ClientConfig{ - ServerUrls: []*url.URL{u}, - ConnectUsername: "", // Must be empty for the authorizer - ConnectPassword: []byte(userTokens.AccessToken), - KeepAlive: 20, // Keepalive message should be sent every 20 seconds - // We don't want the broker to delete any session info when we disconnect - CleanStartOnInitialConnection: true, - SessionExpiryInterval: 60, // Session remains live 60 seconds after disconnect - ReconnectBackoff: autopaho.NewConstantBackoff(time.Second), - OnConnectionUp: func(cm *autopaho.ConnectionManager, connAck *paho.Connack) { - infoLogger.Info("Router", "info", "MQTT connection is up and running") - if _, err := cm.Subscribe(context.Background(), &paho.Subscribe{ - Subscriptions: []paho.SubscribeOptions{ - {Topic: fmt.Sprintf("%s/#", topic), QoS: 1}, //Listen to all messages from this team - }, - }); err != nil { - panic(fmt.Sprintf("failed to subscribe (%s). This is likely to mean no messages will be received.", err)) - } - }, - Errors: logger{prefix: "subscribe"}, - OnConnectError: func(err error) { - infoLogger.Error("Router", "err", fmt.Sprintf("error whilst attempting connection: %s\n", err)) - }, - // eclipse/paho.golang/paho provides base mqtt functionality, the below config will be passed in for each connection - ClientConfig: paho.ClientConfig{ - // If you are using QOS 1/2, then it's important to specify a client id (which must be unique) - ClientID: clientID, - // OnPublishReceived is a slice of functions that will be called when a message is received. - // You can write the function(s) yourself or use the supplied Router - OnPublishReceived: []func(paho.PublishReceived) (bool, error){ - func(pr paho.PublishReceived) (bool, error) { - router.Route(pr.Packet.Packet()) - return true, nil // we assume that the router handles all messages (todo: amend router API) - }}, - OnClientError: func(err error) { infoLogger.Error("Router", "err", fmt.Sprintf("client error: %s\n", err)) }, - OnServerDisconnect: func(d *paho.Disconnect) { - if d.Properties != nil { - infoLogger.Info("Router", "info", fmt.Sprintf("server requested disconnect: %s\n", d.Properties.ReasonString)) - } else { - infoLogger.Info("Router", "info", fmt.Sprintf("server requested disconnect; reason code: %d\n", d.ReasonCode)) - } - }, - }, - } - - c, err := autopaho.NewConnection(ctx, cliCfg) // starts process; will reconnect until context cancelled - if err != nil { - panic(err) - } - - if err = c.AwaitConnection(ctx); err != nil { - panic(err) - } - - // Handlers can be registered/deregistered at any time. It's important to note that you need to subscribe AND create - // a handler - //TODO: Have different routes for different things, like starting a session, stopping a session, and stopping the container altogether - //TODO: Listen on team-slug/container-hostname topic only - router.RegisterHandler(fmt.Sprintf("%s/%s/start", topic, hostname), func(p *paho.Publish) { - infoLogger.Info("Router", "info", fmt.Sprintf("start a game: %s\n", p.Topic)) - }) - router.RegisterHandler(fmt.Sprintf("%s/%s/stop", topic, hostname), func(p *paho.Publish) { fmt.Printf("stop the game that is running: %s\n", p.Topic) }) - router.RegisterHandler(fmt.Sprintf("%s/%s/download", topic, hostname), func(p *paho.Publish) { fmt.Printf("download a game: %s\n", p.Topic) }) - router.RegisterHandler(fmt.Sprintf("%s/%s/quit", topic, hostname), func(p *paho.Publish) { stop() }) // Stop and quit this running container - - // We publish three messages to test out the various route handlers - // topics := []string{"test/test", "test/test/foo", "test/xxNoMatch", "test/quit"} - // for _, t := range topics { - // if _, err := c.Publish(ctx, &paho.Publish{ - // QoS: 1, - // Topic: fmt.Sprintf("%s/%s", topic, t), - // Payload: []byte("TestMessage on topic: " + t), - // }); err != nil { - // if ctx.Err() == nil { - // panic(err) // Publish will exit when context cancelled or if something went wrong - // } - // } - // } - - <-c.Done() // Wait for clean shutdown (cancelling the context triggered the shutdown) -} diff --git a/packages/maitred/pkg/party/utils.go b/packages/maitred/pkg/party/utils.go deleted file mode 100644 index 25c70b7d..00000000 --- a/packages/maitred/pkg/party/utils.go +++ /dev/null @@ -1,31 +0,0 @@ -package party - -import ( - "fmt" - "os" - "time" - - "math/rand" - - "github.com/charmbracelet/log" - "github.com/oklog/ulid/v2" -) - -var ( - infoLogger = log.NewWithOptions(os.Stderr, log.Options{ - ReportTimestamp: true, - TimeFormat: time.Kitchen, - // Prefix: "Realtime", - }) -) - -func generateClientID() string { - // Create a source of entropy (use cryptographically secure randomness in production) - entropy := rand.New(rand.NewSource(time.Now().UnixNano())) - - // Generate a new ULID - id := ulid.MustNew(ulid.Timestamp(time.Now()), entropy) - - // Create the client ID string - return fmt.Sprintf("client_%s", id.String()) -} diff --git a/packages/maitred/sst-env.d.ts b/packages/maitred/sst-env.d.ts new file mode 100644 index 00000000..b6a7e906 --- /dev/null +++ b/packages/maitred/sst-env.d.ts @@ -0,0 +1,9 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/relay/go.mod b/packages/relay/go.mod index 38719c23..a6dd1edc 100644 --- a/packages/relay/go.mod +++ b/packages/relay/go.mod @@ -3,30 +3,110 @@ module relay go 1.24 require ( - github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 - github.com/pion/ice/v4 v4.0.7 + github.com/libp2p/go-libp2p v0.41.1 + github.com/libp2p/go-libp2p-pubsub v0.13.1 + github.com/libp2p/go-reuseport v0.4.0 + github.com/multiformats/go-multiaddr v0.15.0 + github.com/oklog/ulid/v2 v2.1.0 + github.com/pion/ice/v4 v4.0.9 github.com/pion/interceptor v0.1.37 - github.com/pion/webrtc/v4 v4.0.12 - google.golang.org/protobuf v1.36.5 + github.com/pion/rtp v1.8.13 + github.com/pion/webrtc/v4 v4.0.14 + google.golang.org/protobuf v1.36.6 ) require ( + github.com/benbjohnson/clock v1.3.5 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/cgroups v1.1.0 // indirect + github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/elastic/gosigar v0.14.3 // indirect + github.com/flynn/noise v1.1.0 // indirect + github.com/francoispqt/gojay v1.2.13 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gopacket v1.1.19 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/huin/goupnp v1.3.0 // indirect + github.com/ipfs/go-cid v0.5.0 // indirect + github.com/ipfs/go-log/v2 v2.5.1 // indirect + github.com/jackpal/go-nat-pmp v1.0.2 // indirect + github.com/jbenet/go-temp-err-catcher v0.1.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/koron/go-ssdp v0.0.5 // indirect + github.com/libp2p/go-buffer-pool v0.1.0 // indirect + github.com/libp2p/go-flow-metrics v0.2.0 // indirect + github.com/libp2p/go-libp2p-asn-util v0.4.1 // indirect + github.com/libp2p/go-msgio v0.3.0 // indirect + github.com/libp2p/go-netroute v0.2.2 // indirect + github.com/libp2p/go-yamux/v5 v5.0.0 // indirect + github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/miekg/dns v1.1.64 // indirect + github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect + github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect + github.com/minio/sha256-simd v1.0.1 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/multiformats/go-base32 v0.1.0 // indirect + github.com/multiformats/go-base36 v0.2.0 // indirect + github.com/multiformats/go-multiaddr-dns v0.4.1 // indirect + github.com/multiformats/go-multiaddr-fmt v0.1.0 // indirect + github.com/multiformats/go-multibase v0.2.0 // indirect + github.com/multiformats/go-multicodec v0.9.0 // indirect + github.com/multiformats/go-multihash v0.2.3 // indirect + github.com/multiformats/go-multistream v0.6.0 // indirect + github.com/multiformats/go-varint v0.0.7 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/onsi/ginkgo/v2 v2.23.3 // indirect + github.com/opencontainers/runtime-spec v1.2.1 // indirect + github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect github.com/pion/datachannel v1.5.10 // indirect - github.com/pion/dtls/v3 v3.0.4 // indirect + github.com/pion/dtls/v2 v2.2.12 // indirect + github.com/pion/dtls/v3 v3.0.6 // indirect github.com/pion/logging v0.2.3 // indirect github.com/pion/mdns/v2 v2.0.7 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.15 // indirect - github.com/pion/rtp v1.8.12 // indirect - github.com/pion/sctp v1.8.36 // indirect - github.com/pion/sdp/v3 v3.0.10 // indirect + github.com/pion/sctp v1.8.37 // indirect + github.com/pion/sdp/v3 v3.0.11 // indirect github.com/pion/srtp/v3 v3.0.4 // indirect + github.com/pion/stun v0.6.1 // indirect github.com/pion/stun/v3 v3.0.0 // indirect + github.com/pion/transport/v2 v2.2.10 // indirect github.com/pion/transport/v3 v3.0.7 // indirect github.com/pion/turn/v4 v4.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_golang v1.21.1 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.63.0 // indirect + github.com/prometheus/procfs v0.16.0 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.50.1 // indirect + github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 // indirect + github.com/raulk/go-watchdog v1.3.0 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/crypto v0.35.0 // indirect - golang.org/x/net v0.35.0 // indirect - golang.org/x/sys v0.30.0 // indirect + go.uber.org/dig v1.18.1 // indirect + go.uber.org/fx v1.23.0 // indirect + go.uber.org/mock v0.5.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect + golang.org/x/mod v0.24.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + golang.org/x/tools v0.31.0 // indirect + lukechampine.com/blake3 v1.4.0 // indirect ) diff --git a/packages/relay/go.sum b/packages/relay/go.sum index 015ec394..1dcab89a 100644 --- a/packages/relay/go.sum +++ b/packages/relay/go.sum @@ -1,19 +1,231 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.37.0/go.mod h1:TS1dMSSfndXH133OKGwekG838Om/cQT0BUHV3HcBgoo= +dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU= +dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= +dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= +dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= +git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= +github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cilium/ebpf v0.2.0/go.mod h1:To2CFviqOWL/M0gIMsvSMlqe7em/l1ALkX1PyjrX2Qs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/containerd/cgroups v0.0.0-20201119153540-4cbc285b3327/go.mod h1:ZJeTFisyysqgcCdecO57Dj79RfL0LNeGiFUqLYQRYLE= +github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM= +github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw= +github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd/v22 v22.1.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU= +github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/elastic/gosigar v0.12.0/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs= +github.com/elastic/gosigar v0.14.3 h1:xwkKwPia+hSfg9GqrCUKYdId102m9qTJIIr7egmK/uo= +github.com/elastic/gosigar v0.14.3/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= +github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= +github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= +github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= +github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= +github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= +github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= +github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABoLk/+KKHggpk= +github.com/jbenet/go-temp-err-catcher v0.1.0/go.mod h1:0kJRvmDZXNMIiJirNPEYfhpPwbGVtZVWC34vc5WLsDk= +github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/koron/go-ssdp v0.0.5 h1:E1iSMxIs4WqxTbIBLtmNBeOOC+1sCIXQeqTWVnpmwhk= +github.com/koron/go-ssdp v0.0.5/go.mod h1:Qm59B7hpKpDqfyRNWRNr00jGwLdXjDyZh6y7rH6VS0w= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= +github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= +github.com/libp2p/go-flow-metrics v0.2.0 h1:EIZzjmeOE6c8Dav0sNv35vhZxATIXWZg6j/C08XmmDw= +github.com/libp2p/go-flow-metrics v0.2.0/go.mod h1:st3qqfu8+pMfh+9Mzqb2GTiwrAGjIPszEjZmtksN8Jc= +github.com/libp2p/go-libp2p v0.41.1 h1:8ecNQVT5ev/jqALTvisSJeVNvXYJyK4NhQx1nNRXQZE= +github.com/libp2p/go-libp2p v0.41.1/go.mod h1:DcGTovJzQl/I7HMrby5ZRjeD0kQkGiy+9w6aEkSZpRI= +github.com/libp2p/go-libp2p-asn-util v0.4.1 h1:xqL7++IKD9TBFMgnLPZR6/6iYhawHKHl950SO9L6n94= +github.com/libp2p/go-libp2p-asn-util v0.4.1/go.mod h1:d/NI6XZ9qxw67b4e+NgpQexCIiFYJjErASrYW4PFDN8= +github.com/libp2p/go-libp2p-pubsub v0.13.1 h1:tV3ttzzZSCk0EtEXnxVmWIXgjVxXx+20Jwjbs/Ctzjo= +github.com/libp2p/go-libp2p-pubsub v0.13.1/go.mod h1:MKPU5vMI8RRFyTP0HfdsF9cLmL1nHAeJm44AxJGJx44= +github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUIK5WDu6iPUA= +github.com/libp2p/go-libp2p-testing v0.12.0/go.mod h1:KcGDRXyN7sQCllucn1cOOS+Dmm7ujhfEyXQL5lvkcPg= +github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0= +github.com/libp2p/go-msgio v0.3.0/go.mod h1:nyRM819GmVaF9LX3l03RMh10QdOroF++NBbxAb0mmDM= +github.com/libp2p/go-netroute v0.2.2 h1:Dejd8cQ47Qx2kRABg6lPwknU7+nBnFRpko45/fFPuZ8= +github.com/libp2p/go-netroute v0.2.2/go.mod h1:Rntq6jUAH0l9Gg17w5bFGhcC9a+vk4KNXs6s7IljKYE= +github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQscQm2s= +github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU= +github.com/libp2p/go-yamux/v5 v5.0.0 h1:2djUh96d3Jiac/JpGkKs4TO49YhsfLopAoryfPmf+Po= +github.com/libp2p/go-yamux/v5 v5.0.0/go.mod h1:en+3cdX51U0ZslwRdRLrvQsdayFt3TSUKvBGErzpWbU= +github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd h1:br0buuQ854V8u83wA0rVZ8ttrq5CpaPZdvrK0LP2lOk= +github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs1Nt24+FYQEqAAncTDPJIuGs+LxK1MCiFL25pMU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= +github.com/miekg/dns v1.1.64 h1:wuZgD9wwCE6XMT05UU/mlSko71eRSXEAm2EbjQXLKnQ= +github.com/miekg/dns v1.1.64/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck= +github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c h1:bzE/A84HN25pxAuk9Eej1Kz9OUelF97nAc82bDquQI8= +github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c/go.mod h1:0SQS9kMwD2VsyFEB++InYyBJroV/FRmBgcydeSUcJms= +github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b h1:z78hV3sbSMAUoyUMM0I83AUIT6Hu17AWfgjzIbtrYFc= +github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b/go.mod h1:lxPUiZwKoFL8DUUmalo2yJJUCxbPKtm8OKfqr2/FTNU= +github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc h1:PTfri+PuQmWDqERdnNMiD9ZejrlswWrCpBEZgWOiTrc= +github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc/go.mod h1:cGKTAVKx4SxOuR/czcZ/E2RSJ3sfHs8FpHhQ5CWMf9s= +github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ= +github.com/minio/sha256-simd v0.1.1-0.20190913151208-6de447530771/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= +github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= +github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= +github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= +github.com/multiformats/go-multiaddr v0.1.1/go.mod h1:aMKBKNEYmzmDmxfX88/vz+J5IU55txyt0p4aiWVohjo= +github.com/multiformats/go-multiaddr v0.15.0 h1:zB/HeaI/apcZiTDwhY5YqMvNVl/oQYvs3XySU+qeAVo= +github.com/multiformats/go-multiaddr v0.15.0/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0= +github.com/multiformats/go-multiaddr-dns v0.4.1 h1:whi/uCLbDS3mSEUMb1MsoT4uzUeZB0N32yzufqS0i5M= +github.com/multiformats/go-multiaddr-dns v0.4.1/go.mod h1:7hfthtB4E4pQwirrz+J0CcDUfbWzTqEzVyYKKIKpgkc= +github.com/multiformats/go-multiaddr-fmt v0.1.0 h1:WLEFClPycPkp4fnIzoFoV9FVd49/eQsuaL3/CWe167E= +github.com/multiformats/go-multiaddr-fmt v0.1.0/go.mod h1:hGtDIW4PU4BqJ50gW2quDuPVjyWNZxToGUh/HwTZYJo= +github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= +github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= +github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg= +github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k= +github.com/multiformats/go-multihash v0.0.8/go.mod h1:YSLudS+Pi8NHE7o6tb3D8vrpKa63epEDmG8nTduyAew= +github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= +github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= +github.com/multiformats/go-multistream v0.6.0 h1:ZaHKbsL404720283o4c/IHQXiS6gb8qAN5EIJ4PN5EA= +github.com/multiformats/go-multistream v0.6.0/go.mod h1:MOyoG5otO24cHIg8kf9QW2/NozURlkP/rvi2FQJyCPg= +github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= +github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= +github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= +github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= +github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0= +github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= +github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= +github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/opencontainers/runtime-spec v1.0.2/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww= +github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= -github.com/pion/dtls/v3 v3.0.4 h1:44CZekewMzfrn9pmGrj5BNnTMDCFwr+6sLH+cCuLM7U= -github.com/pion/dtls/v3 v3.0.4/go.mod h1:R373CsjxWqNPf6MEkfdy3aSe9niZvL/JaKlGeFphtMg= -github.com/pion/ice/v4 v4.0.7 h1:mnwuT3n3RE/9va41/9QJqN5+Bhc0H/x/ZyiVlWMw35M= -github.com/pion/ice/v4 v4.0.7/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= +github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk= +github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= +github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= +github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= +github.com/pion/ice/v4 v4.0.9 h1:VKgU4MwA2LUDVLq+WBkpEHTcAb8c5iCvFMECeuPOZNk= +github.com/pion/ice/v4 v4.0.9/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90= github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= @@ -22,37 +234,302 @@ github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= -github.com/pion/rtp v1.8.12 h1:nsKs8Wi0jQyBFHU3qmn/OvtZrhktVfJY0vRxwACsL5U= -github.com/pion/rtp v1.8.12/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4= -github.com/pion/sctp v1.8.36 h1:owNudmnz1xmhfYje5L/FCav3V9wpPRePHle3Zi+P+M0= -github.com/pion/sctp v1.8.36/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= -github.com/pion/sdp/v3 v3.0.10 h1:6MChLE/1xYB+CjumMw+gZ9ufp2DPApuVSnDT8t5MIgA= -github.com/pion/sdp/v3 v3.0.10/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= +github.com/pion/rtp v1.8.13 h1:8uSUPpjSL4OlwZI8Ygqu7+h2p9NPFB+yAZ461Xn5sNg= +github.com/pion/rtp v1.8.13/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4= +github.com/pion/sctp v1.8.37 h1:ZDmGPtRPX9mKCiVXtMbTWybFw3z/hVKAZgU81wcOrqs= +github.com/pion/sctp v1.8.37/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= +github.com/pion/sdp/v3 v3.0.11 h1:VhgVSopdsBKwhCFoyyPmT1fKMeV9nLMrEKxNOdy3IVI= +github.com/pion/sdp/v3 v3.0.11/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= +github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4= +github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8= github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= +github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= +github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q= +github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= -github.com/pion/webrtc/v4 v4.0.12 h1:/omInB15DdJDlA3WoAQAAhIQQvFCWNHdJ2t5e2+ozx4= -github.com/pion/webrtc/v4 v4.0.12/go.mod h1:sMOtH6DSNVu6tfndczTMvJkKnyFVVeq+/G3dval418g= +github.com/pion/webrtc/v4 v4.0.14 h1:nyds/sFRR+HvmWoBa6wrL46sSfpArE0qR883MBW96lg= +github.com/pion/webrtc/v4 v4.0.14/go.mod h1:R3+qTnQTS03UzwDarYecgioNf7DYgTsldxnCXB821Kk= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= +github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= +github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= +github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM= +github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.50.1 h1:unsgjFIUqW8a2oopkY7YNONpV1gYND6Nt9hnt1PN94Q= +github.com/quic-go/quic-go v0.50.1/go.mod h1:Vim6OmUvlYdwBhXP9ZVrtGmCMWa3wEqhq3NgYrI8b4E= +github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 h1:4WFk6u3sOT6pLa1kQ50ZVdm8BQFgJNA117cepZxtLIg= +github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66/go.mod h1:Vp72IJajgeOL6ddqrAhmp7IM9zbTcgkQxD/YdxrVwMw= +github.com/raulk/go-watchdog v1.3.0 h1:oUmdlHxdkXRJlwfG0O9omj8ukerm8MEQavSiDTEtBsk= +github.com/raulk/go-watchdog v1.3.0/go.mod h1:fIvOnLbF0b0ZwkB9YU4mOW9Did//4vPZtDqv66NfsMU= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= +github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= +github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= +github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= +github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= +github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw= +github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI= +github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU= +github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag= +github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9ARFAs37qieuu7ohDNQ3gds9msbT2yn85sg= +github.com/shurcooL/htmlg v0.0.0-20170918183704-d01228ac9e50/go.mod h1:zPn1wHpTIePGnXSHpsVPWEktKXHr6+SS6x/IKRb7cpw= +github.com/shurcooL/httperror v0.0.0-20170206035902-86b7830d14cc/go.mod h1:aYMfkZ6DWSJPJ6c4Wwz3QtW22G7mf/PEgaB9k/ik5+Y= +github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= +github.com/shurcooL/httpgzip v0.0.0-20180522190206-b1c53ac65af9/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q= +github.com/shurcooL/issues v0.0.0-20181008053335-6292fdc1e191/go.mod h1:e2qWDig5bLteJ4fwvDAc2NHzqFEthkqn7aOZAOpj+PQ= +github.com/shurcooL/issuesapp v0.0.0-20180602232740-048589ce2241/go.mod h1:NPpHK2TI7iSaM0buivtFUc9offApnI0Alt/K8hcHy0I= +github.com/shurcooL/notifications v0.0.0-20181007000457-627ab5aea122/go.mod h1:b5uSkrEVM1jQUspwbixRBhaIjIzL2xazXp6kntxYle0= +github.com/shurcooL/octicon v0.0.0-20181028054416-fa4f57f9efb2/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ= +github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1lToEk4d2s07G3XGfz2QrgHXg4RJBvjrOozvoWfk= +github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= +github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= +github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= +github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= +github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= +github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= -golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= -golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/dig v1.18.1 h1:rLww6NuajVjeQn+49u5NcezUJEGwd5uXmyoCKW2g5Es= +go.uber.org/dig v1.18.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/fx v1.23.0 h1:lIr/gYWQGfTwGcSXWXu4vP5Ws6iqnNEIY+F/aFzCKTg= +go.uber.org/fx v1.23.0/go.mod h1:o/D9n+2mLP6v1EG+qsdT1O8wKopYAsqZasju97SDFCU= +go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= +golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= +golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= +golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= +google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +lukechampine.com/blake3 v1.4.0 h1:xDbKOZCVbnZsfzM6mHSYcGRHZ3YrLDzqz8XnV4uaD5w= +lukechampine.com/blake3 v1.4.0/go.mod h1:MQJNQCTnR+kwOP/JEZSxj3MaQjp80FOFSNMMHXcSeX0= +sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= +sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= diff --git a/packages/relay/internal/common.go b/packages/relay/internal/common/common.go similarity index 72% rename from packages/relay/internal/common.go rename to packages/relay/internal/common/common.go index b09abd3a..baf2dd35 100644 --- a/packages/relay/internal/common.go +++ b/packages/relay/internal/common/common.go @@ -1,10 +1,13 @@ -package relay +package common import ( + "fmt" + "github.com/libp2p/go-reuseport" "github.com/pion/ice/v4" "github.com/pion/interceptor" "github.com/pion/webrtc/v4" - "log" + "log/slog" + "strconv" ) var globalWebRTCAPI *webrtc.API @@ -21,6 +24,19 @@ func InitWebRTCAPI() error { // Media engine mediaEngine := &webrtc.MediaEngine{} + // Register additional header extensions to reduce latency + // Playout Delay + if err := mediaEngine.RegisterHeaderExtension(webrtc.RTPHeaderExtensionCapability{ + URI: ExtensionPlayoutDelay, + }, webrtc.RTPCodecTypeVideo); err != nil { + return err + } + if err := mediaEngine.RegisterHeaderExtension(webrtc.RTPHeaderExtensionCapability{ + URI: ExtensionPlayoutDelay, + }, webrtc.RTPCodecTypeAudio); err != nil { + return err + } + // Default codecs cover most of our needs err = mediaEngine.RegisterDefaultCodecs() if err != nil { @@ -66,17 +82,23 @@ func InitWebRTCAPI() error { muxPort := GetFlags().UDPMuxPort if muxPort > 0 { - mux, err := ice.NewMultiUDPMuxFromPort(muxPort) + // Use reuseport to allow multiple listeners on the same port + pktListener, err := reuseport.ListenPacket("udp", ":"+strconv.Itoa(muxPort)) if err != nil { - return err + return fmt.Errorf("failed to create UDP listener: %w", err) } + + mux := ice.NewMultiUDPMuxDefault(ice.NewUDPMuxDefault(ice.UDPMuxParams{ + UDPConn: pktListener, + })) + slog.Info("Using UDP Mux for WebRTC", "port", muxPort) settingEngine.SetICEUDPMux(mux) - } else { - // Set the UDP port range used by WebRTC - err = settingEngine.SetEphemeralUDPPortRange(uint16(flags.WebRTCUDPStart), uint16(flags.WebRTCUDPEnd)) - if err != nil { - return err - } + } + + // Set the UDP port range used by WebRTC + err = settingEngine.SetEphemeralUDPPortRange(uint16(flags.WebRTCUDPStart), uint16(flags.WebRTCUDPEnd)) + if err != nil { + return err } settingEngine.SetIncludeLoopbackCandidate(true) // Just in case @@ -107,7 +129,7 @@ func CreatePeerConnection(onClose func()) (*webrtc.PeerConnection, error) { connectionState == webrtc.PeerConnectionStateClosed { err = pc.Close() if err != nil { - log.Printf("Error closing PeerConnection: %s\n", err.Error()) + slog.Error("Failed to close PeerConnection", "err", err) } onClose() } diff --git a/packages/relay/internal/common/crypto.go b/packages/relay/internal/common/crypto.go new file mode 100644 index 00000000..45f85626 --- /dev/null +++ b/packages/relay/internal/common/crypto.go @@ -0,0 +1,19 @@ +package common + +import ( + "crypto/rand" + "crypto/sha256" + "github.com/oklog/ulid/v2" + "time" +) + +func NewULID() (ulid.ULID, error) { + return ulid.New(ulid.Timestamp(time.Now()), ulid.Monotonic(rand.Reader, 0)) +} + +// Helper function to generate PSK from token +func GeneratePSKFromToken(token string) ([]byte, error) { + // Simple hash-based PSK generation (32 bytes for libp2p) + hash := sha256.Sum256([]byte(token)) + return hash[:], nil +} diff --git a/packages/relay/internal/common/extensions.go b/packages/relay/internal/common/extensions.go new file mode 100644 index 00000000..79559bc4 --- /dev/null +++ b/packages/relay/internal/common/extensions.go @@ -0,0 +1,11 @@ +package common + +const ( + ExtensionPlayoutDelay string = "http://www.webrtc.org/experiments/rtp-hdrext/playout-delay" +) + +// ExtensionMap maps URIs to their IDs based on registration order +// IMPORTANT: This must match the order in which extensions are registered in common.go! +var ExtensionMap = map[string]uint8{ + ExtensionPlayoutDelay: 1, +} diff --git a/packages/relay/internal/flags.go b/packages/relay/internal/common/flags.go similarity index 74% rename from packages/relay/internal/flags.go rename to packages/relay/internal/common/flags.go index 765ff804..87c333c3 100644 --- a/packages/relay/internal/flags.go +++ b/packages/relay/internal/common/flags.go @@ -1,9 +1,9 @@ -package relay +package common import ( "flag" "github.com/pion/webrtc/v4" - "log" + "log/slog" "net" "os" "strconv" @@ -13,9 +13,10 @@ import ( var globalFlags *Flags type Flags struct { - Verbose bool // Verbose mode - log more information to console - Debug bool // Debug mode - log deeper debug information to console + Verbose bool // Log everything to console + Debug bool // Enable debug mode, implies Verbose EndpointPort int // Port for HTTP/S and WS/S endpoint (TCP) + MeshPort int // Port for Mesh connections (TCP) WebRTCUDPStart int // WebRTC UDP port range start - ignored if UDPMuxPort is set WebRTCUDPEnd int // WebRTC UDP port range end - ignored if UDPMuxPort is set STUNServer string // WebRTC STUN server @@ -24,23 +25,25 @@ type Flags struct { NAT11IPs []string // WebRTC NAT 1 to 1 IP(s) - allows specifying host IP(s) if behind NAT TLSCert string // Path to TLS certificate TLSKey string // Path to TLS key + ControlSecret string // Shared secret for this relay's control endpoint } func (flags *Flags) DebugLog() { - log.Println("Relay Flags:") - log.Println("> Verbose: ", flags.Verbose) - log.Println("> Debug: ", flags.Debug) - log.Println("> Endpoint Port: ", flags.EndpointPort) - log.Println("> WebRTC UDP Range Start: ", flags.WebRTCUDPStart) - log.Println("> WebRTC UDP Range End: ", flags.WebRTCUDPEnd) - log.Println("> WebRTC STUN Server: ", flags.STUNServer) - log.Println("> WebRTC UDP Mux Port: ", flags.UDPMuxPort) - log.Println("> Auto Add Local IP: ", flags.AutoAddLocalIP) - for i, ip := range flags.NAT11IPs { - log.Printf("> WebRTC NAT 1 to 1 IP (%d): %s\n", i, ip) - } - log.Println("> Path to TLS Cert: ", flags.TLSCert) - log.Println("> Path to TLS Key: ", flags.TLSKey) + slog.Info("Relay flags", + "verbose", flags.Verbose, + "debug", flags.Debug, + "endpointPort", flags.EndpointPort, + "meshPort", flags.MeshPort, + "webrtcUDPStart", flags.WebRTCUDPStart, + "webrtcUDPEnd", flags.WebRTCUDPEnd, + "stunServer", flags.STUNServer, + "webrtcUDPMux", flags.UDPMuxPort, + "autoAddLocalIP", flags.AutoAddLocalIP, + "webrtcNAT11IPs", strings.Join(flags.NAT11IPs, ","), + "tlsCert", flags.TLSCert, + "tlsKey", flags.TLSKey, + "controlSecret", flags.ControlSecret, + ) } func getEnvAsInt(name string, defaultVal int) int { @@ -76,6 +79,7 @@ func InitFlags() { flag.BoolVar(&globalFlags.Verbose, "verbose", getEnvAsBool("VERBOSE", false), "Verbose mode") flag.BoolVar(&globalFlags.Debug, "debug", getEnvAsBool("DEBUG", false), "Debug mode") flag.IntVar(&globalFlags.EndpointPort, "endpointPort", getEnvAsInt("ENDPOINT_PORT", 8088), "HTTP endpoint port") + flag.IntVar(&globalFlags.MeshPort, "meshPort", getEnvAsInt("MESH_PORT", 8089), "Mesh connections TCP port") flag.IntVar(&globalFlags.WebRTCUDPStart, "webrtcUDPStart", getEnvAsInt("WEBRTC_UDP_START", 10000), "WebRTC UDP port range start") flag.IntVar(&globalFlags.WebRTCUDPEnd, "webrtcUDPEnd", getEnvAsInt("WEBRTC_UDP_END", 20000), "WebRTC UDP port range end") flag.StringVar(&globalFlags.STUNServer, "stunServer", getEnvAsString("STUN_SERVER", "stun.l.google.com:19302"), "WebRTC STUN server") @@ -83,12 +87,20 @@ func InitFlags() { flag.BoolVar(&globalFlags.AutoAddLocalIP, "autoAddLocalIP", getEnvAsBool("AUTO_ADD_LOCAL_IP", true), "Automatically add local IP to NAT 1 to 1 IPs") // String with comma separated IPs nat11IPs := "" - flag.StringVar(&nat11IPs, "webrtcNAT11IPs", getEnvAsString("WEBRTC_NAT_IPS", ""), "WebRTC NAT 1 to 1 IP(s)") + flag.StringVar(&nat11IPs, "webrtcNAT11IPs", getEnvAsString("WEBRTC_NAT_IPS", ""), "WebRTC NAT 1 to 1 IP(s), comma delimited") flag.StringVar(&globalFlags.TLSCert, "tlsCert", getEnvAsString("TLS_CERT", ""), "Path to TLS certificate") flag.StringVar(&globalFlags.TLSKey, "tlsKey", getEnvAsString("TLS_KEY", ""), "Path to TLS key") + flag.StringVar(&globalFlags.ControlSecret, "controlSecret", getEnvAsString("CONTROL_SECRET", ""), "Shared secret for control endpoint") // Parse flags flag.Parse() + // If debug is enabled, verbose is also enabled + if globalFlags.Debug { + globalFlags.Verbose = true + // If Debug is enabled, set ControlSecret to 1234 + globalFlags.ControlSecret = "1234" + } + // ICE STUN servers globalWebRTCConfig.ICEServers = []webrtc.ICEServer{ { diff --git a/packages/relay/internal/latency.go b/packages/relay/internal/common/latency.go similarity index 99% rename from packages/relay/internal/latency.go rename to packages/relay/internal/common/latency.go index 152f3492..fb8073d9 100644 --- a/packages/relay/internal/latency.go +++ b/packages/relay/internal/common/latency.go @@ -1,4 +1,4 @@ -package relay +package common import ( "fmt" diff --git a/packages/relay/internal/common/loghandler.go b/packages/relay/internal/common/loghandler.go new file mode 100644 index 00000000..42141652 --- /dev/null +++ b/packages/relay/internal/common/loghandler.go @@ -0,0 +1,48 @@ +package common + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" +) + +type CustomHandler struct { + Handler slog.Handler +} + +func (h *CustomHandler) Enabled(_ context.Context, level slog.Level) bool { + return h.Handler.Enabled(nil, level) +} + +func (h *CustomHandler) Handle(_ context.Context, r slog.Record) error { + // Format the timestamp as "2006/01/02 15:04:05" + timestamp := r.Time.Format("2006/01/02 15:04:05") + // Convert level to uppercase string (e.g., "INFO") + level := strings.ToUpper(r.Level.String()) + // Build the message + msg := fmt.Sprintf("%s %s %s", timestamp, level, r.Message) + + // Handle additional attributes if they exist + var attrs []string + r.Attrs(func(a slog.Attr) bool { + attrs = append(attrs, fmt.Sprintf("%s=%v", a.Key, a.Value)) + return true + }) + if len(attrs) > 0 { + msg += " " + strings.Join(attrs, " ") + } + + // Write the formatted message to stdout + _, err := fmt.Fprintln(os.Stdout, msg) + return err +} + +func (h *CustomHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &CustomHandler{Handler: h.Handler.WithAttrs(attrs)} +} + +func (h *CustomHandler) WithGroup(name string) slog.Handler { + return &CustomHandler{Handler: h.Handler.WithGroup(name)} +} diff --git a/packages/relay/internal/common/map.go b/packages/relay/internal/common/map.go new file mode 100644 index 00000000..c8cffa9a --- /dev/null +++ b/packages/relay/internal/common/map.go @@ -0,0 +1,101 @@ +package common + +import ( + "errors" + "reflect" + "sync" +) + +var ( + ErrKeyNotFound = errors.New("key not found") + ErrValueNotPointer = errors.New("value is not a pointer") + ErrFieldNotFound = errors.New("field not found") + ErrTypeMismatch = errors.New("type mismatch") +) + +// SafeMap is a generic thread-safe map with its own mutex +type SafeMap[K comparable, V any] struct { + mu sync.RWMutex + m map[K]V +} + +// NewSafeMap creates a new SafeMap instance +func NewSafeMap[K comparable, V any]() *SafeMap[K, V] { + return &SafeMap[K, V]{ + m: make(map[K]V), + } +} + +// Get retrieves a value from the map +func (sm *SafeMap[K, V]) Get(key K) (V, bool) { + sm.mu.RLock() + defer sm.mu.RUnlock() + v, ok := sm.m[key] + return v, ok +} + +// Set adds or updates a value in the map +func (sm *SafeMap[K, V]) Set(key K, value V) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.m[key] = value +} + +// Delete removes a key from the map +func (sm *SafeMap[K, V]) Delete(key K) { + sm.mu.Lock() + defer sm.mu.Unlock() + delete(sm.m, key) +} + +// Len returns the number of items in the map +func (sm *SafeMap[K, V]) Len() int { + sm.mu.RLock() + defer sm.mu.RUnlock() + return len(sm.m) +} + +// Copy creates a shallow copy of the map and returns it +func (sm *SafeMap[K, V]) Copy() map[K]V { + sm.mu.RLock() + defer sm.mu.RUnlock() + copied := make(map[K]V, len(sm.m)) + for k, v := range sm.m { + copied[k] = v + } + return copied +} + +// Update updates a specific field in the value data +func (sm *SafeMap[K, V]) Update(key K, fieldName string, newValue any) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + v, ok := sm.m[key] + if !ok { + return ErrKeyNotFound + } + + // Use reflect to update the field + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Ptr { + return ErrValueNotPointer + } + + rv = rv.Elem() + // Check if the field exists + field := rv.FieldByName(fieldName) + if !field.IsValid() || !field.CanSet() { + return ErrFieldNotFound + } + + newRV := reflect.ValueOf(newValue) + if newRV.Type() != field.Type() { + return ErrTypeMismatch + } + + field.Set(newRV) + sm.m[key] = v + + return nil +} diff --git a/packages/relay/internal/datachannel.go b/packages/relay/internal/connections/datachannel.go similarity index 94% rename from packages/relay/internal/datachannel.go rename to packages/relay/internal/connections/datachannel.go index ae902f00..ba50b006 100644 --- a/packages/relay/internal/datachannel.go +++ b/packages/relay/internal/connections/datachannel.go @@ -1,9 +1,9 @@ -package relay +package connections import ( "github.com/pion/webrtc/v4" "google.golang.org/protobuf/proto" - "log" + "log/slog" gen "relay/internal/proto" ) @@ -30,7 +30,7 @@ func NewNestriDataChannel(dc *webrtc.DataChannel) *NestriDataChannel { // Decode message var base gen.ProtoMessageInput if err := proto.Unmarshal(msg.Data, &base); err != nil { - log.Printf("Failed to decode binary DataChannel message, reason: %s\n", err) + slog.Error("failed to decode binary DataChannel message", "err", err) return } diff --git a/packages/relay/internal/messages.go b/packages/relay/internal/connections/messages.go similarity index 94% rename from packages/relay/internal/messages.go rename to packages/relay/internal/connections/messages.go index 05ba5c44..6837e77b 100644 --- a/packages/relay/internal/messages.go +++ b/packages/relay/internal/connections/messages.go @@ -1,17 +1,15 @@ -package relay +package connections import ( "github.com/pion/webrtc/v4" + "relay/internal/common" "time" ) -// OnMessageCallback is a callback for messages of given type -type OnMessageCallback func(data []byte) - // MessageBase is the base type for WS/DC messages. type MessageBase struct { - PayloadType string `json:"payload_type"` - Latency *LatencyTracker `json:"latency,omitempty"` + PayloadType string `json:"payload_type"` + Latency *common.LatencyTracker `json:"latency,omitempty"` } // MessageLog represents a log message. diff --git a/packages/relay/internal/connections/messages_mesh.go b/packages/relay/internal/connections/messages_mesh.go new file mode 100644 index 00000000..6b2dbffb --- /dev/null +++ b/packages/relay/internal/connections/messages_mesh.go @@ -0,0 +1,119 @@ +package connections + +import ( + "github.com/pion/webrtc/v4" + "google.golang.org/protobuf/proto" + gen "relay/internal/proto" +) + +// SendMeshHandshake sends a handshake message to another relay. +func (ws *SafeWebSocket) SendMeshHandshake(relayID, publicKey string) error { + msg := &gen.MeshMessage{ + Type: &gen.MeshMessage_Handshake{ + Handshake: &gen.Handshake{ + RelayId: relayID, + DhPublicKey: publicKey, + }, + }, + } + data, err := proto.Marshal(msg) + if err != nil { + return err + } + return ws.SendBinary(data) +} + +// SendMeshHandshakeResponse sends a handshake response to a relay. +func (ws *SafeWebSocket) SendMeshHandshakeResponse(relayID, dhPublicKey string, approvals map[string]string) error { + msg := &gen.MeshMessage{ + Type: &gen.MeshMessage_HandshakeResponse{ + HandshakeResponse: &gen.HandshakeResponse{ + RelayId: relayID, + DhPublicKey: dhPublicKey, + Approvals: approvals, + }, + }, + } + data, err := proto.Marshal(msg) + if err != nil { + return err + } + return ws.SendBinary(data) +} + +// SendMeshForwardSDP sends a forwarded SDP message to another relay +func (ws *SafeWebSocket) SendMeshForwardSDP(roomName, participantID string, sdp webrtc.SessionDescription) error { + msg := &gen.MeshMessage{ + Type: &gen.MeshMessage_ForwardSdp{ + ForwardSdp: &gen.ForwardSDP{ + RoomName: roomName, + ParticipantId: participantID, + Sdp: sdp.SDP, + Type: sdp.Type.String(), + }, + }, + } + data, err := proto.Marshal(msg) + if err != nil { + return err + } + return ws.SendBinary(data) +} + +// SendMeshForwardICE sends a forwarded ICE candidate to another relay +func (ws *SafeWebSocket) SendMeshForwardICE(roomName, participantID string, candidate webrtc.ICECandidateInit) error { + var sdpMLineIndex uint32 + if candidate.SDPMLineIndex != nil { + sdpMLineIndex = uint32(*candidate.SDPMLineIndex) + } + + msg := &gen.MeshMessage{ + Type: &gen.MeshMessage_ForwardIce{ + ForwardIce: &gen.ForwardICE{ + RoomName: roomName, + ParticipantId: participantID, + Candidate: &gen.ICECandidateInit{ + Candidate: candidate.Candidate, + SdpMid: candidate.SDPMid, + SdpMLineIndex: &sdpMLineIndex, + UsernameFragment: candidate.UsernameFragment, + }, + }, + }, + } + data, err := proto.Marshal(msg) + if err != nil { + return err + } + return ws.SendBinary(data) +} + +func (ws *SafeWebSocket) SendMeshForwardIngest(roomName string) error { + msg := &gen.MeshMessage{ + Type: &gen.MeshMessage_ForwardIngest{ + ForwardIngest: &gen.ForwardIngest{ + RoomName: roomName, + }, + }, + } + data, err := proto.Marshal(msg) + if err != nil { + return err + } + return ws.SendBinary(data) +} + +func (ws *SafeWebSocket) SendMeshStreamRequest(roomName string) error { + msg := &gen.MeshMessage{ + Type: &gen.MeshMessage_StreamRequest{ + StreamRequest: &gen.StreamRequest{ + RoomName: roomName, + }, + }, + } + data, err := proto.Marshal(msg) + if err != nil { + return err + } + return ws.SendBinary(data) +} diff --git a/packages/relay/internal/websocket.go b/packages/relay/internal/connections/websocket.go similarity index 53% rename from packages/relay/internal/websocket.go rename to packages/relay/internal/connections/websocket.go index 2c85fdbc..f0eab341 100644 --- a/packages/relay/internal/websocket.go +++ b/packages/relay/internal/connections/websocket.go @@ -1,26 +1,37 @@ -package relay +package connections import ( "encoding/json" "github.com/gorilla/websocket" - "log" + "log/slog" "sync" ) +// OnMessageCallback is a callback for messages of given type +type OnMessageCallback func(data []byte) + // SafeWebSocket is a websocket with a mutex type SafeWebSocket struct { *websocket.Conn sync.Mutex - closeCallback func() // OnClose callback - callbacks map[string]OnMessageCallback // MessageBase type -> callback + closed bool + closeCallback func() // Callback to call on close + closeChan chan struct{} // Channel to signal closure + callbacks map[string]OnMessageCallback // MessageBase type -> callback + binaryCallback OnMessageCallback // Binary message callback + sharedSecret []byte } // NewSafeWebSocket creates a new SafeWebSocket from *websocket.Conn func NewSafeWebSocket(conn *websocket.Conn) *SafeWebSocket { ws := &SafeWebSocket{ - Conn: conn, - closeCallback: nil, - callbacks: make(map[string]OnMessageCallback), + Conn: conn, + closed: false, + closeCallback: nil, + closeChan: make(chan struct{}), + callbacks: make(map[string]OnMessageCallback), + binaryCallback: nil, + sharedSecret: nil, } // Launch a goroutine to handle messages @@ -30,14 +41,12 @@ func NewSafeWebSocket(conn *websocket.Conn) *SafeWebSocket { kind, data, err := ws.Conn.ReadMessage() if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure, websocket.CloseNoStatusReceived) { // If unexpected close error, break - if GetFlags().Verbose { - log.Printf("Unexpected WebSocket close error, reason: %s\n", err) - } + slog.Debug("WebSocket closed unexpectedly", "err", err) break } else if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure, websocket.CloseNoStatusReceived) { break } else if err != nil { - log.Printf("Failed to read WebSocket message, reason: %s\n", err) + slog.Error("Failed reading WebSocket message", "err", err) break } @@ -46,32 +55,48 @@ func NewSafeWebSocket(conn *websocket.Conn) *SafeWebSocket { // Decode message var msg MessageBase if err = json.Unmarshal(data, &msg); err != nil { - log.Printf("Failed to decode text WebSocket message, reason: %s\n", err) + slog.Error("Failed decoding WebSocket message", "err", err) continue } // Handle message type callback if callback, ok := ws.callbacks[msg.PayloadType]; ok { callback(data) - } // TODO: Log unknown message type? + } // TODO: Log unknown message payload type? break case websocket.BinaryMessage: + // Handle binary message callback + if ws.binaryCallback != nil { + ws.binaryCallback(data) + } break default: - log.Printf("Unknown WebSocket message type: %d\n", kind) + slog.Warn("Unknown WebSocket message type", "type", kind) break } } - // Call close callback + // Signal closure to callback first if ws.closeCallback != nil { ws.closeCallback() } + close(ws.closeChan) + ws.closed = true }() return ws } +// SetSharedSecret sets the shared secret for the websocket +func (ws *SafeWebSocket) SetSharedSecret(secret []byte) { + ws.sharedSecret = secret +} + +// GetSharedSecret returns the shared secret for the websocket +func (ws *SafeWebSocket) GetSharedSecret() []byte { + return ws.sharedSecret +} + // SendJSON writes JSON to a websocket with a mutex func (ws *SafeWebSocket) SendJSON(v interface{}) error { ws.Lock() @@ -88,31 +113,46 @@ func (ws *SafeWebSocket) SendBinary(data []byte) error { // RegisterMessageCallback sets the callback for binary message of given type func (ws *SafeWebSocket) RegisterMessageCallback(msgType string, callback OnMessageCallback) { - ws.Lock() - defer ws.Unlock() if ws.callbacks == nil { ws.callbacks = make(map[string]OnMessageCallback) } ws.callbacks[msgType] = callback } +// RegisterBinaryMessageCallback sets the callback for all binary messages +func (ws *SafeWebSocket) RegisterBinaryMessageCallback(callback OnMessageCallback) { + ws.binaryCallback = callback +} + // UnregisterMessageCallback removes the callback for binary message of given type func (ws *SafeWebSocket) UnregisterMessageCallback(msgType string) { - ws.Lock() - defer ws.Unlock() if ws.callbacks != nil { delete(ws.callbacks, msgType) } } +// UnregisterBinaryMessageCallback removes the callback for all binary messages +func (ws *SafeWebSocket) UnregisterBinaryMessageCallback() { + ws.binaryCallback = nil +} + // RegisterOnClose sets the callback for websocket closing func (ws *SafeWebSocket) RegisterOnClose(callback func()) { ws.closeCallback = func() { // Clear our callbacks - ws.Lock() ws.callbacks = nil - ws.Unlock() + ws.binaryCallback = nil // Call the callback callback() } } + +// Closed returns a channel that closes when the WebSocket connection is terminated +func (ws *SafeWebSocket) Closed() <-chan struct{} { + return ws.closeChan +} + +// IsClosed returns true if the WebSocket connection is closed +func (ws *SafeWebSocket) IsClosed() bool { + return ws.closed +} diff --git a/packages/relay/internal/egress.go b/packages/relay/internal/egress.go index 2cf3d661..2222c569 100644 --- a/packages/relay/internal/egress.go +++ b/packages/relay/internal/egress.go @@ -1,26 +1,26 @@ -package relay +package internal import ( + "context" "encoding/json" "github.com/pion/webrtc/v4" "google.golang.org/protobuf/proto" - "log" + "log/slog" + "relay/internal/common" + "relay/internal/connections" gen "relay/internal/proto" ) -func participantHandler(participant *Participant, room *Room) { - // Callback for closing PeerConnection +func ParticipantHandler(participant *Participant, room *Room, relay *Relay) { onPCClose := func() { - if GetFlags().Verbose { - log.Printf("Closed PeerConnection for participant: '%s'\n", participant.ID) - } + slog.Debug("Participant PeerConnection closed", "participant", participant.ID, "room", room.Name) room.removeParticipantByID(participant.ID) } var err error - participant.PeerConnection, err = CreatePeerConnection(onPCClose) + participant.PeerConnection, err = common.CreatePeerConnection(onPCClose) if err != nil { - log.Printf("Failed to create PeerConnection for participant: '%s' - reason: %s\n", participant.ID, err) + slog.Error("Failed to create participant PeerConnection", "participant", participant.ID, "room", room.Name, "err", err) return } @@ -32,67 +32,32 @@ func participantHandler(participant *Participant, room *Room) { MaxRetransmits: &settingMaxRetransmits, }) if err != nil { - log.Printf("Failed to create data channel for participant: '%s' in room: '%s' - reason: %s\n", participant.ID, room.Name, err) + slog.Error("Failed to create data channel for participant", "participant", participant.ID, "room", room.Name, "err", err) return } - participant.DataChannel = NewNestriDataChannel(dc) + participant.DataChannel = connections.NewNestriDataChannel(dc) // Register channel opening handling participant.DataChannel.RegisterOnOpen(func() { - if GetFlags().Verbose { - log.Printf("DataChannel open for participant: %s\n", participant.ID) - } + slog.Debug("DataChannel opened for participant", "participant", participant.ID, "room", room.Name) }) // Register channel closing handling participant.DataChannel.RegisterOnClose(func() { - if GetFlags().Verbose { - log.Printf("DataChannel closed for participant: %s\n", participant.ID) - } + slog.Debug("DataChannel closed for participant", "participant", participant.ID, "room", room.Name) }) // Register text message handling participant.DataChannel.RegisterMessageCallback("input", func(data []byte) { - // Send to room if it has a DataChannel - if room.DataChannel != nil { - // If debug mode, decode and add our timestamp, otherwise just send to room - if GetFlags().Debug { - var inputMsg gen.ProtoMessageInput - if err = proto.Unmarshal(data, &inputMsg); err != nil { - log.Printf("Failed to decode input message from participant: '%s' in room: '%s' - reason: %s\n", participant.ID, room.Name, err) - return - } - - protoLat := inputMsg.GetMessageBase().GetLatency() - if protoLat != nil { - lat := LatencyTrackerFromProto(protoLat) - lat.AddTimestamp("relay_to_node") - protoLat = lat.ToProto() - } - - // Marshal and send - if data, err = proto.Marshal(&inputMsg); err != nil { - log.Printf("Failed to marshal input message for participant: '%s' in room: '%s' - reason: %s\n", participant.ID, room.Name, err) - return - } - } - - if err = room.DataChannel.SendBinary(data); err != nil { - log.Printf("Failed to send input message to room: '%s' - reason: %s\n", room.Name, err) - } - } + ForwardParticipantDataChannelMessage(participant, room, data) }) participant.PeerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) { if candidate == nil { return } - if GetFlags().Verbose { - log.Printf("ICE candidate for participant: '%s' in room: '%s'\n", participant.ID, room.Name) - } - err = participant.WebSocket.SendICECandidateMessageWS(candidate.ToJSON()) - if err != nil { - log.Printf("Failed to send ICE candidate for participant: '%s' in room: '%s' - reason: %s\n", participant.ID, room.Name, err) + if err := participant.WebSocket.SendICECandidateMessageWS(candidate.ToJSON()); err != nil { + slog.Error("Failed to send ICE candidate to participant", "participant", participant.ID, "room", room.Name, "err", err) } }) @@ -100,35 +65,32 @@ func participantHandler(participant *Participant, room *Room) { // ICE callback participant.WebSocket.RegisterMessageCallback("ice", func(data []byte) { - var iceMsg MessageICECandidate + var iceMsg connections.MessageICECandidate if err = json.Unmarshal(data, &iceMsg); err != nil { - log.Printf("Failed to decode ICE message from participant: '%s' in room: '%s' - reason: %s\n", participant.ID, room.Name, err) + slog.Error("Failed to decode ICE candidate message from participant", "participant", participant.ID, "room", room.Name, "err", err) return } - candidate := webrtc.ICECandidateInit{ - Candidate: iceMsg.Candidate.Candidate, - } if participant.PeerConnection.RemoteDescription() != nil { - if err = participant.PeerConnection.AddICECandidate(candidate); err != nil { - log.Printf("Failed to add ICE candidate from participant: '%s' in room: '%s' - reason: %s\n", participant.ID, room.Name, err) + if err = participant.PeerConnection.AddICECandidate(iceMsg.Candidate); err != nil { + slog.Error("Failed to add ICE candidate for participant", "participant", participant.ID, "room", room.Name, "err", err) } // Add held ICE candidates for _, heldCandidate := range iceHolder { if err = participant.PeerConnection.AddICECandidate(heldCandidate); err != nil { - log.Printf("Failed to add held ICE candidate from participant: '%s' in room: '%s' - reason: %s\n", participant.ID, room.Name, err) + slog.Error("Failed to add held ICE candidate for participant", "participant", participant.ID, "room", room.Name, "err", err) } } iceHolder = nil } else { - iceHolder = append(iceHolder, candidate) + iceHolder = append(iceHolder, iceMsg.Candidate) } }) // SDP answer callback participant.WebSocket.RegisterMessageCallback("sdp", func(data []byte) { - var sdpMsg MessageSDP + var sdpMsg connections.MessageSDP if err = json.Unmarshal(data, &sdpMsg); err != nil { - log.Printf("Failed to decode SDP message from participant: '%s' in room: '%s' - reason: %s\n", participant.ID, room.Name, err) + slog.Error("Failed to decode SDP message from participant", "participant", participant.ID, "room", room.Name, "err", err) return } handleParticipantSDP(participant, sdpMsg) @@ -136,9 +98,9 @@ func participantHandler(participant *Participant, room *Room) { // Log callback participant.WebSocket.RegisterMessageCallback("log", func(data []byte) { - var logMsg MessageLog + var logMsg connections.MessageLog if err = json.Unmarshal(data, &logMsg); err != nil { - log.Printf("Failed to decode log message from participant: '%s' in room: '%s' - reason: %s\n", participant.ID, room.Name, err) + slog.Error("Failed to decode log message from participant", "participant", participant.ID, "room", room.Name, "err", err) return } // TODO: Handle log message sending to metrics server @@ -150,38 +112,36 @@ func participantHandler(participant *Participant, room *Room) { }) participant.WebSocket.RegisterOnClose(func() { - if GetFlags().Verbose { - log.Printf("WebSocket closed for participant: '%s' in room: '%s'\n", participant.ID, room.Name) - } + slog.Debug("WebSocket closed for participant", "participant", participant.ID, "room", room.Name) // Remove from Room room.removeParticipantByID(participant.ID) }) - log.Printf("Participant: '%s' in room: '%s' is now ready, sending an OK\n", participant.ID, room.Name) - if err = participant.WebSocket.SendAnswerMessageWS(AnswerOK); err != nil { - log.Printf("Failed to send OK answer for participant: '%s' in room: '%s' - reason: %s\n", participant.ID, room.Name, err) + slog.Info("Participant ready, sending OK answer", "participant", participant.ID, "room", room.Name) + if err := participant.WebSocket.SendAnswerMessageWS(connections.AnswerOK); err != nil { + slog.Error("Failed to send OK answer", "participant", participant.ID, "room", room.Name, "err", err) } - // If room is already online, send also offer + // If room is online, also send offer if room.Online { - if room.AudioTrack != nil { - if err = participant.addTrack(&room.AudioTrack); err != nil { - log.Printf("Failed to add audio track for participant: '%s' in room: '%s' - reason: %s\n", participant.ID, room.Name, err) - } + if err = room.signalParticipantWithTracks(participant); err != nil { + slog.Error("Failed to signal participant with tracks", "participant", participant.ID, "room", room.Name, "err", err) } - if room.VideoTrack != nil { - if err = participant.addTrack(&room.VideoTrack); err != nil { - log.Printf("Failed to add video track for participant: '%s' in room: '%s' - reason: %s\n", participant.ID, room.Name, err) + } else { + active, provider := relay.IsRoomActive(room.ID) + if active { + slog.Debug("Room active remotely, requesting stream", "room", room.Name, "provider", provider) + if _, err := relay.requestStream(context.Background(), room.Name, room.ID, provider); err != nil { + slog.Error("Failed to request stream", "room", room.Name, "err", err) + } else { + slog.Debug("Stream requested successfully", "room", room.Name, "provider", provider) } } - if err = participant.signalOffer(); err != nil { - log.Printf("Failed to signal offer for participant: '%s' in room: '%s' - reason: %s\n", participant.ID, room.Name, err) - } } } // SDP answer handler for participants -func handleParticipantSDP(participant *Participant, answerMsg MessageSDP) { +func handleParticipantSDP(participant *Participant, answerMsg connections.MessageSDP) { // Get SDP offer sdpAnswer := answerMsg.SDP.SDP @@ -191,6 +151,37 @@ func handleParticipantSDP(participant *Participant, answerMsg MessageSDP) { SDP: sdpAnswer, }) if err != nil { - log.Printf("Failed to set remote description for participant: '%s' - reason: %s\n", participant.ID, err) + slog.Error("Failed to set remote SDP answer for participant", "participant", participant.ID, "err", err) + } +} + +func ForwardParticipantDataChannelMessage(participant *Participant, room *Room, data []byte) { + // Debug mode: Add latency timestamp + if common.GetFlags().Debug { + var inputMsg gen.ProtoMessageInput + if err := proto.Unmarshal(data, &inputMsg); err != nil { + slog.Error("Failed to decode input message from participant", "participant", participant.ID, "room", room.Name, "err", err) + return + } + protoLat := inputMsg.GetMessageBase().GetLatency() + if protoLat != nil { + lat := common.LatencyTrackerFromProto(protoLat) + lat.AddTimestamp("relay_to_node") + protoLat = lat.ToProto() + } + if newData, err := proto.Marshal(&inputMsg); err != nil { + slog.Error("Failed to marshal input message from participant", "participant", participant.ID, "room", room.Name, "err", err) + return + } else { + // Update data with the modified message + data = newData + } + } + + // Forward to local room DataChannel if it exists (e.g., local ingest) + if room.DataChannel != nil { + if err := room.DataChannel.SendBinary(data); err != nil { + slog.Error("Failed to send input message to room", "participant", participant.ID, "room", room.Name, "err", err) + } } } diff --git a/packages/relay/internal/http.go b/packages/relay/internal/http.go index f01a0aa3..26c1d8ec 100644 --- a/packages/relay/internal/http.go +++ b/packages/relay/internal/http.go @@ -1,45 +1,61 @@ -package relay +package internal import ( + "context" "encoding/json" "errors" + "fmt" "github.com/gorilla/websocket" - "log" + "github.com/libp2p/go-reuseport" + "log/slog" "net/http" + "relay/internal/common" + "relay/internal/connections" "strconv" ) var httpMux *http.ServeMux -func InitHTTPEndpoint() error { +func InitHTTPEndpoint(_ context.Context, ctxCancel context.CancelFunc) error { // Create HTTP mux which serves our WS endpoint httpMux = http.NewServeMux() // Endpoints themselves httpMux.Handle("/", http.NotFoundHandler()) + // If control endpoint secret is set, enable the control endpoint + if len(common.GetFlags().ControlSecret) > 0 { + httpMux.HandleFunc("/api/control", corsAnyHandler(controlHandler)) + } + // WS endpoint httpMux.HandleFunc("/api/ws/{roomName}", corsAnyHandler(wsHandler)) // Get our serving port - port := GetFlags().EndpointPort - tlsCert := GetFlags().TLSCert - tlsKey := GetFlags().TLSKey + port := common.GetFlags().EndpointPort + tlsCert := common.GetFlags().TLSCert + tlsKey := common.GetFlags().TLSKey + + // Create re-usable listener port + httpListener, err := reuseport.Listen("tcp", ":"+strconv.Itoa(port)) + if err != nil { + return fmt.Errorf("failed to create TCP listener: %w", err) + } // Log and start the endpoint server if len(tlsCert) <= 0 && len(tlsKey) <= 0 { - log.Println("Starting HTTP endpoint server on :", strconv.Itoa(port)) + slog.Info("Starting HTTP endpoint server", "port", port) go func() { - log.Fatal((&http.Server{ - Handler: httpMux, - Addr: ":" + strconv.Itoa(port), - }).ListenAndServe()) + if err := http.Serve(httpListener, httpMux); err != nil { + slog.Error("Failed to start HTTP server", "err", err) + ctxCancel() + } }() } else if len(tlsCert) > 0 && len(tlsKey) > 0 { - log.Println("Starting HTTPS endpoint server on :", strconv.Itoa(port)) + slog.Info("Starting HTTPS endpoint server", "port", port) go func() { - log.Fatal((&http.Server{ - Handler: httpMux, - Addr: ":" + strconv.Itoa(port), - }).ListenAndServeTLS(tlsCert, tlsKey)) + if err := http.ServeTLS(httpListener, httpMux, tlsCert, tlsKey); err != nil { + slog.Error("Failed to start HTTPS server", "err", err) + ctxCancel() + } }() } else { return errors.New("no TLS certificate or TLS key provided") @@ -49,8 +65,8 @@ func InitHTTPEndpoint() error { // logHTTPError logs (if verbose) and sends an error code to requester func logHTTPError(w http.ResponseWriter, err string, code int) { - if GetFlags().Verbose { - log.Println(err) + if common.GetFlags().Verbose { + slog.Error("HTTP error", "code", code, "message", err) } http.Error(w, err, code) } @@ -78,8 +94,9 @@ func wsHandler(w http.ResponseWriter, r *http.Request) { return } + rel := GetRelay() // Get or create room in any case - room := GetOrCreateRoom(roomName) + room := rel.GetOrCreateRoom(roomName) // Upgrade to WebSocket upgrader := websocket.Upgrader{ @@ -94,47 +111,92 @@ func wsHandler(w http.ResponseWriter, r *http.Request) { } // Create SafeWebSocket - ws := NewSafeWebSocket(wsConn) + ws := connections.NewSafeWebSocket(wsConn) // Assign message handler for join request ws.RegisterMessageCallback("join", func(data []byte) { - var joinMsg MessageJoin + var joinMsg connections.MessageJoin if err = json.Unmarshal(data, &joinMsg); err != nil { - log.Printf("Failed to decode join message: %s\n", err) + slog.Error("Failed to unmarshal join message", "err", err) return } - if GetFlags().Verbose { - log.Printf("Join request for room: '%s' from: '%s'\n", room.Name, joinMsg.JoinerType.String()) - } + slog.Debug("Join message", "room", room.Name, "joinerType", joinMsg.JoinerType) // Handle join request, depending if it's from ingest/node or participant/client switch joinMsg.JoinerType { - case JoinerNode: + case connections.JoinerNode: // If room already online, send InUse answer if room.Online { - if err = ws.SendAnswerMessageWS(AnswerInUse); err != nil { - log.Printf("Failed to send InUse answer for Room: '%s' - reason: %s\n", room.Name, err) + if err = ws.SendAnswerMessageWS(connections.AnswerInUse); err != nil { + slog.Error("Failed to send InUse answer to node", "room", room.Name, "err", err) } return } - room.assignWebSocket(ws) - go ingestHandler(room) - case JoinerClient: + room.AssignWebSocket(ws) + go IngestHandler(room) + case connections.JoinerClient: // Create participant and add to room regardless of online status participant := NewParticipant(ws) - room.addParticipant(participant) + room.AddParticipant(participant) // If room not online, send Offline answer if !room.Online { - if err = ws.SendAnswerMessageWS(AnswerOffline); err != nil { - log.Printf("Failed to send Offline answer for Room: '%s' - reason: %s\n", room.Name, err) + if err = ws.SendAnswerMessageWS(connections.AnswerOffline); err != nil { + slog.Error("Failed to send offline answer to participant", "room", room.Name, "err", err) } } - go participantHandler(participant, room) + go ParticipantHandler(participant, room, rel) default: - log.Printf("Unknown joiner type: %d\n", joinMsg.JoinerType) + slog.Error("Unknown joiner type", "joinerType", joinMsg.JoinerType) } // Unregister ourselves, if something happens on the other side they should just reconnect? ws.UnregisterMessageCallback("join") }) } + +// controlMessage is the JSON struct for the control messages +type controlMessage struct { + Type string `json:"type"` + Value string `json:"value"` +} + +// controlHandler is the handler for the /api/control endpoint, for controlling this relay +func controlHandler(w http.ResponseWriter, r *http.Request) { + // Check for control secret in Authorization header + authHeader := r.Header.Get("Authorization") + if len(authHeader) <= 0 || authHeader != common.GetFlags().ControlSecret { + logHTTPError(w, "missing or invalid Authorization header", http.StatusUnauthorized) + return + } + + // Handle CORS preflight request + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + // Decode the control message + var msg controlMessage + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + logHTTPError(w, "failed to decode control message", http.StatusBadRequest) + return + } + + //relay := GetRelay() + switch msg.Type { + case "join_mesh": + // Join the mesh network, get relay address from msg.Value + if len(msg.Value) <= 0 { + logHTTPError(w, "missing relay address", http.StatusBadRequest) + return + } + ctx := r.Context() + if err := GetRelay().ConnectToRelay(ctx, msg.Value); err != nil { + http.Error(w, fmt.Sprintf("Failed to connect: %v", err), http.StatusInternalServerError) + return + } + w.Write([]byte("Successfully connected to relay")) + default: + logHTTPError(w, "unknown control message type", http.StatusBadRequest) + } +} diff --git a/packages/relay/internal/ingest.go b/packages/relay/internal/ingest.go index f3b6a085..25af2e29 100644 --- a/packages/relay/internal/ingest.go +++ b/packages/relay/internal/ingest.go @@ -1,117 +1,96 @@ -package relay +package internal import ( "encoding/json" "errors" "fmt" + "github.com/pion/rtp" "github.com/pion/webrtc/v4" "io" - "log" + "log/slog" + "relay/internal/common" + "relay/internal/connections" "strings" ) -func ingestHandler(room *Room) { +func IngestHandler(room *Room) { + relay := GetRelay() + // Callback for closing PeerConnection onPCClose := func() { - if GetFlags().Verbose { - log.Printf("Closed PeerConnection for room: '%s'\n", room.Name) - } + slog.Debug("ingest PeerConnection closed", "room", room.Name) room.Online = false - DeleteRoomIfEmpty(room) + room.signalParticipantsOffline() + relay.DeleteRoomIfEmpty(room) } var err error - room.PeerConnection, err = CreatePeerConnection(onPCClose) + room.PeerConnection, err = common.CreatePeerConnection(onPCClose) if err != nil { - log.Printf("Failed to create PeerConnection for room: '%s' - reason: %s\n", room.Name, err) + slog.Error("Failed to create ingest PeerConnection", "room", room.Name, "err", err) return } room.PeerConnection.OnTrack(func(remoteTrack *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { - var localTrack *webrtc.TrackLocalStaticRTP - if remoteTrack.Kind() == webrtc.RTPCodecTypeVideo { - if GetFlags().Verbose { - log.Printf("Received video track for room: '%s'\n", room.Name) - } - localTrack, err = webrtc.NewTrackLocalStaticRTP(remoteTrack.Codec().RTPCodecCapability, "video", fmt.Sprint("nestri-", room.Name)) - if err != nil { - log.Printf("Failed to create local video track for room: '%s' - reason: %s\n", room.Name, err) - return - } - room.VideoTrack = localTrack - } else if remoteTrack.Kind() == webrtc.RTPCodecTypeAudio { - if GetFlags().Verbose { - log.Printf("Received audio track for room: '%s'\n", room.Name) - } - localTrack, err = webrtc.NewTrackLocalStaticRTP(remoteTrack.Codec().RTPCodecCapability, "audio", fmt.Sprint("nestri-", room.Name)) - if err != nil { - log.Printf("Failed to create local audio track for room: '%s' - reason: %s\n", room.Name, err) - return - } - room.AudioTrack = localTrack + localTrack, err := webrtc.NewTrackLocalStaticRTP(remoteTrack.Codec().RTPCodecCapability, remoteTrack.Kind().String(), fmt.Sprintf("nestri-%s-%s", room.Name, remoteTrack.Kind().String())) + if err != nil { + slog.Error("Failed to create local track for room", "room", room.Name, "kind", remoteTrack.Kind(), "err", err) + return + } + slog.Debug("Received track for room", "room", room.Name, "kind", remoteTrack.Kind()) + + // Set track and let Room handle state + room.SetTrack(remoteTrack.Kind(), localTrack) + + // Prepare PlayoutDelayExtension so we don't need to recreate it for each packet + playoutExt := &rtp.PlayoutDelayExtension{ + MinDelay: 0, + MaxDelay: 0, + } + playoutPayload, err := playoutExt.Marshal() + if err != nil { + slog.Error("Failed to marshal PlayoutDelayExtension for room", "room", room.Name, "err", err) + return } - // If both audio and video tracks are set, set online state - if room.AudioTrack != nil && room.VideoTrack != nil { - room.Online = true - if GetFlags().Verbose { - log.Printf("Room online and receiving: '%s' - signaling participants\n", room.Name) - } - room.signalParticipantsWithTracks() - } - - rtpBuffer := make([]byte, 1400) for { - read, _, err := remoteTrack.Read(rtpBuffer) + rtpPacket, _, err := remoteTrack.ReadRTP() if err != nil { - // EOF is expected when stopping room if !errors.Is(err, io.EOF) { - log.Printf("RTP read error from room: '%s' - reason: %s\n", room.Name, err) + slog.Error("Failed to read RTP from remote track for room", "room", room.Name, "err", err) } break } - _, err = localTrack.Write(rtpBuffer[:read]) + // Use PlayoutDelayExtension for low latency, only for video tracks + if err := rtpPacket.SetExtension(common.ExtensionMap[common.ExtensionPlayoutDelay], playoutPayload); err != nil { + slog.Error("Failed to set PlayoutDelayExtension for room", "room", room.Name, "err", err) + continue + } + + err = localTrack.WriteRTP(rtpPacket) if err != nil && !errors.Is(err, io.ErrClosedPipe) { - log.Printf("Failed to write RTP to local track for room: '%s' - reason: %s\n", room.Name, err) + slog.Error("Failed to write RTP to local track for room", "room", room.Name, "err", err) break } } - if remoteTrack.Kind() == webrtc.RTPCodecTypeVideo { - room.VideoTrack = nil - } else if remoteTrack.Kind() == webrtc.RTPCodecTypeAudio { - room.AudioTrack = nil - } + slog.Debug("Track closed for room", "room", room.Name, "kind", remoteTrack.Kind()) - if room.VideoTrack == nil && room.AudioTrack == nil { - room.Online = false - if GetFlags().Verbose { - log.Printf("Room offline and not receiving: '%s'\n", room.Name) - } - // Signal participants of room offline - room.signalParticipantsOffline() - DeleteRoomIfEmpty(room) - } + // Clear track when done + room.SetTrack(remoteTrack.Kind(), nil) }) room.PeerConnection.OnDataChannel(func(dc *webrtc.DataChannel) { - room.DataChannel = NewNestriDataChannel(dc) - if GetFlags().Verbose { - log.Printf("New DataChannel for room: '%s' - '%s'\n", room.Name, room.DataChannel.Label()) - } + room.DataChannel = connections.NewNestriDataChannel(dc) + slog.Debug("Ingest received DataChannel for room", "room", room.Name) - // Register channel opening handling room.DataChannel.RegisterOnOpen(func() { - if GetFlags().Verbose { - log.Printf("DataChannel for room: '%s' - '%s' open\n", room.Name, room.DataChannel.Label()) - } + slog.Debug("ingest DataChannel opened for room", "room", room.Name) }) room.DataChannel.OnClose(func() { - if GetFlags().Verbose { - log.Printf("DataChannel for room: '%s' - '%s' closed\n", room.Name, room.DataChannel.Label()) - } + slog.Debug("ingest DataChannel closed for room", "room", room.Name) }) // We do not handle any messages from ingest via DataChannel yet @@ -121,12 +100,10 @@ func ingestHandler(room *Room) { if candidate == nil { return } - if GetFlags().Verbose { - log.Printf("ICE candidate for room: '%s'\n", room.Name) - } + slog.Debug("ingest received ICECandidate for room", "room", room.Name) err = room.WebSocket.SendICECandidateMessageWS(candidate.ToJSON()) if err != nil { - log.Printf("Failed to send ICE candidate for room: '%s' - reason: %s\n", room.Name, err) + slog.Error("Failed to send ICE candidate message to ingest for room", "room", room.Name, "err", err) } }) @@ -134,57 +111,52 @@ func ingestHandler(room *Room) { // ICE callback room.WebSocket.RegisterMessageCallback("ice", func(data []byte) { - var iceMsg MessageICECandidate + var iceMsg connections.MessageICECandidate if err = json.Unmarshal(data, &iceMsg); err != nil { - log.Printf("Failed to decode ICE candidate message from ingest for room: '%s' - reason: %s\n", room.Name, err) + slog.Error("Failed to decode ICE candidate message from ingest for room", "room", room.Name, "err", err) return } - candidate := webrtc.ICECandidateInit{ - Candidate: iceMsg.Candidate.Candidate, - } if room.PeerConnection != nil { - // If remote isn't set yet, store ICE candidates if room.PeerConnection.RemoteDescription() != nil { - if err = room.PeerConnection.AddICECandidate(candidate); err != nil { - log.Printf("Failed to add ICE candidate for room: '%s' - reason: %s\n", room.Name, err) + if err = room.PeerConnection.AddICECandidate(iceMsg.Candidate); err != nil { + slog.Error("Failed to add ICE candidate for room", "room", room.Name, "err", err) } - // Add any held ICE candidates for _, heldCandidate := range iceHolder { if err = room.PeerConnection.AddICECandidate(heldCandidate); err != nil { - log.Printf("Failed to add held ICE candidate for room: '%s' - reason: %s\n", room.Name, err) + slog.Error("Failed to add held ICE candidate for room", "room", room.Name, "err", err) } } - iceHolder = nil + iceHolder = make([]webrtc.ICECandidateInit, 0) } else { - iceHolder = append(iceHolder, candidate) + iceHolder = append(iceHolder, iceMsg.Candidate) } } else { - log.Printf("ICE candidate received before PeerConnection for room: '%s'\n", room.Name) + slog.Error("ICE candidate received but PeerConnection is nil for room", "room", room.Name) } }) // SDP offer callback room.WebSocket.RegisterMessageCallback("sdp", func(data []byte) { - var sdpMsg MessageSDP + var sdpMsg connections.MessageSDP if err = json.Unmarshal(data, &sdpMsg); err != nil { - log.Printf("Failed to decode SDP message from ingest for room: '%s' - reason: %s\n", room.Name, err) + slog.Error("Failed to decode SDP message from ingest for room", "room", room.Name, "err", err) return } answer := handleIngestSDP(room, sdpMsg) if answer != nil { if err = room.WebSocket.SendSDPMessageWS(*answer); err != nil { - log.Printf("Failed to send SDP answer to ingest for room: '%s' - reason: %s\n", room.Name, err) + slog.Error("Failed to send SDP answer message to ingest for room", "room", room.Name, "err", err) } } else { - log.Printf("Failed to handle SDP message from ingest for room: '%s'\n", room.Name) + slog.Error("Failed to handle ingest SDP message for room", "room", room.Name) } }) // Log callback room.WebSocket.RegisterMessageCallback("log", func(data []byte) { - var logMsg MessageLog + var logMsg connections.MessageLog if err = json.Unmarshal(data, &logMsg); err != nil { - log.Printf("Failed to decode log message from ingest for room: '%s' - reason: %s\n", room.Name, err) + slog.Error("Failed to decode log message from ingest for room", "room", room.Name, "err", err) return } // TODO: Handle log message sending to metrics server @@ -192,63 +164,52 @@ func ingestHandler(room *Room) { // Metrics callback room.WebSocket.RegisterMessageCallback("metrics", func(data []byte) { - var metricsMsg MessageMetrics + var metricsMsg connections.MessageMetrics if err = json.Unmarshal(data, &metricsMsg); err != nil { - log.Printf("Failed to decode metrics message from ingest for room: '%s' - reason: %s\n", room.Name, err) + slog.Error("Failed to decode metrics message from ingest for room", "room", room.Name, "err", err) return } // TODO: Handle metrics message sending to metrics server }) room.WebSocket.RegisterOnClose(func() { - // If PeerConnection is still open, close it - if room.PeerConnection != nil { - if err = room.PeerConnection.Close(); err != nil { - log.Printf("Failed to close PeerConnection for room: '%s' - reason: %s\n", room.Name, err) - } - room.PeerConnection = nil - } + slog.Debug("ingest WebSocket closed for room", "room", room.Name) room.Online = false - DeleteRoomIfEmpty(room) + room.signalParticipantsOffline() + relay.DeleteRoomIfEmpty(room) }) - log.Printf("Room: '%s' is ready, sending an OK\n", room.Name) - if err = room.WebSocket.SendAnswerMessageWS(AnswerOK); err != nil { - log.Printf("Failed to send OK answer for room: '%s' - reason: %s\n", room.Name, err) + slog.Info("Room is ready, sending OK answer to ingest", "room", room.Name) + if err = room.WebSocket.SendAnswerMessageWS(connections.AnswerOK); err != nil { + slog.Error("Failed to send OK answer message to ingest for room", "room", room.Name, "err", err) } } // SDP offer handler, returns SDP answer -func handleIngestSDP(room *Room, offerMsg MessageSDP) *webrtc.SessionDescription { +func handleIngestSDP(room *Room, offerMsg connections.MessageSDP) *webrtc.SessionDescription { var err error - // Get SDP offer sdpOffer := offerMsg.SDP.SDP - - // Modify SDP offer to remove opus "sprop-maxcapturerate=24000" (fixes opus bad quality issue, present in GStreamer) sdpOffer = strings.Replace(sdpOffer, ";sprop-maxcapturerate=24000", "", -1) - // Set new remote description err = room.PeerConnection.SetRemoteDescription(webrtc.SessionDescription{ Type: webrtc.SDPTypeOffer, SDP: sdpOffer, }) if err != nil { - log.Printf("Failed to set remote description for room: '%s' - reason: %s\n", room.Name, err) + slog.Error("Failed to set remote description for room", "room", room.Name, "err", err) return nil } - // Create SDP answer answer, err := room.PeerConnection.CreateAnswer(nil) if err != nil { - log.Printf("Failed to create SDP answer for room: '%s' - reason: %s\n", room.Name, err) + slog.Error("Failed to create SDP answer for room", "room", room.Name, "err", err) return nil } - // Set local description err = room.PeerConnection.SetLocalDescription(answer) if err != nil { - log.Printf("Failed to set local description for room: '%s' - reason: %s\n", room.Name, err) + slog.Error("Failed to set local description for room", "room", room.Name, "err", err) return nil } diff --git a/packages/relay/internal/participant.go b/packages/relay/internal/participant.go index 6734990c..9ec116ff 100644 --- a/packages/relay/internal/participant.go +++ b/packages/relay/internal/participant.go @@ -1,30 +1,38 @@ -package relay +package internal import ( "fmt" - "github.com/google/uuid" + "github.com/oklog/ulid/v2" "github.com/pion/webrtc/v4" + "log/slog" "math/rand" + "relay/internal/common" + "relay/internal/connections" ) type Participant struct { - ID uuid.UUID //< Internal IDs are useful to keeping unique internal track and not have conflicts later + ID ulid.ULID //< Internal IDs are useful to keeping unique internal track and not have conflicts later Name string - WebSocket *SafeWebSocket + WebSocket *connections.SafeWebSocket PeerConnection *webrtc.PeerConnection - DataChannel *NestriDataChannel + DataChannel *connections.NestriDataChannel } -func NewParticipant(ws *SafeWebSocket) *Participant { +func NewParticipant(ws *connections.SafeWebSocket) *Participant { + id, err := common.NewULID() + if err != nil { + slog.Error("Failed to create ULID for Participant", "err", err) + return nil + } return &Participant{ - ID: uuid.New(), + ID: id, Name: createRandomName(), WebSocket: ws, } } -func (vw *Participant) addTrack(trackLocal *webrtc.TrackLocal) error { - rtpSender, err := vw.PeerConnection.AddTrack(*trackLocal) +func (p *Participant) addTrack(trackLocal *webrtc.TrackLocalStaticRTP) error { + rtpSender, err := p.PeerConnection.AddTrack(trackLocal) if err != nil { return err } @@ -41,22 +49,22 @@ func (vw *Participant) addTrack(trackLocal *webrtc.TrackLocal) error { return nil } -func (vw *Participant) signalOffer() error { - if vw.PeerConnection == nil { - return fmt.Errorf("peer connection is nil for participant: '%s' - cannot signal offer", vw.ID) +func (p *Participant) signalOffer() error { + if p.PeerConnection == nil { + return fmt.Errorf("peer connection is nil for participant: '%s' - cannot signal offer", p.ID) } - offer, err := vw.PeerConnection.CreateOffer(nil) + offer, err := p.PeerConnection.CreateOffer(nil) if err != nil { return err } - err = vw.PeerConnection.SetLocalDescription(offer) + err = p.PeerConnection.SetLocalDescription(offer) if err != nil { return err } - return vw.WebSocket.SendSDPMessageWS(offer) + return p.WebSocket.SendSDPMessageWS(offer) } var namesFirst = []string{"Happy", "Sad", "Angry", "Calm", "Excited", "Bored", "Confused", "Confident", "Curious", "Depressed", "Disappointed", "Embarrassed", "Energetic", "Fearful", "Frustrated", "Glad", "Guilty", "Hopeful", "Impatient", "Jealous", "Lonely", "Motivated", "Nervous", "Optimistic", "Pessimistic", "Proud", "Relaxed", "Shy", "Stressed", "Surprised", "Tired", "Worried"} diff --git a/packages/relay/internal/peer.go.txt b/packages/relay/internal/peer.go.txt deleted file mode 100644 index c42b2032..00000000 --- a/packages/relay/internal/peer.go.txt +++ /dev/null @@ -1,295 +0,0 @@ -package relay - -import ( - "encoding/json" - "fmt" - "net/http" - "sync" - "time" - - // "github.com/gorilla/mux" - "github.com/hashicorp/memberlist" - "github.com/pion/webrtc/v4" -) - -// PeerInfo represents information about an SFU peer -type PeerInfo struct { - NodeID string `json:"nodeId"` - Zone string `json:"zone"` - PublicIP string `json:"publicIp"` - PrivateIP string `json:"privateIp,omitempty"` - Streams map[string]bool `json:"streams"` // streamID -> isOrigin -} - -// StreamInfo tracks a stream's origin and local subscribers -type StreamInfo struct { - ID string - OriginPeerID string - IsLocal bool - Publisher *webrtc.PeerConnection - Subscribers map[string]*webrtc.PeerConnection - InterPeerConn map[string]*webrtc.PeerConnection // connections to other SFU peers - mu sync.RWMutex -} - -// DistributedSFU manages streams and peer communication -type DistributedSFU struct { - nodeID string - zone string - publicIP string - privateIP string - streams map[string]*StreamInfo - peers map[string]*PeerInfo - memberlist *memberlist.Memberlist - mu sync.RWMutex - config webrtc.Configuration -} - -// NewDistributedSFU creates a new distributed SFU instance -func NewDistributedSFU(nodeID, zone, publicIP, privateIP string, seeds []string) (*DistributedSFU, error) { - sfu := &DistributedSFU{ - nodeID: nodeID, - zone: zone, - publicIP: publicIP, - privateIP: privateIP, - streams: make(map[string]*StreamInfo), - peers: make(map[string]*PeerInfo), - config: webrtc.Configuration{ - ICEServers: []webrtc.ICEServer{ - {URLs: []string{"stun:stun.l.google.com:19302"}}, - }, - }, - } - - // Configure memberlist for peer discovery - config := memberlist.DefaultLANConfig() - config.Name = nodeID - config.BindAddr = privateIP - config.AdvertiseAddr = publicIP - - // Add delegate for handling peer updates - config.Delegate = &peerDelegate{sfu: sfu} - - // Initialize memberlist - list, err := memberlist.Create(config) - if err != nil { - return nil, err - } - - // Join the cluster if seeds are provided - if len(seeds) > 0 { - _, err = list.Join(seeds) - if err != nil { - return nil, err - } - } - - sfu.memberlist = list - return sfu, nil -} - -// peerDelegate implements memberlist.Delegate -type peerDelegate struct { - sfu *DistributedSFU -} - -// NodeMeta returns metadata about the current node -func (d *peerDelegate) NodeMeta(limit int) []byte { - meta := PeerInfo{ - NodeID: d.sfu.nodeID, - Zone: d.sfu.zone, - PublicIP: d.sfu.publicIP, - PrivateIP: d.sfu.privateIP, - Streams: make(map[string]bool), - } - - d.sfu.mu.RLock() - for id, info := range d.sfu.streams { - meta.Streams[id] = info.IsLocal - } - d.sfu.mu.RUnlock() - - data, _ := json.Marshal(meta) - return data -} - -// NotifyMsg handles peer updates -func (d *peerDelegate) NotifyMsg(msg []byte) { - var peer PeerInfo - if err := json.Unmarshal(msg, &peer); err != nil { - return - } - - d.sfu.mu.Lock() - d.sfu.peers[peer.NodeID] = &peer - - // Check for new streams we don't have locally - for streamID, isOrigin := range peer.Streams { - if isOrigin { - if _, exists := d.sfu.streams[streamID]; !exists { - // Initialize inter-peer connection for this stream - d.sfu.initInterPeerStream(streamID, peer.NodeID) - } - } - } - d.sfu.mu.Unlock() -} - -// initInterPeerStream sets up connection to another SFU for a stream -func (sfu *DistributedSFU) initInterPeerStream(streamID, peerID string) { - stream := &StreamInfo{ - ID: streamID, - OriginPeerID: peerID, - IsLocal: false, - Subscribers: make(map[string]*webrtc.PeerConnection), - InterPeerConn: make(map[string]*webrtc.PeerConnection), - } - - // Create peer connection to the origin SFU - pc, err := webrtc.NewPeerConnection(sfu.config) - if err != nil { - return - } - - stream.InterPeerConn[peerID] = pc - sfu.streams[streamID] = stream - - // Setup inter-peer WebRTC connection - go sfu.establishInterPeerConnection(streamID, peerID, pc) -} - -// establishInterPeerConnection handles WebRTC signaling between SFU peers -func (sfu *DistributedSFU) establishInterPeerConnection(streamID, peerID string, pc *webrtc.PeerConnection) { - // This would typically involve making an HTTP request to the peer's control endpoint - // to exchange SDP offers/answers and ICE candidates - peerInfo := sfu.peers[peerID] - - // Example endpoint URL construction - peerURL := fmt.Sprintf("http://%s:8080/peer/%s/stream/%s", - peerInfo.PublicIP, sfu.nodeID, streamID) - - // Handle incoming tracks from peer - pc.OnTrack(func(remoteTrack *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { - sfu.mu.RLock() - stream := sfu.streams[streamID] - sfu.mu.RUnlock() - - // Forward the track to local subscribers - stream.mu.RLock() - for _, subscriber := range stream.Subscribers { - localTrack, err := webrtc.NewTrackLocalStaticRTP( - remoteTrack.Codec().RTPCodecCapability, - remoteTrack.ID(), - remoteTrack.StreamID(), - ) - if err != nil { - continue - } - - if _, err := subscriber.AddTrack(localTrack); err != nil { - continue - } - - go func() { - for { - packet, _, err := remoteTrack.ReadRTP() - if err != nil { - return - } - if err := localTrack.WriteRTP(packet); err != nil { - return - } - } - }() - } - stream.mu.RUnlock() - }) - - // Implement SDP exchange with peer - // ... (signaling implementation) -} - -// HandleWHIPPublish now includes peer notification -func (sfu *DistributedSFU) HandleWHIPPublish(w http.ResponseWriter, r *http.Request) { - streamID := mux.Vars(r)["streamID"] - - // Create stream info - stream := &StreamInfo{ - ID: streamID, - IsLocal: true, - Subscribers: make(map[string]*webrtc.PeerConnection), - InterPeerConn: make(map[string]*webrtc.PeerConnection), - } - - // ... (rest of WHIP publish logic) - - // Notify other peers about the new stream - sfu.broadcastStreamUpdate(streamID, true) -} - -// HandleWHEPSubscribe now checks both local and remote streams -func (sfu *DistributedSFU) HandleWHEPSubscribe(w http.ResponseWriter, r *http.Request) { - streamID := mux.Vars(r)["streamID"] - - sfu.mu.RLock() - stream, exists := sfu.streams[streamID] - sfu.mu.RUnlock() - - if !exists { - // Check if any peer has this stream - if peer := sfu.findStreamPeer(streamID); peer != nil { - // Initialize inter-peer connection if needed - sfu.initInterPeerStream(streamID, peer.NodeID) - } else { - http.Error(w, "Stream not found", http.StatusNotFound) - return - } - } - - // ... (rest of WHEP subscribe logic) -} - -// findStreamPeer finds the peer that has the origin of a stream -func (sfu *DistributedSFU) findStreamPeer(streamID string) *PeerInfo { - sfu.mu.RLock() - defer sfu.mu.RUnlock() - - for _, peer := range sfu.peers { - if isOrigin, exists := peer.Streams[streamID]; exists && isOrigin { - return peer - } - } - return nil -} - -func main() { - // Initialize the distributed SFU - sfu, err := NewDistributedSFU( - "sfu-1", - "us-east", - "203.0.113.1", - "10.0.0.1", - []string{"203.0.113.2:7946", "203.0.113.3:7946"}, - ) - if err != nil { - panic(err) - } - - router := mux.NewRouter() - - // Regular WHIP/WHEP endpoints - router.HandleFunc("/whip/{streamID}", sfu.HandleWHIPPublish).Methods("POST") - router.HandleFunc("/whep/{streamID}/{subscriberID}", sfu.HandleWHEPSubscribe).Methods("POST") - - // Inter-peer communication endpoint - router.HandleFunc("/peer/{peerID}/stream/{streamID}", sfu.HandlePeerSignaling).Methods("POST") - - server := &http.Server{ - Addr: ":8080", - Handler: router, - ReadTimeout: 10 * time.Second, - WriteTimeout: 10 * time.Second, - } - - server.ListenAndServe() -} diff --git a/packages/relay/internal/proto/latency_tracker.pb.go b/packages/relay/internal/proto/latency_tracker.pb.go index a6bda859..f5367e6e 100644 --- a/packages/relay/internal/proto/latency_tracker.pb.go +++ b/packages/relay/internal/proto/latency_tracker.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.4 +// protoc-gen-go v1.36.6 // protoc (unknown) // source: latency_tracker.proto @@ -128,27 +128,18 @@ func (x *ProtoLatencyTracker) GetTimestamps() []*ProtoTimestampEntry { var File_latency_tracker_proto protoreflect.FileDescriptor -var file_latency_tracker_proto_rawDesc = string([]byte{ - 0x0a, 0x15, 0x6c, 0x61, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x5f, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x65, - 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x05, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, - 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, - 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, - 0x5b, 0x0a, 0x13, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, - 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x67, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x67, 0x65, 0x12, 0x2e, 0x0a, 0x04, - 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x72, 0x0a, 0x13, - 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x4c, 0x61, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x54, 0x72, 0x61, 0x63, - 0x6b, 0x65, 0x72, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x65, 0x5f, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x71, 0x75, 0x65, 0x6e, - 0x63, 0x65, 0x49, 0x64, 0x12, 0x3a, 0x0a, 0x0a, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, - 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x45, - 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0a, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, - 0x42, 0x16, 0x5a, 0x14, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, - 0x61, 0x6c, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -}) +const file_latency_tracker_proto_rawDesc = "" + + "\n" + + "\x15latency_tracker.proto\x12\x05proto\x1a\x1fgoogle/protobuf/timestamp.proto\"[\n" + + "\x13ProtoTimestampEntry\x12\x14\n" + + "\x05stage\x18\x01 \x01(\tR\x05stage\x12.\n" + + "\x04time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x04time\"r\n" + + "\x13ProtoLatencyTracker\x12\x1f\n" + + "\vsequence_id\x18\x01 \x01(\tR\n" + + "sequenceId\x12:\n" + + "\n" + + "timestamps\x18\x02 \x03(\v2\x1a.proto.ProtoTimestampEntryR\n" + + "timestampsB\x16Z\x14relay/internal/protob\x06proto3" var ( file_latency_tracker_proto_rawDescOnce sync.Once diff --git a/packages/relay/internal/proto/mesh.pb.go b/packages/relay/internal/proto/mesh.pb.go new file mode 100644 index 00000000..b25b8114 --- /dev/null +++ b/packages/relay/internal/proto/mesh.pb.go @@ -0,0 +1,1170 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.6 +// protoc (unknown) +// source: mesh.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// MeshMessage is the top-level message for all relay-to-relay communication. +type MeshMessage struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Type: + // + // *MeshMessage_StateUpdate + // *MeshMessage_Ack + // *MeshMessage_RetransmissionRequest + // *MeshMessage_Retransmission + // *MeshMessage_Heartbeat + // *MeshMessage_SuspectRelay + // *MeshMessage_Disconnect + // *MeshMessage_ForwardSdp + // *MeshMessage_ForwardIce + // *MeshMessage_ForwardIngest + // *MeshMessage_StreamRequest + // *MeshMessage_Handshake + // *MeshMessage_HandshakeResponse + Type isMeshMessage_Type `protobuf_oneof:"type"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MeshMessage) Reset() { + *x = MeshMessage{} + mi := &file_mesh_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MeshMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MeshMessage) ProtoMessage() {} + +func (x *MeshMessage) ProtoReflect() protoreflect.Message { + mi := &file_mesh_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MeshMessage.ProtoReflect.Descriptor instead. +func (*MeshMessage) Descriptor() ([]byte, []int) { + return file_mesh_proto_rawDescGZIP(), []int{0} +} + +func (x *MeshMessage) GetType() isMeshMessage_Type { + if x != nil { + return x.Type + } + return nil +} + +func (x *MeshMessage) GetStateUpdate() *StateUpdate { + if x != nil { + if x, ok := x.Type.(*MeshMessage_StateUpdate); ok { + return x.StateUpdate + } + } + return nil +} + +func (x *MeshMessage) GetAck() *Ack { + if x != nil { + if x, ok := x.Type.(*MeshMessage_Ack); ok { + return x.Ack + } + } + return nil +} + +func (x *MeshMessage) GetRetransmissionRequest() *RetransmissionRequest { + if x != nil { + if x, ok := x.Type.(*MeshMessage_RetransmissionRequest); ok { + return x.RetransmissionRequest + } + } + return nil +} + +func (x *MeshMessage) GetRetransmission() *Retransmission { + if x != nil { + if x, ok := x.Type.(*MeshMessage_Retransmission); ok { + return x.Retransmission + } + } + return nil +} + +func (x *MeshMessage) GetHeartbeat() *Heartbeat { + if x != nil { + if x, ok := x.Type.(*MeshMessage_Heartbeat); ok { + return x.Heartbeat + } + } + return nil +} + +func (x *MeshMessage) GetSuspectRelay() *SuspectRelay { + if x != nil { + if x, ok := x.Type.(*MeshMessage_SuspectRelay); ok { + return x.SuspectRelay + } + } + return nil +} + +func (x *MeshMessage) GetDisconnect() *Disconnect { + if x != nil { + if x, ok := x.Type.(*MeshMessage_Disconnect); ok { + return x.Disconnect + } + } + return nil +} + +func (x *MeshMessage) GetForwardSdp() *ForwardSDP { + if x != nil { + if x, ok := x.Type.(*MeshMessage_ForwardSdp); ok { + return x.ForwardSdp + } + } + return nil +} + +func (x *MeshMessage) GetForwardIce() *ForwardICE { + if x != nil { + if x, ok := x.Type.(*MeshMessage_ForwardIce); ok { + return x.ForwardIce + } + } + return nil +} + +func (x *MeshMessage) GetForwardIngest() *ForwardIngest { + if x != nil { + if x, ok := x.Type.(*MeshMessage_ForwardIngest); ok { + return x.ForwardIngest + } + } + return nil +} + +func (x *MeshMessage) GetStreamRequest() *StreamRequest { + if x != nil { + if x, ok := x.Type.(*MeshMessage_StreamRequest); ok { + return x.StreamRequest + } + } + return nil +} + +func (x *MeshMessage) GetHandshake() *Handshake { + if x != nil { + if x, ok := x.Type.(*MeshMessage_Handshake); ok { + return x.Handshake + } + } + return nil +} + +func (x *MeshMessage) GetHandshakeResponse() *HandshakeResponse { + if x != nil { + if x, ok := x.Type.(*MeshMessage_HandshakeResponse); ok { + return x.HandshakeResponse + } + } + return nil +} + +type isMeshMessage_Type interface { + isMeshMessage_Type() +} + +type MeshMessage_StateUpdate struct { + // Level 0 + StateUpdate *StateUpdate `protobuf:"bytes,1,opt,name=state_update,json=stateUpdate,proto3,oneof"` +} + +type MeshMessage_Ack struct { + Ack *Ack `protobuf:"bytes,2,opt,name=ack,proto3,oneof"` +} + +type MeshMessage_RetransmissionRequest struct { + RetransmissionRequest *RetransmissionRequest `protobuf:"bytes,3,opt,name=retransmission_request,json=retransmissionRequest,proto3,oneof"` +} + +type MeshMessage_Retransmission struct { + Retransmission *Retransmission `protobuf:"bytes,4,opt,name=retransmission,proto3,oneof"` +} + +type MeshMessage_Heartbeat struct { + Heartbeat *Heartbeat `protobuf:"bytes,5,opt,name=heartbeat,proto3,oneof"` +} + +type MeshMessage_SuspectRelay struct { + SuspectRelay *SuspectRelay `protobuf:"bytes,6,opt,name=suspect_relay,json=suspectRelay,proto3,oneof"` +} + +type MeshMessage_Disconnect struct { + Disconnect *Disconnect `protobuf:"bytes,7,opt,name=disconnect,proto3,oneof"` +} + +type MeshMessage_ForwardSdp struct { + // Level 1 + ForwardSdp *ForwardSDP `protobuf:"bytes,8,opt,name=forward_sdp,json=forwardSdp,proto3,oneof"` +} + +type MeshMessage_ForwardIce struct { + ForwardIce *ForwardICE `protobuf:"bytes,9,opt,name=forward_ice,json=forwardIce,proto3,oneof"` +} + +type MeshMessage_ForwardIngest struct { + ForwardIngest *ForwardIngest `protobuf:"bytes,10,opt,name=forward_ingest,json=forwardIngest,proto3,oneof"` +} + +type MeshMessage_StreamRequest struct { + StreamRequest *StreamRequest `protobuf:"bytes,11,opt,name=stream_request,json=streamRequest,proto3,oneof"` +} + +type MeshMessage_Handshake struct { + // Level 2 + Handshake *Handshake `protobuf:"bytes,12,opt,name=handshake,proto3,oneof"` +} + +type MeshMessage_HandshakeResponse struct { + HandshakeResponse *HandshakeResponse `protobuf:"bytes,13,opt,name=handshake_response,json=handshakeResponse,proto3,oneof"` +} + +func (*MeshMessage_StateUpdate) isMeshMessage_Type() {} + +func (*MeshMessage_Ack) isMeshMessage_Type() {} + +func (*MeshMessage_RetransmissionRequest) isMeshMessage_Type() {} + +func (*MeshMessage_Retransmission) isMeshMessage_Type() {} + +func (*MeshMessage_Heartbeat) isMeshMessage_Type() {} + +func (*MeshMessage_SuspectRelay) isMeshMessage_Type() {} + +func (*MeshMessage_Disconnect) isMeshMessage_Type() {} + +func (*MeshMessage_ForwardSdp) isMeshMessage_Type() {} + +func (*MeshMessage_ForwardIce) isMeshMessage_Type() {} + +func (*MeshMessage_ForwardIngest) isMeshMessage_Type() {} + +func (*MeshMessage_StreamRequest) isMeshMessage_Type() {} + +func (*MeshMessage_Handshake) isMeshMessage_Type() {} + +func (*MeshMessage_HandshakeResponse) isMeshMessage_Type() {} + +// Handshake to inititiate new connection to mesh. +type Handshake struct { + state protoimpl.MessageState `protogen:"open.v1"` + RelayId string `protobuf:"bytes,1,opt,name=relay_id,json=relayId,proto3" json:"relay_id,omitempty"` // UUID of the relay + DhPublicKey string `protobuf:"bytes,2,opt,name=dh_public_key,json=dhPublicKey,proto3" json:"dh_public_key,omitempty"` // base64 encoded Diffie-Hellman public key + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Handshake) Reset() { + *x = Handshake{} + mi := &file_mesh_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Handshake) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Handshake) ProtoMessage() {} + +func (x *Handshake) ProtoReflect() protoreflect.Message { + mi := &file_mesh_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Handshake.ProtoReflect.Descriptor instead. +func (*Handshake) Descriptor() ([]byte, []int) { + return file_mesh_proto_rawDescGZIP(), []int{1} +} + +func (x *Handshake) GetRelayId() string { + if x != nil { + return x.RelayId + } + return "" +} + +func (x *Handshake) GetDhPublicKey() string { + if x != nil { + return x.DhPublicKey + } + return "" +} + +// HandshakeResponse to respond to a mesh joiner. +type HandshakeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + RelayId string `protobuf:"bytes,1,opt,name=relay_id,json=relayId,proto3" json:"relay_id,omitempty"` + DhPublicKey string `protobuf:"bytes,2,opt,name=dh_public_key,json=dhPublicKey,proto3" json:"dh_public_key,omitempty"` + Approvals map[string]string `protobuf:"bytes,3,rep,name=approvals,proto3" json:"approvals,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // relay id to signature + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HandshakeResponse) Reset() { + *x = HandshakeResponse{} + mi := &file_mesh_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HandshakeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HandshakeResponse) ProtoMessage() {} + +func (x *HandshakeResponse) ProtoReflect() protoreflect.Message { + mi := &file_mesh_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HandshakeResponse.ProtoReflect.Descriptor instead. +func (*HandshakeResponse) Descriptor() ([]byte, []int) { + return file_mesh_proto_rawDescGZIP(), []int{2} +} + +func (x *HandshakeResponse) GetRelayId() string { + if x != nil { + return x.RelayId + } + return "" +} + +func (x *HandshakeResponse) GetDhPublicKey() string { + if x != nil { + return x.DhPublicKey + } + return "" +} + +func (x *HandshakeResponse) GetApprovals() map[string]string { + if x != nil { + return x.Approvals + } + return nil +} + +// Forwarded SDP from another relay. +type ForwardSDP struct { + state protoimpl.MessageState `protogen:"open.v1"` + RoomName string `protobuf:"bytes,1,opt,name=room_name,json=roomName,proto3" json:"room_name,omitempty"` + ParticipantId string `protobuf:"bytes,2,opt,name=participant_id,json=participantId,proto3" json:"participant_id,omitempty"` + Sdp string `protobuf:"bytes,3,opt,name=sdp,proto3" json:"sdp,omitempty"` + Type string `protobuf:"bytes,4,opt,name=type,proto3" json:"type,omitempty"` // "offer" or "answer" + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardSDP) Reset() { + *x = ForwardSDP{} + mi := &file_mesh_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardSDP) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardSDP) ProtoMessage() {} + +func (x *ForwardSDP) ProtoReflect() protoreflect.Message { + mi := &file_mesh_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardSDP.ProtoReflect.Descriptor instead. +func (*ForwardSDP) Descriptor() ([]byte, []int) { + return file_mesh_proto_rawDescGZIP(), []int{3} +} + +func (x *ForwardSDP) GetRoomName() string { + if x != nil { + return x.RoomName + } + return "" +} + +func (x *ForwardSDP) GetParticipantId() string { + if x != nil { + return x.ParticipantId + } + return "" +} + +func (x *ForwardSDP) GetSdp() string { + if x != nil { + return x.Sdp + } + return "" +} + +func (x *ForwardSDP) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +// Forwarded ICE candidate from another relay. +type ForwardICE struct { + state protoimpl.MessageState `protogen:"open.v1"` + RoomName string `protobuf:"bytes,1,opt,name=room_name,json=roomName,proto3" json:"room_name,omitempty"` + ParticipantId string `protobuf:"bytes,2,opt,name=participant_id,json=participantId,proto3" json:"participant_id,omitempty"` + Candidate *ICECandidateInit `protobuf:"bytes,3,opt,name=candidate,proto3" json:"candidate,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardICE) Reset() { + *x = ForwardICE{} + mi := &file_mesh_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardICE) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardICE) ProtoMessage() {} + +func (x *ForwardICE) ProtoReflect() protoreflect.Message { + mi := &file_mesh_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardICE.ProtoReflect.Descriptor instead. +func (*ForwardICE) Descriptor() ([]byte, []int) { + return file_mesh_proto_rawDescGZIP(), []int{4} +} + +func (x *ForwardICE) GetRoomName() string { + if x != nil { + return x.RoomName + } + return "" +} + +func (x *ForwardICE) GetParticipantId() string { + if x != nil { + return x.ParticipantId + } + return "" +} + +func (x *ForwardICE) GetCandidate() *ICECandidateInit { + if x != nil { + return x.Candidate + } + return nil +} + +// Forwarded ingest room from another relay. +type ForwardIngest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RoomName string `protobuf:"bytes,1,opt,name=room_name,json=roomName,proto3" json:"room_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardIngest) Reset() { + *x = ForwardIngest{} + mi := &file_mesh_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardIngest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardIngest) ProtoMessage() {} + +func (x *ForwardIngest) ProtoReflect() protoreflect.Message { + mi := &file_mesh_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardIngest.ProtoReflect.Descriptor instead. +func (*ForwardIngest) Descriptor() ([]byte, []int) { + return file_mesh_proto_rawDescGZIP(), []int{5} +} + +func (x *ForwardIngest) GetRoomName() string { + if x != nil { + return x.RoomName + } + return "" +} + +// Stream request from mesh. +type StreamRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RoomName string `protobuf:"bytes,1,opt,name=room_name,json=roomName,proto3" json:"room_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamRequest) Reset() { + *x = StreamRequest{} + mi := &file_mesh_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamRequest) ProtoMessage() {} + +func (x *StreamRequest) ProtoReflect() protoreflect.Message { + mi := &file_mesh_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamRequest.ProtoReflect.Descriptor instead. +func (*StreamRequest) Descriptor() ([]byte, []int) { + return file_mesh_proto_rawDescGZIP(), []int{6} +} + +func (x *StreamRequest) GetRoomName() string { + if x != nil { + return x.RoomName + } + return "" +} + +// StateUpdate propagates entity state changes across the mesh. +type StateUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + SequenceNumber uint64 `protobuf:"varint,1,opt,name=sequence_number,json=sequenceNumber,proto3" json:"sequence_number,omitempty"` // Unique sequence number for this update + Entities map[string]*EntityState `protobuf:"bytes,2,rep,name=entities,proto3" json:"entities,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // Key: entity_id (e.g., room name), Value: EntityState + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StateUpdate) Reset() { + *x = StateUpdate{} + mi := &file_mesh_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StateUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StateUpdate) ProtoMessage() {} + +func (x *StateUpdate) ProtoReflect() protoreflect.Message { + mi := &file_mesh_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StateUpdate.ProtoReflect.Descriptor instead. +func (*StateUpdate) Descriptor() ([]byte, []int) { + return file_mesh_proto_rawDescGZIP(), []int{7} +} + +func (x *StateUpdate) GetSequenceNumber() uint64 { + if x != nil { + return x.SequenceNumber + } + return 0 +} + +func (x *StateUpdate) GetEntities() map[string]*EntityState { + if x != nil { + return x.Entities + } + return nil +} + +// Ack acknowledges receipt of a StateUpdate. +type Ack struct { + state protoimpl.MessageState `protogen:"open.v1"` + RelayId string `protobuf:"bytes,1,opt,name=relay_id,json=relayId,proto3" json:"relay_id,omitempty"` // UUID of the acknowledging relay + SequenceNumber uint64 `protobuf:"varint,2,opt,name=sequence_number,json=sequenceNumber,proto3" json:"sequence_number,omitempty"` // Sequence number being acknowledged + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Ack) Reset() { + *x = Ack{} + mi := &file_mesh_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Ack) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Ack) ProtoMessage() {} + +func (x *Ack) ProtoReflect() protoreflect.Message { + mi := &file_mesh_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Ack.ProtoReflect.Descriptor instead. +func (*Ack) Descriptor() ([]byte, []int) { + return file_mesh_proto_rawDescGZIP(), []int{8} +} + +func (x *Ack) GetRelayId() string { + if x != nil { + return x.RelayId + } + return "" +} + +func (x *Ack) GetSequenceNumber() uint64 { + if x != nil { + return x.SequenceNumber + } + return 0 +} + +// RetransmissionRequest requests a missed StateUpdate. +type RetransmissionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RelayId string `protobuf:"bytes,1,opt,name=relay_id,json=relayId,proto3" json:"relay_id,omitempty"` // UUID of the requesting relay + SequenceNumber uint64 `protobuf:"varint,2,opt,name=sequence_number,json=sequenceNumber,proto3" json:"sequence_number,omitempty"` // Sequence number of the missed update + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RetransmissionRequest) Reset() { + *x = RetransmissionRequest{} + mi := &file_mesh_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RetransmissionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RetransmissionRequest) ProtoMessage() {} + +func (x *RetransmissionRequest) ProtoReflect() protoreflect.Message { + mi := &file_mesh_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RetransmissionRequest.ProtoReflect.Descriptor instead. +func (*RetransmissionRequest) Descriptor() ([]byte, []int) { + return file_mesh_proto_rawDescGZIP(), []int{9} +} + +func (x *RetransmissionRequest) GetRelayId() string { + if x != nil { + return x.RelayId + } + return "" +} + +func (x *RetransmissionRequest) GetSequenceNumber() uint64 { + if x != nil { + return x.SequenceNumber + } + return 0 +} + +// Retransmission resends a StateUpdate. +type Retransmission struct { + state protoimpl.MessageState `protogen:"open.v1"` + RelayId string `protobuf:"bytes,1,opt,name=relay_id,json=relayId,proto3" json:"relay_id,omitempty"` // UUID of the sending relay + StateUpdate *StateUpdate `protobuf:"bytes,2,opt,name=state_update,json=stateUpdate,proto3" json:"state_update,omitempty"` // The retransmitted update + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Retransmission) Reset() { + *x = Retransmission{} + mi := &file_mesh_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Retransmission) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Retransmission) ProtoMessage() {} + +func (x *Retransmission) ProtoReflect() protoreflect.Message { + mi := &file_mesh_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Retransmission.ProtoReflect.Descriptor instead. +func (*Retransmission) Descriptor() ([]byte, []int) { + return file_mesh_proto_rawDescGZIP(), []int{10} +} + +func (x *Retransmission) GetRelayId() string { + if x != nil { + return x.RelayId + } + return "" +} + +func (x *Retransmission) GetStateUpdate() *StateUpdate { + if x != nil { + return x.StateUpdate + } + return nil +} + +// Heartbeat signals relay liveness. +type Heartbeat struct { + state protoimpl.MessageState `protogen:"open.v1"` + RelayId string `protobuf:"bytes,1,opt,name=relay_id,json=relayId,proto3" json:"relay_id,omitempty"` // UUID of the sending relay + Timestamp *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // Time of the heartbeat + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Heartbeat) Reset() { + *x = Heartbeat{} + mi := &file_mesh_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Heartbeat) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Heartbeat) ProtoMessage() {} + +func (x *Heartbeat) ProtoReflect() protoreflect.Message { + mi := &file_mesh_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Heartbeat.ProtoReflect.Descriptor instead. +func (*Heartbeat) Descriptor() ([]byte, []int) { + return file_mesh_proto_rawDescGZIP(), []int{11} +} + +func (x *Heartbeat) GetRelayId() string { + if x != nil { + return x.RelayId + } + return "" +} + +func (x *Heartbeat) GetTimestamp() *timestamppb.Timestamp { + if x != nil { + return x.Timestamp + } + return nil +} + +// SuspectRelay marks a relay as potentially unresponsive. +type SuspectRelay struct { + state protoimpl.MessageState `protogen:"open.v1"` + RelayId string `protobuf:"bytes,1,opt,name=relay_id,json=relayId,proto3" json:"relay_id,omitempty"` // UUID of the suspected relay + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` // Reason for suspicion (e.g., "no heartbeat") + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SuspectRelay) Reset() { + *x = SuspectRelay{} + mi := &file_mesh_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SuspectRelay) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SuspectRelay) ProtoMessage() {} + +func (x *SuspectRelay) ProtoReflect() protoreflect.Message { + mi := &file_mesh_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SuspectRelay.ProtoReflect.Descriptor instead. +func (*SuspectRelay) Descriptor() ([]byte, []int) { + return file_mesh_proto_rawDescGZIP(), []int{12} +} + +func (x *SuspectRelay) GetRelayId() string { + if x != nil { + return x.RelayId + } + return "" +} + +func (x *SuspectRelay) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +// Disconnect signals to remove a relay from the mesh. +type Disconnect struct { + state protoimpl.MessageState `protogen:"open.v1"` + RelayId string `protobuf:"bytes,1,opt,name=relay_id,json=relayId,proto3" json:"relay_id,omitempty"` // UUID of the relay to disconnect + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` // Reason for disconnection (e.g., "unresponsive") + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Disconnect) Reset() { + *x = Disconnect{} + mi := &file_mesh_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Disconnect) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Disconnect) ProtoMessage() {} + +func (x *Disconnect) ProtoReflect() protoreflect.Message { + mi := &file_mesh_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Disconnect.ProtoReflect.Descriptor instead. +func (*Disconnect) Descriptor() ([]byte, []int) { + return file_mesh_proto_rawDescGZIP(), []int{13} +} + +func (x *Disconnect) GetRelayId() string { + if x != nil { + return x.RelayId + } + return "" +} + +func (x *Disconnect) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +var File_mesh_proto protoreflect.FileDescriptor + +const file_mesh_proto_rawDesc = "" + + "\n" + + "\n" + + "mesh.proto\x12\x05proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\vstate.proto\x1a\fwebrtc.proto\"\x90\x06\n" + + "\vMeshMessage\x127\n" + + "\fstate_update\x18\x01 \x01(\v2\x12.proto.StateUpdateH\x00R\vstateUpdate\x12\x1e\n" + + "\x03ack\x18\x02 \x01(\v2\n" + + ".proto.AckH\x00R\x03ack\x12U\n" + + "\x16retransmission_request\x18\x03 \x01(\v2\x1c.proto.RetransmissionRequestH\x00R\x15retransmissionRequest\x12?\n" + + "\x0eretransmission\x18\x04 \x01(\v2\x15.proto.RetransmissionH\x00R\x0eretransmission\x120\n" + + "\theartbeat\x18\x05 \x01(\v2\x10.proto.HeartbeatH\x00R\theartbeat\x12:\n" + + "\rsuspect_relay\x18\x06 \x01(\v2\x13.proto.SuspectRelayH\x00R\fsuspectRelay\x123\n" + + "\n" + + "disconnect\x18\a \x01(\v2\x11.proto.DisconnectH\x00R\n" + + "disconnect\x124\n" + + "\vforward_sdp\x18\b \x01(\v2\x11.proto.ForwardSDPH\x00R\n" + + "forwardSdp\x124\n" + + "\vforward_ice\x18\t \x01(\v2\x11.proto.ForwardICEH\x00R\n" + + "forwardIce\x12=\n" + + "\x0eforward_ingest\x18\n" + + " \x01(\v2\x14.proto.ForwardIngestH\x00R\rforwardIngest\x12=\n" + + "\x0estream_request\x18\v \x01(\v2\x14.proto.StreamRequestH\x00R\rstreamRequest\x120\n" + + "\thandshake\x18\f \x01(\v2\x10.proto.HandshakeH\x00R\thandshake\x12I\n" + + "\x12handshake_response\x18\r \x01(\v2\x18.proto.HandshakeResponseH\x00R\x11handshakeResponseB\x06\n" + + "\x04type\"J\n" + + "\tHandshake\x12\x19\n" + + "\brelay_id\x18\x01 \x01(\tR\arelayId\x12\"\n" + + "\rdh_public_key\x18\x02 \x01(\tR\vdhPublicKey\"\xd7\x01\n" + + "\x11HandshakeResponse\x12\x19\n" + + "\brelay_id\x18\x01 \x01(\tR\arelayId\x12\"\n" + + "\rdh_public_key\x18\x02 \x01(\tR\vdhPublicKey\x12E\n" + + "\tapprovals\x18\x03 \x03(\v2'.proto.HandshakeResponse.ApprovalsEntryR\tapprovals\x1a<\n" + + "\x0eApprovalsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"v\n" + + "\n" + + "ForwardSDP\x12\x1b\n" + + "\troom_name\x18\x01 \x01(\tR\broomName\x12%\n" + + "\x0eparticipant_id\x18\x02 \x01(\tR\rparticipantId\x12\x10\n" + + "\x03sdp\x18\x03 \x01(\tR\x03sdp\x12\x12\n" + + "\x04type\x18\x04 \x01(\tR\x04type\"\x87\x01\n" + + "\n" + + "ForwardICE\x12\x1b\n" + + "\troom_name\x18\x01 \x01(\tR\broomName\x12%\n" + + "\x0eparticipant_id\x18\x02 \x01(\tR\rparticipantId\x125\n" + + "\tcandidate\x18\x03 \x01(\v2\x17.proto.ICECandidateInitR\tcandidate\",\n" + + "\rForwardIngest\x12\x1b\n" + + "\troom_name\x18\x01 \x01(\tR\broomName\",\n" + + "\rStreamRequest\x12\x1b\n" + + "\troom_name\x18\x01 \x01(\tR\broomName\"\xc5\x01\n" + + "\vStateUpdate\x12'\n" + + "\x0fsequence_number\x18\x01 \x01(\x04R\x0esequenceNumber\x12<\n" + + "\bentities\x18\x02 \x03(\v2 .proto.StateUpdate.EntitiesEntryR\bentities\x1aO\n" + + "\rEntitiesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12(\n" + + "\x05value\x18\x02 \x01(\v2\x12.proto.EntityStateR\x05value:\x028\x01\"I\n" + + "\x03Ack\x12\x19\n" + + "\brelay_id\x18\x01 \x01(\tR\arelayId\x12'\n" + + "\x0fsequence_number\x18\x02 \x01(\x04R\x0esequenceNumber\"[\n" + + "\x15RetransmissionRequest\x12\x19\n" + + "\brelay_id\x18\x01 \x01(\tR\arelayId\x12'\n" + + "\x0fsequence_number\x18\x02 \x01(\x04R\x0esequenceNumber\"b\n" + + "\x0eRetransmission\x12\x19\n" + + "\brelay_id\x18\x01 \x01(\tR\arelayId\x125\n" + + "\fstate_update\x18\x02 \x01(\v2\x12.proto.StateUpdateR\vstateUpdate\"`\n" + + "\tHeartbeat\x12\x19\n" + + "\brelay_id\x18\x01 \x01(\tR\arelayId\x128\n" + + "\ttimestamp\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\"A\n" + + "\fSuspectRelay\x12\x19\n" + + "\brelay_id\x18\x01 \x01(\tR\arelayId\x12\x16\n" + + "\x06reason\x18\x02 \x01(\tR\x06reason\"?\n" + + "\n" + + "Disconnect\x12\x19\n" + + "\brelay_id\x18\x01 \x01(\tR\arelayId\x12\x16\n" + + "\x06reason\x18\x02 \x01(\tR\x06reasonB\x16Z\x14relay/internal/protob\x06proto3" + +var ( + file_mesh_proto_rawDescOnce sync.Once + file_mesh_proto_rawDescData []byte +) + +func file_mesh_proto_rawDescGZIP() []byte { + file_mesh_proto_rawDescOnce.Do(func() { + file_mesh_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_mesh_proto_rawDesc), len(file_mesh_proto_rawDesc))) + }) + return file_mesh_proto_rawDescData +} + +var file_mesh_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_mesh_proto_goTypes = []any{ + (*MeshMessage)(nil), // 0: proto.MeshMessage + (*Handshake)(nil), // 1: proto.Handshake + (*HandshakeResponse)(nil), // 2: proto.HandshakeResponse + (*ForwardSDP)(nil), // 3: proto.ForwardSDP + (*ForwardICE)(nil), // 4: proto.ForwardICE + (*ForwardIngest)(nil), // 5: proto.ForwardIngest + (*StreamRequest)(nil), // 6: proto.StreamRequest + (*StateUpdate)(nil), // 7: proto.StateUpdate + (*Ack)(nil), // 8: proto.Ack + (*RetransmissionRequest)(nil), // 9: proto.RetransmissionRequest + (*Retransmission)(nil), // 10: proto.Retransmission + (*Heartbeat)(nil), // 11: proto.Heartbeat + (*SuspectRelay)(nil), // 12: proto.SuspectRelay + (*Disconnect)(nil), // 13: proto.Disconnect + nil, // 14: proto.HandshakeResponse.ApprovalsEntry + nil, // 15: proto.StateUpdate.EntitiesEntry + (*ICECandidateInit)(nil), // 16: proto.ICECandidateInit + (*timestamppb.Timestamp)(nil), // 17: google.protobuf.Timestamp + (*EntityState)(nil), // 18: proto.EntityState +} +var file_mesh_proto_depIdxs = []int32{ + 7, // 0: proto.MeshMessage.state_update:type_name -> proto.StateUpdate + 8, // 1: proto.MeshMessage.ack:type_name -> proto.Ack + 9, // 2: proto.MeshMessage.retransmission_request:type_name -> proto.RetransmissionRequest + 10, // 3: proto.MeshMessage.retransmission:type_name -> proto.Retransmission + 11, // 4: proto.MeshMessage.heartbeat:type_name -> proto.Heartbeat + 12, // 5: proto.MeshMessage.suspect_relay:type_name -> proto.SuspectRelay + 13, // 6: proto.MeshMessage.disconnect:type_name -> proto.Disconnect + 3, // 7: proto.MeshMessage.forward_sdp:type_name -> proto.ForwardSDP + 4, // 8: proto.MeshMessage.forward_ice:type_name -> proto.ForwardICE + 5, // 9: proto.MeshMessage.forward_ingest:type_name -> proto.ForwardIngest + 6, // 10: proto.MeshMessage.stream_request:type_name -> proto.StreamRequest + 1, // 11: proto.MeshMessage.handshake:type_name -> proto.Handshake + 2, // 12: proto.MeshMessage.handshake_response:type_name -> proto.HandshakeResponse + 14, // 13: proto.HandshakeResponse.approvals:type_name -> proto.HandshakeResponse.ApprovalsEntry + 16, // 14: proto.ForwardICE.candidate:type_name -> proto.ICECandidateInit + 15, // 15: proto.StateUpdate.entities:type_name -> proto.StateUpdate.EntitiesEntry + 7, // 16: proto.Retransmission.state_update:type_name -> proto.StateUpdate + 17, // 17: proto.Heartbeat.timestamp:type_name -> google.protobuf.Timestamp + 18, // 18: proto.StateUpdate.EntitiesEntry.value:type_name -> proto.EntityState + 19, // [19:19] is the sub-list for method output_type + 19, // [19:19] is the sub-list for method input_type + 19, // [19:19] is the sub-list for extension type_name + 19, // [19:19] is the sub-list for extension extendee + 0, // [0:19] is the sub-list for field type_name +} + +func init() { file_mesh_proto_init() } +func file_mesh_proto_init() { + if File_mesh_proto != nil { + return + } + file_state_proto_init() + file_webrtc_proto_init() + file_mesh_proto_msgTypes[0].OneofWrappers = []any{ + (*MeshMessage_StateUpdate)(nil), + (*MeshMessage_Ack)(nil), + (*MeshMessage_RetransmissionRequest)(nil), + (*MeshMessage_Retransmission)(nil), + (*MeshMessage_Heartbeat)(nil), + (*MeshMessage_SuspectRelay)(nil), + (*MeshMessage_Disconnect)(nil), + (*MeshMessage_ForwardSdp)(nil), + (*MeshMessage_ForwardIce)(nil), + (*MeshMessage_ForwardIngest)(nil), + (*MeshMessage_StreamRequest)(nil), + (*MeshMessage_Handshake)(nil), + (*MeshMessage_HandshakeResponse)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_mesh_proto_rawDesc), len(file_mesh_proto_rawDesc)), + NumEnums: 0, + NumMessages: 16, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_mesh_proto_goTypes, + DependencyIndexes: file_mesh_proto_depIdxs, + MessageInfos: file_mesh_proto_msgTypes, + }.Build() + File_mesh_proto = out.File + file_mesh_proto_goTypes = nil + file_mesh_proto_depIdxs = nil +} diff --git a/packages/relay/internal/proto/messages.pb.go b/packages/relay/internal/proto/messages.pb.go index 8faf7974..81bf30a7 100644 --- a/packages/relay/internal/proto/messages.pb.go +++ b/packages/relay/internal/proto/messages.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.4 +// protoc-gen-go v1.36.6 // protoc (unknown) // source: messages.proto @@ -127,28 +127,15 @@ func (x *ProtoMessageInput) GetData() *ProtoInput { var File_messages_proto protoreflect.FileDescriptor -var file_messages_proto_rawDesc = string([]byte{ - 0x0a, 0x0e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x12, 0x05, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x0b, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x15, 0x6c, 0x61, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x5f, 0x74, 0x72, - 0x61, 0x63, 0x6b, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x6b, 0x0a, 0x10, 0x50, - 0x72, 0x6f, 0x74, 0x6f, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x61, 0x73, 0x65, 0x12, - 0x21, 0x0a, 0x0c, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x54, 0x79, - 0x70, 0x65, 0x12, 0x34, 0x0a, 0x07, 0x6c, 0x61, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x50, 0x72, 0x6f, 0x74, - 0x6f, 0x4c, 0x61, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x54, 0x72, 0x61, 0x63, 0x6b, 0x65, 0x72, 0x52, - 0x07, 0x6c, 0x61, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x22, 0x76, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x74, - 0x6f, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x3a, 0x0a, - 0x0c, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x5f, 0x62, 0x61, 0x73, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x50, 0x72, 0x6f, 0x74, - 0x6f, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x61, 0x73, 0x65, 0x52, 0x0b, 0x6d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x61, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x04, 0x64, 0x61, 0x74, - 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, - 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, - 0x42, 0x16, 0x5a, 0x14, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, - 0x61, 0x6c, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -}) +const file_messages_proto_rawDesc = "" + + "\n" + + "\x0emessages.proto\x12\x05proto\x1a\vtypes.proto\x1a\x15latency_tracker.proto\"k\n" + + "\x10ProtoMessageBase\x12!\n" + + "\fpayload_type\x18\x01 \x01(\tR\vpayloadType\x124\n" + + "\alatency\x18\x02 \x01(\v2\x1a.proto.ProtoLatencyTrackerR\alatency\"v\n" + + "\x11ProtoMessageInput\x12:\n" + + "\fmessage_base\x18\x01 \x01(\v2\x17.proto.ProtoMessageBaseR\vmessageBase\x12%\n" + + "\x04data\x18\x02 \x01(\v2\x11.proto.ProtoInputR\x04dataB\x16Z\x14relay/internal/protob\x06proto3" var ( file_messages_proto_rawDescOnce sync.Once diff --git a/packages/relay/internal/proto/state.pb.go b/packages/relay/internal/proto/state.pb.go new file mode 100644 index 00000000..9c99eae1 --- /dev/null +++ b/packages/relay/internal/proto/state.pb.go @@ -0,0 +1,151 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.6 +// protoc (unknown) +// source: state.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// EntityState represents the state of an entity in the mesh (e.g., a room). +type EntityState struct { + state protoimpl.MessageState `protogen:"open.v1"` + EntityType string `protobuf:"bytes,1,opt,name=entity_type,json=entityType,proto3" json:"entity_type,omitempty"` // Type of entity (e.g., "room") + EntityId string `protobuf:"bytes,2,opt,name=entity_id,json=entityId,proto3" json:"entity_id,omitempty"` // Unique identifier (e.g., room name) + Active bool `protobuf:"varint,3,opt,name=active,proto3" json:"active,omitempty"` // Whether the entity is active + OwnerRelayId string `protobuf:"bytes,4,opt,name=owner_relay_id,json=ownerRelayId,proto3" json:"owner_relay_id,omitempty"` // Relay ID that owns this entity + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EntityState) Reset() { + *x = EntityState{} + mi := &file_state_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EntityState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EntityState) ProtoMessage() {} + +func (x *EntityState) ProtoReflect() protoreflect.Message { + mi := &file_state_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EntityState.ProtoReflect.Descriptor instead. +func (*EntityState) Descriptor() ([]byte, []int) { + return file_state_proto_rawDescGZIP(), []int{0} +} + +func (x *EntityState) GetEntityType() string { + if x != nil { + return x.EntityType + } + return "" +} + +func (x *EntityState) GetEntityId() string { + if x != nil { + return x.EntityId + } + return "" +} + +func (x *EntityState) GetActive() bool { + if x != nil { + return x.Active + } + return false +} + +func (x *EntityState) GetOwnerRelayId() string { + if x != nil { + return x.OwnerRelayId + } + return "" +} + +var File_state_proto protoreflect.FileDescriptor + +const file_state_proto_rawDesc = "" + + "\n" + + "\vstate.proto\x12\x05proto\"\x89\x01\n" + + "\vEntityState\x12\x1f\n" + + "\ventity_type\x18\x01 \x01(\tR\n" + + "entityType\x12\x1b\n" + + "\tentity_id\x18\x02 \x01(\tR\bentityId\x12\x16\n" + + "\x06active\x18\x03 \x01(\bR\x06active\x12$\n" + + "\x0eowner_relay_id\x18\x04 \x01(\tR\fownerRelayIdB\x16Z\x14relay/internal/protob\x06proto3" + +var ( + file_state_proto_rawDescOnce sync.Once + file_state_proto_rawDescData []byte +) + +func file_state_proto_rawDescGZIP() []byte { + file_state_proto_rawDescOnce.Do(func() { + file_state_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_state_proto_rawDesc), len(file_state_proto_rawDesc))) + }) + return file_state_proto_rawDescData +} + +var file_state_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_state_proto_goTypes = []any{ + (*EntityState)(nil), // 0: proto.EntityState +} +var file_state_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_state_proto_init() } +func file_state_proto_init() { + if File_state_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_state_proto_rawDesc), len(file_state_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_state_proto_goTypes, + DependencyIndexes: file_state_proto_depIdxs, + MessageInfos: file_state_proto_msgTypes, + }.Build() + File_state_proto = out.File + file_state_proto_goTypes = nil + file_state_proto_depIdxs = nil +} diff --git a/packages/relay/internal/proto/types.pb.go b/packages/relay/internal/proto/types.pb.go index ab989112..00f1cdbf 100644 --- a/packages/relay/internal/proto/types.pb.go +++ b/packages/relay/internal/proto/types.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.4 +// protoc-gen-go v1.36.6 // protoc (unknown) // source: types.proto @@ -581,65 +581,48 @@ func (*ProtoInput_KeyUp) isProtoInput_InputType() {} var File_types_proto protoreflect.FileDescriptor -var file_types_proto_rawDesc = string([]byte{ - 0x0a, 0x0b, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x05, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x40, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x4d, 0x6f, 0x75, - 0x73, 0x65, 0x4d, 0x6f, 0x76, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x0c, 0x0a, 0x01, 0x78, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x01, 0x78, 0x12, 0x0c, 0x0a, 0x01, 0x79, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x05, 0x52, 0x01, 0x79, 0x22, 0x43, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x4d, - 0x6f, 0x75, 0x73, 0x65, 0x4d, 0x6f, 0x76, 0x65, 0x41, 0x62, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x74, - 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, - 0x0c, 0x0a, 0x01, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x01, 0x78, 0x12, 0x0c, 0x0a, - 0x01, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x01, 0x79, 0x22, 0x41, 0x0a, 0x0f, 0x50, - 0x72, 0x6f, 0x74, 0x6f, 0x4d, 0x6f, 0x75, 0x73, 0x65, 0x57, 0x68, 0x65, 0x65, 0x6c, 0x12, 0x12, - 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, - 0x70, 0x65, 0x12, 0x0c, 0x0a, 0x01, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x01, 0x78, - 0x12, 0x0c, 0x0a, 0x01, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x01, 0x79, 0x22, 0x39, - 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x4d, 0x6f, 0x75, 0x73, 0x65, 0x4b, 0x65, 0x79, 0x44, - 0x6f, 0x77, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x22, 0x37, 0x0a, 0x0f, 0x50, 0x72, 0x6f, - 0x74, 0x6f, 0x4d, 0x6f, 0x75, 0x73, 0x65, 0x4b, 0x65, 0x79, 0x55, 0x70, 0x12, 0x12, 0x0a, 0x04, - 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, - 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x6b, - 0x65, 0x79, 0x22, 0x34, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x4b, 0x65, 0x79, 0x44, 0x6f, - 0x77, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x05, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x22, 0x32, 0x0a, 0x0a, 0x50, 0x72, 0x6f, 0x74, - 0x6f, 0x4b, 0x65, 0x79, 0x55, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, - 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x22, 0xab, 0x03, 0x0a, - 0x0a, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x36, 0x0a, 0x0a, 0x6d, - 0x6f, 0x75, 0x73, 0x65, 0x5f, 0x6d, 0x6f, 0x76, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x4d, 0x6f, 0x75, - 0x73, 0x65, 0x4d, 0x6f, 0x76, 0x65, 0x48, 0x00, 0x52, 0x09, 0x6d, 0x6f, 0x75, 0x73, 0x65, 0x4d, - 0x6f, 0x76, 0x65, 0x12, 0x40, 0x0a, 0x0e, 0x6d, 0x6f, 0x75, 0x73, 0x65, 0x5f, 0x6d, 0x6f, 0x76, - 0x65, 0x5f, 0x61, 0x62, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x4d, 0x6f, 0x75, 0x73, 0x65, 0x4d, 0x6f, - 0x76, 0x65, 0x41, 0x62, 0x73, 0x48, 0x00, 0x52, 0x0c, 0x6d, 0x6f, 0x75, 0x73, 0x65, 0x4d, 0x6f, - 0x76, 0x65, 0x41, 0x62, 0x73, 0x12, 0x39, 0x0a, 0x0b, 0x6d, 0x6f, 0x75, 0x73, 0x65, 0x5f, 0x77, - 0x68, 0x65, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x4d, 0x6f, 0x75, 0x73, 0x65, 0x57, 0x68, 0x65, - 0x65, 0x6c, 0x48, 0x00, 0x52, 0x0a, 0x6d, 0x6f, 0x75, 0x73, 0x65, 0x57, 0x68, 0x65, 0x65, 0x6c, - 0x12, 0x40, 0x0a, 0x0e, 0x6d, 0x6f, 0x75, 0x73, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x64, 0x6f, - 0x77, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x4d, 0x6f, 0x75, 0x73, 0x65, 0x4b, 0x65, 0x79, 0x44, 0x6f, - 0x77, 0x6e, 0x48, 0x00, 0x52, 0x0c, 0x6d, 0x6f, 0x75, 0x73, 0x65, 0x4b, 0x65, 0x79, 0x44, 0x6f, - 0x77, 0x6e, 0x12, 0x3a, 0x0a, 0x0c, 0x6d, 0x6f, 0x75, 0x73, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x5f, - 0x75, 0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x4d, 0x6f, 0x75, 0x73, 0x65, 0x4b, 0x65, 0x79, 0x55, 0x70, - 0x48, 0x00, 0x52, 0x0a, 0x6d, 0x6f, 0x75, 0x73, 0x65, 0x4b, 0x65, 0x79, 0x55, 0x70, 0x12, 0x30, - 0x0a, 0x08, 0x6b, 0x65, 0x79, 0x5f, 0x64, 0x6f, 0x77, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x4b, 0x65, - 0x79, 0x44, 0x6f, 0x77, 0x6e, 0x48, 0x00, 0x52, 0x07, 0x6b, 0x65, 0x79, 0x44, 0x6f, 0x77, 0x6e, - 0x12, 0x2a, 0x0a, 0x06, 0x6b, 0x65, 0x79, 0x5f, 0x75, 0x70, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x4b, 0x65, - 0x79, 0x55, 0x70, 0x48, 0x00, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x55, 0x70, 0x42, 0x0c, 0x0a, 0x0a, - 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x42, 0x16, 0x5a, 0x14, 0x72, 0x65, - 0x6c, 0x61, 0x79, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -}) +const file_types_proto_rawDesc = "" + + "\n" + + "\vtypes.proto\x12\x05proto\"@\n" + + "\x0eProtoMouseMove\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x12\f\n" + + "\x01x\x18\x02 \x01(\x05R\x01x\x12\f\n" + + "\x01y\x18\x03 \x01(\x05R\x01y\"C\n" + + "\x11ProtoMouseMoveAbs\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x12\f\n" + + "\x01x\x18\x02 \x01(\x05R\x01x\x12\f\n" + + "\x01y\x18\x03 \x01(\x05R\x01y\"A\n" + + "\x0fProtoMouseWheel\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x12\f\n" + + "\x01x\x18\x02 \x01(\x05R\x01x\x12\f\n" + + "\x01y\x18\x03 \x01(\x05R\x01y\"9\n" + + "\x11ProtoMouseKeyDown\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x12\x10\n" + + "\x03key\x18\x02 \x01(\x05R\x03key\"7\n" + + "\x0fProtoMouseKeyUp\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x12\x10\n" + + "\x03key\x18\x02 \x01(\x05R\x03key\"4\n" + + "\fProtoKeyDown\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x12\x10\n" + + "\x03key\x18\x02 \x01(\x05R\x03key\"2\n" + + "\n" + + "ProtoKeyUp\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x12\x10\n" + + "\x03key\x18\x02 \x01(\x05R\x03key\"\xab\x03\n" + + "\n" + + "ProtoInput\x126\n" + + "\n" + + "mouse_move\x18\x01 \x01(\v2\x15.proto.ProtoMouseMoveH\x00R\tmouseMove\x12@\n" + + "\x0emouse_move_abs\x18\x02 \x01(\v2\x18.proto.ProtoMouseMoveAbsH\x00R\fmouseMoveAbs\x129\n" + + "\vmouse_wheel\x18\x03 \x01(\v2\x16.proto.ProtoMouseWheelH\x00R\n" + + "mouseWheel\x12@\n" + + "\x0emouse_key_down\x18\x04 \x01(\v2\x18.proto.ProtoMouseKeyDownH\x00R\fmouseKeyDown\x12:\n" + + "\fmouse_key_up\x18\x05 \x01(\v2\x16.proto.ProtoMouseKeyUpH\x00R\n" + + "mouseKeyUp\x120\n" + + "\bkey_down\x18\x06 \x01(\v2\x13.proto.ProtoKeyDownH\x00R\akeyDown\x12*\n" + + "\x06key_up\x18\a \x01(\v2\x11.proto.ProtoKeyUpH\x00R\x05keyUpB\f\n" + + "\n" + + "input_typeB\x16Z\x14relay/internal/protob\x06proto3" var ( file_types_proto_rawDescOnce sync.Once diff --git a/packages/relay/internal/proto/webrtc.pb.go b/packages/relay/internal/proto/webrtc.pb.go new file mode 100644 index 00000000..349d0c1a --- /dev/null +++ b/packages/relay/internal/proto/webrtc.pb.go @@ -0,0 +1,154 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.6 +// protoc (unknown) +// source: webrtc.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ICECandidateInit struct { + state protoimpl.MessageState `protogen:"open.v1"` + Candidate string `protobuf:"bytes,1,opt,name=candidate,proto3" json:"candidate,omitempty"` + SdpMid *string `protobuf:"bytes,2,opt,name=sdp_mid,json=sdpMid,proto3,oneof" json:"sdp_mid,omitempty"` + SdpMLineIndex *uint32 `protobuf:"varint,3,opt,name=sdp_m_line_index,json=sdpMLineIndex,proto3,oneof" json:"sdp_m_line_index,omitempty"` + UsernameFragment *string `protobuf:"bytes,4,opt,name=username_fragment,json=usernameFragment,proto3,oneof" json:"username_fragment,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ICECandidateInit) Reset() { + *x = ICECandidateInit{} + mi := &file_webrtc_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ICECandidateInit) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ICECandidateInit) ProtoMessage() {} + +func (x *ICECandidateInit) ProtoReflect() protoreflect.Message { + mi := &file_webrtc_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ICECandidateInit.ProtoReflect.Descriptor instead. +func (*ICECandidateInit) Descriptor() ([]byte, []int) { + return file_webrtc_proto_rawDescGZIP(), []int{0} +} + +func (x *ICECandidateInit) GetCandidate() string { + if x != nil { + return x.Candidate + } + return "" +} + +func (x *ICECandidateInit) GetSdpMid() string { + if x != nil && x.SdpMid != nil { + return *x.SdpMid + } + return "" +} + +func (x *ICECandidateInit) GetSdpMLineIndex() uint32 { + if x != nil && x.SdpMLineIndex != nil { + return *x.SdpMLineIndex + } + return 0 +} + +func (x *ICECandidateInit) GetUsernameFragment() string { + if x != nil && x.UsernameFragment != nil { + return *x.UsernameFragment + } + return "" +} + +var File_webrtc_proto protoreflect.FileDescriptor + +const file_webrtc_proto_rawDesc = "" + + "\n" + + "\fwebrtc.proto\x12\x05proto\"\xe5\x01\n" + + "\x10ICECandidateInit\x12\x1c\n" + + "\tcandidate\x18\x01 \x01(\tR\tcandidate\x12\x1c\n" + + "\asdp_mid\x18\x02 \x01(\tH\x00R\x06sdpMid\x88\x01\x01\x12,\n" + + "\x10sdp_m_line_index\x18\x03 \x01(\rH\x01R\rsdpMLineIndex\x88\x01\x01\x120\n" + + "\x11username_fragment\x18\x04 \x01(\tH\x02R\x10usernameFragment\x88\x01\x01B\n" + + "\n" + + "\b_sdp_midB\x13\n" + + "\x11_sdp_m_line_indexB\x14\n" + + "\x12_username_fragmentB\x16Z\x14relay/internal/protob\x06proto3" + +var ( + file_webrtc_proto_rawDescOnce sync.Once + file_webrtc_proto_rawDescData []byte +) + +func file_webrtc_proto_rawDescGZIP() []byte { + file_webrtc_proto_rawDescOnce.Do(func() { + file_webrtc_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_webrtc_proto_rawDesc), len(file_webrtc_proto_rawDesc))) + }) + return file_webrtc_proto_rawDescData +} + +var file_webrtc_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_webrtc_proto_goTypes = []any{ + (*ICECandidateInit)(nil), // 0: proto.ICECandidateInit +} +var file_webrtc_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_webrtc_proto_init() } +func file_webrtc_proto_init() { + if File_webrtc_proto != nil { + return + } + file_webrtc_proto_msgTypes[0].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_webrtc_proto_rawDesc), len(file_webrtc_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_webrtc_proto_goTypes, + DependencyIndexes: file_webrtc_proto_depIdxs, + MessageInfos: file_webrtc_proto_msgTypes, + }.Build() + File_webrtc_proto = out.File + file_webrtc_proto_goTypes = nil + file_webrtc_proto_depIdxs = nil +} diff --git a/packages/relay/internal/relay.go b/packages/relay/internal/relay.go new file mode 100644 index 00000000..b8894ea1 --- /dev/null +++ b/packages/relay/internal/relay.go @@ -0,0 +1,702 @@ +package internal + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "github.com/libp2p/go-libp2p" + "github.com/libp2p/go-libp2p-pubsub" + "github.com/libp2p/go-libp2p/core/host" + "github.com/libp2p/go-libp2p/core/network" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/core/pnet" + "github.com/libp2p/go-libp2p/p2p/security/noise" + "github.com/multiformats/go-multiaddr" + "github.com/oklog/ulid/v2" + "github.com/pion/webrtc/v4" + "io" + "log/slog" + "relay/internal/common" + "relay/internal/connections" +) + +var globalRelay *Relay + +// networkNotifier logs connection events +type networkNotifier struct{} + +func (n *networkNotifier) Connected(net network.Network, conn network.Conn) { + slog.Info("Peer connected", "local", conn.LocalPeer(), "remote", conn.RemotePeer()) +} +func (n *networkNotifier) Disconnected(net network.Network, conn network.Conn) { + slog.Info("Peer disconnected", "local", conn.LocalPeer(), "remote", conn.RemotePeer()) +} +func (n *networkNotifier) Listen(net network.Network, addr multiaddr.Multiaddr) {} +func (n *networkNotifier) ListenClose(net network.Network, addr multiaddr.Multiaddr) {} + +type ICEMessage struct { + PeerID string + TargetID string + RoomID ulid.ULID + Candidate []byte +} + +type Relay struct { + ID peer.ID + Rooms *common.SafeMap[ulid.ULID, *Room] + Host host.Host // libp2p host for peer-to-peer networking + PubSub *pubsub.PubSub // PubSub for state synchronization + MeshState *common.SafeMap[ulid.ULID, RoomInfo] // room ID -> state + RelayPCs *common.SafeMap[ulid.ULID, *webrtc.PeerConnection] // room ID -> relay PeerConnection + pubTopicState *pubsub.Topic // topic for room states + pubTopicICECandidate *pubsub.Topic // topic for ICE candidates aimed to this relay +} + +func NewRelay(ctx context.Context, port int) (*Relay, error) { + listenAddrs := []string{ + fmt.Sprintf("/ip4/0.0.0.0/tcp/%d", port), // IPv4 + fmt.Sprintf("/ip6/::/tcp/%d", port), // IPv6 + } + + // Use "testToken" as the pre-shared token for authentication + // TODO: Give via flags, before PR commit + token := "testToken" + // Generate 32-byte PSK from the token using SHA-256 + shaToken := sha256.Sum256([]byte(token)) + tokenPSK := pnet.PSK(shaToken[:]) + + // Initialize libp2p host + p2pHost, err := libp2p.New( + libp2p.ListenAddrStrings(listenAddrs...), + libp2p.Security(noise.ID, noise.New), + libp2p.EnableRelay(), + libp2p.EnableHolePunching(), + libp2p.PrivateNetwork(tokenPSK), + ) + if err != nil { + return nil, fmt.Errorf("failed to create libp2p host for relay: %w", err) + } + + // Set up pubsub + p2pPubsub, err := pubsub.NewGossipSub(ctx, p2pHost) + if err != nil { + return nil, fmt.Errorf("failed to create pubsub: %w", err) + } + + // Add network notifier to log connections + p2pHost.Network().Notify(&networkNotifier{}) + + r := &Relay{ + ID: p2pHost.ID(), + Host: p2pHost, + PubSub: p2pPubsub, + Rooms: common.NewSafeMap[ulid.ULID, *Room](), + MeshState: common.NewSafeMap[ulid.ULID, RoomInfo](), + RelayPCs: common.NewSafeMap[ulid.ULID, *webrtc.PeerConnection](), + } + + // Set up state synchronization and stream handling + r.setupStateSync(ctx) + r.setupStreamHandler() + + slog.Info("Relay initialized", "id", r.ID, "addrs", p2pHost.Addrs()) + + peerInfo := peer.AddrInfo{ + ID: p2pHost.ID(), + Addrs: p2pHost.Addrs(), + } + addrs, err := peer.AddrInfoToP2pAddrs(&peerInfo) + if err != nil { + return nil, fmt.Errorf("failed to convert peer info to addresses: %w", err) + } + + slog.Debug("Connect with one of the following URLs below:") + for _, addr := range addrs { + slog.Debug(fmt.Sprintf("- %s", addr.String())) + } + return r, nil +} + +func InitRelay(ctx context.Context, ctxCancel context.CancelFunc, port int) error { + var err error + globalRelay, err = NewRelay(ctx, port) + if err != nil { + return fmt.Errorf("failed to create relay: %w", err) + } + + if err := common.InitWebRTCAPI(); err != nil { + return err + } + + if err := InitHTTPEndpoint(ctx, ctxCancel); err != nil { + return err + } + + slog.Info("Relay initialized", "id", globalRelay.ID) + return nil +} + +func GetRelay() *Relay { + return globalRelay +} + +func (r *Relay) GetRoomByID(id ulid.ULID) *Room { + if room, ok := r.Rooms.Get(id); ok { + return room + } + return nil +} + +func (r *Relay) GetOrCreateRoom(name string) *Room { + if room := r.GetRoomByName(name); room != nil { + return room + } + + id, err := common.NewULID() + if err != nil { + slog.Error("Failed to generate new ULID for room", "err", err) + return nil + } + + room := NewRoom(name, id, r.ID) + room.Relay = r + r.Rooms.Set(room.ID, room) + + slog.Debug("Created new room", "name", name, "id", room.ID) + return room +} + +func (r *Relay) DeleteRoomIfEmpty(room *Room) { + participantCount := room.Participants.Len() + if participantCount > 0 { + slog.Debug("Room not empty, not deleting", "name", room.Name, "id", room.ID, "participants", participantCount) + return + } + + // Create a "tombstone" state for the room, this allows propagation of the room deletion + tombstoneState := RoomInfo{ + ID: room.ID, + Name: room.Name, + Online: false, + OwnerID: room.OwnerID, + } + + // Publish updated state to mesh + if err := r.publishRoomState(context.Background(), tombstoneState); err != nil { + slog.Error("Failed to publish room states on change", "room", room.Name, "err", err) + } + + slog.Info("Deleting room since empty and offline", "name", room.Name, "id", room.ID) + r.Rooms.Delete(room.ID) +} + +func (r *Relay) setupStateSync(ctx context.Context) { + var err error + r.pubTopicState, err = r.PubSub.Join("room-states") + if err != nil { + slog.Error("Failed to join pubsub topic", "err", err) + return + } + + sub, err := r.pubTopicState.Subscribe() + if err != nil { + slog.Error("Failed to subscribe to topic", "err", err) + return + } + + r.pubTopicICECandidate, err = r.PubSub.Join("ice-candidates") + if err != nil { + slog.Error("Failed to join ICE candidates topic", "err", err) + return + } + + iceCandidateSub, err := r.pubTopicICECandidate.Subscribe() + if err != nil { + slog.Error("Failed to subscribe to ICE candidates topic", "err", err) + return + } + + // Handle state updates only from authenticated peers + go func() { + for { + msg, err := sub.Next(ctx) + if err != nil { + slog.Error("Error receiving pubsub message", "err", err) + return + } + if msg.GetFrom() == r.Host.ID() { + continue // Ignore own messages + } + var states []RoomInfo + if err := json.Unmarshal(msg.Data, &states); err != nil { + slog.Error("Failed to unmarshal room states", "err", err) + continue + } + r.updateMeshState(states) + } + }() + + // Handle incoming ICE candidates for given room + go func() { + // Map of ICE candidate slices per room ID + iceHolder := make(map[ulid.ULID][]webrtc.ICECandidateInit) + + for { + msg, err := iceCandidateSub.Next(ctx) + if err != nil { + slog.Error("Error receiving ICE candidate message", "err", err) + return + } + if msg.GetFrom() == r.Host.ID() { + continue // Ignore own messages + } + + var iceMsg ICEMessage + if err := json.Unmarshal(msg.Data, &iceMsg); err != nil { + slog.Error("Failed to unmarshal ICE candidate message", "err", err) + continue + } + if iceMsg.TargetID != r.ID.String() { + continue // Ignore messages not meant for this relay + } + + if iceHolder[iceMsg.RoomID] == nil { + iceHolder[iceMsg.RoomID] = make([]webrtc.ICECandidateInit, 0) + } + + if pc, ok := r.RelayPCs.Get(iceMsg.RoomID); ok { + // Unmarshal ice candidate + var candidate webrtc.ICECandidateInit + if err := json.Unmarshal(iceMsg.Candidate, &candidate); err != nil { + slog.Error("Failed to unmarshal ICE candidate", "err", err) + continue + } + if pc.RemoteDescription() != nil { + if err := pc.AddICECandidate(candidate); err != nil { + slog.Error("Failed to add ICE candidate", "err", err) + } + // Add any held candidates + for _, heldCandidate := range iceHolder[iceMsg.RoomID] { + if err := pc.AddICECandidate(heldCandidate); err != nil { + slog.Error("Failed to add held ICE candidate", "err", err) + } + } + iceHolder[iceMsg.RoomID] = make([]webrtc.ICECandidateInit, 0) + } else { + iceHolder[iceMsg.RoomID] = append(iceHolder[iceMsg.RoomID], candidate) + } + } else { + slog.Error("PeerConnection for room not found when adding ICE candidate", "roomID", iceMsg.RoomID) + } + } + }() +} + +func (r *Relay) publishRoomState(ctx context.Context, state RoomInfo) error { + data, err := json.Marshal([]RoomInfo{state}) + if err != nil { + return err + } + return r.pubTopicState.Publish(ctx, data) +} + +func (r *Relay) publishRoomStates(ctx context.Context) error { + var states []RoomInfo + for _, room := range r.Rooms.Copy() { + states = append(states, RoomInfo{ + ID: room.ID, + Name: room.Name, + Online: room.Online, + OwnerID: r.ID, + }) + } + data, err := json.Marshal(states) + if err != nil { + return err + } + return r.pubTopicState.Publish(ctx, data) +} + +func (r *Relay) updateMeshState(states []RoomInfo) { + for _, state := range states { + if state.OwnerID == r.ID { + continue // Skip own state + } + existing, exists := r.MeshState.Get(state.ID) + r.MeshState.Set(state.ID, state) + slog.Debug("Updated mesh state", "room", state.Name, "online", state.Online, "owner", state.OwnerID) + + // React to state changes + if !exists || existing.Online != state.Online { + room := r.GetRoomByName(state.Name) + if state.Online { + if room == nil || !room.Online { + slog.Info("Room became active remotely, requesting stream", "room", state.Name, "owner", state.OwnerID) + go func() { + if _, err := r.requestStream(context.Background(), state.Name, state.ID, state.OwnerID); err != nil { + slog.Error("Failed to request stream", "room", state.Name, "err", err) + } else { + slog.Info("Successfully requested stream", "room", state.Name, "owner", state.OwnerID) + } + }() + } + } else if room != nil && room.Online { + slog.Info("Room became inactive remotely, stopping local stream", "room", state.Name) + if pc, ok := r.RelayPCs.Get(state.ID); ok { + _ = pc.Close() + r.RelayPCs.Delete(state.ID) + } + room.Online = false + room.signalParticipantsOffline() + } else if room == nil && !exists { + slog.Info("Received tombstone state for room", "name", state.Name, "id", state.ID) + if pc, ok := r.RelayPCs.Get(state.ID); ok { + _ = pc.Close() + r.RelayPCs.Delete(state.ID) + } + } + } + } +} + +func (r *Relay) IsRoomActive(roomID ulid.ULID) (bool, peer.ID) { + if state, exists := r.MeshState.Get(roomID); exists && state.Online { + return true, state.OwnerID + } + return false, "" +} + +func (r *Relay) GetRoomByName(name string) *Room { + for _, room := range r.Rooms.Copy() { + if room.Name == name { + return room + } + } + return nil +} + +func writeMessage(stream network.Stream, data []byte) error { + length := uint32(len(data)) + if err := binary.Write(stream, binary.BigEndian, length); err != nil { + return err + } + _, err := stream.Write(data) + return err +} + +func readMessage(stream network.Stream) ([]byte, error) { + var length uint32 + if err := binary.Read(stream, binary.BigEndian, &length); err != nil { + return nil, err + } + data := make([]byte, length) + _, err := io.ReadFull(stream, data) + return data, err +} + +func (r *Relay) setupStreamHandler() { + r.Host.SetStreamHandler("/nestri-relay/stream/1.0.0", func(stream network.Stream) { + defer func(stream network.Stream) { + err := stream.Close() + if err != nil { + slog.Error("Failed to close stream", "err", err) + } + }(stream) + remotePeer := stream.Conn().RemotePeer() + + roomNameData, err := readMessage(stream) + if err != nil && err != io.EOF { + slog.Error("Failed to read room name", "peer", remotePeer, "err", err) + return + } + roomName := string(roomNameData) + + slog.Info("Stream request from peer", "peer", remotePeer, "room", roomName) + + room := r.GetRoomByName(roomName) + if room == nil || !room.Online { + slog.Error("Cannot provide stream for inactive room", "room", roomName) + return + } + + pc, err := common.CreatePeerConnection(func() { + r.RelayPCs.Delete(room.ID) + }) + if err != nil { + slog.Error("Failed to create relay PeerConnection", "err", err) + return + } + + r.RelayPCs.Set(room.ID, pc) + + if room.AudioTrack != nil { + _, err := pc.AddTrack(room.AudioTrack) + if err != nil { + slog.Error("Failed to add audio track", "err", err) + return + } + } + if room.VideoTrack != nil { + _, err := pc.AddTrack(room.VideoTrack) + if err != nil { + slog.Error("Failed to add video track", "err", err) + return + } + } + + settingOrdered := true + settingMaxRetransmits := uint16(0) + dc, err := pc.CreateDataChannel("relay-data", &webrtc.DataChannelInit{ + Ordered: &settingOrdered, + MaxRetransmits: &settingMaxRetransmits, + }) + if err != nil { + slog.Error("Failed to create relay DataChannel", "err", err) + return + } + relayDC := connections.NewNestriDataChannel(dc) + + relayDC.RegisterOnOpen(func() { + slog.Debug("Relay DataChannel opened", "room", roomName) + }) + + relayDC.RegisterOnClose(func() { + slog.Debug("Relay DataChannel closed", "room", roomName) + }) + + relayDC.RegisterMessageCallback("input", func(data []byte) { + if room.DataChannel != nil { + // Forward message to the room's data channel + if err := room.DataChannel.SendBinary(data); err != nil { + slog.Error("Failed to send DataChannel message", "room", roomName, "err", err) + } + } + }) + + offer, err := pc.CreateOffer(nil) + if err != nil { + slog.Error("Failed to create offer", "err", err) + return + } + if err := pc.SetLocalDescription(offer); err != nil { + slog.Error("Failed to set local description", "err", err) + return + } + offerData, err := json.Marshal(offer) + if err != nil { + slog.Error("Failed to marshal offer", "err", err) + return + } + if err := writeMessage(stream, offerData); err != nil { + slog.Error("Failed to send offer", "peer", remotePeer, "err", err) + return + } + + // Handle our generated ICE candidates + pc.OnICECandidate(func(candidate *webrtc.ICECandidate) { + if candidate == nil { + return + } + candidateData, err := json.Marshal(candidate.ToJSON()) + if err != nil { + slog.Error("Failed to marshal ICE candidate", "err", err) + return + } + iceMsg := ICEMessage{ + PeerID: r.Host.ID().String(), + TargetID: remotePeer.String(), + RoomID: room.ID, + Candidate: candidateData, + } + data, err := json.Marshal(iceMsg) + if err != nil { + slog.Error("Failed to marshal ICE message", "err", err) + return + } + if err := r.pubTopicICECandidate.Publish(context.Background(), data); err != nil { + slog.Error("Failed to publish ICE candidate message", "err", err) + } + }) + + answerData, err := readMessage(stream) + if err != nil && err != io.EOF { + slog.Error("Failed to read answer", "peer", remotePeer, "err", err) + return + } + var answer webrtc.SessionDescription + if err := json.Unmarshal(answerData, &answer); err != nil { + slog.Error("Failed to unmarshal answer", "err", err) + return + } + if err := pc.SetRemoteDescription(answer); err != nil { + slog.Error("Failed to set remote description", "err", err) + return + } + }) +} + +func (r *Relay) requestStream(ctx context.Context, roomName string, roomID ulid.ULID, providerPeer peer.ID) (*webrtc.PeerConnection, error) { + stream, err := r.Host.NewStream(ctx, providerPeer, "/nestri-relay/stream/1.0.0") + if err != nil { + return nil, fmt.Errorf("failed to create stream: %w", err) + } + defer func(stream network.Stream) { + err := stream.Close() + if err != nil { + slog.Error("Failed to close stream", "err", err) + } + }(stream) + + if err := writeMessage(stream, []byte(roomName)); err != nil { + return nil, fmt.Errorf("failed to send room name: %w", err) + } + + room := r.GetRoomByName(roomName) + if room == nil { + room = NewRoom(roomName, roomID, providerPeer) + r.Rooms.Set(roomID, room) + } else if room.ID != roomID { + // Mismatch, prefer the one from the provider + // TODO: When mesh is created, if there are mismatches, we should have relays negotiate common room IDs + room.ID = roomID + room.OwnerID = providerPeer + r.Rooms.Set(roomID, room) + } + + pc, err := common.CreatePeerConnection(func() { + r.RelayPCs.Delete(roomID) + }) + if err != nil { + return nil, fmt.Errorf("failed to create PeerConnection: %w", err) + } + + r.RelayPCs.Set(roomID, pc) + + offerData, err := readMessage(stream) + if err != nil && err != io.EOF { + return nil, fmt.Errorf("failed to read offer: %w", err) + } + var offer webrtc.SessionDescription + if err := json.Unmarshal(offerData, &offer); err != nil { + return nil, fmt.Errorf("failed to unmarshal offer: %w", err) + } + if err := pc.SetRemoteDescription(offer); err != nil { + return nil, fmt.Errorf("failed to set remote description: %w", err) + } + + pc.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { + localTrack, _ := webrtc.NewTrackLocalStaticRTP(track.Codec().RTPCodecCapability, track.ID(), "relay-"+roomName+"-"+track.Kind().String()) + slog.Debug("Received track for mesh relay room", "room", roomName, "kind", track.Kind()) + + room.SetTrack(track.Kind(), localTrack) + + go func() { + for { + rtpPacket, _, err := track.ReadRTP() + if err != nil { + if !errors.Is(err, io.EOF) { + slog.Error("Failed to read RTP packet from remote track for room", "room", roomName, "err", err) + } + break + } + + err = localTrack.WriteRTP(rtpPacket) + if err != nil && !errors.Is(err, io.ErrClosedPipe) { + slog.Error("Failed to write RTP to local track for room", "room", room.Name, "err", err) + break + } + } + }() + }) + + // ICE candidate handling + pc.OnICECandidate(func(candidate *webrtc.ICECandidate) { + if candidate == nil { + return + } + candidateData, err := json.Marshal(candidate.ToJSON()) + if err != nil { + slog.Error("Failed to marshal ICE candidate", "err", err) + return + } + iceMsg := ICEMessage{ + PeerID: r.Host.ID().String(), + TargetID: providerPeer.String(), + RoomID: roomID, + Candidate: candidateData, + } + data, err := json.Marshal(iceMsg) + if err != nil { + slog.Error("Failed to marshal ICE message", "err", err) + return + } + if err := r.pubTopicICECandidate.Publish(ctx, data); err != nil { + slog.Error("Failed to publish ICE candidate message", "err", err) + } + }) + + pc.OnDataChannel(func(dc *webrtc.DataChannel) { + relayDC := connections.NewNestriDataChannel(dc) + slog.Debug("Received DataChannel from peer", "room", roomName) + + relayDC.RegisterOnOpen(func() { + slog.Debug("Relay DataChannel opened", "room", roomName) + }) + + relayDC.OnClose(func() { + slog.Debug("Relay DataChannel closed", "room", roomName) + }) + + // Override room DataChannel with the mesh-relay one to forward messages + room.DataChannel = relayDC + }) + + answer, err := pc.CreateAnswer(nil) + if err != nil { + return nil, fmt.Errorf("failed to create answer: %w", err) + } + if err := pc.SetLocalDescription(answer); err != nil { + return nil, fmt.Errorf("failed to set local description: %w", err) + } + answerData, err := json.Marshal(answer) + if err != nil { + return nil, fmt.Errorf("failed to marshal answer: %w", err) + } + if err := writeMessage(stream, answerData); err != nil { + return nil, fmt.Errorf("failed to send answer: %w", err) + } + + return pc, nil +} + +// ConnectToRelay manually connects to another relay by its multiaddress +func (r *Relay) ConnectToRelay(ctx context.Context, addr string) error { + // Parse the multiaddress + ma, err := multiaddr.NewMultiaddr(addr) + if err != nil { + slog.Error("Invalid multiaddress", "addr", addr, "err", err) + return fmt.Errorf("invalid multiaddress: %w", err) + } + + // Extract peer ID from multiaddress + peerInfo, err := peer.AddrInfoFromP2pAddr(ma) + if err != nil { + slog.Error("Failed to extract peer info", "addr", addr, "err", err) + return fmt.Errorf("failed to extract peer info: %w", err) + } + + // Connect to the peer + if err := r.Host.Connect(ctx, *peerInfo); err != nil { + slog.Error("Failed to connect to peer", "peer", peerInfo.ID, "addr", addr, "err", err) + return fmt.Errorf("failed to connect: %w", err) + } + + // Publish challenge on join + //go r.sendAuthChallenge(ctx) + + slog.Info("Successfully connected to peer", "peer", peerInfo.ID, "addr", addr) + return nil +} diff --git a/packages/relay/internal/room.go b/packages/relay/internal/room.go index 29ab850f..14625824 100644 --- a/packages/relay/internal/room.go +++ b/packages/relay/internal/room.go @@ -1,179 +1,164 @@ -package relay +package internal import ( - "github.com/google/uuid" + "context" + "fmt" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/oklog/ulid/v2" "github.com/pion/webrtc/v4" - "log" - "sync" + "log/slog" + "relay/internal/common" + "relay/internal/connections" ) -var Rooms = make(map[uuid.UUID]*Room) //< Room ID -> Room -var RoomsMutex = sync.RWMutex{} - -func GetRoomByID(id uuid.UUID) *Room { - RoomsMutex.RLock() - defer RoomsMutex.RUnlock() - if room, ok := Rooms[id]; ok { - return room - } - return nil -} - -func GetRoomByName(name string) *Room { - RoomsMutex.RLock() - defer RoomsMutex.RUnlock() - for _, room := range Rooms { - if room.Name == name { - return room - } - } - return nil -} - -func GetOrCreateRoom(name string) *Room { - if room := GetRoomByName(name); room != nil { - return room - } - RoomsMutex.Lock() - room := NewRoom(name) - Rooms[room.ID] = room - if GetFlags().Verbose { - log.Printf("New room: '%s'\n", room.Name) - } - RoomsMutex.Unlock() - return room -} - -func DeleteRoomIfEmpty(room *Room) { - room.ParticipantsMutex.RLock() - defer room.ParticipantsMutex.RUnlock() - if !room.Online && len(room.Participants) <= 0 { - RoomsMutex.Lock() - delete(Rooms, room.ID) - RoomsMutex.Unlock() - } +type RoomInfo struct { + ID ulid.ULID `json:"id"` + Name string `json:"name"` + Online bool `json:"online"` + OwnerID peer.ID `json:"owner_id"` } type Room struct { - ID uuid.UUID //< Internal IDs are useful to keeping unique internal track - Name string - Online bool //< Whether the room is currently online, i.e. receiving data from a nestri-server - WebSocket *SafeWebSocket - PeerConnection *webrtc.PeerConnection - AudioTrack webrtc.TrackLocal - VideoTrack webrtc.TrackLocal - DataChannel *NestriDataChannel - Participants map[uuid.UUID]*Participant - ParticipantsMutex sync.RWMutex + RoomInfo + WebSocket *connections.SafeWebSocket + PeerConnection *webrtc.PeerConnection + AudioTrack *webrtc.TrackLocalStaticRTP + VideoTrack *webrtc.TrackLocalStaticRTP + DataChannel *connections.NestriDataChannel + Participants *common.SafeMap[ulid.ULID, *Participant] + Relay *Relay } -func NewRoom(name string) *Room { +func NewRoom(name string, roomID ulid.ULID, ownerID peer.ID) *Room { return &Room{ - ID: uuid.New(), - Name: name, - Online: false, - Participants: make(map[uuid.UUID]*Participant), + RoomInfo: RoomInfo{ + ID: roomID, + Name: name, + Online: false, + OwnerID: ownerID, + }, + Participants: common.NewSafeMap[ulid.ULID, *Participant](), } } -// Assigns a WebSocket connection to a Room -func (r *Room) assignWebSocket(ws *SafeWebSocket) { - // If WS already assigned, warn +// AssignWebSocket assigns a WebSocket connection to a Room +func (r *Room) AssignWebSocket(ws *connections.SafeWebSocket) { if r.WebSocket != nil { - log.Printf("Warning: Room '%s' already has a WebSocket assigned\n", r.Name) + slog.Warn("WebSocket already assigned to room", "room", r.Name) } r.WebSocket = ws } -// Adds a Participant to a Room -func (r *Room) addParticipant(participant *Participant) { - r.ParticipantsMutex.Lock() - r.Participants[participant.ID] = participant - r.ParticipantsMutex.Unlock() +// AddParticipant adds a Participant to a Room +func (r *Room) AddParticipant(participant *Participant) { + slog.Debug("Adding participant to room", "participant", participant.ID, "room", r.Name) + r.Participants.Set(participant.ID, participant) } -// Removes a Participant from a Room by participant's ID. -// If Room is offline and this is the last participant, the room is deleted -func (r *Room) removeParticipantByID(pID uuid.UUID) { - r.ParticipantsMutex.Lock() - delete(r.Participants, pID) - r.ParticipantsMutex.Unlock() - DeleteRoomIfEmpty(r) +// Removes a Participant from a Room by participant's ID +func (r *Room) removeParticipantByID(pID ulid.ULID) { + if _, ok := r.Participants.Get(pID); ok { + r.Participants.Delete(pID) + } } -// Removes a Participant from a Room by participant's name. -// If Room is offline and this is the last participant, the room is deleted +// Removes a Participant from a Room by participant's name func (r *Room) removeParticipantByName(pName string) { - r.ParticipantsMutex.Lock() - for id, p := range r.Participants { - if p.Name == pName { - delete(r.Participants, id) + for id, participant := range r.Participants.Copy() { + if participant.Name == pName { + if err := r.signalParticipantOffline(participant); err != nil { + slog.Error("Failed to signal participant offline", "participant", participant.ID, "room", r.Name, "err", err) + } + r.Participants.Delete(id) break } } - r.ParticipantsMutex.Unlock() - DeleteRoomIfEmpty(r) } -// Signals all participants with offer and add tracks to their PeerConnections +// Removes all participants from a Room +func (r *Room) removeAllParticipants() { + for id, participant := range r.Participants.Copy() { + if err := r.signalParticipantOffline(participant); err != nil { + slog.Error("Failed to signal participant offline", "participant", participant.ID, "room", r.Name, "err", err) + } + r.Participants.Delete(id) + slog.Debug("Removed participant from room", "participant", id, "room", r.Name) + } +} + +func (r *Room) SetTrack(trackType webrtc.RTPCodecType, track *webrtc.TrackLocalStaticRTP) { + switch trackType { + case webrtc.RTPCodecTypeAudio: + r.AudioTrack = track + slog.Debug("Audio track set", "room", r.Name, "track", track != nil) + case webrtc.RTPCodecTypeVideo: + r.VideoTrack = track + slog.Debug("Video track set", "room", r.Name, "track", track != nil) + default: + slog.Warn("Unknown track type", "room", r.Name, "trackType", trackType) + } + + newOnline := r.AudioTrack != nil && r.VideoTrack != nil + if r.Online != newOnline { + r.Online = newOnline + if r.Online { + slog.Debug("Room online, participants will be signaled", "room", r.Name) + r.signalParticipantsWithTracks() + } else { + slog.Debug("Room offline, signaling participants", "room", r.Name) + r.signalParticipantsOffline() + } + + // Publish updated state to mesh + go func() { + if err := r.Relay.publishRoomStates(context.Background()); err != nil { + slog.Error("Failed to publish room states on change", "room", r.Name, "err", err) + } + }() + } +} + func (r *Room) signalParticipantsWithTracks() { - r.ParticipantsMutex.RLock() - for _, participant := range r.Participants { - // Add tracks to participant's PeerConnection - if r.AudioTrack != nil { - if err := participant.addTrack(&r.AudioTrack); err != nil { - log.Printf("Failed to add audio track to participant: '%s' - reason: %s\n", participant.ID, err) - } - } - if r.VideoTrack != nil { - if err := participant.addTrack(&r.VideoTrack); err != nil { - log.Printf("Failed to add video track to participant: '%s' - reason: %s\n", participant.ID, err) - } - } - // Signal participant with offer - if err := participant.signalOffer(); err != nil { - log.Printf("Error signaling participant: %v\n", err) + for _, participant := range r.Participants.Copy() { + if err := r.signalParticipantWithTracks(participant); err != nil { + slog.Error("Failed to signal participant with tracks", "participant", participant.ID, "room", r.Name, "err", err) } } - r.ParticipantsMutex.RUnlock() } -// Signals all participants that the Room is offline +func (r *Room) signalParticipantWithTracks(participant *Participant) error { + if r.AudioTrack != nil { + if err := participant.addTrack(r.AudioTrack); err != nil { + return fmt.Errorf("failed to add audio track: %w", err) + } + } + if r.VideoTrack != nil { + if err := participant.addTrack(r.VideoTrack); err != nil { + return fmt.Errorf("failed to add video track: %w", err) + } + } + if err := participant.signalOffer(); err != nil { + return fmt.Errorf("failed to signal offer: %w", err) + } + return nil +} + func (r *Room) signalParticipantsOffline() { - r.ParticipantsMutex.RLock() - for _, participant := range r.Participants { - if err := participant.WebSocket.SendAnswerMessageWS(AnswerOffline); err != nil { - log.Printf("Failed to send Offline answer for participant: '%s' - reason: %s\n", participant.ID, err) + for _, participant := range r.Participants.Copy() { + if err := r.signalParticipantOffline(participant); err != nil { + slog.Error("Failed to signal participant offline", "participant", participant.ID, "room", r.Name, "err", err) } } - r.ParticipantsMutex.RUnlock() } -// Broadcasts a message to Room's Participant's - excluding one given ID of -func (r *Room) broadcastMessage(msg webrtc.DataChannelMessage, excludeID uuid.UUID) { - r.ParticipantsMutex.RLock() - for d, participant := range r.Participants { - if participant.DataChannel != nil { - if d != excludeID { // Don't send back to the sender - if err := participant.DataChannel.SendText(string(msg.Data)); err != nil { - log.Printf("Error broadcasting to %s: %v\n", participant.Name, err) - } - } - } +// signalParticipantOffline signals a single participant offline +func (r *Room) signalParticipantOffline(participant *Participant) error { + // Skip if websocket is nil or closed + if participant.WebSocket == nil || participant.WebSocket.IsClosed() { + return nil } - if r.DataChannel != nil { - if err := r.DataChannel.SendText(string(msg.Data)); err != nil { - log.Printf("Error broadcasting to Room: %v\n", err) - } - } - r.ParticipantsMutex.RUnlock() -} - -// Sends message to Room (nestri-server) -func (r *Room) sendToRoom(msg webrtc.DataChannelMessage) { - if r.DataChannel != nil { - if err := r.DataChannel.SendText(string(msg.Data)); err != nil { - log.Printf("Error broadcasting to Room: %v\n", err) - } + if err := participant.WebSocket.SendAnswerMessageWS(connections.AnswerOffline); err != nil { + return err } + return nil } diff --git a/packages/relay/main.go b/packages/relay/main.go index 45951b00..a62f455d 100644 --- a/packages/relay/main.go +++ b/packages/relay/main.go @@ -1,32 +1,46 @@ package main import ( + "context" "log" + "log/slog" "os" "os/signal" - relay "relay/internal" + "relay/internal" + "relay/internal/common" "syscall" ) func main() { - var err error - stopCh := make(chan os.Signal, 1) - signal.Notify(stopCh, os.Interrupt, syscall.SIGTERM) + // Setup main context and stopper + mainCtx, mainStopper := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) // Get flags and log them - relay.InitFlags() - relay.GetFlags().DebugLog() + common.InitFlags() + common.GetFlags().DebugLog() - // Init WebRTC API - err = relay.InitWebRTCAPI() - if err != nil { - log.Fatal("Failed to initialize WebRTC API: ", err) + logLevel := slog.LevelInfo + if common.GetFlags().Verbose { + logLevel = slog.LevelDebug } - // Start our HTTP endpoints - relay.InitHTTPEndpoint() + // Create the base handler with debug level + baseHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: logLevel, + }) + customHandler := &common.CustomHandler{Handler: baseHandler} + logger := slog.New(customHandler) + slog.SetDefault(logger) + + // Start relay + err := internal.InitRelay(mainCtx, mainStopper, common.GetFlags().MeshPort) + if err != nil { + slog.Error("Failed to initialize relay", "err", err) + mainStopper() + return + } // Wait for exit signal - <-stopCh + <-mainCtx.Done() log.Println("Shutting down gracefully by signal...") } diff --git a/sst-env.d.ts b/sst-env.d.ts index c1463f67..4f8d03e1 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -68,6 +68,11 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "Realtime": { + "authorizer": string + "endpoint": string + "type": "sst.aws.Realtime" + } "Steam": { "service": string "type": "sst.aws.Service"