diff --git a/docs/settings.md b/docs/settings.md index 39fec4a..7f2d026 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -12,12 +12,12 @@ ## Матрица совместимости -| Transport | telemost | jazz | wbstream | -|-----------|:--------:|:----:|:--------:| -| datachannel | - | ~ | ~ | -| vp8channel | + | - | + | -| seichannel | - | - | + | -| videochannel | + | - | + | +| Transport | telemost | jazz | wbstream | jitsi | +|-----------|:--------:|:----:|:--------:|:-----:| +| datachannel | - | ~ | ~ | + | +| vp8channel | + | - | + | ~ | +| seichannel | - | - | + | ~ | +| videochannel | + | - | + | ~ | **Легенда:** - `+` - работает (pass в E2E тестах) @@ -30,7 +30,9 @@ **WBStream:** все транспорты кроме datachannel работают. DataChannel в обычном guest flow без выдавания модератора не работает — WB Stream выдаёт токены с `canPublishData=false`, и DC не маршрутизирует данные. -**Рекомендуемая комбинация: `wbstream + vp8channel`** — работает стабильно, не требует специальных прав. +**Jitsi:** datachannel стабильно проходит — реализован поверх colibri-ws bridge channel и шлёт байты через `EndpointMessage{raw}` broadcast. Подходит для self-hosted и публичных Jitsi Meet инстансов без аутентификации (`https://meet.cryptopro.ru/...`, `https://meet.jit.si/...` и т.п.). Видео-транспорты (vp8channel, seichannel, videochannel) экспонируют sendable VideoTrack через pion PeerConnection после Jingle session-accept, но Jicofo требует дополнительных протокольных шагов (LastN, ReceiverVideoConstraints, source-add) для маршрутизации видео — поэтому они помечены `~` (best effort). + +**Рекомендуемая комбинация: `wbstream + vp8channel`** — работает стабильно, не требует специальных прав. **`jitsi + datachannel`** — рекомендация для self-hosted Jitsi инстансов. Скорость по убыванию: `datachannel` > `vp8channel` > `seichannel` > `videochannel` @@ -41,7 +43,7 @@ | YAML поле | Что вводить | |-----------|-------------| | `mode` | `srv` на сервере, `cnc` на клиенте, `gen` для генерации Room ID | -| `auth.provider` | `telemost`, `jazz` или `wbstream` | +| `auth.provider` | `telemost`, `jazz`, `wbstream` или `jitsi` | | `net.transport` | `datachannel`, `vp8channel`, `seichannel` или `videochannel` | | `room.id` | Room ID | | `crypto.key` | Ключ шифрования hex 64 символа. Генерация: `openssl rand -hex 32` | diff --git a/docs/uri.md b/docs/uri.md index 8c61034..03fd2cb 100644 --- a/docs/uri.md +++ b/docs/uri.md @@ -33,7 +33,7 @@ olcrtc://?@#$ | Поле | Значение | |------|----------| -| `` | Имя auth-провайдера, например `telemost`, `jazz`, `wbstream` | +| `` | Имя auth-провайдера, например `telemost`, `jazz`, `wbstream`, `jitsi` | | `` | Имя транспорта, например `datachannel`, `vp8channel`, `seichannel`, `videochannel` | | payload | Параметры транспорта в ``. Ключи совпадают с YAML полями. Блок опускается если используются defaults | | `` | Идентификатор комнаты или auth-specific room URL/ID | @@ -220,6 +220,32 @@ data: data --- +### jitsi + datachannel + +```text +olcrtc://jitsi?datachannel@https://meet.cryptopro.ru/myroom#d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799$RU / olc free sub +``` + +`` для jitsi — полный URL комнаты в формате `https://host/room` (или `host/room`). Поддерживается любой self-hosted Jitsi Meet инстанс без аутентификации; для публичных серверов вроде `meet.jit.si` тот же формат. + +### Эквивалент YAML + +```yaml +mode: cnc +link: direct +auth: + provider: jitsi +room: + id: "https://meet.cryptopro.ru/myroom" +crypto: + key: "d823fa01cb3e0609b67322f7cf984c4ee2e4ce2e294936fc24ef38c9e59f4799" +net: + transport: datachannel +data: data +``` + +--- + ## Короткие алиасы Как хотите но лично я был бы против. diff --git a/go.mod b/go.mod index f1e9288..e40c2b8 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/magefile/mage v1.17.1 github.com/pion/logging v0.2.4 github.com/pion/rtp v1.10.1 - github.com/pion/webrtc/v4 v4.2.11 + github.com/pion/webrtc/v4 v4.2.12 github.com/xtaci/kcp-go/v5 v5.6.72 github.com/xtaci/smux v1.5.57 github.com/zarazaex69/gr v0.0.0-20260430043628-45b595f4fef0 @@ -29,6 +29,7 @@ require ( github.com/benbjohnson/clock v1.3.5 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coder/websocket v1.8.14 // indirect github.com/dennwc/iters v1.2.2 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/frostbyte73/core v0.1.1 // indirect @@ -53,23 +54,25 @@ require ( github.com/nats-io/nuid v1.0.1 // indirect github.com/pion/datachannel v1.6.0 // indirect github.com/pion/dtls/v3 v3.1.2 // indirect - github.com/pion/ice/v4 v4.2.2 // indirect + github.com/pion/ice/v4 v4.2.5 // indirect github.com/pion/interceptor v0.1.44 // indirect github.com/pion/mdns/v2 v2.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.16 // indirect - github.com/pion/sctp v1.9.4 // indirect + github.com/pion/sctp v1.9.5 // indirect github.com/pion/sdp/v3 v3.0.18 // indirect github.com/pion/srtp/v3 v3.0.10 // indirect github.com/pion/stun/v3 v3.1.2 // indirect github.com/pion/transport/v4 v4.0.1 // indirect github.com/pion/turn/v4 v4.1.4 // indirect + github.com/pion/turn/v5 v5.0.3 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect github.com/redis/go-redis/v9 v9.17.2 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect github.com/twitchtv/twirp v8.1.3+incompatible // indirect github.com/wlynxg/anet v0.0.5 // indirect + github.com/zarazaex69/j v0.0.0-20260515120905-b26b7b6563cd // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.uber.org/atomic v1.11.0 // indirect diff --git a/go.sum b/go.sum index f31e6ba..3450243 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -162,6 +164,8 @@ github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= github.com/pion/ice/v4 v4.2.2 h1:dQJzzcgTFHDYyV3BoCfjPeX+JEtr58BWPi4PGyo6Vjg= github.com/pion/ice/v4 v4.2.2/go.mod h1:2quLV1S5v1tAx3VvAJaH//KGitRXvo4RKlX6D3tnN+c= +github.com/pion/ice/v4 v4.2.5 h1:5umUQy4hX6HwMsCnJ0SX337YYCeTWDgC9JWyvUqHIHs= +github.com/pion/ice/v4 v4.2.5/go.mod h1:aaABRaykEYnNjccjbiimuYxViaASeuv5mk9BpplUxK0= github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I= github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= @@ -176,6 +180,8 @@ github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA= github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= github.com/pion/sctp v1.9.4 h1:cMxEu0F5tbP4qH07bKf1Zjf4rUih9LIo0qQt424e258= github.com/pion/sctp v1.9.4/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw= +github.com/pion/sctp v1.9.5 h1:QoSFB/drmAsmSeSFNQNI3xx010nW4HsycCZckRVWWag= +github.com/pion/sctp v1.9.5/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw= github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI= github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8= github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ= @@ -188,8 +194,12 @@ github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ= github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ= +github.com/pion/turn/v5 v5.0.3 h1:I+Nw0fQgdPWF1SXDj0egWDhCkcff7gWiigdQpOK52Ak= +github.com/pion/turn/v5 v5.0.3/go.mod h1:fs4SogUh/aRGQzonc4Lx3Jp4EU3j3t0PfNDEd9KcD/w= github.com/pion/webrtc/v4 v4.2.11 h1:QUX1QZKlNIn4O7U5JxLPGP0sV5RTncZkzu9SPR3jVNU= github.com/pion/webrtc/v4 v4.2.11/go.mod h1:s/rAiyy77GyRFrZMx+Ls6aua26dIBPudH8/ZHYbIRWY= +github.com/pion/webrtc/v4 v4.2.12 h1:ux8i+aJxu0OdhcAcVO39JEeodWugD0wdVJoRDtXk1CY= +github.com/pion/webrtc/v4 v4.2.12/go.mod h1:M/DeGZkhdWZVmVgGr34HOD9yUDekVJtz9c9PGO18urQ= 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -231,6 +241,10 @@ github.com/xtaci/smux v1.5.57/go.mod h1:IGQ9QYrBphmb/4aTnLEcJby0TNr3NV+OslIOMrX8 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zarazaex69/gr v0.0.0-20260430043628-45b595f4fef0 h1:dMjHX/YPV3ZD/KJKFjQdlMBwj2/rZIuOVKOvGv26m9k= github.com/zarazaex69/gr v0.0.0-20260430043628-45b595f4fef0/go.mod h1:7vALI2tjaLTOGiDKV7V2JkVU9bA1YADBDQA6uvpp1ac= +github.com/zarazaex69/j v0.0.0-20260514230609-494beaacfc77 h1:ROB1mdhnPKfkUg1VUeLEd6U+eFX15+Sh/JVcJnmF0cs= +github.com/zarazaex69/j v0.0.0-20260514230609-494beaacfc77/go.mod h1:uTrpW61I20aWMTxGMZ+eViDBFCrEtgHWggCdQjgvJ4I= +github.com/zarazaex69/j v0.0.0-20260515120905-b26b7b6563cd h1:2ewKEjqduZIPURn5CPmQQikF+qrp9Jn0VVeESXn3Hss= +github.com/zarazaex69/j v0.0.0-20260515120905-b26b7b6563cd/go.mod h1:7/ypJTenOIPx23fpo5uF7l4u+rxZqg9cFbTL/N77Ktc= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= diff --git a/internal/auth/jitsi/jitsi.go b/internal/auth/jitsi/jitsi.go new file mode 100644 index 0000000..1584c97 --- /dev/null +++ b/internal/auth/jitsi/jitsi.go @@ -0,0 +1,94 @@ +// Package jitsi implements a pass-through auth provider for self-hosted Jitsi +// Meet instances. +// +// Public Jitsi Meet servers do not require authentication for guest access; +// the only "credentials" the engine needs are the host+room pair extracted +// from a user-supplied room URL. This provider does no HTTP at all — it just +// parses the URL and forwards host+room to the engine via auth.Credentials. +// +// Supported RoomURL forms: +// +// - "https://meet.example.com/myroom" +// - "http://meet.example.com/myroom" +// - "meet.example.com/myroom" +// +// Optional URL path prefixes (e.g. "/jitsi") are preserved as part of the +// host when present, so deployments behind a path-mounted reverse proxy work +// transparently — the j library accepts any host string the WebSocket dial +// can resolve. +package jitsi + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/openlibrecommunity/olcrtc/internal/auth" +) + +// CredentialKeyRoom is the auth.Credentials.Extra key that carries the Jitsi +// room name (the conference identifier on the host). +const CredentialKeyRoom = "room" + +// ErrInvalidRoomURL is returned when the supplied RoomURL cannot be parsed +// into a host+room pair. +var ErrInvalidRoomURL = errors.New("jitsi: invalid room URL (expected host/room or https://host/room)") + +// Provider produces engine credentials for a Jitsi Meet room. +type Provider struct{} + +// Engine reports which engine consumes credentials from this auth provider. +func (Provider) Engine() string { return "jitsi" } + +// DefaultServiceURL returns the empty string: there is no canonical default +// Jitsi instance — every deployment is user-supplied. +func (Provider) DefaultServiceURL() string { return "" } + +// Issue parses cfg.RoomURL into host+room and returns engine credentials. +// +// The URL field of the returned Credentials carries the Jitsi host (e.g. +// "meet.example.com"); the room name lives in Extra under CredentialKeyRoom. +// Token is unused — Jitsi guest access requires no token. +func (Provider) Issue(_ context.Context, cfg auth.Config) (auth.Credentials, error) { + host, room, err := parseRoomURL(cfg.RoomURL) + if err != nil { + return auth.Credentials{}, err + } + return auth.Credentials{ + URL: host, + Token: "", + Extra: map[string]string{CredentialKeyRoom: room}, + }, nil +} + +// parseRoomURL splits a Jitsi room URL into (host, room). +// +// Accepts URLs with or without scheme. The host part is the segment before +// the first "/" after stripping the scheme; the room is everything that +// follows, with leading/trailing slashes trimmed. +func parseRoomURL(raw string) (host string, room string, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", auth.ErrRoomIDRequired + } + if idx := strings.Index(raw, "://"); idx >= 0 { + raw = raw[idx+3:] + } + raw = strings.TrimPrefix(raw, "//") + raw = strings.TrimPrefix(raw, "/") + slash := strings.Index(raw, "/") + if slash <= 0 { + return "", "", fmt.Errorf("%w: %q", ErrInvalidRoomURL, raw) + } + host = strings.TrimSpace(raw[:slash]) + room = strings.Trim(raw[slash+1:], "/") + if host == "" || room == "" { + return "", "", fmt.Errorf("%w: %q", ErrInvalidRoomURL, raw) + } + return host, room, nil +} + +func init() { //nolint:gochecknoinits // auth registration is the canonical Go pattern for plugins + auth.Register("jitsi", Provider{}) +} diff --git a/internal/auth/jitsi/jitsi_test.go b/internal/auth/jitsi/jitsi_test.go new file mode 100644 index 0000000..64d9120 --- /dev/null +++ b/internal/auth/jitsi/jitsi_test.go @@ -0,0 +1,83 @@ +package jitsi + +import ( + "context" + "errors" + "testing" + + "github.com/openlibrecommunity/olcrtc/internal/auth" +) + +func TestParseRoomURL(t *testing.T) { + tests := []struct { + name string + raw string + host string + room string + wantErr bool + }{ + {name: "https url", raw: "https://meet.cryptopro.ru/myroom", host: "meet.cryptopro.ru", room: "myroom"}, + {name: "http url", raw: "http://meet.example/myroom", host: "meet.example", room: "myroom"}, + {name: "scheme-less", raw: "meet.example.com/myroom", host: "meet.example.com", room: "myroom"}, + {name: "trailing slash", raw: "https://meet.example/myroom/", host: "meet.example", room: "myroom"}, + {name: "double slash leader", raw: "//meet.example/myroom", host: "meet.example", room: "myroom"}, + {name: "uppercase room", raw: "https://meet.example/MyRoom", host: "meet.example", room: "MyRoom"}, + {name: "empty", raw: "", wantErr: true}, + {name: "host only", raw: "meet.example.com", wantErr: true}, + {name: "no room", raw: "https://meet.example/", wantErr: true}, + {name: "scheme only", raw: "https://", wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + host, room, err := parseRoomURL(tc.raw) + if tc.wantErr { + if err == nil { + t.Fatalf("parseRoomURL(%q) = (%q, %q), want error", tc.raw, host, room) + } + return + } + if err != nil { + t.Fatalf("parseRoomURL(%q) error = %v, want nil", tc.raw, err) + } + if host != tc.host || room != tc.room { + t.Fatalf("parseRoomURL(%q) = (%q, %q), want (%q, %q)", + tc.raw, host, room, tc.host, tc.room) + } + }) + } +} + +func TestProviderIssue(t *testing.T) { + creds, err := Provider{}.Issue(context.Background(), auth.Config{ + RoomURL: "https://meet.cryptopro.ru/olcrtc", + Name: "olcrtc-test", + }) + if err != nil { + t.Fatalf("Issue: %v", err) + } + if creds.URL != "meet.cryptopro.ru" { + t.Fatalf("URL = %q, want %q", creds.URL, "meet.cryptopro.ru") + } + if got := creds.Extra[CredentialKeyRoom]; got != "olcrtc" { + t.Fatalf("room = %q, want %q", got, "olcrtc") + } + if creds.Token != "" { + t.Fatalf("Token = %q, want empty", creds.Token) + } +} + +func TestProviderIssueRequiresRoom(t *testing.T) { + _, err := Provider{}.Issue(context.Background(), auth.Config{RoomURL: ""}) + if !errors.Is(err, auth.ErrRoomIDRequired) { + t.Fatalf("Issue() err = %v, want ErrRoomIDRequired", err) + } +} + +func TestProviderEngine(t *testing.T) { + if got := (Provider{}).Engine(); got != "jitsi" { + t.Fatalf("Engine() = %q, want %q", got, "jitsi") + } + if got := (Provider{}).DefaultServiceURL(); got != "" { + t.Fatalf("DefaultServiceURL() = %q, want empty", got) + } +} diff --git a/internal/carrier/builtin/register.go b/internal/carrier/builtin/register.go index 28c3885..50ded3a 100644 --- a/internal/carrier/builtin/register.go +++ b/internal/carrier/builtin/register.go @@ -2,10 +2,12 @@ package builtin import ( + authJitsi "github.com/openlibrecommunity/olcrtc/internal/auth/jitsi" authSaluteJazz "github.com/openlibrecommunity/olcrtc/internal/auth/salutejazz" authTelemost "github.com/openlibrecommunity/olcrtc/internal/auth/telemost" authWBStream "github.com/openlibrecommunity/olcrtc/internal/auth/wbstream" _ "github.com/openlibrecommunity/olcrtc/internal/engine/goolom" // engine registration via init + _ "github.com/openlibrecommunity/olcrtc/internal/engine/jitsi" // engine registration via init _ "github.com/openlibrecommunity/olcrtc/internal/engine/livekit" // engine registration via init _ "github.com/openlibrecommunity/olcrtc/internal/engine/salutejazz" // engine registration via init ) @@ -15,5 +17,6 @@ func Register() { registerEngineAuth("wbstream", authWBStream.Provider{}) registerEngineAuth("jazz", authSaluteJazz.Provider{}) registerEngineAuth("telemost", authTelemost.Provider{}) + registerEngineAuth("jitsi", authJitsi.Provider{}) registerDirect("none") } diff --git a/internal/e2e/tunnel_test.go b/internal/e2e/tunnel_test.go index 3685ba9..2d2ed89 100644 --- a/internal/e2e/tunnel_test.go +++ b/internal/e2e/tunnel_test.go @@ -80,6 +80,11 @@ var ( "019e23c2-a580-7550-b08a-7ac5342ca21f", "WB Stream room id for real e2e; autogenerated when empty", ) + realE2EJitsiRoom = flag.String( //nolint:gochecknoglobals // package-level state intentional + "olcrtc.real-jitsi-room", + "https://meet.cryptopro.ru/deadbeef", + "Jitsi Meet room URL for real e2e (format https://host/room or host/room)", + ) realE2ETimeout = flag.Duration( //nolint:gochecknoglobals // package-level state intentional "olcrtc.real-timeout", 90*time.Second, @@ -337,7 +342,7 @@ func registerMemoryCarrierAs(t *testing.T, name string) { } func builtInCarrierNames() []string { - return []string{"jazz", "telemost", "wbstream"} //nolint:goconst // test literal, repetition is intentional + return []string{"jazz", "telemost", "wbstream", "jitsi"} //nolint:goconst // test literal, repetition is intentional } func builtInTransportNames() []string { @@ -365,6 +370,21 @@ func realE2ECaseExpectation(carrierName, transportName string) realE2EExpectatio return realE2EExpectPass } return realE2EExpectFail + case "jitsi": + // Jitsi colibri-ws bridge channel maps cleanly onto the + // datachannel transport (raw bytes broadcast through + // EndpointMessage). Video transports go through pion's + // PeerConnection negotiated via Jingle session-accept; results + // are bridge/instance dependent (some operators throttle or + // strip non-camera video), hence best-effort. + switch transportName { + case transportData: + return realE2EExpectPass + case transportVP8, transportVideo, transportSEI: + return realE2EBestEffort + default: + return realE2EBestEffort + } default: return realE2EExpectPass } @@ -432,6 +452,30 @@ func TestRealE2ECaseExpectation(t *testing.T) { transport: transportData, want: realE2EExpectFail, }, + { + name: "jitsi datachannel is expected to pass", + carrier: "jitsi", + transport: transportData, + want: realE2EExpectPass, + }, + { + name: "jitsi vp8channel is best effort", + carrier: "jitsi", + transport: transportVP8, + want: realE2EBestEffort, + }, + { + name: "jitsi videochannel is best effort", + carrier: "jitsi", + transport: transportVideo, + want: realE2EBestEffort, + }, + { + name: "jitsi seichannel is best effort", + carrier: "jitsi", + transport: transportSEI, + want: realE2EBestEffort, + }, } for _, tt := range tests { @@ -484,6 +528,17 @@ func realRoomURL(ctx context.Context, t *testing.T, carrierName string) string { t.Skipf("skip wbstream real e2e: create room failed: %v", err) } return room + case "jitsi": + // Jitsi has no notion of "creating" a room — names are conjured + // on first join. The default flag points at meet.cryptopro.ru + // (a CryptoPro-operated public Jitsi instance) with a fixed + // room slug so the server and client land in the same MUC. + _ = ctx + room := *realE2EJitsiRoom + if room == "" { + t.Skip("skip jitsi real e2e: empty -olcrtc.real-jitsi-room") + } + return room default: return "" } diff --git a/internal/engine/jitsi/helpers_test.go b/internal/engine/jitsi/helpers_test.go new file mode 100644 index 0000000..55fd823 --- /dev/null +++ b/internal/engine/jitsi/helpers_test.go @@ -0,0 +1,20 @@ +package jitsi + +import ( + "encoding/base64" + "testing" + + "github.com/zarazaex69/j" +) + +func encodeForTest(t *testing.T, data []byte) string { + t.Helper() + return base64.StdEncoding.EncodeToString(data) +} + +func makeBridgeMessage(class string, fields map[string]any) j.BridgeMessage { + return j.BridgeMessage{ + Class: class, + Fields: fields, + } +} diff --git a/internal/engine/jitsi/jitsi.go b/internal/engine/jitsi/jitsi.go new file mode 100644 index 0000000..4000e7c --- /dev/null +++ b/internal/engine/jitsi/jitsi.go @@ -0,0 +1,543 @@ +// Package jitsi implements an engine.Session backed by the Jitsi Meet +// XMPP/Jingle/colibri-ws stack via the github.com/zarazaex69/j library. +// +// The engine speaks the wire protocol of a self-hosted Jitsi instance: it +// joins the MUC, waits for a Jingle session-initiate from Jicofo, opens the +// JVB bridge channel (colibri-ws) for byte transport, and optionally +// negotiates a pion *webrtc.PeerConnection for video tracks. +// +// Service-specific bits (URL parsing) live in the auth/jitsi package; this +// engine is told the host and room name through engine.Config (URL carries +// the host string, Extra["room"] carries the room name). +// +// The Jingle session-initiate is only delivered by Jicofo once at least one +// other participant is present in the conference, mirroring the Telemost / +// SaluteJazz two-peer requirement that olcrtc already accommodates. +package jitsi + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/openlibrecommunity/olcrtc/internal/engine" + "github.com/openlibrecommunity/olcrtc/internal/logger" + "github.com/pion/webrtc/v4" + "github.com/zarazaex69/j" +) + +const ( + defaultSendQueueSize = 5000 + // bridgeMaxMessageSize is the practical upper bound on a single colibri-ws + // payload. JVB enforces a max-message-size around 16 KiB; payloads above + // that cause the bridge to drop the websocket. The default datachannel + // transport in olcrtc already uses 12 KiB chunks, well under this limit. + bridgeMaxMessageSize = 16 * 1024 + bridgeOpenTimeout = 30 * time.Second + defaultNick = "olcrtc" + credentialKeyRoom = "room" + videoTrackName = "videochannel" +) + +var ( + // ErrSessionClosed is returned when an operation is attempted on a closed session. + ErrSessionClosed = errors.New("jitsi session closed") + // ErrSendQueueFull is returned when the outbound queue cannot accept more data. + ErrSendQueueFull = errors.New("jitsi send queue full") + // ErrBridgeNotReady is returned when send is attempted before the bridge is open. + ErrBridgeNotReady = errors.New("jitsi bridge not ready") + // ErrSendTooLarge is returned when a single payload exceeds the JVB max-message-size limit. + ErrSendTooLarge = errors.New("jitsi payload exceeds bridge max-message-size") + // ErrHostRequired is returned when no Jitsi host was supplied. + ErrHostRequired = errors.New("jitsi host required") + // ErrRoomRequired is returned when no Jitsi room was supplied. + ErrRoomRequired = errors.New("jitsi room required") +) + +// Session is the Jitsi engine handle. +type Session struct { + host string + room string + name string + + onData func([]byte) + onReconnect func(*webrtc.DataChannel) + shouldReconnect func() bool + onEnded func(string) + + jSess atomic.Pointer[j.Session] + + pcMu sync.Mutex + pc *webrtc.PeerConnection + + sendQueue chan []byte + bridgeReady atomic.Bool + closed atomic.Bool + done chan struct{} + doneOnce sync.Once + cancel context.CancelFunc + runCtx context.Context //nolint:containedctx // engine owns the supervisor lifetime + wg sync.WaitGroup + + videoTrackMu sync.RWMutex + videoTracks []webrtc.TrackLocal + onVideoTrack func(*webrtc.TrackRemote, *webrtc.RTPReceiver) +} + +// New creates a new Jitsi engine session. +// +// cfg.URL carries the Jitsi host (e.g. "meet.cryptopro.ru") — populated by the +// jitsi auth provider after parsing the user-supplied room URL. cfg.Extra +// must contain the room name under the "room" key. +func New(_ context.Context, cfg engine.Config) (engine.Session, error) { + host := normaliseHost(cfg.URL) + if host == "" { + return nil, ErrHostRequired + } + var room string + if cfg.Extra != nil { + room = strings.TrimSpace(cfg.Extra[credentialKeyRoom]) + } + if room == "" { + return nil, ErrRoomRequired + } + name := sanitiseNick(cfg.Name) + if name == "" { + name = defaultNick + } + + runCtx, cancel := context.WithCancel(context.Background()) + return &Session{ + host: host, + room: room, + name: name, + onData: cfg.OnData, + sendQueue: make(chan []byte, defaultSendQueueSize), + done: make(chan struct{}), + cancel: cancel, + runCtx: runCtx, + }, nil +} + +// sanitiseNick reduces a display name to a 7-bit ASCII slug acceptable to +// the j library's MUC presence helper. The helper currently uses byte-level +// slicing on the supplied name to derive a stats-id, so multi-byte UTF-8 +// inputs (e.g. Cyrillic) get sliced mid-codepoint and Prosody silently +// rejects the resulting presence stanza. +// +// Non-ASCII characters are dropped; spaces and punctuation are normalised +// to '-'. The result is bounded to 16 characters. +func sanitiseNick(raw string) string { + const maxNickLen = 16 + var b strings.Builder + b.Grow(len(raw)) + prevDash := false + for _, r := range raw { + switch { + case r >= 'a' && r <= 'z', + r >= 'A' && r <= 'Z', + r >= '0' && r <= '9': + b.WriteRune(r) + prevDash = false + case r == '-' || r == '_': + b.WriteRune(r) + prevDash = false + default: + if !prevDash && b.Len() > 0 { + b.WriteRune('-') + prevDash = true + } + } + if b.Len() >= maxNickLen { + break + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + return "" + } + return out +} + +// Capabilities reports what this engine can do. +func (s *Session) Capabilities() engine.Capabilities { + return engine.Capabilities{ByteStream: true, VideoTrack: true} +} + +// Connect joins the Jitsi conference, optionally opens the bridge channel, +// and (if video tracks are pending or a remote handler is set) negotiates a +// pion PeerConnection. +func (s *Session) Connect(ctx context.Context) error { + if s.closed.Load() { + return ErrSessionClosed + } + + logger.Infof("jitsi: joining %s/%s as %s …", s.host, s.room, s.name) + jSess, err := j.Join(ctx, j.Config{ + Host: s.host, + Room: s.room, + Nick: s.name, + Debug: logger.IsVerbose(), + }) + if err != nil { + return fmt.Errorf("jitsi join: %w", err) + } + logger.Infof("jitsi: joined %s/%s; colibri-ws=%s", s.host, s.room, jSess.ColibriWS) + s.jSess.Store(jSess) + + if s.onData != nil { + bctx, bcancel := context.WithTimeout(ctx, bridgeOpenTimeout) + err := jSess.OpenBridge(bctx) + bcancel() + if err != nil { + _ = jSess.Close() + s.jSess.Store(nil) + return fmt.Errorf("open bridge: %w", err) + } + s.bridgeReady.Store(true) + logger.Infof("jitsi: bridge open (endpoints=%v)", jSess.Endpoints()) + } + + if s.shouldNegotiatePC() { + if err := s.negotiatePC(ctx, jSess); err != nil { + _ = jSess.Close() + s.jSess.Store(nil) + s.bridgeReady.Store(false) + return err + } + } + + s.wg.Add(2) + go s.sendLoop() + go s.recvLoop() + return nil +} + +func (s *Session) shouldNegotiatePC() bool { + s.videoTrackMu.RLock() + defer s.videoTrackMu.RUnlock() + return len(s.videoTracks) > 0 || s.onVideoTrack != nil +} + +func (s *Session) videoTrackHandler() func(*webrtc.TrackRemote, *webrtc.RTPReceiver) { + s.videoTrackMu.RLock() + defer s.videoTrackMu.RUnlock() + return s.onVideoTrack +} + +func (s *Session) negotiatePC(ctx context.Context, jSess *j.Session) error { + settings := webrtc.SettingEngine{} + settings.LoggerFactory = logger.NewPionLoggerFactory() + api := webrtc.NewAPI(webrtc.WithSettingEngine(settings)) + + // Jicofo emits Plan B style SDP with separate sections per + // media kind and SSRC-keyed source descriptors. pion's default + // UnifiedPlan parser rejects this with "remote SessionDescription + // semantics does not match configuration", so we explicitly request + // Plan B for the conference PeerConnection. + pcConfig := jSess.IceConfig() + pcConfig.SDPSemantics = webrtc.SDPSemanticsPlanB + + pc, err := api.NewPeerConnection(pcConfig) + if err != nil { + return fmt.Errorf("new pc: %w", err) + } + + s.videoTrackMu.RLock() + for _, track := range s.videoTracks { + if _, addErr := pc.AddTrack(track); addErr != nil { + s.videoTrackMu.RUnlock() + _ = pc.Close() + return fmt.Errorf("add track: %w", addErr) + } + } + s.videoTrackMu.RUnlock() + + pc.OnTrack(func(track *webrtc.TrackRemote, recv *webrtc.RTPReceiver) { + if track.Kind() != webrtc.RTPCodecTypeVideo { + return + } + if cb := s.videoTrackHandler(); cb != nil { + cb(track, recv) + } + }) + pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { + logger.Debugf("jitsi pc state: %s", state.String()) + if state == webrtc.PeerConnectionStateFailed && !s.closed.Load() && s.onEnded != nil { + s.onEnded("jitsi peer connection failed") + } + }) + + neg := jSess.Negotiator() + neg.PC = pc + if err := neg.Accept(ctx); err != nil { + _ = pc.Close() + return fmt.Errorf("session-accept: %w", err) + } + + s.pcMu.Lock() + s.pc = pc + s.pcMu.Unlock() + return nil +} + +// Send queues data for transmission over the bridge. +// +// Send is non-blocking: data is enqueued onto the engine's outbound channel +// and a background goroutine pumps the queue into the colibri-ws bridge with +// the bridge's own backpressure window. +func (s *Session) Send(data []byte) error { + if s.closed.Load() { + return ErrSessionClosed + } + if !s.bridgeReady.Load() { + return ErrBridgeNotReady + } + if len(data) > bridgeMaxMessageSize { + return ErrSendTooLarge + } + select { + case s.sendQueue <- data: + return nil + case <-s.done: + return ErrSessionClosed + default: + return ErrSendQueueFull + } +} + +func (s *Session) sendLoop() { + defer s.wg.Done() + for { + select { + case <-s.done: + return + case data, ok := <-s.sendQueue: + if !ok { + return + } + jSess := s.jSess.Load() + if jSess == nil { + return + } + if err := jSess.BridgeSendRaw("", data); err != nil { + if s.closed.Load() { + return + } + logger.Debugf("jitsi bridge send: %v", err) + } + } + } +} + +func (s *Session) recvLoop() { + defer s.wg.Done() + + jSess := s.jSess.Load() + if jSess == nil || s.onData == nil || !s.bridgeReady.Load() { + return + } + msgs := jSess.BridgeMessages() + if msgs == nil { + return + } + for { + select { + case <-s.done: + return + case msg, ok := <-msgs: + if !ok { + if !s.closed.Load() { + s.signalEnded("jitsi bridge closed") + } + return + } + payload := decodeRaw(msg) + if payload == nil { + continue + } + s.onData(payload) + } + } +} + +// decodeRaw extracts the bytes from an EndpointMessage produced by the j +// library's BridgeSendRaw helper. Mirrors the unexported colibri.DecodeRaw — +// the j library's BridgeMessage type alias keeps the necessary fields public, +// but the helper itself lives in an internal package. +func decodeRaw(m j.BridgeMessage) []byte { + if m.Class != "EndpointMessage" { + return nil + } + enc, ok := m.Fields["raw"].(string) + if !ok { + return nil + } + out, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return nil + } + return out +} + +// Close terminates the session and releases resources. +func (s *Session) Close() error { + if !s.closed.CompareAndSwap(false, true) { + return nil + } + + if s.cancel != nil { + s.cancel() + } + s.doneOnce.Do(func() { close(s.done) }) + + s.pcMu.Lock() + pc := s.pc + s.pc = nil + s.pcMu.Unlock() + if pc != nil { + _ = pc.Close() + } + + jSess := s.jSess.Swap(nil) + if jSess != nil { + _ = jSess.Close() + } + + s.bridgeReady.Store(false) + + stopped := make(chan struct{}) + go func() { + s.wg.Wait() + close(stopped) + }() + select { + case <-stopped: + case <-time.After(2 * time.Second): + } + return nil +} + +// SetReconnectCallback registers a callback for reconnection events. +// +// The Jitsi engine itself does not currently drive a reconnect loop; the +// callback is stored for API parity and wired through the carrier adapter +// for future use. +func (s *Session) SetReconnectCallback(cb func(*webrtc.DataChannel)) { s.onReconnect = cb } + +// SetShouldReconnect stores the reconnect predicate (kept for API parity). +func (s *Session) SetShouldReconnect(fn func() bool) { s.shouldReconnect = fn } + +// SetEndedCallback registers a function to call when the session ends. +func (s *Session) SetEndedCallback(cb func(string)) { s.onEnded = cb } + +// WatchConnection blocks until the session is closed, the parent context +// fires, or the bridge tears down. +func (s *Session) WatchConnection(ctx context.Context) { + select { + case <-ctx.Done(): + return + case <-s.done: + return + } +} + +// CanSend reports whether the session is ready to accept new data. +func (s *Session) CanSend() bool { + if s.closed.Load() { + return false + } + if s.onData == nil { + // pure video mode — readiness driven by PC connection state + s.pcMu.Lock() + ready := s.pc != nil && s.pc.ConnectionState() == webrtc.PeerConnectionStateConnected + s.pcMu.Unlock() + return ready + } + return s.bridgeReady.Load() +} + +// GetSendQueue exposes the outbound queue for upstream metrics. +func (s *Session) GetSendQueue() chan []byte { return s.sendQueue } + +// GetBufferedAmount returns a coarse estimate of bytes pending on the wire. +// +// The j library's bridge connection only exposes message-count depth, so we +// approximate bytes by multiplying queue depth by the bridge max-message-size. +// This is enough for upper-layer pacing heuristics; engines that need +// byte-accurate pressure should consult GetSendQueue directly. +func (s *Session) GetBufferedAmount() uint64 { + jSess := s.jSess.Load() + if jSess == nil { + return 0 + } + depth := jSess.BridgeSendQueueDepth() + if depth <= 0 { + return 0 + } + return uint64(depth) * uint64(bridgeMaxMessageSize) //nolint:gosec // depth is small and bounded by queue cap +} + +// AddVideoTrack publishes a video track to the Jitsi conference. +// +// Tracks added before Connect are sent as part of the session-accept SDP +// (so Jicofo announces them to other participants automatically). Tracks +// added afterwards are attached to the live PeerConnection — Jitsi's +// source-add flow is not yet implemented in this engine, so late tracks +// will only be visible on the next reconnect. +func (s *Session) AddVideoTrack(track webrtc.TrackLocal) error { + s.videoTrackMu.Lock() + s.videoTracks = append(s.videoTracks, track) + s.videoTrackMu.Unlock() + + s.pcMu.Lock() + pc := s.pc + s.pcMu.Unlock() + if pc == nil { + return nil + } + if _, err := pc.AddTrack(track); err != nil { + return fmt.Errorf("add track: %w", err) + } + return nil +} + +// SetVideoTrackHandler registers a callback invoked on every remote video +// track received from the conference. +func (s *Session) SetVideoTrackHandler(cb func(*webrtc.TrackRemote, *webrtc.RTPReceiver)) { + s.videoTrackMu.Lock() + defer s.videoTrackMu.Unlock() + s.onVideoTrack = cb +} + +func (s *Session) signalEnded(reason string) { + s.bridgeReady.Store(false) + if s.onEnded != nil { + s.onEnded(reason) + } +} + +// normaliseHost strips an optional scheme and trailing slashes off a Jitsi +// host string. The j library expects a bare host; auth providers might pass +// a full URL through verbatim. +func normaliseHost(raw string) string { + raw = strings.TrimSpace(raw) + if idx := strings.Index(raw, "://"); idx >= 0 { + raw = raw[idx+3:] + } + raw = strings.TrimPrefix(raw, "//") + raw = strings.TrimSuffix(raw, "/") + if i := strings.Index(raw, "/"); i >= 0 { + raw = raw[:i] + } + return raw +} + +func init() { //nolint:gochecknoinits // engine registration is the canonical Go pattern for plugins + engine.Register("jitsi", New) +} diff --git a/internal/engine/jitsi/jitsi_test.go b/internal/engine/jitsi/jitsi_test.go new file mode 100644 index 0000000..80ab1c4 --- /dev/null +++ b/internal/engine/jitsi/jitsi_test.go @@ -0,0 +1,147 @@ +package jitsi + +import ( + "context" + "errors" + "testing" + + "github.com/openlibrecommunity/olcrtc/internal/engine" +) + +func TestNormaliseHost(t *testing.T) { + tests := []struct { + raw string + want string + }{ + {"meet.example.com", "meet.example.com"}, + {"https://meet.example.com", "meet.example.com"}, + {"https://meet.example.com/", "meet.example.com"}, + {"https://meet.example.com/path", "meet.example.com"}, + {"//meet.example.com", "meet.example.com"}, + {" https://meet.example.com ", "meet.example.com"}, + {"", ""}, + } + for _, tc := range tests { + t.Run(tc.raw, func(t *testing.T) { + if got := normaliseHost(tc.raw); got != tc.want { + t.Fatalf("normaliseHost(%q) = %q, want %q", tc.raw, got, tc.want) + } + }) + } +} + +func TestDecodeRaw(t *testing.T) { + const payload = "hello world" + raw := encodeForTest(t, []byte(payload)) + + got := decodeRaw(makeBridgeMessage("EndpointMessage", map[string]any{"raw": raw})) + if string(got) != payload { + t.Fatalf("decodeRaw = %q, want %q", got, payload) + } + + if got := decodeRaw(makeBridgeMessage("OtherClass", map[string]any{"raw": raw})); got != nil { + t.Fatalf("decodeRaw(other class) = %q, want nil", got) + } + if got := decodeRaw(makeBridgeMessage("EndpointMessage", map[string]any{})); got != nil { + t.Fatalf("decodeRaw(no raw) = %q, want nil", got) + } + if got := decodeRaw(makeBridgeMessage("EndpointMessage", map[string]any{"raw": "not-base64!!!"})); got != nil { + t.Fatalf("decodeRaw(bad base64) = %q, want nil", got) + } +} + +func TestNewRequiresHost(t *testing.T) { + _, err := New(context.Background(), engine.Config{ + Extra: map[string]string{"room": "myroom"}, + }) + if !errors.Is(err, ErrHostRequired) { + t.Fatalf("err = %v, want ErrHostRequired", err) + } +} + +func TestNewRequiresRoom(t *testing.T) { + _, err := New(context.Background(), engine.Config{ + URL: "meet.example.com", + }) + if !errors.Is(err, ErrRoomRequired) { + t.Fatalf("err = %v, want ErrRoomRequired", err) + } +} + +func TestNewSucceeds(t *testing.T) { + sess, err := New(context.Background(), engine.Config{ + URL: "https://meet.example.com", + Extra: map[string]string{"room": "myroom"}, + Name: "olcrtc-test", + }) + if err != nil { + t.Fatalf("New: %v", err) + } + defer func() { _ = sess.Close() }() + caps := sess.Capabilities() + if !caps.ByteStream || !caps.VideoTrack { + t.Fatalf("Capabilities = %+v, want ByteStream && VideoTrack", caps) + } +} + +func TestSendBeforeConnect(t *testing.T) { + sess, err := New(context.Background(), engine.Config{ + URL: "meet.example.com", + Extra: map[string]string{"room": "myroom"}, + OnData: func([]byte) {}, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + defer func() { _ = sess.Close() }() + if err := sess.Send([]byte("data")); !errors.Is(err, ErrBridgeNotReady) { + t.Fatalf("Send err = %v, want ErrBridgeNotReady", err) + } +} + +func TestSendAfterClose(t *testing.T) { + sess, err := New(context.Background(), engine.Config{ + URL: "meet.example.com", + Extra: map[string]string{"room": "myroom"}, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := sess.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if err := sess.Send([]byte("data")); !errors.Is(err, ErrSessionClosed) { + t.Fatalf("Send err = %v, want ErrSessionClosed", err) + } +} + +func TestSanitiseNick(t *testing.T) { + tests := []struct { + raw string + want string + }{ + {"alice", "alice"}, + {"Alice Smith", "Alice-Smith"}, + {"Конрад Олег", ""}, + {"olcrtc-bot42", "olcrtc-bot42"}, + {" bob ", "bob"}, + {"$$$ %%%", ""}, + {"verylongnicknamethatexceedslimit", "verylongnicknamet"[:16]}, + } + for _, tc := range tests { + t.Run(tc.raw, func(t *testing.T) { + if got := sanitiseNick(tc.raw); got != tc.want { + t.Fatalf("sanitiseNick(%q) = %q, want %q", tc.raw, got, tc.want) + } + }) + } +} + +func TestEngineRegistration(t *testing.T) { + if _, err := engine.New(context.Background(), "jitsi", engine.Config{ + URL: "meet.example.com", + Extra: map[string]string{"room": "myroom"}, + }); err != nil { + t.Fatalf("engine.New(jitsi) = %v, want nil", err) + } +}