From 5eca0b57a61495dcf6ae265ff1f984cab16c2cef Mon Sep 17 00:00:00 2001 From: dhkatz Date: Thu, 29 Oct 2026 19:18:14 -0700 Subject: [PATCH] Sign in and out of the hub from the client: the proxy answers t5de-proxy.local A manager runs the hub connection, so signing in or out takes effect while the client runs; the client's T5DE settings page asks it for the status and to sign in (showing the code and opening the hub's page) or out. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- cmd/t5de-proxy/main.go | 50 ++++---- internal/control/control.go | 204 +++++++++++++++++++++++++++++++ internal/control/control_test.go | 134 ++++++++++++++++++++ 4 files changed, 368 insertions(+), 23 deletions(-) create mode 100644 internal/control/control.go create mode 100644 internal/control/control_test.go diff --git a/README.md b/README.md index 8e639c8..70f4b99 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,8 @@ leaves the machine. ## The hub -`t5de-proxy signin [-hub URL]` signs the proxy in to T5DE's hub (git.t5de.dev/t5de/t5de-hub; URL is the hub's +The client's Settings > T5DE page signs in and out (the proxy answers it at `http://t5de-proxy.local/hub`, +`internal/control`). `t5de-proxy signin [-hub URL]` also signs the proxy in to T5DE's hub (git.t5de.dev/t5de/t5de-hub; URL is the hub's API): it shows a code to enter on the hub's website (`/device`, OAuth 2.0 device authorization) and saves the session token it gets in `%APPDATA%\T5DE\proxy\hub.json`, encrypted for the Windows user (DPAPI). From then on the running proxy keeps a WebSocket to the hub (`internal/hub`) with the token as a bearer token, reporting which IMVU diff --git a/cmd/t5de-proxy/main.go b/cmd/t5de-proxy/main.go index a4e6b45..e6a0c26 100644 --- a/cmd/t5de-proxy/main.go +++ b/cmd/t5de-proxy/main.go @@ -17,7 +17,6 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" - "errors" "flag" "fmt" "io" @@ -33,6 +32,7 @@ import ( "time" "git.t5de.dev/t5de/t5de-proxy/internal/buildinfo" + "git.t5de.dev/t5de/t5de-proxy/internal/control" "git.t5de.dev/t5de/t5de-proxy/internal/fetch" "git.t5de.dev/t5de/t5de-proxy/internal/hub" "git.t5de.dev/t5de/t5de-proxy/internal/parent" @@ -138,30 +138,36 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool, pa 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) + // The hub connection, which the client's T5DE settings can sign in and out (internal/control). + manager := &control.Manager{Path: filepath.Join(data, "hub.json"), DefaultHub: envOr("T5DE_HUB", defaultHub), + Log: logger, OpenBrowser: openBrowser} + manager.Attach = func(client *hub.Client) { fetcher := fetch.New(client, hubProducts, logger.Printf) - if prods != nil { - prods.Observer = client - client.OnChat = prods.Others - fetcher.OnReady = prods.Ready - fetcher.OnLibrary = prods.RefreshInventory - prods.Want = fetcher.Want - prods.HubDir, prods.Owned = hubProducts, fetcher.Owned - prods.OnHide = func(id string) { - if fetch.IsHubID(id) { - go func() { - if err := client.Call(context.Background(), "DELETE", "/api/v1/library/"+id, nil, nil); err != nil { - logger.Printf("hub: removing %s from the library: %v", id, err) - } - }() - } + client.OnLibrary = func() { fetcher.SyncLibrary(context.Background()) } + if prods == nil { + return + } + prods.Observer = client + client.OnChat = prods.Others + fetcher.OnReady = prods.Ready + fetcher.OnLibrary = prods.RefreshInventory + prods.Want = fetcher.Want + prods.HubDir, prods.Owned = hubProducts, fetcher.Owned + prods.OnHide = func(id string) { + if fetch.IsHubID(id) { + go func() { + if err := client.Call(context.Background(), "DELETE", "/api/v1/library/"+id, nil, nil); err != nil { + logger.Printf("hub: removing %s from the library: %v", id, err) + } + }() } } - client.OnLibrary = func() { fetcher.SyncLibrary(context.Background()) } - go client.Run(context.Background()) - } else if !errors.Is(err, hub.ErrSignedOut) { - logger.Printf("hub: %v", err) + } + manager.Start() + if prods != nil { + handler.Interceptor = proxy.Interceptors{manager, prods} + } else { + handler.Interceptor = manager } server := &http.Server{ Handler: handler, diff --git a/internal/control/control.go b/internal/control/control.go new file mode 100644 index 0000000..e4fcdf6 --- /dev/null +++ b/internal/control/control.go @@ -0,0 +1,204 @@ +// Package control runs the proxy's connection to T5DE's hub, and lets the client's T5DE settings page see and +// change it: requests to http://t5de-proxy.local/ (only the client can make them: they need the proxy's token) +// are answered here. +// +// GET /hub {signedIn, hub, username, signingIn, code, page, error} +// POST /hub/signin starts signing in (hub= optional): opens the hub's page for the code in the browser +// POST /hub/signout signs this computer out +package control + +import ( + "context" + "encoding/json" + "errors" + "log" + "net/url" + "os" + "strings" + "sync" + + "git.t5de.dev/t5de/t5de-proxy/internal/hub" + "git.t5de.dev/t5de/t5de-proxy/internal/proxy" +) + +// Host is the address the client uses for the proxy itself. +const Host = "t5de-proxy.local" + +// Manager keeps the hub connection running and answers the client's settings page. +type Manager struct { + Path string // hub.json + DefaultHub string + Log *log.Logger + // Attach is called with each new hub client before it runs (to connect it to the rest of the proxy). + Attach func(c *hub.Client) + // OpenBrowser opens a page in the person's browser. + OpenBrowser func(page string) + + mu sync.Mutex + client *hub.Client + saved *hub.Saved + stop context.CancelFunc + username string + signin *signin +} + +type signin struct { + code, page, err string + running bool + cancel context.CancelFunc +} + +// Start connects to the hub if the proxy is signed in. +func (m *Manager) Start() { + saved, err := hub.Load(m.Path) + if err != nil { + if !errors.Is(err, hub.ErrSignedOut) { + m.Log.Printf("hub: %v", err) + } + return + } + m.run(saved) +} + +func (m *Manager) run(saved *hub.Saved) { + c := hub.New(m.Path, saved, m.Log) + if m.Attach != nil { + m.Attach(c) + } + ctx, cancel := context.WithCancel(context.Background()) + m.mu.Lock() + if m.stop != nil { + m.stop() + } + m.client, m.saved, m.stop, m.username = c, saved, cancel, "" + m.mu.Unlock() + go c.Run(ctx) + go m.lookUpName(ctx, c) +} + +func (m *Manager) lookUpName(ctx context.Context, c *hub.Client) { + var me struct { + Person struct { + Username string `json:"username"` + } `json:"person"` + } + if err := c.Call(ctx, "GET", "/api/v1/me", nil, &me); err != nil { + return + } + m.mu.Lock() + if m.client == c { + m.username = me.Person.Username + } + m.mu.Unlock() +} + +type status struct { + SignedIn bool `json:"signedIn"` + Hub string `json:"hub"` + Username string `json:"username"` + SigningIn bool `json:"signingIn"` + Code string `json:"code"` + Page string `json:"page"` + Error string `json:"error"` +} + +func (m *Manager) status() status { + m.mu.Lock() + defer m.mu.Unlock() + s := status{Hub: m.DefaultHub} + if m.saved != nil { + s.SignedIn, s.Hub, s.Username = true, m.saved.URL, m.username + } + if si := m.signin; si != nil { + s.SigningIn, s.Code, s.Page, s.Error = si.running, si.code, si.page, si.err + } + return s +} + +// SignIn starts signing in to a hub; the code to enter shows in the status, and the browser opens the page. +func (m *Manager) SignIn(hubURL string) { + if hubURL == "" { + hubURL = m.DefaultHub + } + ctx, cancel := context.WithCancel(context.Background()) + si := &signin{running: true, cancel: cancel} + m.mu.Lock() + if m.signin != nil && m.signin.running { + m.signin.cancel() + } + m.signin = si + m.mu.Unlock() + go func() { + saved, err := hub.SignIn(ctx, hubURL, func(code, page string) { + m.mu.Lock() + si.code, si.page = code, page + m.mu.Unlock() + if m.OpenBrowser != nil { + m.OpenBrowser(page) + } + }) + if err == nil { + err = saved.Save(m.Path) + } + m.mu.Lock() + si.running = false + if err != nil { + si.err = err.Error() + } + m.mu.Unlock() + if err != nil { + m.Log.Printf("hub: signing in: %v", err) + return + } + m.Log.Printf("hub: signed in to %s", saved.URL) + m.run(saved) + }() +} + +// SignOut ends this computer's sign-in: the session on the hub, the saved sign-in, and the connection. +func (m *Manager) SignOut() { + m.mu.Lock() + c, stop := m.client, m.stop + m.client, m.saved, m.stop, m.username, m.signin = nil, nil, nil, "", nil + m.mu.Unlock() + if c != nil { + c.Call(context.Background(), "POST", "/api/auth/sign-out", map[string]any{}, nil) + } + if stop != nil { + stop() + } + if err := os.Remove(m.Path); err != nil && !errors.Is(err, os.ErrNotExist) { + m.Log.Printf("hub: signing out: %v", err) + } + m.Log.Printf("hub: signed out") +} + +// Intercept implements proxy.Interceptor: it answers requests to Host. +func (m *Manager) Intercept(x *proxy.Exchange) error { + if !strings.EqualFold(x.URL.Hostname(), Host) { + return nil + } + method := x.Request.Method + switch { + case x.URL.Path == "/hub" && method == "GET": + case x.URL.Path == "/hub/signin" && method == "POST": + form, _ := url.ParseQuery(string(x.Body)) + hubURL := strings.TrimSuffix(form.Get("hub"), "/") + if hubURL != "" { + u, err := url.Parse(hubURL) + if err != nil || (u.Scheme != "https" && u.Hostname() != "localhost" && u.Hostname() != "127.0.0.1") { + x.Response = proxy.LocalResponse(400, "application/json", []byte(`{"error":"the hub needs an https address"}`)) + return nil + } + } + m.SignIn(hubURL) + case x.URL.Path == "/hub/signout" && method == "POST": + m.SignOut() + default: + x.Response = proxy.LocalResponse(404, "application/json", []byte(`{"error":"not found"}`)) + return nil + } + b, _ := json.Marshal(m.status()) + x.Response = proxy.LocalResponse(200, "application/json", b) + return nil +} diff --git a/internal/control/control_test.go b/internal/control/control_test.go new file mode 100644 index 0000000..63661a1 --- /dev/null +++ b/internal/control/control_test.go @@ -0,0 +1,134 @@ +package control + +import ( + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "git.t5de.dev/t5de/t5de-proxy/internal/hub" + "git.t5de.dev/t5de/t5de-proxy/internal/proxy" +) + +// fakeHub does device sign-in (approved on the second poll), /me and sign-out. +func fakeHub(t *testing.T, signedOut *atomic.Bool) *httptest.Server { + var polls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/auth/device/code": + json.NewEncoder(w).Encode(map[string]any{"device_code": "dev", "user_code": "ABCD1234", + "verification_uri_complete": "http://hub.test/device?code=ABCD1234", "interval": 1, "expires_in": 60}) + case "/api/auth/device/token": + if polls.Add(1) < 2 { + json.NewEncoder(w).Encode(map[string]string{"error": "authorization_pending"}) + return + } + json.NewEncoder(w).Encode(map[string]string{"access_token": "session-token"}) + case "/api/v1/me": + if r.Header.Get("Authorization") != "Bearer session-token" { + w.WriteHeader(401) + return + } + io.WriteString(w, `{"person": {"username": "erin"}}`) + case "/api/auth/sign-out": + signedOut.Store(r.Header.Get("Authorization") == "Bearer session-token") + io.WriteString(w, `{"success": true}`) + default: + w.WriteHeader(404) // the realtime connection: not needed here + } + })) + t.Cleanup(srv.Close) + return srv +} + +func ask(t *testing.T, m *Manager, method, path, body string) status { + t.Helper() + u, _ := url.Parse("http://" + Host + path) + req, _ := http.NewRequest(method, u.String(), strings.NewReader(body)) + x := &proxy.Exchange{URL: u, Request: req, Body: []byte(body)} + if err := m.Intercept(x); err != nil || x.Response == nil { + t.Fatalf("%s %s: %v %v", method, path, err, x.Response) + } + var s status + b, _ := io.ReadAll(x.Response.Body) + if err := json.Unmarshal(b, &s); err != nil { + t.Fatalf("%s: %s", path, b) + } + return s +} + +func waitFor(t *testing.T, ok func() bool) { + t.Helper() + for i := 0; i < 100 && !ok(); i++ { + time.Sleep(50 * time.Millisecond) + } + if !ok() { + t.Fatal("timed out") + } +} + +func TestSignInAndOut(t *testing.T) { + var signedOut atomic.Bool + srv := fakeHub(t, &signedOut) + path := filepath.Join(t.TempDir(), "hub.json") + var opened []string + attached := 0 + m := &Manager{Path: path, DefaultHub: srv.URL, Log: log.New(io.Discard, "", 0), + OpenBrowser: func(p string) { opened = append(opened, p) }, Attach: func(*hub.Client) { attached++ }} + m.Start() + if s := ask(t, m, "GET", "/hub", ""); s.SignedIn || s.Hub != srv.URL { + t.Fatalf("status %+v", s) + } + + // Signing in: the code shows, the browser opens the page, and once approved the proxy is connected. + ask(t, m, "POST", "/hub/signin", "") + waitFor(t, func() bool { return ask(t, m, "GET", "/hub", "").Code == "ABCD1234" }) + waitFor(t, func() bool { s := ask(t, m, "GET", "/hub", ""); return s.SignedIn && s.Username == "erin" }) + if len(opened) != 1 || attached != 1 { + t.Fatalf("opened %v, attached %d", opened, attached) + } + if saved, err := hub.Load(path); err != nil || saved.Token != "session-token" { + t.Fatalf("saved %+v %v", saved, err) + } + + // Signing out ends the session on the hub and forgets the sign-in. + if s := ask(t, m, "POST", "/hub/signout", ""); s.SignedIn { + t.Fatalf("still signed in: %+v", s) + } + if !signedOut.Load() { + t.Fatal("the hub wasn't told") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("hub.json: %v", err) + } + + // Only https hubs (or this computer's); other requests to the proxy's address are refused. + u, _ := url.Parse("http://" + Host + "/hub/signin") + req, _ := http.NewRequest("POST", u.String(), nil) + x := &proxy.Exchange{URL: u, Request: req, Body: []byte("hub=http://evil.test")} + m.Intercept(x) + if x.Response.StatusCode != 400 { + t.Fatalf("plain http hub: %d", x.Response.StatusCode) + } + u, _ = url.Parse("http://" + Host + "/other") + x = &proxy.Exchange{URL: u, Request: req} + m.Intercept(x) + if x.Response.StatusCode != 404 { + t.Fatalf("other path: %d", x.Response.StatusCode) + } + // Requests to other hosts are left alone. + u, _ = url.Parse("http://api.imvu.com/hub") + x = &proxy.Exchange{URL: u, Request: req} + m.Intercept(x) + if x.Response != nil { + t.Fatal("answered another host") + } +}