From 7887eada61049dc553ce13e670af805fb422cd38 Mon Sep 17 00:00:00 2001 From: dhkatz Date: Thu, 29 Oct 2026 03:45:55 -0700 Subject: [PATCH] Sign in to T5DE's hub and report presence and community outfits Co-Authored-By: Claude Opus 5.5 --- README.md | 7 + cmd/t5de-proxy/main.go | 67 ++++++- go.mod | 6 +- go.sum | 4 + internal/hub/hub.go | 319 ++++++++++++++++++++++++++++++++ internal/hub/protect_other.go | 7 + internal/hub/protect_windows.go | 31 ++++ internal/products/intercept.go | 7 + internal/products/products.go | 30 +++ 9 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 internal/hub/hub.go create mode 100644 internal/hub/protect_other.go create mode 100644 internal/hub/protect_windows.go diff --git a/README.md b/README.md index 69aa2f3..1f2e60b 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,13 @@ products with the outfit they were saved with, and adds them back when the clien Requests it changes are re-signed (`X-imvu-auth`) with the session key from the login response, which never leaves the machine. +## The hub + +`t5de-proxy signin [-hub URL]` signs the proxy in to T5DE's hub (git.t5de.dev/t5de/t5de-hub): it shows a code to +enter on the hub's website (`/device`) and saves the sign-in in `%APPDATA%\T5DE\proxy\hub.json`, the refresh +token encrypted for the Windows user (DPAPI). From then on the running proxy keeps a WebSocket to the hub +(`internal/hub`), reporting which IMVU account the client signed in as and the community products it wears. + ## Building go test ./... diff --git a/cmd/t5de-proxy/main.go b/cmd/t5de-proxy/main.go index b6fc869..2eb18b9 100644 --- a/cmd/t5de-proxy/main.go +++ b/cmd/t5de-proxy/main.go @@ -5,12 +5,19 @@ // // It listens on a free port of 127.0.0.1 and, once it's listening, writes {"address", "token"} to the ready file // (written whole, by renaming), which the client reads. +// +// t5de-proxy signin [-hub URL] +// +// signs the proxy in to T5DE's hub: it shows a code to enter on the hub's website and saves the sign-in (encrypted +// for the Windows user). From then on the proxy connects to the hub as it runs. package main import ( + "context" "crypto/rand" "encoding/hex" "encoding/json" + "errors" "flag" "fmt" "io" @@ -18,17 +25,28 @@ import ( "net" "net/http" "os" + "os/exec" + "os/signal" "path/filepath" + "runtime" "strings" "time" "git.t5de.dev/t5de/t5de-proxy/internal/buildinfo" + "git.t5de.dev/t5de/t5de-proxy/internal/hub" "git.t5de.dev/t5de/t5de-proxy/internal/parent" "git.t5de.dev/t5de/t5de-proxy/internal/products" "git.t5de.dev/t5de/t5de-proxy/internal/proxy" ) func main() { + if len(os.Args) > 1 && os.Args[1] == "signin" { + if err := signin(os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, "t5de-proxy signin:", err) + os.Exit(1) + } + return + } listen := flag.String("listen", "127.0.0.1:0", "address to listen on") ready := flag.String("ready", "", "file to write the address and token to once listening") parentPID := flag.Int("parent", 0, "exit when this process exits") @@ -107,12 +125,22 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool, pa }() } handler := &proxy.Server{Token: token, Log: logger, Trace: trace} - if prods, err := products.New(packs, data, logger.Printf); err != nil { + prods, err := products.New(packs, data, logger.Printf) + if err != nil { logger.Printf("community products are off: %v", err) } else { handler.Interceptor, handler.IMQFilter = prods, prods logger.Printf("community products from %s", strings.Join(packs, ", ")) } + if saved, err := hub.Load(filepath.Join(data, "hub.json")); err == nil { + client := hub.New(filepath.Join(data, "hub.json"), saved, logger) + if prods != nil { + prods.Observer = client + } + go client.Run(context.Background()) + } else if !errors.Is(err, hub.ErrSignedOut) { + logger.Printf("hub: %v", err) + } server := &http.Server{ Handler: handler, ReadHeaderTimeout: 30 * time.Second, @@ -133,3 +161,40 @@ func writeReady(path, address, token string) error { } return os.Rename(tmp, path) } + +// defaultHub is T5DE's hub. +const defaultHub = "https://hub.t5de.dev" + +func signin(args []string) error { + fs := flag.NewFlagSet("signin", flag.ExitOnError) + hubURL := fs.String("hub", envOr("T5DE_HUB", defaultHub), "the hub's address (or $T5DE_HUB)") + data := fs.String("data", "", "where the proxy keeps its state (default: %APPDATA%/T5DE/proxy)") + fs.Parse(args) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + saved, err := hub.SignIn(ctx, *hubURL, func(code, page string) { + fmt.Printf("To sign T5DE in, enter this code at %s:\n\n %s\n\nOpening it in your browser...\n", page, code) + openBrowser(page) + }) + if err != nil { + return err + } + if err := saved.Save(filepath.Join(dataDir(*data), "hub.json")); err != nil { + return err + } + fmt.Println("Signed in. T5DE connects to the hub the next time the client starts.") + return nil +} + +func envOr(name, fallback string) string { + if v := os.Getenv(name); v != "" { + return v + } + return fallback +} + +func openBrowser(page string) { + if runtime.GOOS == "windows" { + exec.Command("rundll32", "url.dll,FileProtocolHandler", page).Start() + } +} diff --git a/go.mod b/go.mod index cf1391c..4109ae8 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,8 @@ module git.t5de.dev/t5de/t5de-proxy go 1.27.0 -require git.t5de.dev/t5de/t5de-api v0.0.0-20261029101713-106b4fcc6064 +require ( + git.t5de.dev/t5de/t5de-api v0.0.0-20261029101713-106b4fcc6064 + github.com/coder/websocket v1.8.15 + golang.org/x/sys v0.49.0 +) diff --git a/go.sum b/go.sum index 7346c0a..136578d 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,6 @@ git.t5de.dev/t5de/t5de-api v0.0.0-20261029101713-106b4fcc6064 h1:UdRdoSTTZHjUTW7wF9+EVJ7UFgBktXrls3FsMKiOp2Y= git.t5de.dev/t5de/t5de-api v0.0.0-20261029101713-106b4fcc6064/go.mod h1:mRXJdl43TzJN3JlUeVhi6BSqle37+ALmqzxqTYchXLw= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/internal/hub/hub.go b/internal/hub/hub.go new file mode 100644 index 0000000..87185a3 --- /dev/null +++ b/internal/hub/hub.go @@ -0,0 +1,319 @@ +// Package hub connects the proxy to T5DE's hub (git.t5de.dev/t5de/t5de-hub): signing in with a code the person +// enters on the hub's website, and a live connection reporting which IMVU account the client is using and the +// community products it wears. +package hub + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/coder/websocket" +) + +// Item is a community product worn. +type Item struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Saved is the proxy's sign-in, in /hub.json. The refresh token is encrypted for the Windows user. +type Saved struct { + URL string `json:"url"` + RefreshToken string `json:"-"` + Protected []byte `json:"refresh_token"` +} + +// ErrSignedOut means there's no sign-in, or the hub ended it. +var ErrSignedOut = errors.New("not signed in to the hub") + +func Load(path string) (*Saved, error) { + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, ErrSignedOut + } + if err != nil { + return nil, err + } + var s Saved + if err := json.Unmarshal(b, &s); err != nil { + return nil, err + } + tok, err := unprotect(s.Protected) + if err != nil { + return nil, fmt.Errorf("reading the hub sign-in: %w", err) + } + s.RefreshToken = string(tok) + return &s, nil +} + +func (s *Saved) Save(path string) error { + p, err := protect([]byte(s.RefreshToken)) + if err != nil { + return err + } + s.Protected = p + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +} + +type tokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Error string `json:"error"` +} + +func post(ctx context.Context, endpoint string, form url.Values) (*tokens, int, error) { + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, 0, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "t5de-proxy") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + var t tokens + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err := json.Unmarshal(body, &t); err != nil { + return nil, resp.StatusCode, fmt.Errorf("the hub answered %d", resp.StatusCode) + } + return &t, resp.StatusCode, nil +} + +// SignIn signs the proxy in: it gets a code from the hub, shows it (show gets the code and the page to enter it +// on), and waits until the person has entered it. +func SignIn(ctx context.Context, hubURL string, show func(code, page string)) (*Saved, error) { + hubURL = strings.TrimSuffix(hubURL, "/") + req, _ := http.NewRequestWithContext(ctx, "POST", hubURL+"/api/v1/device/code", nil) + req.Header.Set("User-Agent", "t5de-proxy") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + var device struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + Complete string `json:"verification_uri_complete"` + Interval int `json:"interval"` + ExpiresIn int `json:"expires_in"` + } + err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&device) + resp.Body.Close() + if err != nil || device.DeviceCode == "" { + return nil, fmt.Errorf("the hub didn't give a sign-in code (%d)", resp.StatusCode) + } + show(device.UserCode, device.Complete) + interval := time.Duration(max(device.Interval, 2)) * time.Second + deadline := time.Now().Add(time.Duration(device.ExpiresIn) * time.Second) + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(interval): + } + t, _, err := post(ctx, hubURL+"/api/v1/device/token", url.Values{"device_code": {device.DeviceCode}}) + if err != nil { + continue + } + switch t.Error { + case "": + return &Saved{URL: hubURL, RefreshToken: t.RefreshToken}, nil + case "authorization_pending", "slow_down": + continue + default: + return nil, fmt.Errorf("sign-in failed: %s", t.Error) + } + } + return nil, errors.New("the code expired") +} + +// Client is the live connection to the hub. +type Client struct { + path string + log *log.Logger + + mu sync.Mutex + saved *Saved + imvuID int64 + outfit []Item + wake chan struct{} +} + +// New makes a client for the sign-in saved at path. +func New(path string, saved *Saved, logger *log.Logger) *Client { + return &Client{path: path, saved: saved, log: logger, wake: make(chan struct{}, 1)} +} + +// Session reports the IMVU account the client signed in as. +func (c *Client) Session(imvuID int64) { + c.mu.Lock() + changed := c.imvuID != imvuID + c.imvuID = imvuID + if changed { + c.outfit = nil + } + c.mu.Unlock() + if changed { + c.poke() + } +} + +// Outfit reports the community products the account wears. +func (c *Client) Outfit(imvuID int64, items []Item) { + c.mu.Lock() + if c.imvuID != imvuID { + c.mu.Unlock() + return + } + c.outfit = append([]Item(nil), items...) + c.mu.Unlock() + c.poke() +} + +func (c *Client) poke() { + select { + case c.wake <- struct{}{}: + default: + } +} + +// Run keeps the connection up until ctx ends or the hub signs the proxy out. +func (c *Client) Run(ctx context.Context) { + backoff := time.Second + for ctx.Err() == nil { + start := time.Now() + err := c.connect(ctx) + if errors.Is(err, ErrSignedOut) { + c.log.Printf("hub: signed out (%v); run t5de-proxy signin again", err) + return + } + if ctx.Err() != nil { + return + } + if time.Since(start) > time.Minute { + backoff = time.Second + } + c.log.Printf("hub: disconnected (%v); retrying in %s", err, backoff) + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + backoff = min(backoff*2, 2*time.Minute) + } +} + +// accessToken trades the refresh token for an access token (and a new refresh token, saved). +func (c *Client) accessToken(ctx context.Context) (string, error) { + c.mu.Lock() + saved := *c.saved + c.mu.Unlock() + t, status, err := post(ctx, saved.URL+"/api/v1/token/refresh", url.Values{"refresh_token": {saved.RefreshToken}}) + if err != nil { + return "", err + } + if status == http.StatusUnauthorized || status == http.StatusForbidden { + return "", fmt.Errorf("%w: %s", ErrSignedOut, t.Error) + } + if t.Error != "" || t.AccessToken == "" { + return "", fmt.Errorf("the hub answered %d %s", status, t.Error) + } + saved.RefreshToken = t.RefreshToken + if err := saved.Save(c.path); err != nil { + return "", err + } + c.mu.Lock() + c.saved = &saved + c.mu.Unlock() + return t.AccessToken, nil +} + +func (c *Client) connect(ctx context.Context) error { + access, err := c.accessToken(ctx) + if err != nil { + return err + } + wsURL := strings.Replace(strings.Replace(c.saved.URL, "https://", "wss://", 1), "http://", "ws://", 1) + "/api/v1/realtime" + conn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: http.Header{ + "Authorization": {"Bearer " + access}, "User-Agent": {"t5de-proxy"}}}) + if err != nil { + return err + } + defer conn.CloseNow() + c.log.Printf("hub: connected to %s", c.saved.URL) + + errs := make(chan error, 1) + go func() { + for { + _, data, err := conn.Read(ctx) + if err != nil { + errs <- err + return + } + var m struct{ Type, Message string } + if json.Unmarshal(data, &m) == nil && m.Type == "error" { + c.log.Printf("hub: %s", m.Message) + } + } + }() + send := func(v any) error { + b, _ := json.Marshal(v) + wctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + return conn.Write(wctx, websocket.MessageText, b) + } + // Access tokens are short-lived; reconnecting with a fresh one before it expires keeps the hub's check current. + renew := time.After(12 * time.Minute) + sentID, sentOutfit := int64(-1), "" + for { + c.mu.Lock() + id, outfit := c.imvuID, append([]Item(nil), c.outfit...) + c.mu.Unlock() + if id != sentID && id != 0 { + if err := send(map[string]any{"type": "session", "imvu_id": id}); err != nil { + return err + } + sentID, sentOutfit = id, "" + } + if b, _ := json.Marshal(outfit); id != 0 && string(b) != sentOutfit { + if err := send(map[string]any{"type": "outfit", "items": outfit}); err != nil { + return err + } + sentOutfit = string(b) + } + select { + case <-ctx.Done(): + conn.Close(websocket.StatusNormalClosure, "") + return ctx.Err() + case err := <-errs: + return err + case <-renew: + conn.Close(websocket.StatusNormalClosure, "renewing") + return errors.New("renewing the access token") + case <-c.wake: + } + } +} diff --git a/internal/hub/protect_other.go b/internal/hub/protect_other.go new file mode 100644 index 0000000..964f954 --- /dev/null +++ b/internal/hub/protect_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package hub + +// Elsewhere (tests, tools) the token is stored as is: the client only runs on Windows. +func protect(data []byte) ([]byte, error) { return data, nil } +func unprotect(data []byte) ([]byte, error) { return data, nil } diff --git a/internal/hub/protect_windows.go b/internal/hub/protect_windows.go new file mode 100644 index 0000000..4a15093 --- /dev/null +++ b/internal/hub/protect_windows.go @@ -0,0 +1,31 @@ +package hub + +import ( + "unsafe" + + "golang.org/x/sys/windows" +) + +// protect encrypts the refresh token for the Windows user (DPAPI), so other accounts on the machine can't use it. +func protect(data []byte) ([]byte, error) { + in := windows.DataBlob{Size: uint32(len(data)), Data: unsafe.SliceData(data)} + var out windows.DataBlob + if err := windows.CryptProtectData(&in, windows.StringToUTF16Ptr("T5DE hub sign-in"), nil, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out); err != nil { + return nil, err + } + defer windows.LocalFree(windows.Handle(unsafe.Pointer(out.Data))) + return append([]byte(nil), unsafe.Slice(out.Data, out.Size)...), nil +} + +func unprotect(data []byte) ([]byte, error) { + if len(data) == 0 { + return nil, nil + } + in := windows.DataBlob{Size: uint32(len(data)), Data: unsafe.SliceData(data)} + var out windows.DataBlob + if err := windows.CryptUnprotectData(&in, nil, nil, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out); err != nil { + return nil, err + } + defer windows.LocalFree(windows.Handle(unsafe.Pointer(out.Data))) + return append([]byte(nil), unsafe.Slice(out.Data, out.Size)...), nil +} diff --git a/internal/products/intercept.go b/internal/products/intercept.go index 0ca01e6..9fdf1d1 100644 --- a/internal/products/intercept.go +++ b/internal/products/intercept.go @@ -50,6 +50,11 @@ func (p *Products) interceptXMLRPC(x *proxy.Exchange) error { case "test.avatarInfoForLogin2": x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) { p.keys.learn(body) + if m := userIDPattern.FindSubmatch(body); m != nil && p.Observer != nil { + if id, err := strconv.ParseInt(string(m[1]), 10, 64); err == nil { + p.Observer.Session(id) + } + } return p.addToLook(body) } case "test.inventoryProductBatch": @@ -90,6 +95,7 @@ func (p *Products) addToLook(body []byte) ([]byte, error) { if mem.Name == "look" { if look, ok := productList(mem.Value); ok { if worn := p.wornLocal(user, look); len(worn) > 0 { + p.reportOutfit(user, worn) v[i].Value = appendIDs(mem.Value.([]xmlrpc.Value), worn) changed = true } @@ -401,6 +407,7 @@ func (p *Products) interceptBaseLook(x *proxy.Exchange) error { return body, err } worn := p.wornLocal(user, ids) + p.reportOutfit(user, worn) if len(worn) == 0 { return body, nil } diff --git a/internal/products/products.go b/internal/products/products.go index 4ff0769..f11b9b6 100644 --- a/internal/products/products.go +++ b/internal/products/products.go @@ -28,6 +28,7 @@ import ( "git.t5de.dev/t5de/t5de-api/cfl" "git.t5de.dev/t5de/t5de-api/pack" + "git.t5de.dev/t5de/t5de-proxy/internal/hub" ) // Products is the proxy's community products: an Interceptor for HTTP and an IMQFilter for chat. @@ -42,6 +43,16 @@ type Products struct { byLocal map[int64]*served byID map[string]*served problems map[string]string // pack directory -> last error logged + + // Observer, if set, hears which IMVU account the client signs in as and the community products it wears (the + // hub client). + Observer Observer +} + +// Observer hears about the client's IMVU account and its community outfit. +type Observer interface { + Session(imvuID int64) + Outfit(imvuID int64, items []hub.Item) } // served is a pack ready to serve. @@ -268,6 +279,7 @@ func (p *Products) wornLocal(user string, look []int64) []int64 { // recordWorn remembers the user's outfit: the IMVU products sent to IMVU, and the community products (local IDs) // taken out. func (p *Products) recordWorn(user string, imvu, locals []int64) { + p.reportOutfit(user, locals) var ids []string for _, local := range locals { if id, ok := p.state.productID(local); ok { @@ -280,3 +292,21 @@ func (p *Products) recordWorn(user string, imvu, locals []int64) { } var errNoKey = errors.New("products: can't re-sign the request (no session key yet)") + +// reportOutfit tells the observer the community products (local IDs) the user wears. +func (p *Products) reportOutfit(user string, locals []int64) { + if p.Observer == nil { + return + } + id, err := strconv.ParseInt(user, 10, 64) + if err != nil { + return + } + items := []hub.Item{} + for _, local := range locals { + if s := p.lookup(local); s != nil { + items = append(items, hub.Item{ID: s.pack.Manifest.ID, Name: s.pack.Manifest.Name}) + } + } + p.Observer.Outfit(id, items) +}