openvibers/openvibe.link Commits
Host shared party desktops with Link (#140)
Changes
@@@ -42,6 +42,8 @@ serves the share this computer was picked for at `https://<name>--<you>.openvibe`openvibe-link share --key ovc_… --port 8790` with a key), and `openvibe-link share add` keeps it up with the service;`openvibe-link status` lists each share ([docs/connect.md](docs/connect.md)).++**Host a shared party computer** with `openvibe-link party host <party-id>` from [openvibe.party](https://openvibe.party). Link shows the running desktop and its Stop action in its window ([guide](docs/party-desktop.md)).++**Sell a local model** on [OpenVibe.Exchange](https://openvibe.exchange):`openvibe-link exchange serve --model qwen3:32b --public-url https://gpu.example.com/v1`. It serves the model from thelocal OpenAI-compatible server (Ollama by default at `127.0.0.1:11434`), registers the public https address through the@@@ -5,8 +5,8 @@"wave": "T15","plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)","kind": "client",-- "version": "0.48.0",-- "release": "v0.48.0: Desktop and Terminal can be turned on again from the Link window, after a plain-words confirmation, now that the 11-05 review fixes are in (#102-#104); the first-run answer never turns them on. `openvibe-node share` defaults to connect.openvibe.link.",++ "version": "0.49.0",++ "release": "v0.49.0: Link can host a shared party desktop in a temporary neko Google Chrome container, with a bridge-bound public-web egress proxy and managed Chrome policy. The party owner can stop it from the Link window.","runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library","database": "none","deployed": false,@@@ -103,6 +103,7 @@ Usage:openvibe-link share --key ovc_… --port N put this machine's port N on the web (OpenVibe Connect),behind OpenVibe sign-in, until Ctrl-Copenvibe-link share add|list|remove … the shares the Node service keeps up (openvibe-link share)++ openvibe-link party host <party-id> host a shared browser for an openvibe.party room, until stoppedopenvibe-link versionGlobal flags (before or after the command):@@@ -174,6 +175,13 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) intif cmd == "stream" { // its own flags: --probe and the latency probe'sreturn cmdStream(rest, stdin, stdout, stderr)}++ if cmd == "party" {++ if err := cmdParty(rest, stdout, stderr); err != nil {++ fmt.Fprintln(stderr, "error:", err)++ return 1++ }++ return 0++ }fs := flag.NewFlagSet(cmd, flag.ContinueOnError)fs.SetOutput(stderr)g := &globals{}nnew file mode 100644@@@ -0,0 +1,76 @@++package main++++import (++ "context"++ "errors"++ "flag"++ "fmt"++ "io"++ "os"++ "os/signal"++ "syscall"++ "time"++++ "github.com/OpenVibers/OpenVibe.Node/internal/config"++ "github.com/OpenVibers/OpenVibe.Node/internal/connect"++ "github.com/OpenVibers/OpenVibe.Node/internal/credentials"++ "github.com/OpenVibers/OpenVibe.Node/internal/link"++ "github.com/OpenVibers/OpenVibe.Node/internal/localctl"++ "github.com/OpenVibers/OpenVibe.Node/internal/party"++)++++func cmdParty(args []string, stdout, stderr io.Writer) error {++ if len(args) == 0 || args[0] != "host" {++ return errors.New("usage: openvibe-link party host <party-id> [--home DIR]")++ }++ fs := flag.NewFlagSet("party host", flag.ContinueOnError)++ fs.SetOutput(stderr)++ home := fs.String("home", "", "Link state directory")++ partyURL := fs.String("party-url", "https://openvibe.party", "party site (local testing only)")++ connectServer := fs.String("connect-server", connect.DefaultServer, "Connect tunnel")++ // A party id comes before flags in the published command form.++ if len(args) < 2 {++ return errors.New("usage: openvibe-link party host <party-id> [--home DIR]")++ }++ id := args[1]++ if err := fs.Parse(args[2:]); err != nil {++ return err++ }++ if len(fs.Args()) != 0 {++ return errors.New("unexpected party arguments")++ }++ paths := config.DefaultPaths(*home)++ if *partyURL == "https://openvibe.party" && *connectServer == connect.DefaultServer {++ if response, err := localctl.Call(paths.SocketPath(), localctl.Request{Cmd: localctl.CmdPartyHost, ID: id}, 2*time.Second); err == nil {++ if !response.OK {++ return errors.New(response.Error)++ }++ fmt.Fprintln(stdout, "Starting the shared computer. Open Link to see and stop it.")++ return nil++ }++ }++ creds, err := credentials.Load(paths.CredentialFile(), nil)++ if err != nil || !creds.NetworkPaired() {++ return errors.New("pair this computer with OpenVibe Link before hosting a party")++ }++ chatSource := link.NewTokenSourceFor(creds, nil, "openvibe-node/"+version, "openvibe.chat")++ connectSource := link.NewTokenSourceFor(creds, nil, "openvibe-node/"+version, link.ConnectAudience)++ client := party.Client{PartyURL: *partyURL, ConnectServer: *connectServer, PartyID: id,++ ChatToken: func(ctx context.Context) (string, error) {++ t, err := chatSource.Token(ctx)++ if err != nil {++ return "", err++ }++ return t.Reveal(), nil++ },++ ConnectToken: func(ctx context.Context) (string, error) {++ t, err := connectSource.Token(ctx)++ if err != nil {++ return "", err++ }++ return t.Reveal(), nil++ }}++ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)++ defer cancel()++ return party.Host(ctx, client, version, stdout)++}nnew file mode 100644@@@ -0,0 +1,15 @@++# Host a party desktop with Link++++Open [openvibe.party](https://openvibe.party), sign in, and choose **Start a shared computer → On my computer**. The party page gives you a command containing its party ID:++++```sh++openvibe-link party host party-0123abcd++```++++Use the ID from your own page. Link must already be paired to your OpenVibe account. Install Docker or Podman on this computer before starting. Link pulls a pinned neko Google Chrome image, runs a temporary container with a private loopback HTTP port, and opens a party-only Connect share. The browser inside the container is separate from your own browser and files. A service sign-in happens inside that shared browser, so everyone watching the desktop can see what you type there. Sign out of streaming services before ending the party if you want to clear their sessions immediately.++++When the Link service is running, the command starts hosting in the background. Open **Link** from the tray or run `openvibe-link panel` to see **Hosting a party desktop**. Select **Stop** there, or end the party on openvibe.party. If Link is not running as a service, the command hosts in the foreground; press Ctrl-C to stop. Stopping removes the container and closes the Connect share.++++The party's existing remote decides who can drive. Members use **Ask for it**, **Pass it**, and **Take it back** on openvibe.party. The shared browser is framed in the party page, and members must sign in to OpenVibe before they can watch. **Listen** uses the same browser for Spotify or YouTube Music.++++For operators: Chat requires `TURN_URL=turns:<host>:443` with `TURN_AUTH_SECRET`, and the TURN service must accept TLS on port 443. Chat's `CHAT_PARTY_CONNECT_SECRET` and Connect's `CONNECT_PARTY_SHARED_SECRET` must be the same random value of at least 32 characters. The Node needs Network-issued node tokens for `openvibe.chat` and `openvibe.link`.@@@ -34,7 +34,9 @@ const (CmdFirstRun = "first_run"// CmdPanel answers where OpenVibe Link's own window is: its loopback URL with the per-run token in the fragment.// It goes to the tray (or `openvibe-node panel`), which runs as the Node's owner, never into a status line.-- CmdPanel = "panel"++ CmdPanel = "panel"++ CmdPartyHost = "party_host"++ CmdPartyStop = "party_stop")type Request struct {@@@ -101,8 +101,11 @@ type Node struct {quit context.CancelFuncquitting atomic.Bool-- mu sync.Mutex-- limits safety.Limits++ mu sync.Mutex++ partyID string++ partyCancel context.CancelFunc++ partyDone chan struct{}++ limits safety.Limits// haltEpochs: per robot, the newest turn_epoch a halt carried (noteHaltEpoch); older commands are refused.haltEpochs map[string]int64allowed map[string]bool@@@ -349,6 +352,16 @@ func (n *Node) Run(parent context.Context) error {n.log.Warn("not paired: running local only (run `openvibe-node pair <CODE>`)")}<-ctx.Done()++ n.mu.Lock()++ partyDone := n.partyDone++ n.mu.Unlock()++ if partyDone != nil {++ select {++ case <-partyDone:++ case <-time.After(55 * time.Second):++ n.log.Warn("party desktop cleanup timed out")++ }++ }n.log.Info("shutting down: stopping every actuator")n.stopDeskStream(false)n.mgr.StopAll(plugins.StopShutdown)@@@ -1525,6 +1538,16 @@ func (n *Node) control(r localctl.Request) localctl.Response {case localctl.CmdTray:n.tellTray(r.On)return localctl.Response{OK: true}++ case localctl.CmdPartyHost:++ if err := n.startParty(r.ID); err != nil {++ return localctl.Response{Error: err.Error()}++ }++ return localctl.Response{OK: true}++ case localctl.CmdPartyStop:++ if err := n.stopParty(r.ID); err != nil {++ return localctl.Response{Error: err.Error()}++ }++ return localctl.Response{OK: true}case localctl.CmdFirstRun:// The guided first run's one event per step, sent on this connection (the installer starts the service before// the guide, so a second device connection would replace it). Only a first-run event for a known step goes on.@@@ -221,6 +221,13 @@ func (n *Node) panelRunning(st Status) []panel.Running {Title: fmt.Sprintf("Port %d shared", sh.Port), Detail: sh.URL})}}++ n.mu.Lock()++ partyID, hosting := n.partyID, n.partyCancel != nil++ n.mu.Unlock()++ if hosting {++ out = append(out, panel.Running{ID: "party:" + partyID, Kind: "desk",++ Title: "Hosting a party desktop", Detail: partyID})++ }if desks := sensorInt(st.Telemetry.Sensors["computer_desks"]); desks > 0 {title := fmt.Sprintf("%d desks for your agents", desks)if desks == 1 {@@@ -477,6 +484,8 @@ func (n *Node) panelStop(id string) error {break}return n.shareRemove(port)++ case strings.HasPrefix(id, "party:"):++ return n.stopParty(strings.TrimPrefix(id, "party:"))case id == "desks":return errors.New("a desk is stopped by the agent that started it, or by Pause everything")}nnew file mode 100644@@@ -0,0 +1,88 @@++package node++++import (++ "context"++ "errors"++ "io"++ "regexp"++ "time"++++ "github.com/OpenVibers/OpenVibe.Node/internal/connect"++ "github.com/OpenVibers/OpenVibe.Node/internal/link"++ "github.com/OpenVibers/OpenVibe.Node/internal/party"++)++++var validPartyID = regexp.MustCompile(`^party-[a-f0-9]{8}$`)++++// startParty runs the same host path as the CLI, tied to the Link service's++// lifetime. The window lists it and can cancel it without stopping other work.++func (n *Node) startParty(id string) error {++ if !validPartyID.MatchString(id) {++ return errors.New("invalid party id")++ }++ if n.ctx == nil || n.opt.Creds == nil || !n.opt.Creds.NetworkPaired() {++ return errors.New("pair this computer with OpenVibe Link before hosting")++ }++ if _, err := party.FindRuntime(); err != nil {++ return err++ }++ n.mu.Lock()++ if n.partyCancel != nil {++ n.mu.Unlock()++ return errors.New("this Link is already hosting a party desktop")++ }++ ctx, cancel := context.WithCancel(n.ctx)++ done := make(chan struct{})++ n.partyID, n.partyCancel, n.partyDone = id, cancel, done++ n.mu.Unlock()++++ chatTokens := link.NewTokenSourceFor(n.opt.Creds, nil, "openvibe-node/"+n.opt.Version, "openvibe.chat")++ connectTokens := link.NewTokenSourceFor(n.opt.Creds, nil, "openvibe-node/"+n.opt.Version, link.ConnectAudience)++ client := party.Client{PartyURL: "https://openvibe.party", ConnectServer: connect.DefaultServer, PartyID: id,++ ChatToken: func(ctx context.Context) (string, error) {++ t, err := chatTokens.Token(ctx)++ if err != nil {++ return "", err++ }++ return t.Reveal(), nil++ },++ ConnectToken: func(ctx context.Context) (string, error) {++ t, err := connectTokens.Token(ctx)++ if err != nil {++ return "", err++ }++ return t.Reveal(), nil++ }}++ go func() {++ defer close(done)++ if err := party.Host(ctx, client, n.opt.Version, io.Discard); err != nil && ctx.Err() == nil {++ n.log.Warn("party desktop stopped", "party", id, "err", err)++ n.addActivity("Party desktop could not start")++ }++ cancel()++ n.mu.Lock()++ if n.partyDone == done {++ n.partyID, n.partyCancel, n.partyDone = "", nil, nil++ }++ n.mu.Unlock()++ }()++ return nil++}++++func (n *Node) stopParty(id string) error {++ n.mu.Lock()++ if n.partyCancel == nil || n.partyID != id {++ n.mu.Unlock()++ return errors.New("that party is not hosted here")++ }++ cancel, done := n.partyCancel, n.partyDone++ n.mu.Unlock()++ cancel()++ select {++ case <-done:++ n.addActivity("Stopped the party desktop")++ return nil++ case <-time.After(55 * time.Second):++ return errors.New("party desktop is still stopping")++ }++}nnew file mode 100644@@@ -0,0 +1,172 @@++package party++++import (++ "bytes"++ "context"++ "encoding/json"++ "errors"++ "fmt"++ "io"++ "net"++ "net/http"++ "net/url"++ "strings"++ "time"++)++++type TokenSource func(context.Context) (string, error)++++type Client struct {++ PartyURL string++ ConnectServer string++ PartyID string++ ChatToken TokenSource++ ConnectToken TokenSource++ HTTP *http.Client++}++++type Claim struct {++ ICEServers []ICEServer `json:"ice_servers"`++ ShareGrant string `json:"share_grant"`++}++++type Share struct {++ ID string `json:"id"`++ URL string `json:"url"`++ Port int `json:"local_port"`++}++++func (c Client) client() *http.Client {++ if c.HTTP != nil {++ return c.HTTP++ }++ return &http.Client{Timeout: 15 * time.Second}++}++++func (c Client) base(chat bool) (string, error) {++ if chat {++ base := c.PartyURL++ if base == "" {++ base = "https://openvibe.party"++ }++ u, err := url.Parse(base)++ if err != nil || u.Host == "" || (u.Scheme != "https" && !(u.Scheme == "http" && loopback(u.Hostname()))) {++ return "", errors.New("party URL must be HTTPS or loopback HTTP")++ }++ return strings.TrimSuffix(u.String(), "/"), nil++ }++ base := c.ConnectServer++ if base == "" {++ base = "wss://connect.openvibe.link/tunnel"++ }++ u, err := url.Parse(base)++ if err != nil || u.Host == "" {++ return "", errors.New("invalid Connect URL")++ }++ if u.Scheme == "wss" {++ u.Scheme = "https"++ } else if u.Scheme == "ws" && loopback(u.Hostname()) {++ u.Scheme = "http"++ } else {++ return "", errors.New("Connect requires TLS")++ }++ u.Path, u.RawQuery, u.Fragment = "", "", ""++ return strings.TrimSuffix(u.String(), "/"), nil++}++++func loopback(host string) bool {++ ip := net.ParseIP(host)++ return host == "localhost" || (ip != nil && ip.IsLoopback())++}++++func (c Client) call(ctx context.Context, chat bool, method, path string, input, output any) error {++ base, err := c.base(chat)++ if err != nil {++ return err++ }++ var body io.Reader++ if input != nil {++ b, err := json.Marshal(input)++ if err != nil {++ return err++ }++ body = bytes.NewReader(b)++ }++ req, err := http.NewRequestWithContext(ctx, method, base+path, body)++ if err != nil {++ return err++ }++ if input != nil {++ req.Header.Set("Content-Type", "application/json")++ }++ var source TokenSource = c.ConnectToken++ if chat {++ source = c.ChatToken++ }++ if source == nil {++ return errors.New("machine token unavailable")++ }++ token, err := source(ctx)++ if err != nil {++ return err++ }++ req.Header.Set("Authorization", "Bearer "+token)++ res, err := c.client().Do(req)++ if err != nil {++ return err++ }++ defer res.Body.Close()++ if res.StatusCode < 200 || res.StatusCode >= 300 {++ return fmt.Errorf("party API %s %s returned %d", method, path, res.StatusCode)++ }++ if output != nil {++ return json.NewDecoder(io.LimitReader(res.Body, 32*1024)).Decode(output)++ }++ return nil++}++++func (c Client) chatPath(suffix string) string {++ return "/api/chat/parties/" + url.PathEscape(c.PartyID) + "/desktop" + suffix++}++++func (c Client) Claim(ctx context.Context, port int) (Claim, error) {++ var out Claim++ err := c.call(ctx, true, http.MethodPost, c.chatPath("/claim"), map[string]int{"port": port}, &out)++ return out, err++}++++func (c Client) Access(ctx context.Context, subject string) (Access, error) {++ var out Access++ err := c.call(ctx, true, http.MethodGet, c.chatPath("/access")+"?subject="+url.QueryEscape(subject), nil, &out)++ return out, err++}++++func (c Client) CreateShare(ctx context.Context, grant string) (Share, error) {++ var out struct {++ Share Share `json:"share"`++ }++ err := c.call(ctx, false, http.MethodPost, "/api/v1/node/party-shares", map[string]string{"grant": grant}, &out)++ return out.Share, err++}++++func (c Client) Ready(ctx context.Context, share Share) error {++ return c.call(ctx, true, http.MethodPost, c.chatPath("/ready"), map[string]string{"share_id": share.ID, "url": share.URL}, nil)++}++++func (c Client) Stopped(ctx context.Context) error {++ return c.call(ctx, true, http.MethodPost, c.chatPath("/stopped"), nil, nil)++}++++func (c Client) SyncMembers(ctx context.Context, share Share, access Access) error {++ members := make([]string, 0, len(access.Members))++ for _, m := range access.Members {++ if m.Username != "" {++ members = append(members, m.Username)++ }++ }++ return c.call(ctx, false, http.MethodPut, "/api/v1/node/shares/"+url.PathEscape(share.ID)+"/party-members", map[string]any{"members": members}, nil)++}++++func (c Client) CloseShare(ctx context.Context, share Share) error {++ return c.call(ctx, false, http.MethodDelete, "/api/v1/node/shares/"+url.PathEscape(share.ID)+"/party", nil, nil)++}nnew file mode 100644@@@ -0,0 +1,278 @@++// Package party runs one short-lived shared browser in a container. Nothing from++// the host is mounted into it; Connect reaches only its loopback HTTP port.++package party++++import (++ "context"++ "crypto/rand"++ "encoding/base64"++ "encoding/json"++ "errors"++ "fmt"++ "net"++ "net/http"++ "os"++ "os/exec"++ "regexp"++ "strings"++ "time"++)++++// Image is the pinned v3.1.6 multi-architecture index (amd64 and arm64).++const Image = "ghcr.io/m1k1o/neko/google-chrome@sha256:dce2a6286cfb7e747d7fb05fadd03b25bf2db155c4f71ae53e0f40be71837547"++++var partyID = regexp.MustCompile(`^party-[a-f0-9]{8}$`)++++type ICEServer struct {++ URLs any `json:"urls"`++ Username string `json:"username,omitempty"`++ Credential string `json:"credential,omitempty"`++}++++type Plan struct {++ PartyID string++ Port int++ ICEServers []ICEServer++ MemberPassword string++ AdminPassword string++ APIToken string++ ProxyAddress string++}++++// NewPlan generates independent credentials for this run. The caller obtains++// fresh TURN credentials before constructing it.++func NewPlan(id string, port int, ice []ICEServer) (Plan, error) {++ if !partyID.MatchString(id) || port < 1 || port > 65535 {++ return Plan{}, errors.New("party id or loopback port is invalid")++ }++ if len(ice) == 0 {++ return Plan{}, errors.New("a party desktop needs TURN servers")++ }++ hasTurnTLS := false++ for _, server := range ice {++ urls, ok := server.URLs.(string)++ if !ok {++ return Plan{}, errors.New("ICE URL must be a string")++ }++ if strings.HasPrefix(urls, "turns:") && strings.Contains(urls, ":443") && server.Username != "" && server.Credential != "" {++ hasTurnTLS = true++ }++ }++ if !hasTurnTLS {++ return Plan{}, errors.New("a party desktop needs authenticated TURN over TLS on port 443")++ }++ member, err := random()++ if err != nil {++ return Plan{}, err++ }++ admin, err := random()++ if err != nil {++ return Plan{}, err++ }++ api, err := random()++ if err != nil {++ return Plan{}, err++ }++ return Plan{PartyID: id, Port: port, ICEServers: ice, MemberPassword: member, AdminPassword: admin, APIToken: api}, nil++}++++func random() (string, error) {++ b := make([]byte, 32)++ if _, err := rand.Read(b); err != nil {++ return "", err++ }++ return base64.RawURLEncoding.EncodeToString(b), nil++}++++func (p Plan) Name() string { return "openvibe-" + p.PartyID }++++// RunArgs contain no credential. The runtime receives values in its process++// environment, while Docker/Podman receive only environment variable names.++func (p Plan) RunArgs() []string {++ return []string{"create", "--name", p.Name(), "--network", "bridge",++ "--security-opt=no-new-privileges", "--pids-limit=512",++ "--cpus=2", "--memory=3g", "--shm-size=2g",++ "-p", fmt.Sprintf("127.0.0.1:%d:8080", p.Port), Image}++}++++func (p Plan) EnvFile() (string, error) {++ ice, err := json.Marshal(p.ICEServers)++ if err != nil {++ return "", err++ }++ return "NEKO_MEMBER_MULTIUSER_USER_PASSWORD=" + p.MemberPassword + "\n" +++ "NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD=" + p.AdminPassword + "\n" +++ "NEKO_SESSION_API_TOKEN=" + p.APIToken + "\n" +++ "NEKO_SESSION_COOKIE_ENABLED=true\n" +++ "NEKO_SESSION_COOKIE_SECURE=true\n" +++ "NEKO_SESSION_LOCKED_CONTROLS=true\n" +++ "NEKO_SESSION_IMPLICIT_HOSTING=false\n" +++ "NEKO_WEBRTC_ICELITE=false\n" +++ "NEKO_WEBRTC_ICESERVERS_FRONTEND=" + string(ice) + "\n" +++ "NEKO_WEBRTC_ICESERVERS_BACKEND=" + string(ice) + "\n", nil++}++++type Runtime interface {++ Run(ctx context.Context, stdin string, args ...string) error++ Gateway(ctx context.Context) (string, error)++}++++type CLIRuntime struct{ Binary string }++++func FindRuntime() (CLIRuntime, error) {++ for _, name := range []string{"docker", "podman"} {++ if path, err := exec.LookPath(name); err == nil {++ return CLIRuntime{Binary: path}, nil++ }++ }++ return CLIRuntime{}, errors.New("install Docker or Podman to host a party desktop")++}++++func (r CLIRuntime) Run(ctx context.Context, stdin string, args ...string) error {++ if stdin != "" {++ for _, line := range strings.Split(strings.TrimSpace(stdin), "\n") {++ key, _, ok := strings.Cut(line, "=")++ if !ok || key == "" {++ return errors.New("invalid container environment")++ }++ args = append(args[:len(args)-1], append([]string{"--env", key}, args[len(args)-1:]...)...)++ }++ }++ cmd := exec.CommandContext(ctx, r.Binary, args...)++ cmd.Env = os.Environ()++ if stdin != "" {++ cmd.Env = append(cmd.Env, strings.Split(strings.TrimSpace(stdin), "\n")...)++ }++ // Never return runtime output: a failed runtime may echo environment values.++ if err := cmd.Run(); err != nil {++ return fmt.Errorf("container runtime %s failed: %w", args[0], err)++ }++ return nil++}++++func (r CLIRuntime) Gateway(ctx context.Context) (string, error) {++ name := "bridge"++ if strings.Contains(r.Binary, "podman") {++ name = "podman"++ }++ cmd := exec.CommandContext(ctx, r.Binary, "network", "inspect", name)++ data, err := cmd.Output()++ if err != nil {++ return "", errors.New("cannot inspect the container bridge gateway")++ }++ var networks []struct {++ IPAM struct {++ Config []struct {++ Gateway string `json:"Gateway"`++ } `json:"Config"`++ } `json:"IPAM"`++ Subnets []struct {++ Gateway string `json:"gateway"`++ } `json:"subnets"`++ }++ if json.Unmarshal(data, &networks) != nil || len(networks) == 0 {++ return "", errors.New("invalid container bridge network")++ }++ address := ""++ if len(networks[0].IPAM.Config) > 0 {++ address = networks[0].IPAM.Config[0].Gateway++ }++ if address == "" && len(networks[0].Subnets) > 0 {++ address = networks[0].Subnets[0].Gateway++ }++ if net.ParseIP(address) == nil {++ return "", errors.New("container bridge has no gateway")++ }++ return address, nil++}++++// Start pulls exactly the pinned image, then starts the container. A failed run++// cleans up by its validated name. Stop is idempotent and also runs on cancellation.++func Start(ctx context.Context, r Runtime, p Plan) (func(context.Context) error, error) {++ if !partyID.MatchString(p.PartyID) {++ return nil, errors.New("invalid party id")++ }++ gateway, err := r.Gateway(ctx)++ if err != nil {++ return nil, err++ }++ listener, err := net.Listen("tcp", net.JoinHostPort(gateway, "0"))++ if err != nil {++ return nil, fmt.Errorf("cannot bind party egress proxy to the bridge: %w", err)++ }++ server := &http.Server{Handler: NewEgressProxy(gateway), ReadHeaderTimeout: 8 * time.Second}++ go func() { _ = server.Serve(listener) }()++ cleanupProxy := func() { _ = server.Close() }++ if err := r.Run(ctx, "", "pull", Image); err != nil {++ cleanupProxy()++ return nil, err++ }++ env, err := p.EnvFile()++ if err != nil {++ cleanupProxy()++ return nil, err++ }++ p.ProxyAddress = listener.Addr().String()++ if err := r.Run(ctx, env, p.RunArgs()...); err != nil {++ cleanupProxy()++ _ = r.Run(context.Background(), "", "rm", "-f", p.Name())++ return nil, err++ }++ policy, err := policyFile(p.ProxyAddress)++ if err == nil {++ err = r.Run(ctx, "", "cp", policy, p.Name()+":/etc/opt/chrome/policies/managed/openvibe-party.json")++ }++ if policy != "" {++ _ = os.Remove(policy)++ }++ if err == nil {++ err = r.Run(ctx, "", "start", p.Name())++ }++ if err != nil {++ cleanupProxy()++ _ = r.Run(context.Background(), "", "rm", "-f", p.Name())++ return nil, err++ }++ return func(stopCtx context.Context) error {++ cleanupProxy()++ stopErr := r.Run(stopCtx, "", "stop", "--time", "10", p.Name())++ removeErr := r.Run(stopCtx, "", "rm", "-f", p.Name())++ if stopErr != nil {++ return stopErr++ }++ return removeErr++ }, nil++}++++// FreePort asks the kernel for an unused loopback port. Docker's bind is the++// final ownership check; a race fails closed and Start removes the container.++func FreePort() (int, error) {++ l, err := net.Listen("tcp", "127.0.0.1:0")++ if err != nil {++ return 0, err++ }++ defer l.Close()++ return l.Addr().(*net.TCPAddr).Port, nil++}++++func WaitReady(ctx context.Context, port int) error {++ tick := time.NewTicker(250 * time.Millisecond)++ defer tick.Stop()++ client := &http.Client{Timeout: time.Second}++ for {++ req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/", port), nil)++ if err == nil {++ if res, e := client.Do(req); e == nil {++ _ = res.Body.Close()++ if res.StatusCode == http.StatusOK {++ return nil++ }++ }++ }++ select {++ case <-ctx.Done():++ return ctx.Err()++ case <-tick.C:++ }++ }++}nnew file mode 100644@@@ -0,0 +1,117 @@++package party++++import (++ "context"++ "errors"++ "strings"++ "testing"++)++++type call struct {++ stdin string++ args []string++}++type fakeRuntime struct {++ calls []call++ fail string++}++++func (f *fakeRuntime) Gateway(context.Context) (string, error) { return "127.0.0.1", nil }++++func (f *fakeRuntime) Run(_ context.Context, stdin string, args ...string) error {++ f.calls = append(f.calls, call{stdin, args})++ if args[0] == f.fail {++ return errors.New("failed")++ }++ return nil++}++++func turn() []ICEServer {++ return []ICEServer{{URLs: "turns:turn.openvibe.live:443?transport=tcp", Username: "temporary", Credential: "temporary"}}++}++++func TestPlanUsesPinnedImageAndSeparateCredentials(t *testing.T) {++ p, err := NewPlan("party-0123abcd", 18180, turn())++ if err != nil {++ t.Fatal(err)++ }++ if p.MemberPassword == p.AdminPassword || p.MemberPassword == p.APIToken || p.AdminPassword == p.APIToken {++ t.Fatal("credentials must differ")++ }++ args := strings.Join(p.RunArgs(), " ")++ for _, expected := range []string{"--network bridge", "--security-opt=no-new-privileges", "--memory=3g", "--shm-size=2g", "127.0.0.1:18180:8080", Image} {++ if !strings.Contains(args, expected) {++ t.Errorf("run plan is missing %s", expected)++ }++ }++ for _, forbidden := range []string{p.MemberPassword, p.AdminPassword, p.APIToken, "--volume", "--mount", "--network host"} {++ if strings.Contains(args, forbidden) {++ t.Error("run arguments contain a credential or host access")++ }++ }++ env, err := p.EnvFile()++ if err != nil {++ t.Fatal(err)++ }++ if !strings.Contains(env, "NEKO_WEBRTC_ICESERVERS_BACKEND=") || !strings.Contains(env, "NEKO_SESSION_COOKIE_ENABLED=true") {++ t.Fatal("missing TURN or cookie settings")++ }++}++++func TestPlanRequiresTLSRelayAndValidID(t *testing.T) {++ for _, tc := range []struct {++ id string++ ice []ICEServer++ }{++ {"oops", turn()},++ {"party-0123abcd", []ICEServer{{URLs: "turn:turn.openvibe.live:3478", Username: "x", Credential: "y"}}},++ {"party-0123abcd", []ICEServer{{URLs: "turns:turn.openvibe.live:443"}}},++ } {++ if _, err := NewPlan(tc.id, 8080, tc.ice); err == nil {++ t.Fatal("unsafe plan accepted")++ }++ }++}++++func TestLifecycleCleansUp(t *testing.T) {++ p, err := NewPlan("party-0123abcd", 18180, turn())++ if err != nil {++ t.Fatal(err)++ }++ f := &fakeRuntime{}++ stop, err := Start(context.Background(), f, p)++ if err != nil {++ t.Fatal(err)++ }++ if len(f.calls) != 4 || f.calls[0].args[0] != "pull" || f.calls[1].args[0] != "create" || f.calls[2].args[0] != "cp" || f.calls[3].args[0] != "start" {++ t.Fatal("pull, create, policy copy and start are required")++ }++ if !strings.Contains(f.calls[1].stdin, p.MemberPassword) {++ t.Fatal("env file was not passed to stdin")++ }++ if err := stop(context.Background()); err != nil {++ t.Fatal(err)++ }++ if f.calls[4].args[0] != "stop" || f.calls[5].args[0] != "rm" {++ t.Fatal("container did not stop")++ }++ f = &fakeRuntime{fail: "start"}++ if _, err := Start(context.Background(), f, p); err == nil {++ t.Fatal("run failure was lost")++ }++ if f.calls[len(f.calls)-1].args[0] != "rm" {++ t.Fatal("failed container was not removed")++ }++ f = &fakeRuntime{fail: "cp"}++ if _, err := Start(context.Background(), f, p); err == nil {++ t.Fatal("policy copy failure was lost")++ }++ for _, call := range f.calls {++ if call.args[0] == "start" {++ t.Fatal("container started without policy")++ }++ }++ if f.calls[len(f.calls)-1].args[0] != "rm" {++ t.Fatal("container with missing policy was not removed")++ }++}nnew file mode 100644@@@ -0,0 +1,185 @@++package party++++import (++ "bufio"++ "context"++ "encoding/json"++ "errors"++ "fmt"++ "io"++ "net"++ "net/http"++ "net/netip"++ "os"++ "strconv"++ "time"++)++++var deniedNetworks = []netip.Prefix{++ netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("10.0.0.0/8"),++ netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("127.0.0.0/8"),++ netip.MustParsePrefix("169.254.0.0/16"), netip.MustParsePrefix("172.16.0.0/12"),++ netip.MustParsePrefix("192.168.0.0/16"), netip.MustParsePrefix("198.18.0.0/15"),++ netip.MustParsePrefix("224.0.0.0/4"), netip.MustParsePrefix("240.0.0.0/4"),++ netip.MustParsePrefix("fc00::/7"), netip.MustParsePrefix("fe80::/10"),++ netip.MustParsePrefix("64:ff9b::/96"),++}++++func publicAddress(ip netip.Addr) bool {++ ip = ip.Unmap()++ if !ip.IsValid() || !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() {++ return false++ }++ for _, block := range deniedNetworks {++ if block.Contains(ip) {++ return false++ }++ }++ return true++}++++type addressResolver func(context.Context, string) ([]net.IPAddr, error)++type addressDialer func(context.Context, string, string) (net.Conn, error)++++func checkedDial(ctx context.Context, address string, resolve addressResolver, dial addressDialer, denied ...netip.Addr) (net.Conn, error) {++ host, port, err := net.SplitHostPort(address)++ if err != nil {++ return nil, errors.New("invalid proxy destination")++ }++ n, err := strconv.Atoi(port)++ if err != nil || n < 1 || n > 65535 || host == "" {++ return nil, errors.New("invalid proxy port")++ }++ var ips []net.IPAddr++ if literal, err := netip.ParseAddr(host); err == nil {++ ips = []net.IPAddr{{IP: net.IP(literal.AsSlice())}}++ } else {++ ips, err = resolve(ctx, host)++ if err != nil {++ return nil, errors.New("proxy DNS lookup failed")++ }++ }++ if len(ips) == 0 {++ return nil, errors.New("proxy destination has no address")++ }++ // Reject the entire answer if any address is private. Dial only the checked++ // address: a second name lookup would reintroduce DNS rebinding.++ for _, item := range ips {++ ip, ok := netip.AddrFromSlice(item.IP)++ if !ok || !publicAddress(ip) {++ return nil, errors.New("private proxy destination refused")++ }++ for _, blocked := range denied {++ if ip.Unmap() == blocked.Unmap() {++ return nil, errors.New("bridge gateway refused")++ }++ }++ }++ return dial(ctx, "tcp", net.JoinHostPort(ips[0].IP.String(), port))++}++++func NewEgressProxy(gateway string) http.Handler {++ resolver := net.DefaultResolver.LookupIPAddr++ dialer := (&net.Dialer{Timeout: 10 * time.Second}).DialContext++ address, _ := netip.ParseAddr(gateway)++ return egressProxy(resolver, dialer, address)++}++++func egressProxy(resolve addressResolver, dial addressDialer, denied ...netip.Addr) http.Handler {++ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {++ if r.Method == http.MethodConnect {++ upstream, err := checkedDial(r.Context(), r.Host, resolve, dial, denied...)++ if err != nil {++ http.Error(w, err.Error(), http.StatusForbidden)++ return++ }++ hijacker, ok := w.(http.Hijacker)++ if !ok {++ _ = upstream.Close()++ http.Error(w, "CONNECT unavailable", http.StatusInternalServerError)++ return++ }++ client, _, err := hijacker.Hijack()++ if err != nil {++ _ = upstream.Close()++ return++ }++ _, _ = client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))++ go func() { _, _ = io.Copy(upstream, client); _ = upstream.Close() }()++ _, _ = io.Copy(client, upstream)++ _ = client.Close()++ return++ }++ if r.URL == nil || r.URL.Scheme != "http" || r.URL.Hostname() == "" || r.URL.User != nil {++ http.Error(w, "HTTP proxy requires an absolute HTTP URL", http.StatusBadRequest)++ return++ }++ port := r.URL.Port()++ if port == "" {++ port = "80"++ }++ address := net.JoinHostPort(r.URL.Hostname(), port)++ upstream, err := checkedDial(r.Context(), address, resolve, dial, denied...)++ if err != nil {++ http.Error(w, err.Error(), http.StatusForbidden)++ return++ }++ defer upstream.Close()++ out := r.Clone(r.Context())++ out.RequestURI = ""++ out.URL.Scheme, out.URL.Host = "", ""++ out.Header.Del("Proxy-Connection")++ out.Header.Del("Proxy-Authorization")++ if err = out.Write(upstream); err != nil {++ http.Error(w, "upstream request failed", http.StatusBadGateway)++ return++ }++ response, err := http.ReadResponse(bufio.NewReader(upstream), out)++ if err != nil {++ http.Error(w, "upstream response failed", http.StatusBadGateway)++ return++ }++ defer response.Body.Close()++ for key, values := range response.Header {++ for _, value := range values {++ w.Header().Add(key, value)++ }++ }++ w.WriteHeader(response.StatusCode)++ _, _ = io.Copy(w, response.Body)++ })++}++++func policyFile(address string) (string, error) {++ if _, _, err := net.SplitHostPort(address); err != nil {++ return "", err++ }++ policy := map[string]any{++ "ProxySettings": map[string]string{++ "ProxyMode": "fixed_servers",++ "ProxyServer": "http=" + address + ";https=" + address,++ "ProxyBypassList": "<-loopback>",++ },++ "URLBlocklist": []string{"file://*", "chrome://settings", "chrome://settings/*", "chrome://flags/*"},++ // A page's own WebRTC would otherwise send UDP straight past the proxy and could reach the host's network:++ // only proxied (TCP) WebRTC is allowed. The desktop's own stream is neko's, outside the browser.++ "WebRtcIPHandling": "disable_non_proxied_udp",++ }++ data, err := json.Marshal(policy)++ if err != nil {++ return "", err++ }++ file, err := os.CreateTemp("", "openvibe-party-policy-*.json")++ if err != nil {++ return "", err++ }++ defer file.Close()++ if err = file.Chmod(0600); err == nil {++ _, err = file.Write(data)++ }++ if err != nil {++ _ = os.Remove(file.Name())++ return "", fmt.Errorf("write Chrome policy: %w", err)++ }++ return file.Name(), nil++}nnew file mode 100644@@@ -0,0 +1,155 @@++package party++++import (++ "context"++ "encoding/json"++ "errors"++ "io"++ "net"++ "net/http"++ "net/http/httptest"++ "net/netip"++ "os"++ "strings"++ "testing"++ "time"++)++++func TestEgressRefusesPrivateRanges(t *testing.T) {++ for _, value := range []string{++ "127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.1.1", "100.64.0.1",++ "169.254.1.1", "224.0.0.1", "172.17.0.1", "::1", "fc00::1", "fe80::1",++ "::ffff:192.168.1.1", "64:ff9b::c0a8:101",++ } {++ if publicAddress(netip.MustParseAddr(value)) {++ t.Errorf("accepted private address %s", value)++ }++ }++ if !publicAddress(netip.MustParseAddr("8.8.8.8")) || !publicAddress(netip.MustParseAddr("2606:4700:4700::1111")) {++ t.Fatal("public web address was refused")++ }++}++++func TestEgressResolvesOnceAndDialsCheckedIP(t *testing.T) {++ lookup := 0++ resolve := func(_ context.Context, host string) ([]net.IPAddr, error) {++ lookup++++ if host == "lan.example" {++ return []net.IPAddr{{IP: net.ParseIP("192.168.1.1")}}, nil++ }++ if host == "mixed.example" {++ return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}, {IP: net.ParseIP("10.0.0.1")}}, nil++ }++ return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}, nil++ }++ dialed := ""++ dial := func(_ context.Context, _, address string) (net.Conn, error) {++ dialed = address++ return nil, errors.New("test dial reached")++ }++ if _, err := checkedDial(context.Background(), "lan.example:443", resolve, dial); err == nil || dialed != "" {++ t.Fatal("private DNS answer reached dial")++ }++ if _, err := checkedDial(context.Background(), "mixed.example:443", resolve, dial); err == nil || dialed != "" {++ t.Fatal("mixed DNS answer reached dial")++ }++ if _, err := checkedDial(context.Background(), "public.example:443", resolve, dial); err == nil || dialed != "8.8.8.8:443" || lookup != 3 {++ t.Fatalf("public destination was not dialed at checked IP: %s, lookups %d", dialed, lookup)++ }++ dialed = ""++ if _, err := checkedDial(context.Background(), "public.example:443", resolve, dial, netip.MustParseAddr("8.8.8.8")); err == nil || dialed != "" {++ t.Fatal("bridge gateway bypassed its explicit block")++ }++}++++func TestEgressHTTPRefusesPrivateDNSAndAllowsPublic(t *testing.T) {++ resolve := func(_ context.Context, host string) ([]net.IPAddr, error) {++ if host == "lan.example" {++ return []net.IPAddr{{IP: net.ParseIP("10.1.2.3")}}, nil++ }++ return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}, nil++ }++ dialed := ""++ dial := func(_ context.Context, _, address string) (net.Conn, error) {++ dialed = address++ client, server := net.Pipe()++ go func() {++ defer server.Close()++ buffer := make([]byte, 4096)++ _, _ = server.Read(buffer)++ _, _ = io.WriteString(server, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")++ }()++ return client, nil++ }++ proxy := egressProxy(resolve, dial)++ private := httptest.NewRecorder()++ proxy.ServeHTTP(private, httptest.NewRequest(http.MethodGet, "http://lan.example/", nil))++ if private.Code != http.StatusForbidden || dialed != "" {++ t.Fatal("private DNS destination was not refused")++ }++ public := httptest.NewRecorder()++ proxy.ServeHTTP(public, httptest.NewRequest(http.MethodGet, "http://public.example/", nil))++ if public.Code != http.StatusOK || public.Body.String() != "ok" || dialed != "8.8.8.8:80" {++ t.Fatal("public HTTP destination failed")++ }++}++++func TestPolicyForcesProxyAndBlocksLocalPages(t *testing.T) {++ path, err := policyFile("172.17.0.1:45678")++ if err != nil {++ t.Fatal(err)++ }++ defer os.Remove(path)++ data, err := os.ReadFile(path)++ if err != nil {++ t.Fatal(err)++ }++ var policy struct {++ ProxySettings map[string]string `json:"ProxySettings"`++ URLBlocklist []string `json:"URLBlocklist"`++ WebRTC string `json:"WebRtcIPHandling"`++ }++ if json.Unmarshal(data, &policy) != nil {++ t.Fatal("invalid policy JSON")++ }++ if policy.ProxySettings["ProxyMode"] != "fixed_servers" || !strings.Contains(policy.ProxySettings["ProxyServer"], "172.17.0.1:45678") || policy.ProxySettings["ProxyBypassList"] != "<-loopback>" {++ t.Fatal("proxy policy is incomplete")++ }++ if policy.WebRTC != "disable_non_proxied_udp" {++ t.Fatal("a page's WebRTC could send UDP past the proxy")++ }++ for _, blocked := range []string{"file://*", "chrome://settings", "chrome://settings/*"} {++ if !strings.Contains(strings.Join(policy.URLBlocklist, " "), blocked) {++ t.Errorf("missing URL block %s", blocked)++ }++ }++}++++func TestContainerManagedPolicyLive(t *testing.T) {++ if os.Getenv("PARTY_DOCKER_PROOF") != "1" {++ t.Skip("set PARTY_DOCKER_PROOF=1 for the local Docker proof")++ }++ runtime, err := FindRuntime()++ if err != nil {++ t.Fatal(err)++ }++ plan, err := NewPlan("party-89abcdef", 18181, turn())++ if err != nil {++ t.Fatal(err)++ }++ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)++ defer cancel()++ stop, err := Start(ctx, runtime, plan)++ if err != nil {++ t.Fatal(err)++ }++ defer stop(context.Background())++ if err := runtime.Run(ctx, "", "exec", plan.Name(), "test", "-s", "/etc/opt/chrome/policies/managed/openvibe-party.json"); err != nil {++ t.Fatal("Chrome policy was not present in the container")++ }++ ready, cancelReady := context.WithTimeout(ctx, 45*time.Second)++ defer cancelReady()++ if err := WaitReady(ready, plan.Port); err != nil {++ t.Fatal(err)++ }++}nnew file mode 100644@@@ -0,0 +1,148 @@++package party++++import (++ "context"++ "errors"++ "fmt"++ "io"++ "net"++ "net/http"++ "strings"++ "time"++++ "github.com/OpenVibers/OpenVibe.Node/internal/connect"++)++++func Host(ctx context.Context, client Client, version string, stdout io.Writer) error {++ listener, err := net.Listen("tcp", "127.0.0.1:0")++ if err != nil {++ return err++ }++ defer listener.Close()++ proxyPort := listener.Addr().(*net.TCPAddr).Port++ nekoPort, err := FreePort()++ if err != nil {++ return err++ }++ for nekoPort == proxyPort {++ nekoPort, err = FreePort()++ if err != nil {++ return err++ }++ }++ runtime, err := FindRuntime()++ if err != nil {++ return err++ }++ claim, err := client.Claim(ctx, proxyPort)++ if err != nil {++ return err++ }++ defer func() {++ clean, cancel := context.WithTimeout(context.Background(), 15*time.Second)++ defer cancel()++ _ = client.Stopped(clean)++ }()++ plan, err := NewPlan(client.PartyID, nekoPort, claim.ICEServers)++ if err != nil {++ return err++ }++ stop, err := Start(ctx, runtime, plan)++ if err != nil {++ return err++ }++ defer func() {++ clean, cancel := context.WithTimeout(context.Background(), 15*time.Second)++ defer cancel()++ _ = stop(clean)++ }()++ readyCtx, readyCancel := context.WithTimeout(ctx, 45*time.Second)++ err = WaitReady(readyCtx, nekoPort)++ readyCancel()++ if err != nil {++ return fmt.Errorf("shared browser did not start: %w", err)++ }++ proxy := NewProxy(plan, nekoPort, client.Access)++ server := &http.Server{Handler: proxy, ReadHeaderTimeout: 8 * time.Second}++ go func() { _ = server.Serve(listener) }()++ defer func() {++ clean, cancel := context.WithTimeout(context.Background(), 5*time.Second)++ defer cancel()++ _ = server.Shutdown(clean)++ }()++ share, err := client.CreateShare(ctx, claim.ShareGrant)++ if err != nil {++ return err++ }++ defer func() {++ clean, cancel := context.WithTimeout(context.Background(), 15*time.Second)++ defer cancel()++ _ = client.CloseShare(clean, share)++ }()++ access, err := client.Access(ctx, "")++ if err != nil || !access.Active {++ return errors.New("party ended before the desktop was ready")++ }++ if err := client.SyncMembers(ctx, share, access); err != nil {++ return err++ }++ tunnelCtx, stopTunnel := context.WithCancel(ctx)++ defer stopTunnel()++ connected := make(chan struct{}, 1)++ tunnelErr := make(chan error, 1)++ go func() {++ tunnelErr <- connect.Run(tunnelCtx, connect.Share{ID: share.ID, Port: proxyPort, Server: client.ConnectServer}, connect.Options{++ Version: version, NodeToken: client.ConnectToken, OnReady: func(connect.Ready) {++ select {++ case connected <- struct{}{}:++ default:++ }++ },++ })++ }()++ select {++ case <-connected:++ case err := <-tunnelErr:++ return err++ case <-time.After(40 * time.Second):++ return errors.New("Connect did not open the party share")++ case <-ctx.Done():++ return nil++ }++ if err := client.Ready(ctx, share); err != nil {++ return err++ }++ fmt.Fprintf(stdout, "Hosting %s at %s. Stop with Ctrl-C or end the party.\n", client.PartyID, share.URL)++ tick := time.NewTicker(3 * time.Second)++ defer tick.Stop()++ lastMembers := ""++ for {++ select {++ case <-ctx.Done():++ return nil++ case err := <-tunnelErr:++ if errors.Is(err, context.Canceled) {++ return nil++ }++ return err++ case <-tick.C:++ check, cancel := context.WithTimeout(ctx, 10*time.Second)++ a, err := client.Access(check, "")++ if err == nil && !a.Active {++ cancel()++ return nil++ }++ if err == nil {++ var names []string++ for _, m := range a.Members {++ names = append(names, m.Username)++ }++ key := strings.Join(names, "\x00")++ if key != lastMembers && client.SyncMembers(check, share, a) == nil {++ lastMembers = key++ }++ }++ cancel()++ }++ }++}nnew file mode 100644@@@ -0,0 +1,249 @@++package party++++import (++ "bytes"++ "context"++ "encoding/json"++ "errors"++ "io"++ "net"++ "net/http"++ "net/http/httputil"++ "net/url"++ "regexp"++ "strconv"++ "strings"++ "sync"++ "time"++)++++type Access struct {++ Active bool `json:"active"`++ Member bool `json:"member"`++ Holder string `json:"holder"`++ Members []struct {++ Subject string `json:"subject"`++ Username string `json:"username"`++ } `json:"members"`++}++++type AccessFunc func(context.Context, string) (Access, error)++++type nekoSession struct {++ cookie, id string++ login []byte++ checked time.Time++}++++// Proxy exposes neko only through Link's loopback port. Connect injects the++// signed-in subject after its gate; the proxy checks current Chat membership++// before forwarding any HTTP request or WebSocket upgrade.++type Proxy struct {++ plan Plan++ neko *url.URL++ access AccessFunc++ client *http.Client++ mu sync.Mutex++ sessions map[string]nekoSession++ holder string++}++++func NewProxy(plan Plan, nekoPort int, access AccessFunc) *Proxy {++ u, _ := url.Parse("http://127.0.0.1:" + itoa(nekoPort))++ return &Proxy{plan: plan, neko: u, access: access, client: &http.Client{Timeout: 6 * time.Second}, sessions: map[string]nekoSession{}}++}++++func itoa(n int) string { return strconv.Itoa(n) }++++func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {++ host, _, err := net.SplitHostPort(r.RemoteAddr)++ if err != nil || net.ParseIP(host) == nil || !net.ParseIP(host).IsLoopback() {++ http.Error(w, "Local connection required", http.StatusForbidden)++ return++ }++ subject := r.Header.Get("X-Openvibe-Subject")++ if !regexp.MustCompile(`^usr_[0-9A-HJKMNP-TV-Z]{26}$`).MatchString(subject) {++ http.Error(w, "Sign in to the party", http.StatusUnauthorized)++ return++ }++ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)++ a, err := p.access(ctx, subject)++ cancel()++ if err != nil || !a.Active || !a.Member {++ http.Error(w, "This party is unavailable", http.StatusForbidden)++ return++ }++ // Neko's own control UI is locked; only Chat's lease may move the host.++ if (strings.HasPrefix(r.URL.Path, "/api/oauth") && !(r.URL.Path == "/api/oauth/config" && r.Method == http.MethodGet)) ||++ (strings.HasPrefix(r.URL.Path, "/api/room/control") && r.Method != http.MethodGet) {++ http.Error(w, "The party remote controls this desktop", http.StatusForbidden)++ return++ }++ display := subject++ for _, member := range a.Members {++ if member.Subject == subject && member.Username != "" {++ display = member.Username++ break++ }++ }++ s, err := p.session(r.Context(), subject, display)++ if err != nil {++ http.Error(w, "Desktop sign-in failed", http.StatusBadGateway)++ return++ }++ p.syncControl(r.Context(), a.Holder)++ if r.URL.Path == "/api/login" {++ if r.Method != http.MethodPost {++ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)++ return++ }++ w.Header().Set("Content-Type", "application/json")++ w.Header().Set("Cache-Control", "no-store")++ _, _ = w.Write(s.login)++ return++ }++ proxy := httputil.NewSingleHostReverseProxy(p.neko)++ proxy.Director = func(out *http.Request) {++ out.URL.Scheme, out.URL.Host = p.neko.Scheme, p.neko.Host++ if out.URL.Path == "/ws" {++ q := out.URL.Query()++ q.Del("password")++ q.Del("username")++ out.URL.RawQuery = q.Encode()++ }++ out.Host = p.neko.Host++ out.Header.Del("Authorization")++ out.Header.Del("Cookie")++ out.Header.Del("X-Openvibe-Subject")++ out.Header.Del("X-Openvibe-User")++ out.Header.Set("Cookie", s.cookie)++ }++ proxy.ModifyResponse = func(res *http.Response) error { res.Header.Del("Set-Cookie"); return nil }++ if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {++ wsCtx, wsCancel := context.WithCancel(r.Context())++ defer wsCancel()++ go p.watchMembership(wsCtx, wsCancel, subject)++ r = r.WithContext(wsCtx)++ }++ proxy.ServeHTTP(w, r)++}++++func (p *Proxy) watchMembership(ctx context.Context, cancel context.CancelFunc, subject string) {++ tick := time.NewTicker(5 * time.Second)++ defer tick.Stop()++ for {++ select {++ case <-ctx.Done():++ return++ case <-tick.C:++ }++ check, done := context.WithTimeout(ctx, 4*time.Second)++ a, err := p.access(check, subject)++ done()++ if err != nil || !a.Active || !a.Member {++ cancel()++ return++ }++ p.syncControl(ctx, a.Holder)++ }++}++++func (p *Proxy) session(ctx context.Context, subject, display string) (nekoSession, error) {++ p.mu.Lock()++ defer p.mu.Unlock()++ if s, ok := p.sessions[subject]; ok {++ if time.Since(s.checked) < 5*time.Second {++ return s, nil++ }++ req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.neko.String()+"/api/whoami", nil)++ if err == nil {++ req.Header.Set("Cookie", s.cookie)++ if res, e := p.client.Do(req); e == nil {++ _ = res.Body.Close()++ if res.StatusCode == http.StatusOK {++ s.checked = time.Now()++ p.sessions[subject] = s++ return s, nil++ }++ }++ }++ delete(p.sessions, subject)++ if p.holder == subject {++ p.holder = ""++ }++ }++ body, _ := json.Marshal(map[string]string{"username": display, "password": p.plan.MemberPassword})++ req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.neko.String()+"/api/login", bytes.NewReader(body))++ if err != nil {++ return nekoSession{}, err++ }++ req.Header.Set("Content-Type", "application/json")++ res, err := p.client.Do(req)++ if err != nil {++ return nekoSession{}, err++ }++ defer res.Body.Close()++ if res.StatusCode != http.StatusOK {++ return nekoSession{}, errors.New("neko refused member sign-in")++ }++ var out struct {++ ID string `json:"id"`++ }++ login, err := io.ReadAll(io.LimitReader(res.Body, 4096))++ if err != nil {++ return nekoSession{}, err++ }++ if err := json.Unmarshal(login, &out); err != nil {++ return nekoSession{}, err++ }++ for _, c := range res.Cookies() {++ if c.Name == "NEKO_SESSION" && c.Value != "" && out.ID != "" {++ s := nekoSession{cookie: c.Name + "=" + c.Value, id: out.ID, login: login, checked: time.Now()}++ p.sessions[subject] = s++ return s, nil++ }++ }++ return nekoSession{}, errors.New("neko did not set a session cookie")++}++++func (p *Proxy) admin(ctx context.Context, path string) error {++ req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.neko.String()+path, nil)++ if err != nil {++ return err++ }++ req.Header.Set("Authorization", "Bearer "+p.plan.APIToken)++ res, err := p.client.Do(req)++ if err != nil {++ return err++ }++ defer res.Body.Close()++ _, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 4096))++ if res.StatusCode != http.StatusNoContent {++ return errors.New("neko refused control change")++ }++ return nil++}++++func (p *Proxy) syncControl(ctx context.Context, holder string) {++ p.mu.Lock()++ defer p.mu.Unlock()++ if holder == p.holder {++ return++ }++ controlCtx, cancel := context.WithTimeout(ctx, 4*time.Second)++ defer cancel()++ if err := p.admin(controlCtx, "/api/room/control/reset"); err != nil {++ return++ }++ p.holder = ""++ if holder == "" {++ return++ }++ s, ok := p.sessions[holder]++ if !ok {++ return++ }++ if err := p.admin(controlCtx, "/api/room/control/give/"+url.PathEscape(s.id)); err == nil {++ p.holder = holder++ }++}nnew file mode 100644@@@ -0,0 +1,102 @@++package party++++import (++ "context"++ "encoding/json"++ "net"++ "net/http"++ "net/http/httptest"++ "strconv"++ "strings"++ "sync/atomic"++ "testing"++ "time"++)++++const testSubject = "usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3"++++func TestProxySignsInOneMemberAndChecksCurrentAccess(t *testing.T) {++ var logins atomic.Int32++ var allowed atomic.Bool++ var revoked atomic.Bool++ allowed.Store(true)++ neko := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {++ switch r.URL.Path {++ case "/api/login":++ logins.Add(1)++ revoked.Store(false)++ var body map[string]string++ _ = json.NewDecoder(r.Body).Decode(&body)++ if body["username"] != testSubject || body["password"] != "member" {++ t.Error("wrong login")++ }++ http.SetCookie(w, &http.Cookie{Name: "NEKO_SESSION", Value: "session-one", Path: "/", HttpOnly: true})++ _, _ = w.Write([]byte(`{"id":"session-1"}`))++ case "/api/room/control/reset", "/api/room/control/give/session-1":++ if r.Header.Get("Authorization") != "Bearer admin" {++ t.Error("admin call not authenticated")++ }++ w.WriteHeader(http.StatusNoContent)++ case "/api/whoami":++ if revoked.Load() {++ w.WriteHeader(http.StatusUnauthorized)++ return++ }++ w.WriteHeader(http.StatusOK)++ default:++ if r.Header.Get("Cookie") != "NEKO_SESSION=session-one" {++ t.Error("member cookie missing")++ }++ if r.Header.Get("Authorization") != "" || r.Header.Get("X-Openvibe-Subject") != "" {++ t.Error("visitor identity leaked to neko")++ }++ _, _ = w.Write([]byte("desktop"))++ }++ }))++ defer neko.Close()++ _, portText, _ := net.SplitHostPort(strings.TrimPrefix(neko.URL, "http://"))++ port, _ := strconv.Atoi(portText)++ p := NewProxy(Plan{MemberPassword: "member", APIToken: "admin"}, port, func(_ context.Context, subject string) (Access, error) {++ return Access{Active: true, Member: allowed.Load() && subject == testSubject, Holder: testSubject}, nil++ })++ srv := httptest.NewServer(p)++ defer srv.Close()++ request := func(path, subject, method string) *http.Response {++ req, _ := http.NewRequest(method, srv.URL+path, nil)++ req.Header.Set("X-Openvibe-Subject", subject)++ res, err := http.DefaultClient.Do(req)++ if err != nil {++ t.Fatal(err)++ }++ defer res.Body.Close()++ return res++ }++ if res := request("/", testSubject, "GET"); res.StatusCode != 200 || res.Header.Get("Set-Cookie") != "" {++ t.Fatal("approved member did not reach neko without leaking its cookie")++ }++ if res := request("/", testSubject, "GET"); res.StatusCode != 200 {++ t.Fatal("second request failed")++ }++ if logins.Load() != 1 {++ t.Fatal("member should have one private neko session")++ }++ if res := request("/api/login", testSubject, "POST"); res.StatusCode != 200 || logins.Load() != 1 {++ t.Fatal("member bootstrap did not reuse the private neko session")++ }++ revoked.Store(true)++ p.mu.Lock()++ s := p.sessions[testSubject]++ s.checked = time.Time{}++ p.sessions[testSubject] = s++ p.mu.Unlock()++ if res := request("/", testSubject, "GET"); res.StatusCode != 200 || logins.Load() != 2 {++ t.Fatal("expired neko session did not sign in again")++ }++ if res := request("/api/room/control/take", testSubject, "POST"); res.StatusCode != 403 {++ t.Fatal("direct control API should be blocked")++ }++ allowed.Store(false)++ if res := request("/", testSubject, "GET"); res.StatusCode != 403 {++ t.Fatal("removed member reached desktop")++ }++}
@@ -42,6 +42,8 @@ serves the share this computer was picked for at `https://<name>--<you>.openvibe
@@ -42,6 +42,8 @@ serves the share this computer was picked for at `https://<name>--<you>.openvibe
`openvibe-link share --key ovc_… --port 8790` with a key), and `openvibe-link share add` keeps it up with the service;
`openvibe-link share --key ovc_… --port 8790` with a key), and `openvibe-link share add` keeps it up with the service;
`openvibe-link status` lists each share ([docs/connect.md](docs/connect.md)).
`openvibe-link status` lists each share ([docs/connect.md](docs/connect.md)).
+**Host a shared party computer** with `openvibe-link party host <party-id>` from [openvibe.party](https://openvibe.party). Link shows the running desktop and its Stop action in its window ([guide](docs/party-desktop.md)).
+
**Sell a local model** on [OpenVibe.Exchange](https://openvibe.exchange):
**Sell a local model** on [OpenVibe.Exchange](https://openvibe.exchange):
`openvibe-link exchange serve --model qwen3:32b --public-url https://gpu.example.com/v1`. It serves the model from the
`openvibe-link exchange serve --model qwen3:32b --public-url https://gpu.example.com/v1`. It serves the model from the
local OpenAI-compatible server (Ollama by default at `127.0.0.1:11434`), registers the public https address through the
local OpenAI-compatible server (Ollama by default at `127.0.0.1:11434`), registers the public https address through the
@@ -5,8 +5,8 @@
@@ -5,8 +5,8 @@
"wave": "T15",
"wave": "T15",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"kind": "client",
"kind": "client",
- "version": "0.48.0",
- "release": "v0.48.0: Desktop and Terminal can be turned on again from the Link window, after a plain-words confirmation, now that the 11-05 review fixes are in (#102-#104); the first-run answer never turns them on. `openvibe-node share` defaults to connect.openvibe.link.",
+ "version": "0.49.0",
+ "release": "v0.49.0: Link can host a shared party desktop in a temporary neko Google Chrome container, with a bridge-bound public-web egress proxy and managed Chrome policy. The party owner can stop it from the Link window.",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"database": "none",
"deployed": false,
"deployed": false,
@@ -103,6 +103,7 @@ Usage:
@@ -103,6 +103,7 @@ Usage:
openvibe-link share --key ovc_… --port N put this machine's port N on the web (OpenVibe Connect),
openvibe-link share --key ovc_… --port N put this machine's port N on the web (OpenVibe Connect),
behind OpenVibe sign-in, until Ctrl-C
behind OpenVibe sign-in, until Ctrl-C
openvibe-link share add|list|remove … the shares the Node service keeps up (openvibe-link share)
openvibe-link share add|list|remove … the shares the Node service keeps up (openvibe-link share)
+ openvibe-link party host <party-id> host a shared browser for an openvibe.party room, until stopped
openvibe-link version
openvibe-link version
Global flags (before or after the command):
Global flags (before or after the command):
@@ -174,6 +175,13 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
@@ -174,6 +175,13 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
if cmd == "stream" { // its own flags: --probe and the latency probe's
if cmd == "stream" { // its own flags: --probe and the latency probe's
return cmdStream(rest, stdin, stdout, stderr)
return cmdStream(rest, stdin, stdout, stderr)
}
}
+ if cmd == "party" {
+ if err := cmdParty(rest, stdout, stderr); err != nil {
+ fmt.Fprintln(stderr, "error:", err)
+ return 1
+ }
+ return 0
+ }
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
fs.SetOutput(stderr)
fs.SetOutput(stderr)
g := &globals{}
g := &globals{}
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/config"
+ "github.com/OpenVibers/OpenVibe.Node/internal/connect"
+ "github.com/OpenVibers/OpenVibe.Node/internal/credentials"
+ "github.com/OpenVibers/OpenVibe.Node/internal/link"
+ "github.com/OpenVibers/OpenVibe.Node/internal/localctl"
+ "github.com/OpenVibers/OpenVibe.Node/internal/party"
+)
+
+func cmdParty(args []string, stdout, stderr io.Writer) error {
+ if len(args) == 0 || args[0] != "host" {
+ return errors.New("usage: openvibe-link party host <party-id> [--home DIR]")
+ }
+ fs := flag.NewFlagSet("party host", flag.ContinueOnError)
+ fs.SetOutput(stderr)
+ home := fs.String("home", "", "Link state directory")
+ partyURL := fs.String("party-url", "https://openvibe.party", "party site (local testing only)")
+ connectServer := fs.String("connect-server", connect.DefaultServer, "Connect tunnel")
+ // A party id comes before flags in the published command form.
+ if len(args) < 2 {
+ return errors.New("usage: openvibe-link party host <party-id> [--home DIR]")
+ }
+ id := args[1]
+ if err := fs.Parse(args[2:]); err != nil {
+ return err
+ }
+ if len(fs.Args()) != 0 {
+ return errors.New("unexpected party arguments")
+ }
+ paths := config.DefaultPaths(*home)
+ if *partyURL == "https://openvibe.party" && *connectServer == connect.DefaultServer {
+ if response, err := localctl.Call(paths.SocketPath(), localctl.Request{Cmd: localctl.CmdPartyHost, ID: id}, 2*time.Second); err == nil {
+ if !response.OK {
+ return errors.New(response.Error)
+ }
+ fmt.Fprintln(stdout, "Starting the shared computer. Open Link to see and stop it.")
+ return nil
+ }
+ }
+ creds, err := credentials.Load(paths.CredentialFile(), nil)
+ if err != nil || !creds.NetworkPaired() {
+ return errors.New("pair this computer with OpenVibe Link before hosting a party")
+ }
+ chatSource := link.NewTokenSourceFor(creds, nil, "openvibe-node/"+version, "openvibe.chat")
+ connectSource := link.NewTokenSourceFor(creds, nil, "openvibe-node/"+version, link.ConnectAudience)
+ client := party.Client{PartyURL: *partyURL, ConnectServer: *connectServer, PartyID: id,
+ ChatToken: func(ctx context.Context) (string, error) {
+ t, err := chatSource.Token(ctx)
+ if err != nil {
+ return "", err
+ }
+ return t.Reveal(), nil
+ },
+ ConnectToken: func(ctx context.Context) (string, error) {
+ t, err := connectSource.Token(ctx)
+ if err != nil {
+ return "", err
+ }
+ return t.Reveal(), nil
+ }}
+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer cancel()
+ return party.Host(ctx, client, version, stdout)
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+# Host a party desktop with Link
+
+Open [openvibe.party](https://openvibe.party), sign in, and choose **Start a shared computer → On my computer**. The party page gives you a command containing its party ID:
+
+```sh
+openvibe-link party host party-0123abcd
+```
+
+Use the ID from your own page. Link must already be paired to your OpenVibe account. Install Docker or Podman on this computer before starting. Link pulls a pinned neko Google Chrome image, runs a temporary container with a private loopback HTTP port, and opens a party-only Connect share. The browser inside the container is separate from your own browser and files. A service sign-in happens inside that shared browser, so everyone watching the desktop can see what you type there. Sign out of streaming services before ending the party if you want to clear their sessions immediately.
+
+When the Link service is running, the command starts hosting in the background. Open **Link** from the tray or run `openvibe-link panel` to see **Hosting a party desktop**. Select **Stop** there, or end the party on openvibe.party. If Link is not running as a service, the command hosts in the foreground; press Ctrl-C to stop. Stopping removes the container and closes the Connect share.
+
+The party's existing remote decides who can drive. Members use **Ask for it**, **Pass it**, and **Take it back** on openvibe.party. The shared browser is framed in the party page, and members must sign in to OpenVibe before they can watch. **Listen** uses the same browser for Spotify or YouTube Music.
+
+For operators: Chat requires `TURN_URL=turns:<host>:443` with `TURN_AUTH_SECRET`, and the TURN service must accept TLS on port 443. Chat's `CHAT_PARTY_CONNECT_SECRET` and Connect's `CONNECT_PARTY_SHARED_SECRET` must be the same random value of at least 32 characters. The Node needs Network-issued node tokens for `openvibe.chat` and `openvibe.link`.
@@ -34,7 +34,9 @@ const (
@@ -34,7 +34,9 @@ const (
CmdFirstRun = "first_run"
CmdFirstRun = "first_run"
// CmdPanel answers where OpenVibe Link's own window is: its loopback URL with the per-run token in the fragment.
// CmdPanel answers where OpenVibe Link's own window is: its loopback URL with the per-run token in the fragment.
// It goes to the tray (or `openvibe-node panel`), which runs as the Node's owner, never into a status line.
// It goes to the tray (or `openvibe-node panel`), which runs as the Node's owner, never into a status line.
- CmdPanel = "panel"
+ CmdPanel = "panel"
+ CmdPartyHost = "party_host"
+ CmdPartyStop = "party_stop"
)
)
type Request struct {
type Request struct {
@@ -101,8 +101,11 @@ type Node struct {
@@ -101,8 +101,11 @@ type Node struct {
quit context.CancelFunc
quit context.CancelFunc
quitting atomic.Bool
quitting atomic.Bool
- mu sync.Mutex
- limits safety.Limits
+ mu sync.Mutex
+ partyID string
+ partyCancel context.CancelFunc
+ partyDone chan struct{}
+ limits safety.Limits
// haltEpochs: per robot, the newest turn_epoch a halt carried (noteHaltEpoch); older commands are refused.
// haltEpochs: per robot, the newest turn_epoch a halt carried (noteHaltEpoch); older commands are refused.
haltEpochs map[string]int64
haltEpochs map[string]int64
allowed map[string]bool
allowed map[string]bool
@@ -349,6 +352,16 @@ func (n *Node) Run(parent context.Context) error {
@@ -349,6 +352,16 @@ func (n *Node) Run(parent context.Context) error {
n.log.Warn("not paired: running local only (run `openvibe-node pair <CODE>`)")
n.log.Warn("not paired: running local only (run `openvibe-node pair <CODE>`)")
}
}
<-ctx.Done()
<-ctx.Done()
+ n.mu.Lock()
+ partyDone := n.partyDone
+ n.mu.Unlock()
+ if partyDone != nil {
+ select {
+ case <-partyDone:
+ case <-time.After(55 * time.Second):
+ n.log.Warn("party desktop cleanup timed out")
+ }
+ }
n.log.Info("shutting down: stopping every actuator")
n.log.Info("shutting down: stopping every actuator")
n.stopDeskStream(false)
n.stopDeskStream(false)
n.mgr.StopAll(plugins.StopShutdown)
n.mgr.StopAll(plugins.StopShutdown)
@@ -1525,6 +1538,16 @@ func (n *Node) control(r localctl.Request) localctl.Response {
@@ -1525,6 +1538,16 @@ func (n *Node) control(r localctl.Request) localctl.Response {
case localctl.CmdTray:
case localctl.CmdTray:
n.tellTray(r.On)
n.tellTray(r.On)
return localctl.Response{OK: true}
return localctl.Response{OK: true}
+ case localctl.CmdPartyHost:
+ if err := n.startParty(r.ID); err != nil {
+ return localctl.Response{Error: err.Error()}
+ }
+ return localctl.Response{OK: true}
+ case localctl.CmdPartyStop:
+ if err := n.stopParty(r.ID); err != nil {
+ return localctl.Response{Error: err.Error()}
+ }
+ return localctl.Response{OK: true}
case localctl.CmdFirstRun:
case localctl.CmdFirstRun:
// The guided first run's one event per step, sent on this connection (the installer starts the service before
// The guided first run's one event per step, sent on this connection (the installer starts the service before
// the guide, so a second device connection would replace it). Only a first-run event for a known step goes on.
// the guide, so a second device connection would replace it). Only a first-run event for a known step goes on.
@@ -221,6 +221,13 @@ func (n *Node) panelRunning(st Status) []panel.Running {
@@ -221,6 +221,13 @@ func (n *Node) panelRunning(st Status) []panel.Running {
Title: fmt.Sprintf("Port %d shared", sh.Port), Detail: sh.URL})
Title: fmt.Sprintf("Port %d shared", sh.Port), Detail: sh.URL})
}
}
}
}
+ n.mu.Lock()
+ partyID, hosting := n.partyID, n.partyCancel != nil
+ n.mu.Unlock()
+ if hosting {
+ out = append(out, panel.Running{ID: "party:" + partyID, Kind: "desk",
+ Title: "Hosting a party desktop", Detail: partyID})
+ }
if desks := sensorInt(st.Telemetry.Sensors["computer_desks"]); desks > 0 {
if desks := sensorInt(st.Telemetry.Sensors["computer_desks"]); desks > 0 {
title := fmt.Sprintf("%d desks for your agents", desks)
title := fmt.Sprintf("%d desks for your agents", desks)
if desks == 1 {
if desks == 1 {
@@ -477,6 +484,8 @@ func (n *Node) panelStop(id string) error {
@@ -477,6 +484,8 @@ func (n *Node) panelStop(id string) error {
break
break
}
}
return n.shareRemove(port)
return n.shareRemove(port)
+ case strings.HasPrefix(id, "party:"):
+ return n.stopParty(strings.TrimPrefix(id, "party:"))
case id == "desks":
case id == "desks":
return errors.New("a desk is stopped by the agent that started it, or by Pause everything")
return errors.New("a desk is stopped by the agent that started it, or by Pause everything")
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+package node
+
+import (
+ "context"
+ "errors"
+ "io"
+ "regexp"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/connect"
+ "github.com/OpenVibers/OpenVibe.Node/internal/link"
+ "github.com/OpenVibers/OpenVibe.Node/internal/party"
+)
+
+var validPartyID = regexp.MustCompile(`^party-[a-f0-9]{8}$`)
+
+// startParty runs the same host path as the CLI, tied to the Link service's
+// lifetime. The window lists it and can cancel it without stopping other work.
+func (n *Node) startParty(id string) error {
+ if !validPartyID.MatchString(id) {
+ return errors.New("invalid party id")
+ }
+ if n.ctx == nil || n.opt.Creds == nil || !n.opt.Creds.NetworkPaired() {
+ return errors.New("pair this computer with OpenVibe Link before hosting")
+ }
+ if _, err := party.FindRuntime(); err != nil {
+ return err
+ }
+ n.mu.Lock()
+ if n.partyCancel != nil {
+ n.mu.Unlock()
+ return errors.New("this Link is already hosting a party desktop")
+ }
+ ctx, cancel := context.WithCancel(n.ctx)
+ done := make(chan struct{})
+ n.partyID, n.partyCancel, n.partyDone = id, cancel, done
+ n.mu.Unlock()
+
+ chatTokens := link.NewTokenSourceFor(n.opt.Creds, nil, "openvibe-node/"+n.opt.Version, "openvibe.chat")
+ connectTokens := link.NewTokenSourceFor(n.opt.Creds, nil, "openvibe-node/"+n.opt.Version, link.ConnectAudience)
+ client := party.Client{PartyURL: "https://openvibe.party", ConnectServer: connect.DefaultServer, PartyID: id,
+ ChatToken: func(ctx context.Context) (string, error) {
+ t, err := chatTokens.Token(ctx)
+ if err != nil {
+ return "", err
+ }
+ return t.Reveal(), nil
+ },
+ ConnectToken: func(ctx context.Context) (string, error) {
+ t, err := connectTokens.Token(ctx)
+ if err != nil {
+ return "", err
+ }
+ return t.Reveal(), nil
+ }}
+ go func() {
+ defer close(done)
+ if err := party.Host(ctx, client, n.opt.Version, io.Discard); err != nil && ctx.Err() == nil {
+ n.log.Warn("party desktop stopped", "party", id, "err", err)
+ n.addActivity("Party desktop could not start")
+ }
+ cancel()
+ n.mu.Lock()
+ if n.partyDone == done {
+ n.partyID, n.partyCancel, n.partyDone = "", nil, nil
+ }
+ n.mu.Unlock()
+ }()
+ return nil
+}
+
+func (n *Node) stopParty(id string) error {
+ n.mu.Lock()
+ if n.partyCancel == nil || n.partyID != id {
+ n.mu.Unlock()
+ return errors.New("that party is not hosted here")
+ }
+ cancel, done := n.partyCancel, n.partyDone
+ n.mu.Unlock()
+ cancel()
+ select {
+ case <-done:
+ n.addActivity("Stopped the party desktop")
+ return nil
+ case <-time.After(55 * time.Second):
+ return errors.New("party desktop is still stopping")
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,172 @@
@@ -0,0 +1,172 @@
+package party
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+type TokenSource func(context.Context) (string, error)
+
+type Client struct {
+ PartyURL string
+ ConnectServer string
+ PartyID string
+ ChatToken TokenSource
+ ConnectToken TokenSource
+ HTTP *http.Client
+}
+
+type Claim struct {
+ ICEServers []ICEServer `json:"ice_servers"`
+ ShareGrant string `json:"share_grant"`
+}
+
+type Share struct {
+ ID string `json:"id"`
+ URL string `json:"url"`
+ Port int `json:"local_port"`
+}
+
+func (c Client) client() *http.Client {
+ if c.HTTP != nil {
+ return c.HTTP
+ }
+ return &http.Client{Timeout: 15 * time.Second}
+}
+
+func (c Client) base(chat bool) (string, error) {
+ if chat {
+ base := c.PartyURL
+ if base == "" {
+ base = "https://openvibe.party"
+ }
+ u, err := url.Parse(base)
+ if err != nil || u.Host == "" || (u.Scheme != "https" && !(u.Scheme == "http" && loopback(u.Hostname()))) {
+ return "", errors.New("party URL must be HTTPS or loopback HTTP")
+ }
+ return strings.TrimSuffix(u.String(), "/"), nil
+ }
+ base := c.ConnectServer
+ if base == "" {
+ base = "wss://connect.openvibe.link/tunnel"
+ }
+ u, err := url.Parse(base)
+ if err != nil || u.Host == "" {
+ return "", errors.New("invalid Connect URL")
+ }
+ if u.Scheme == "wss" {
+ u.Scheme = "https"
+ } else if u.Scheme == "ws" && loopback(u.Hostname()) {
+ u.Scheme = "http"
+ } else {
+ return "", errors.New("Connect requires TLS")
+ }
+ u.Path, u.RawQuery, u.Fragment = "", "", ""
+ return strings.TrimSuffix(u.String(), "/"), nil
+}
+
+func loopback(host string) bool {
+ ip := net.ParseIP(host)
+ return host == "localhost" || (ip != nil && ip.IsLoopback())
+}
+
+func (c Client) call(ctx context.Context, chat bool, method, path string, input, output any) error {
+ base, err := c.base(chat)
+ if err != nil {
+ return err
+ }
+ var body io.Reader
+ if input != nil {
+ b, err := json.Marshal(input)
+ if err != nil {
+ return err
+ }
+ body = bytes.NewReader(b)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, base+path, body)
+ if err != nil {
+ return err
+ }
+ if input != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ var source TokenSource = c.ConnectToken
+ if chat {
+ source = c.ChatToken
+ }
+ if source == nil {
+ return errors.New("machine token unavailable")
+ }
+ token, err := source(ctx)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ res, err := c.client().Do(req)
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+ if res.StatusCode < 200 || res.StatusCode >= 300 {
+ return fmt.Errorf("party API %s %s returned %d", method, path, res.StatusCode)
+ }
+ if output != nil {
+ return json.NewDecoder(io.LimitReader(res.Body, 32*1024)).Decode(output)
+ }
+ return nil
+}
+
+func (c Client) chatPath(suffix string) string {
+ return "/api/chat/parties/" + url.PathEscape(c.PartyID) + "/desktop" + suffix
+}
+
+func (c Client) Claim(ctx context.Context, port int) (Claim, error) {
+ var out Claim
+ err := c.call(ctx, true, http.MethodPost, c.chatPath("/claim"), map[string]int{"port": port}, &out)
+ return out, err
+}
+
+func (c Client) Access(ctx context.Context, subject string) (Access, error) {
+ var out Access
+ err := c.call(ctx, true, http.MethodGet, c.chatPath("/access")+"?subject="+url.QueryEscape(subject), nil, &out)
+ return out, err
+}
+
+func (c Client) CreateShare(ctx context.Context, grant string) (Share, error) {
+ var out struct {
+ Share Share `json:"share"`
+ }
+ err := c.call(ctx, false, http.MethodPost, "/api/v1/node/party-shares", map[string]string{"grant": grant}, &out)
+ return out.Share, err
+}
+
+func (c Client) Ready(ctx context.Context, share Share) error {
+ return c.call(ctx, true, http.MethodPost, c.chatPath("/ready"), map[string]string{"share_id": share.ID, "url": share.URL}, nil)
+}
+
+func (c Client) Stopped(ctx context.Context) error {
+ return c.call(ctx, true, http.MethodPost, c.chatPath("/stopped"), nil, nil)
+}
+
+func (c Client) SyncMembers(ctx context.Context, share Share, access Access) error {
+ members := make([]string, 0, len(access.Members))
+ for _, m := range access.Members {
+ if m.Username != "" {
+ members = append(members, m.Username)
+ }
+ }
+ return c.call(ctx, false, http.MethodPut, "/api/v1/node/shares/"+url.PathEscape(share.ID)+"/party-members", map[string]any{"members": members}, nil)
+}
+
+func (c Client) CloseShare(ctx context.Context, share Share) error {
+ return c.call(ctx, false, http.MethodDelete, "/api/v1/node/shares/"+url.PathEscape(share.ID)+"/party", nil, nil)
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,278 @@
@@ -0,0 +1,278 @@
+// Package party runs one short-lived shared browser in a container. Nothing from
+// the host is mounted into it; Connect reaches only its loopback HTTP port.
+package party
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// Image is the pinned v3.1.6 multi-architecture index (amd64 and arm64).
+const Image = "ghcr.io/m1k1o/neko/google-chrome@sha256:dce2a6286cfb7e747d7fb05fadd03b25bf2db155c4f71ae53e0f40be71837547"
+
+var partyID = regexp.MustCompile(`^party-[a-f0-9]{8}$`)
+
+type ICEServer struct {
+ URLs any `json:"urls"`
+ Username string `json:"username,omitempty"`
+ Credential string `json:"credential,omitempty"`
+}
+
+type Plan struct {
+ PartyID string
+ Port int
+ ICEServers []ICEServer
+ MemberPassword string
+ AdminPassword string
+ APIToken string
+ ProxyAddress string
+}
+
+// NewPlan generates independent credentials for this run. The caller obtains
+// fresh TURN credentials before constructing it.
+func NewPlan(id string, port int, ice []ICEServer) (Plan, error) {
+ if !partyID.MatchString(id) || port < 1 || port > 65535 {
+ return Plan{}, errors.New("party id or loopback port is invalid")
+ }
+ if len(ice) == 0 {
+ return Plan{}, errors.New("a party desktop needs TURN servers")
+ }
+ hasTurnTLS := false
+ for _, server := range ice {
+ urls, ok := server.URLs.(string)
+ if !ok {
+ return Plan{}, errors.New("ICE URL must be a string")
+ }
+ if strings.HasPrefix(urls, "turns:") && strings.Contains(urls, ":443") && server.Username != "" && server.Credential != "" {
+ hasTurnTLS = true
+ }
+ }
+ if !hasTurnTLS {
+ return Plan{}, errors.New("a party desktop needs authenticated TURN over TLS on port 443")
+ }
+ member, err := random()
+ if err != nil {
+ return Plan{}, err
+ }
+ admin, err := random()
+ if err != nil {
+ return Plan{}, err
+ }
+ api, err := random()
+ if err != nil {
+ return Plan{}, err
+ }
+ return Plan{PartyID: id, Port: port, ICEServers: ice, MemberPassword: member, AdminPassword: admin, APIToken: api}, nil
+}
+
+func random() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+func (p Plan) Name() string { return "openvibe-" + p.PartyID }
+
+// RunArgs contain no credential. The runtime receives values in its process
+// environment, while Docker/Podman receive only environment variable names.
+func (p Plan) RunArgs() []string {
+ return []string{"create", "--name", p.Name(), "--network", "bridge",
+ "--security-opt=no-new-privileges", "--pids-limit=512",
+ "--cpus=2", "--memory=3g", "--shm-size=2g",
+ "-p", fmt.Sprintf("127.0.0.1:%d:8080", p.Port), Image}
+}
+
+func (p Plan) EnvFile() (string, error) {
+ ice, err := json.Marshal(p.ICEServers)
+ if err != nil {
+ return "", err
+ }
+ return "NEKO_MEMBER_MULTIUSER_USER_PASSWORD=" + p.MemberPassword + "\n" +
+ "NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD=" + p.AdminPassword + "\n" +
+ "NEKO_SESSION_API_TOKEN=" + p.APIToken + "\n" +
+ "NEKO_SESSION_COOKIE_ENABLED=true\n" +
+ "NEKO_SESSION_COOKIE_SECURE=true\n" +
+ "NEKO_SESSION_LOCKED_CONTROLS=true\n" +
+ "NEKO_SESSION_IMPLICIT_HOSTING=false\n" +
+ "NEKO_WEBRTC_ICELITE=false\n" +
+ "NEKO_WEBRTC_ICESERVERS_FRONTEND=" + string(ice) + "\n" +
+ "NEKO_WEBRTC_ICESERVERS_BACKEND=" + string(ice) + "\n", nil
+}
+
+type Runtime interface {
+ Run(ctx context.Context, stdin string, args ...string) error
+ Gateway(ctx context.Context) (string, error)
+}
+
+type CLIRuntime struct{ Binary string }
+
+func FindRuntime() (CLIRuntime, error) {
+ for _, name := range []string{"docker", "podman"} {
+ if path, err := exec.LookPath(name); err == nil {
+ return CLIRuntime{Binary: path}, nil
+ }
+ }
+ return CLIRuntime{}, errors.New("install Docker or Podman to host a party desktop")
+}
+
+func (r CLIRuntime) Run(ctx context.Context, stdin string, args ...string) error {
+ if stdin != "" {
+ for _, line := range strings.Split(strings.TrimSpace(stdin), "\n") {
+ key, _, ok := strings.Cut(line, "=")
+ if !ok || key == "" {
+ return errors.New("invalid container environment")
+ }
+ args = append(args[:len(args)-1], append([]string{"--env", key}, args[len(args)-1:]...)...)
+ }
+ }
+ cmd := exec.CommandContext(ctx, r.Binary, args...)
+ cmd.Env = os.Environ()
+ if stdin != "" {
+ cmd.Env = append(cmd.Env, strings.Split(strings.TrimSpace(stdin), "\n")...)
+ }
+ // Never return runtime output: a failed runtime may echo environment values.
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("container runtime %s failed: %w", args[0], err)
+ }
+ return nil
+}
+
+func (r CLIRuntime) Gateway(ctx context.Context) (string, error) {
+ name := "bridge"
+ if strings.Contains(r.Binary, "podman") {
+ name = "podman"
+ }
+ cmd := exec.CommandContext(ctx, r.Binary, "network", "inspect", name)
+ data, err := cmd.Output()
+ if err != nil {
+ return "", errors.New("cannot inspect the container bridge gateway")
+ }
+ var networks []struct {
+ IPAM struct {
+ Config []struct {
+ Gateway string `json:"Gateway"`
+ } `json:"Config"`
+ } `json:"IPAM"`
+ Subnets []struct {
+ Gateway string `json:"gateway"`
+ } `json:"subnets"`
+ }
+ if json.Unmarshal(data, &networks) != nil || len(networks) == 0 {
+ return "", errors.New("invalid container bridge network")
+ }
+ address := ""
+ if len(networks[0].IPAM.Config) > 0 {
+ address = networks[0].IPAM.Config[0].Gateway
+ }
+ if address == "" && len(networks[0].Subnets) > 0 {
+ address = networks[0].Subnets[0].Gateway
+ }
+ if net.ParseIP(address) == nil {
+ return "", errors.New("container bridge has no gateway")
+ }
+ return address, nil
+}
+
+// Start pulls exactly the pinned image, then starts the container. A failed run
+// cleans up by its validated name. Stop is idempotent and also runs on cancellation.
+func Start(ctx context.Context, r Runtime, p Plan) (func(context.Context) error, error) {
+ if !partyID.MatchString(p.PartyID) {
+ return nil, errors.New("invalid party id")
+ }
+ gateway, err := r.Gateway(ctx)
+ if err != nil {
+ return nil, err
+ }
+ listener, err := net.Listen("tcp", net.JoinHostPort(gateway, "0"))
+ if err != nil {
+ return nil, fmt.Errorf("cannot bind party egress proxy to the bridge: %w", err)
+ }
+ server := &http.Server{Handler: NewEgressProxy(gateway), ReadHeaderTimeout: 8 * time.Second}
+ go func() { _ = server.Serve(listener) }()
+ cleanupProxy := func() { _ = server.Close() }
+ if err := r.Run(ctx, "", "pull", Image); err != nil {
+ cleanupProxy()
+ return nil, err
+ }
+ env, err := p.EnvFile()
+ if err != nil {
+ cleanupProxy()
+ return nil, err
+ }
+ p.ProxyAddress = listener.Addr().String()
+ if err := r.Run(ctx, env, p.RunArgs()...); err != nil {
+ cleanupProxy()
+ _ = r.Run(context.Background(), "", "rm", "-f", p.Name())
+ return nil, err
+ }
+ policy, err := policyFile(p.ProxyAddress)
+ if err == nil {
+ err = r.Run(ctx, "", "cp", policy, p.Name()+":/etc/opt/chrome/policies/managed/openvibe-party.json")
+ }
+ if policy != "" {
+ _ = os.Remove(policy)
+ }
+ if err == nil {
+ err = r.Run(ctx, "", "start", p.Name())
+ }
+ if err != nil {
+ cleanupProxy()
+ _ = r.Run(context.Background(), "", "rm", "-f", p.Name())
+ return nil, err
+ }
+ return func(stopCtx context.Context) error {
+ cleanupProxy()
+ stopErr := r.Run(stopCtx, "", "stop", "--time", "10", p.Name())
+ removeErr := r.Run(stopCtx, "", "rm", "-f", p.Name())
+ if stopErr != nil {
+ return stopErr
+ }
+ return removeErr
+ }, nil
+}
+
+// FreePort asks the kernel for an unused loopback port. Docker's bind is the
+// final ownership check; a race fails closed and Start removes the container.
+func FreePort() (int, error) {
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return 0, err
+ }
+ defer l.Close()
+ return l.Addr().(*net.TCPAddr).Port, nil
+}
+
+func WaitReady(ctx context.Context, port int) error {
+ tick := time.NewTicker(250 * time.Millisecond)
+ defer tick.Stop()
+ client := &http.Client{Timeout: time.Second}
+ for {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/", port), nil)
+ if err == nil {
+ if res, e := client.Do(req); e == nil {
+ _ = res.Body.Close()
+ if res.StatusCode == http.StatusOK {
+ return nil
+ }
+ }
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-tick.C:
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+package party
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+)
+
+type call struct {
+ stdin string
+ args []string
+}
+type fakeRuntime struct {
+ calls []call
+ fail string
+}
+
+func (f *fakeRuntime) Gateway(context.Context) (string, error) { return "127.0.0.1", nil }
+
+func (f *fakeRuntime) Run(_ context.Context, stdin string, args ...string) error {
+ f.calls = append(f.calls, call{stdin, args})
+ if args[0] == f.fail {
+ return errors.New("failed")
+ }
+ return nil
+}
+
+func turn() []ICEServer {
+ return []ICEServer{{URLs: "turns:turn.openvibe.live:443?transport=tcp", Username: "temporary", Credential: "temporary"}}
+}
+
+func TestPlanUsesPinnedImageAndSeparateCredentials(t *testing.T) {
+ p, err := NewPlan("party-0123abcd", 18180, turn())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p.MemberPassword == p.AdminPassword || p.MemberPassword == p.APIToken || p.AdminPassword == p.APIToken {
+ t.Fatal("credentials must differ")
+ }
+ args := strings.Join(p.RunArgs(), " ")
+ for _, expected := range []string{"--network bridge", "--security-opt=no-new-privileges", "--memory=3g", "--shm-size=2g", "127.0.0.1:18180:8080", Image} {
+ if !strings.Contains(args, expected) {
+ t.Errorf("run plan is missing %s", expected)
+ }
+ }
+ for _, forbidden := range []string{p.MemberPassword, p.AdminPassword, p.APIToken, "--volume", "--mount", "--network host"} {
+ if strings.Contains(args, forbidden) {
+ t.Error("run arguments contain a credential or host access")
+ }
+ }
+ env, err := p.EnvFile()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(env, "NEKO_WEBRTC_ICESERVERS_BACKEND=") || !strings.Contains(env, "NEKO_SESSION_COOKIE_ENABLED=true") {
+ t.Fatal("missing TURN or cookie settings")
+ }
+}
+
+func TestPlanRequiresTLSRelayAndValidID(t *testing.T) {
+ for _, tc := range []struct {
+ id string
+ ice []ICEServer
+ }{
+ {"oops", turn()},
+ {"party-0123abcd", []ICEServer{{URLs: "turn:turn.openvibe.live:3478", Username: "x", Credential: "y"}}},
+ {"party-0123abcd", []ICEServer{{URLs: "turns:turn.openvibe.live:443"}}},
+ } {
+ if _, err := NewPlan(tc.id, 8080, tc.ice); err == nil {
+ t.Fatal("unsafe plan accepted")
+ }
+ }
+}
+
+func TestLifecycleCleansUp(t *testing.T) {
+ p, err := NewPlan("party-0123abcd", 18180, turn())
+ if err != nil {
+ t.Fatal(err)
+ }
+ f := &fakeRuntime{}
+ stop, err := Start(context.Background(), f, p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(f.calls) != 4 || f.calls[0].args[0] != "pull" || f.calls[1].args[0] != "create" || f.calls[2].args[0] != "cp" || f.calls[3].args[0] != "start" {
+ t.Fatal("pull, create, policy copy and start are required")
+ }
+ if !strings.Contains(f.calls[1].stdin, p.MemberPassword) {
+ t.Fatal("env file was not passed to stdin")
+ }
+ if err := stop(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if f.calls[4].args[0] != "stop" || f.calls[5].args[0] != "rm" {
+ t.Fatal("container did not stop")
+ }
+ f = &fakeRuntime{fail: "start"}
+ if _, err := Start(context.Background(), f, p); err == nil {
+ t.Fatal("run failure was lost")
+ }
+ if f.calls[len(f.calls)-1].args[0] != "rm" {
+ t.Fatal("failed container was not removed")
+ }
+ f = &fakeRuntime{fail: "cp"}
+ if _, err := Start(context.Background(), f, p); err == nil {
+ t.Fatal("policy copy failure was lost")
+ }
+ for _, call := range f.calls {
+ if call.args[0] == "start" {
+ t.Fatal("container started without policy")
+ }
+ }
+ if f.calls[len(f.calls)-1].args[0] != "rm" {
+ t.Fatal("container with missing policy was not removed")
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,185 @@
@@ -0,0 +1,185 @@
+package party
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/netip"
+ "os"
+ "strconv"
+ "time"
+)
+
+var deniedNetworks = []netip.Prefix{
+ netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("10.0.0.0/8"),
+ netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("127.0.0.0/8"),
+ netip.MustParsePrefix("169.254.0.0/16"), netip.MustParsePrefix("172.16.0.0/12"),
+ netip.MustParsePrefix("192.168.0.0/16"), netip.MustParsePrefix("198.18.0.0/15"),
+ netip.MustParsePrefix("224.0.0.0/4"), netip.MustParsePrefix("240.0.0.0/4"),
+ netip.MustParsePrefix("fc00::/7"), netip.MustParsePrefix("fe80::/10"),
+ netip.MustParsePrefix("64:ff9b::/96"),
+}
+
+func publicAddress(ip netip.Addr) bool {
+ ip = ip.Unmap()
+ if !ip.IsValid() || !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
+ return false
+ }
+ for _, block := range deniedNetworks {
+ if block.Contains(ip) {
+ return false
+ }
+ }
+ return true
+}
+
+type addressResolver func(context.Context, string) ([]net.IPAddr, error)
+type addressDialer func(context.Context, string, string) (net.Conn, error)
+
+func checkedDial(ctx context.Context, address string, resolve addressResolver, dial addressDialer, denied ...netip.Addr) (net.Conn, error) {
+ host, port, err := net.SplitHostPort(address)
+ if err != nil {
+ return nil, errors.New("invalid proxy destination")
+ }
+ n, err := strconv.Atoi(port)
+ if err != nil || n < 1 || n > 65535 || host == "" {
+ return nil, errors.New("invalid proxy port")
+ }
+ var ips []net.IPAddr
+ if literal, err := netip.ParseAddr(host); err == nil {
+ ips = []net.IPAddr{{IP: net.IP(literal.AsSlice())}}
+ } else {
+ ips, err = resolve(ctx, host)
+ if err != nil {
+ return nil, errors.New("proxy DNS lookup failed")
+ }
+ }
+ if len(ips) == 0 {
+ return nil, errors.New("proxy destination has no address")
+ }
+ // Reject the entire answer if any address is private. Dial only the checked
+ // address: a second name lookup would reintroduce DNS rebinding.
+ for _, item := range ips {
+ ip, ok := netip.AddrFromSlice(item.IP)
+ if !ok || !publicAddress(ip) {
+ return nil, errors.New("private proxy destination refused")
+ }
+ for _, blocked := range denied {
+ if ip.Unmap() == blocked.Unmap() {
+ return nil, errors.New("bridge gateway refused")
+ }
+ }
+ }
+ return dial(ctx, "tcp", net.JoinHostPort(ips[0].IP.String(), port))
+}
+
+func NewEgressProxy(gateway string) http.Handler {
+ resolver := net.DefaultResolver.LookupIPAddr
+ dialer := (&net.Dialer{Timeout: 10 * time.Second}).DialContext
+ address, _ := netip.ParseAddr(gateway)
+ return egressProxy(resolver, dialer, address)
+}
+
+func egressProxy(resolve addressResolver, dial addressDialer, denied ...netip.Addr) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodConnect {
+ upstream, err := checkedDial(r.Context(), r.Host, resolve, dial, denied...)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusForbidden)
+ return
+ }
+ hijacker, ok := w.(http.Hijacker)
+ if !ok {
+ _ = upstream.Close()
+ http.Error(w, "CONNECT unavailable", http.StatusInternalServerError)
+ return
+ }
+ client, _, err := hijacker.Hijack()
+ if err != nil {
+ _ = upstream.Close()
+ return
+ }
+ _, _ = client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
+ go func() { _, _ = io.Copy(upstream, client); _ = upstream.Close() }()
+ _, _ = io.Copy(client, upstream)
+ _ = client.Close()
+ return
+ }
+ if r.URL == nil || r.URL.Scheme != "http" || r.URL.Hostname() == "" || r.URL.User != nil {
+ http.Error(w, "HTTP proxy requires an absolute HTTP URL", http.StatusBadRequest)
+ return
+ }
+ port := r.URL.Port()
+ if port == "" {
+ port = "80"
+ }
+ address := net.JoinHostPort(r.URL.Hostname(), port)
+ upstream, err := checkedDial(r.Context(), address, resolve, dial, denied...)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusForbidden)
+ return
+ }
+ defer upstream.Close()
+ out := r.Clone(r.Context())
+ out.RequestURI = ""
+ out.URL.Scheme, out.URL.Host = "", ""
+ out.Header.Del("Proxy-Connection")
+ out.Header.Del("Proxy-Authorization")
+ if err = out.Write(upstream); err != nil {
+ http.Error(w, "upstream request failed", http.StatusBadGateway)
+ return
+ }
+ response, err := http.ReadResponse(bufio.NewReader(upstream), out)
+ if err != nil {
+ http.Error(w, "upstream response failed", http.StatusBadGateway)
+ return
+ }
+ defer response.Body.Close()
+ for key, values := range response.Header {
+ for _, value := range values {
+ w.Header().Add(key, value)
+ }
+ }
+ w.WriteHeader(response.StatusCode)
+ _, _ = io.Copy(w, response.Body)
+ })
+}
+
+func policyFile(address string) (string, error) {
+ if _, _, err := net.SplitHostPort(address); err != nil {
+ return "", err
+ }
+ policy := map[string]any{
+ "ProxySettings": map[string]string{
+ "ProxyMode": "fixed_servers",
+ "ProxyServer": "http=" + address + ";https=" + address,
+ "ProxyBypassList": "<-loopback>",
+ },
+ "URLBlocklist": []string{"file://*", "chrome://settings", "chrome://settings/*", "chrome://flags/*"},
+ // A page's own WebRTC would otherwise send UDP straight past the proxy and could reach the host's network:
+ // only proxied (TCP) WebRTC is allowed. The desktop's own stream is neko's, outside the browser.
+ "WebRtcIPHandling": "disable_non_proxied_udp",
+ }
+ data, err := json.Marshal(policy)
+ if err != nil {
+ return "", err
+ }
+ file, err := os.CreateTemp("", "openvibe-party-policy-*.json")
+ if err != nil {
+ return "", err
+ }
+ defer file.Close()
+ if err = file.Chmod(0600); err == nil {
+ _, err = file.Write(data)
+ }
+ if err != nil {
+ _ = os.Remove(file.Name())
+ return "", fmt.Errorf("write Chrome policy: %w", err)
+ }
+ return file.Name(), nil
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,155 @@
@@ -0,0 +1,155 @@
+package party
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/netip"
+ "os"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestEgressRefusesPrivateRanges(t *testing.T) {
+ for _, value := range []string{
+ "127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.1.1", "100.64.0.1",
+ "169.254.1.1", "224.0.0.1", "172.17.0.1", "::1", "fc00::1", "fe80::1",
+ "::ffff:192.168.1.1", "64:ff9b::c0a8:101",
+ } {
+ if publicAddress(netip.MustParseAddr(value)) {
+ t.Errorf("accepted private address %s", value)
+ }
+ }
+ if !publicAddress(netip.MustParseAddr("8.8.8.8")) || !publicAddress(netip.MustParseAddr("2606:4700:4700::1111")) {
+ t.Fatal("public web address was refused")
+ }
+}
+
+func TestEgressResolvesOnceAndDialsCheckedIP(t *testing.T) {
+ lookup := 0
+ resolve := func(_ context.Context, host string) ([]net.IPAddr, error) {
+ lookup++
+ if host == "lan.example" {
+ return []net.IPAddr{{IP: net.ParseIP("192.168.1.1")}}, nil
+ }
+ if host == "mixed.example" {
+ return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}, {IP: net.ParseIP("10.0.0.1")}}, nil
+ }
+ return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}, nil
+ }
+ dialed := ""
+ dial := func(_ context.Context, _, address string) (net.Conn, error) {
+ dialed = address
+ return nil, errors.New("test dial reached")
+ }
+ if _, err := checkedDial(context.Background(), "lan.example:443", resolve, dial); err == nil || dialed != "" {
+ t.Fatal("private DNS answer reached dial")
+ }
+ if _, err := checkedDial(context.Background(), "mixed.example:443", resolve, dial); err == nil || dialed != "" {
+ t.Fatal("mixed DNS answer reached dial")
+ }
+ if _, err := checkedDial(context.Background(), "public.example:443", resolve, dial); err == nil || dialed != "8.8.8.8:443" || lookup != 3 {
+ t.Fatalf("public destination was not dialed at checked IP: %s, lookups %d", dialed, lookup)
+ }
+ dialed = ""
+ if _, err := checkedDial(context.Background(), "public.example:443", resolve, dial, netip.MustParseAddr("8.8.8.8")); err == nil || dialed != "" {
+ t.Fatal("bridge gateway bypassed its explicit block")
+ }
+}
+
+func TestEgressHTTPRefusesPrivateDNSAndAllowsPublic(t *testing.T) {
+ resolve := func(_ context.Context, host string) ([]net.IPAddr, error) {
+ if host == "lan.example" {
+ return []net.IPAddr{{IP: net.ParseIP("10.1.2.3")}}, nil
+ }
+ return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}, nil
+ }
+ dialed := ""
+ dial := func(_ context.Context, _, address string) (net.Conn, error) {
+ dialed = address
+ client, server := net.Pipe()
+ go func() {
+ defer server.Close()
+ buffer := make([]byte, 4096)
+ _, _ = server.Read(buffer)
+ _, _ = io.WriteString(server, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
+ }()
+ return client, nil
+ }
+ proxy := egressProxy(resolve, dial)
+ private := httptest.NewRecorder()
+ proxy.ServeHTTP(private, httptest.NewRequest(http.MethodGet, "http://lan.example/", nil))
+ if private.Code != http.StatusForbidden || dialed != "" {
+ t.Fatal("private DNS destination was not refused")
+ }
+ public := httptest.NewRecorder()
+ proxy.ServeHTTP(public, httptest.NewRequest(http.MethodGet, "http://public.example/", nil))
+ if public.Code != http.StatusOK || public.Body.String() != "ok" || dialed != "8.8.8.8:80" {
+ t.Fatal("public HTTP destination failed")
+ }
+}
+
+func TestPolicyForcesProxyAndBlocksLocalPages(t *testing.T) {
+ path, err := policyFile("172.17.0.1:45678")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.Remove(path)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var policy struct {
+ ProxySettings map[string]string `json:"ProxySettings"`
+ URLBlocklist []string `json:"URLBlocklist"`
+ WebRTC string `json:"WebRtcIPHandling"`
+ }
+ if json.Unmarshal(data, &policy) != nil {
+ t.Fatal("invalid policy JSON")
+ }
+ if policy.ProxySettings["ProxyMode"] != "fixed_servers" || !strings.Contains(policy.ProxySettings["ProxyServer"], "172.17.0.1:45678") || policy.ProxySettings["ProxyBypassList"] != "<-loopback>" {
+ t.Fatal("proxy policy is incomplete")
+ }
+ if policy.WebRTC != "disable_non_proxied_udp" {
+ t.Fatal("a page's WebRTC could send UDP past the proxy")
+ }
+ for _, blocked := range []string{"file://*", "chrome://settings", "chrome://settings/*"} {
+ if !strings.Contains(strings.Join(policy.URLBlocklist, " "), blocked) {
+ t.Errorf("missing URL block %s", blocked)
+ }
+ }
+}
+
+func TestContainerManagedPolicyLive(t *testing.T) {
+ if os.Getenv("PARTY_DOCKER_PROOF") != "1" {
+ t.Skip("set PARTY_DOCKER_PROOF=1 for the local Docker proof")
+ }
+ runtime, err := FindRuntime()
+ if err != nil {
+ t.Fatal(err)
+ }
+ plan, err := NewPlan("party-89abcdef", 18181, turn())
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
+ defer cancel()
+ stop, err := Start(ctx, runtime, plan)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer stop(context.Background())
+ if err := runtime.Run(ctx, "", "exec", plan.Name(), "test", "-s", "/etc/opt/chrome/policies/managed/openvibe-party.json"); err != nil {
+ t.Fatal("Chrome policy was not present in the container")
+ }
+ ready, cancelReady := context.WithTimeout(ctx, 45*time.Second)
+ defer cancelReady()
+ if err := WaitReady(ready, plan.Port); err != nil {
+ t.Fatal(err)
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
+package party
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/connect"
+)
+
+func Host(ctx context.Context, client Client, version string, stdout io.Writer) error {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return err
+ }
+ defer listener.Close()
+ proxyPort := listener.Addr().(*net.TCPAddr).Port
+ nekoPort, err := FreePort()
+ if err != nil {
+ return err
+ }
+ for nekoPort == proxyPort {
+ nekoPort, err = FreePort()
+ if err != nil {
+ return err
+ }
+ }
+ runtime, err := FindRuntime()
+ if err != nil {
+ return err
+ }
+ claim, err := client.Claim(ctx, proxyPort)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ clean, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ _ = client.Stopped(clean)
+ }()
+ plan, err := NewPlan(client.PartyID, nekoPort, claim.ICEServers)
+ if err != nil {
+ return err
+ }
+ stop, err := Start(ctx, runtime, plan)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ clean, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ _ = stop(clean)
+ }()
+ readyCtx, readyCancel := context.WithTimeout(ctx, 45*time.Second)
+ err = WaitReady(readyCtx, nekoPort)
+ readyCancel()
+ if err != nil {
+ return fmt.Errorf("shared browser did not start: %w", err)
+ }
+ proxy := NewProxy(plan, nekoPort, client.Access)
+ server := &http.Server{Handler: proxy, ReadHeaderTimeout: 8 * time.Second}
+ go func() { _ = server.Serve(listener) }()
+ defer func() {
+ clean, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _ = server.Shutdown(clean)
+ }()
+ share, err := client.CreateShare(ctx, claim.ShareGrant)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ clean, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ _ = client.CloseShare(clean, share)
+ }()
+ access, err := client.Access(ctx, "")
+ if err != nil || !access.Active {
+ return errors.New("party ended before the desktop was ready")
+ }
+ if err := client.SyncMembers(ctx, share, access); err != nil {
+ return err
+ }
+ tunnelCtx, stopTunnel := context.WithCancel(ctx)
+ defer stopTunnel()
+ connected := make(chan struct{}, 1)
+ tunnelErr := make(chan error, 1)
+ go func() {
+ tunnelErr <- connect.Run(tunnelCtx, connect.Share{ID: share.ID, Port: proxyPort, Server: client.ConnectServer}, connect.Options{
+ Version: version, NodeToken: client.ConnectToken, OnReady: func(connect.Ready) {
+ select {
+ case connected <- struct{}{}:
+ default:
+ }
+ },
+ })
+ }()
+ select {
+ case <-connected:
+ case err := <-tunnelErr:
+ return err
+ case <-time.After(40 * time.Second):
+ return errors.New("Connect did not open the party share")
+ case <-ctx.Done():
+ return nil
+ }
+ if err := client.Ready(ctx, share); err != nil {
+ return err
+ }
+ fmt.Fprintf(stdout, "Hosting %s at %s. Stop with Ctrl-C or end the party.\n", client.PartyID, share.URL)
+ tick := time.NewTicker(3 * time.Second)
+ defer tick.Stop()
+ lastMembers := ""
+ for {
+ select {
+ case <-ctx.Done():
+ return nil
+ case err := <-tunnelErr:
+ if errors.Is(err, context.Canceled) {
+ return nil
+ }
+ return err
+ case <-tick.C:
+ check, cancel := context.WithTimeout(ctx, 10*time.Second)
+ a, err := client.Access(check, "")
+ if err == nil && !a.Active {
+ cancel()
+ return nil
+ }
+ if err == nil {
+ var names []string
+ for _, m := range a.Members {
+ names = append(names, m.Username)
+ }
+ key := strings.Join(names, "\x00")
+ if key != lastMembers && client.SyncMembers(check, share, a) == nil {
+ lastMembers = key
+ }
+ }
+ cancel()
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,249 @@
@@ -0,0 +1,249 @@
+package party
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+type Access struct {
+ Active bool `json:"active"`
+ Member bool `json:"member"`
+ Holder string `json:"holder"`
+ Members []struct {
+ Subject string `json:"subject"`
+ Username string `json:"username"`
+ } `json:"members"`
+}
+
+type AccessFunc func(context.Context, string) (Access, error)
+
+type nekoSession struct {
+ cookie, id string
+ login []byte
+ checked time.Time
+}
+
+// Proxy exposes neko only through Link's loopback port. Connect injects the
+// signed-in subject after its gate; the proxy checks current Chat membership
+// before forwarding any HTTP request or WebSocket upgrade.
+type Proxy struct {
+ plan Plan
+ neko *url.URL
+ access AccessFunc
+ client *http.Client
+ mu sync.Mutex
+ sessions map[string]nekoSession
+ holder string
+}
+
+func NewProxy(plan Plan, nekoPort int, access AccessFunc) *Proxy {
+ u, _ := url.Parse("http://127.0.0.1:" + itoa(nekoPort))
+ return &Proxy{plan: plan, neko: u, access: access, client: &http.Client{Timeout: 6 * time.Second}, sessions: map[string]nekoSession{}}
+}
+
+func itoa(n int) string { return strconv.Itoa(n) }
+
+func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil || net.ParseIP(host) == nil || !net.ParseIP(host).IsLoopback() {
+ http.Error(w, "Local connection required", http.StatusForbidden)
+ return
+ }
+ subject := r.Header.Get("X-Openvibe-Subject")
+ if !regexp.MustCompile(`^usr_[0-9A-HJKMNP-TV-Z]{26}$`).MatchString(subject) {
+ http.Error(w, "Sign in to the party", http.StatusUnauthorized)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
+ a, err := p.access(ctx, subject)
+ cancel()
+ if err != nil || !a.Active || !a.Member {
+ http.Error(w, "This party is unavailable", http.StatusForbidden)
+ return
+ }
+ // Neko's own control UI is locked; only Chat's lease may move the host.
+ if (strings.HasPrefix(r.URL.Path, "/api/oauth") && !(r.URL.Path == "/api/oauth/config" && r.Method == http.MethodGet)) ||
+ (strings.HasPrefix(r.URL.Path, "/api/room/control") && r.Method != http.MethodGet) {
+ http.Error(w, "The party remote controls this desktop", http.StatusForbidden)
+ return
+ }
+ display := subject
+ for _, member := range a.Members {
+ if member.Subject == subject && member.Username != "" {
+ display = member.Username
+ break
+ }
+ }
+ s, err := p.session(r.Context(), subject, display)
+ if err != nil {
+ http.Error(w, "Desktop sign-in failed", http.StatusBadGateway)
+ return
+ }
+ p.syncControl(r.Context(), a.Holder)
+ if r.URL.Path == "/api/login" {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ _, _ = w.Write(s.login)
+ return
+ }
+ proxy := httputil.NewSingleHostReverseProxy(p.neko)
+ proxy.Director = func(out *http.Request) {
+ out.URL.Scheme, out.URL.Host = p.neko.Scheme, p.neko.Host
+ if out.URL.Path == "/ws" {
+ q := out.URL.Query()
+ q.Del("password")
+ q.Del("username")
+ out.URL.RawQuery = q.Encode()
+ }
+ out.Host = p.neko.Host
+ out.Header.Del("Authorization")
+ out.Header.Del("Cookie")
+ out.Header.Del("X-Openvibe-Subject")
+ out.Header.Del("X-Openvibe-User")
+ out.Header.Set("Cookie", s.cookie)
+ }
+ proxy.ModifyResponse = func(res *http.Response) error { res.Header.Del("Set-Cookie"); return nil }
+ if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
+ wsCtx, wsCancel := context.WithCancel(r.Context())
+ defer wsCancel()
+ go p.watchMembership(wsCtx, wsCancel, subject)
+ r = r.WithContext(wsCtx)
+ }
+ proxy.ServeHTTP(w, r)
+}
+
+func (p *Proxy) watchMembership(ctx context.Context, cancel context.CancelFunc, subject string) {
+ tick := time.NewTicker(5 * time.Second)
+ defer tick.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-tick.C:
+ }
+ check, done := context.WithTimeout(ctx, 4*time.Second)
+ a, err := p.access(check, subject)
+ done()
+ if err != nil || !a.Active || !a.Member {
+ cancel()
+ return
+ }
+ p.syncControl(ctx, a.Holder)
+ }
+}
+
+func (p *Proxy) session(ctx context.Context, subject, display string) (nekoSession, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if s, ok := p.sessions[subject]; ok {
+ if time.Since(s.checked) < 5*time.Second {
+ return s, nil
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.neko.String()+"/api/whoami", nil)
+ if err == nil {
+ req.Header.Set("Cookie", s.cookie)
+ if res, e := p.client.Do(req); e == nil {
+ _ = res.Body.Close()
+ if res.StatusCode == http.StatusOK {
+ s.checked = time.Now()
+ p.sessions[subject] = s
+ return s, nil
+ }
+ }
+ }
+ delete(p.sessions, subject)
+ if p.holder == subject {
+ p.holder = ""
+ }
+ }
+ body, _ := json.Marshal(map[string]string{"username": display, "password": p.plan.MemberPassword})
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.neko.String()+"/api/login", bytes.NewReader(body))
+ if err != nil {
+ return nekoSession{}, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ res, err := p.client.Do(req)
+ if err != nil {
+ return nekoSession{}, err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return nekoSession{}, errors.New("neko refused member sign-in")
+ }
+ var out struct {
+ ID string `json:"id"`
+ }
+ login, err := io.ReadAll(io.LimitReader(res.Body, 4096))
+ if err != nil {
+ return nekoSession{}, err
+ }
+ if err := json.Unmarshal(login, &out); err != nil {
+ return nekoSession{}, err
+ }
+ for _, c := range res.Cookies() {
+ if c.Name == "NEKO_SESSION" && c.Value != "" && out.ID != "" {
+ s := nekoSession{cookie: c.Name + "=" + c.Value, id: out.ID, login: login, checked: time.Now()}
+ p.sessions[subject] = s
+ return s, nil
+ }
+ }
+ return nekoSession{}, errors.New("neko did not set a session cookie")
+}
+
+func (p *Proxy) admin(ctx context.Context, path string) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.neko.String()+path, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+p.plan.APIToken)
+ res, err := p.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+ _, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 4096))
+ if res.StatusCode != http.StatusNoContent {
+ return errors.New("neko refused control change")
+ }
+ return nil
+}
+
+func (p *Proxy) syncControl(ctx context.Context, holder string) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if holder == p.holder {
+ return
+ }
+ controlCtx, cancel := context.WithTimeout(ctx, 4*time.Second)
+ defer cancel()
+ if err := p.admin(controlCtx, "/api/room/control/reset"); err != nil {
+ return
+ }
+ p.holder = ""
+ if holder == "" {
+ return
+ }
+ s, ok := p.sessions[holder]
+ if !ok {
+ return
+ }
+ if err := p.admin(controlCtx, "/api/room/control/give/"+url.PathEscape(s.id)); err == nil {
+ p.holder = holder
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+package party
+
+import (
+ "context"
+ "encoding/json"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+const testSubject = "usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3"
+
+func TestProxySignsInOneMemberAndChecksCurrentAccess(t *testing.T) {
+ var logins atomic.Int32
+ var allowed atomic.Bool
+ var revoked atomic.Bool
+ allowed.Store(true)
+ neko := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/login":
+ logins.Add(1)
+ revoked.Store(false)
+ var body map[string]string
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ if body["username"] != testSubject || body["password"] != "member" {
+ t.Error("wrong login")
+ }
+ http.SetCookie(w, &http.Cookie{Name: "NEKO_SESSION", Value: "session-one", Path: "/", HttpOnly: true})
+ _, _ = w.Write([]byte(`{"id":"session-1"}`))
+ case "/api/room/control/reset", "/api/room/control/give/session-1":
+ if r.Header.Get("Authorization") != "Bearer admin" {
+ t.Error("admin call not authenticated")
+ }
+ w.WriteHeader(http.StatusNoContent)
+ case "/api/whoami":
+ if revoked.Load() {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ default:
+ if r.Header.Get("Cookie") != "NEKO_SESSION=session-one" {
+ t.Error("member cookie missing")
+ }
+ if r.Header.Get("Authorization") != "" || r.Header.Get("X-Openvibe-Subject") != "" {
+ t.Error("visitor identity leaked to neko")
+ }
+ _, _ = w.Write([]byte("desktop"))
+ }
+ }))
+ defer neko.Close()
+ _, portText, _ := net.SplitHostPort(strings.TrimPrefix(neko.URL, "http://"))
+ port, _ := strconv.Atoi(portText)
+ p := NewProxy(Plan{MemberPassword: "member", APIToken: "admin"}, port, func(_ context.Context, subject string) (Access, error) {
+ return Access{Active: true, Member: allowed.Load() && subject == testSubject, Holder: testSubject}, nil
+ })
+ srv := httptest.NewServer(p)
+ defer srv.Close()
+ request := func(path, subject, method string) *http.Response {
+ req, _ := http.NewRequest(method, srv.URL+path, nil)
+ req.Header.Set("X-Openvibe-Subject", subject)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ return res
+ }
+ if res := request("/", testSubject, "GET"); res.StatusCode != 200 || res.Header.Get("Set-Cookie") != "" {
+ t.Fatal("approved member did not reach neko without leaking its cookie")
+ }
+ if res := request("/", testSubject, "GET"); res.StatusCode != 200 {
+ t.Fatal("second request failed")
+ }
+ if logins.Load() != 1 {
+ t.Fatal("member should have one private neko session")
+ }
+ if res := request("/api/login", testSubject, "POST"); res.StatusCode != 200 || logins.Load() != 1 {
+ t.Fatal("member bootstrap did not reuse the private neko session")
+ }
+ revoked.Store(true)
+ p.mu.Lock()
+ s := p.sessions[testSubject]
+ s.checked = time.Time{}
+ p.sessions[testSubject] = s
+ p.mu.Unlock()
+ if res := request("/", testSubject, "GET"); res.StatusCode != 200 || logins.Load() != 2 {
+ t.Fatal("expired neko session did not sign in again")
+ }
+ if res := request("/api/room/control/take", testSubject, "POST"); res.StatusCode != 403 {
+ t.Fatal("direct control API should be blocked")
+ }
+ allowed.Store(false)
+ if res := request("/", testSubject, "GET"); res.StatusCode != 403 {
+ t.Fatal("removed member reached desktop")
+ }
+}