Choose the hub: Settings' choice, else the install's channel's; state kept per hub

The client's Settings > T5DE chooses the hub from the next start on (POST /hub/server, kept in server.json);
without a choice, -channel (which the client passes) decides: staging for nightly and dev, T5DE's hub for
releases. $T5DE_HUB still wins. Each hub's sign-in, product IDs and products are kept in their own folder
(T5DE's hub in the data folder as before, others in hubs/<host>), so a hub never gets another's token and
product IDs from two hubs never clash; a sign-in saved for another hub is ignored. Signing in always goes to
this run's hub.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-11-02 09:09:34 -08:00
1 parent 8daf91ede3
commit 4d00d8cdf6
6 files changed
+291 -52

No files matched your search

+8 -1
View File
@@ -60,10 +60,17 @@ last check, `products.Guard` refuses any request to IMVU that still names a loca
## The hub
Which hub: `$T5DE_HUB` if set, else the one chosen in the client's Settings > T5DE (kept in
`%APPDATA%\T5DE\proxy\server.json`, used from the next start), else the install's channel's (`-channel`, which
the client passes): T5DE's hub (`https://hub.t5de.dev`) for releases, staging (`https://hub.staging.t5de.dev`) for
nightlies and builds outside an install (`dev`). What belongs to a hub (its sign-in, product IDs, its products) is
kept per hub: in `%APPDATA%\T5DE\proxy` for T5DE's hub, in `hubs\<host>` under it for others, so a hub never gets
another's sign-in, and product IDs from two hubs never clash.
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
session token it gets in `hub.json` in the hub's folder, 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
account the client signed in as, the community products it wears, and the IMVU chats it's in. If the hub ends the
session (signed out everywhere, banned), the proxy stops connecting until `signin` is run again.
+15 -11
View File
@@ -62,6 +62,7 @@ func main() {
packs := flag.String("packs", "", "community product packs: directories of packs, separated by "+string(filepath.ListSeparator)+
" (default: %APPDATA%/T5DE/packs and $T5DE_PACKS)")
data := flag.String("data", "", "where the proxy keeps its state (default: %APPDATA%/T5DE/proxy, or T5DE-<n> for $T5DE_INSTANCE)")
channel := flag.String("channel", "release", "the install's channel (release, nightly, or dev outside an install): nightly and dev use T5DE's staging hub unless the person chooses another")
version := flag.Bool("version", false, "print the version and exit")
flag.Parse()
if *version {
@@ -83,7 +84,7 @@ func main() {
out = f
}
logger := log.New(out, "", log.Ldate|log.Ltime|log.Lmicroseconds)
if err := run(logger, *listen, *ready, *parentPID, *trace, packDirs(*packs), dataDir(*data)); err != nil {
if err := run(logger, *listen, *ready, *parentPID, *trace, packDirs(*packs), dataDir(*data), *channel); err != nil {
logger.Printf("t5de-proxy: %v", err)
os.Exit(1)
}
@@ -114,7 +115,7 @@ func dataDir(flagValue string) string {
return "."
}
func run(logger *log.Logger, listen, ready string, parentPID int, trace bool, packs []string, data string) error {
func run(logger *log.Logger, listen, ready string, parentPID int, trace bool, packs []string, data, channel string) error {
secret := make([]byte, 24)
if _, err := rand.Read(secret); err != nil {
return err
@@ -139,12 +140,18 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool, pa
}()
}
handler := &proxy.Server{Token: token, Log: logger, Trace: trace}
// The hub: the person's choice (the client's Settings), else the install's channel's. Everything that belongs
// to a hub (sign-in, product IDs, its products) is kept in its own folder.
choice := filepath.Join(data, "server.json")
hubURL := control.Server(choice, channel, os.Getenv("T5DE_HUB"))
state := control.StateDir(data, hubURL)
logger.Printf("hub %s (channel %s)", hubURL, channel)
// Products from the hub's catalogs are kept as packs in their own folder, served like the others.
hubProducts := filepath.Join(data, "hub-products")
hubProducts := filepath.Join(state, "hub-products")
if err := os.MkdirAll(hubProducts, 0o755); err != nil {
logger.Printf("hub products: %v", err)
}
prods, err := products.New(append(packs, hubProducts), data, logger.Printf)
prods, err := products.New(append(packs, hubProducts), state, logger.Printf)
if err != nil {
logger.Printf("community products are off: %v", err)
} else {
@@ -164,8 +171,8 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool, pa
}
}
// 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 := &control.Manager{Path: filepath.Join(state, "hub.json"), Hub: hubURL, Default: control.ChannelDefault(channel),
ChoicePath: choice, Fixed: os.Getenv("T5DE_HUB") != "", Log: logger, OpenBrowser: openBrowser}
manager.Attach = func(client *hub.Client) {
hubNow.set(client)
client.OnRoom, client.OnConnect = rooms.FromHub, rooms.Reconnected
@@ -272,12 +279,9 @@ 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)")
hubURL := fs.String("hub", envOr("T5DE_HUB", control.Production), "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)
@@ -289,7 +293,7 @@ func signin(args []string) error {
if err != nil {
return err
}
if err := saved.Save(filepath.Join(dataDir(*data), "hub.json")); err != nil {
if err := saved.Save(filepath.Join(control.StateDir(dataDir(*data), strings.TrimSuffix(*hubURL, "/")), "hub.json")); err != nil {
return err
}
fmt.Println("Signed in. T5DE connects to the hub the next time the client starts.")
+41 -23
View File
@@ -2,9 +2,10 @@
// 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=<URL> optional): opens the hub's page for the code in the browser
// GET /hub {signedIn, hub, username, signingIn, code, page, error, choice, default, fixed, restart}
// POST /hub/signin starts signing in to this run's hub: opens the hub's page for the code in the browser
// POST /hub/signout signs this computer out
// POST /hub/server hub=<URL>, or empty for the install's default: the hub from the next start on (restart)
// POST /badges {"imvuIds": [...]} -> {"badges": [...]}: which of those IMVU accounts show the T5 badge (their
// T5DE users opted in), from the hub, remembered for a while
package control
@@ -29,8 +30,11 @@ 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
Path string // hub.json, in the hub's state folder (StateDir)
// Hub is this run's hub (Server); Default the install's (ChannelDefault); ChoicePath where the person's
// choice is kept. Fixed means $T5DE_HUB chose it, so the choice can't.
Hub, Default, ChoicePath string
Fixed bool
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)
@@ -42,7 +46,6 @@ type Manager struct {
saved *hub.Saved
stop context.CancelFunc
username string
lastHub string // the hub last signed in to, for signing in again after signing out
signin *signin
badges map[int64]badgeAnswer
}
@@ -125,6 +128,11 @@ func (m *Manager) Start() {
}
return
}
if saved.URL != m.Hub {
// Signed in to another hub (by an older proxy, before hubs had their own folders): not this run's.
m.Log.Printf("hub: the sign-in is for %s, not %s; signed out", saved.URL, m.Hub)
return
}
m.run(saved)
}
@@ -168,17 +176,23 @@ type status struct {
Code string `json:"code"`
Page string `json:"page"`
Error string `json:"error"`
Choice string `json:"choice"` // the hub chosen for the next start, "" for the default
Default string `json:"default"` // the install's
Fixed bool `json:"fixed"` // chosen by $T5DE_HUB
Restart bool `json:"restart"` // the next start's hub isn't this run's
}
func (m *Manager) status() status {
m.mu.Lock()
defer m.mu.Unlock()
s := status{Hub: m.DefaultHub}
if m.lastHub != "" {
s.Hub = m.lastHub
s := status{Hub: m.Hub, Default: m.Default, Fixed: m.Fixed, Choice: LoadChoice(m.ChoicePath)}
next := s.Choice
if next == "" {
next = m.Default
}
s.Restart = !m.Fixed && next != m.Hub
if m.saved != nil {
s.SignedIn, s.Hub, s.Username = true, m.saved.URL, m.username
s.SignedIn, s.Username = true, m.username
}
if si := m.signin; si != nil {
s.SigningIn, s.Code, s.Page, s.Error = si.running, si.code, si.page, si.err
@@ -186,11 +200,10 @@ func (m *Manager) status() status {
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.status().Hub
}
// SignIn starts signing in to this run's hub; the code to enter shows in the status, and the browser opens the
// page.
func (m *Manager) SignIn() {
hubURL := m.Hub
ctx, cancel := context.WithCancel(context.Background())
si := &signin{running: true, cancel: cancel}
m.mu.Lock()
@@ -230,9 +243,6 @@ func (m *Manager) SignIn(hubURL string) {
func (m *Manager) SignOut() {
m.mu.Lock()
c, stop := m.client, m.stop
if m.saved != nil {
m.lastHub = m.saved.URL
}
m.client, m.saved, m.stop, m.username, m.signin, m.badges = nil, nil, nil, "", nil, nil
m.mu.Unlock()
if c != nil {
@@ -256,16 +266,24 @@ func (m *Manager) Intercept(x *proxy.Exchange) error {
switch {
case x.URL.Path == "/hub" && method == "GET":
case x.URL.Path == "/hub/signin" && method == "POST":
m.SignIn()
case x.URL.Path == "/hub/server" && 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"}`))
hub := form.Get("hub")
if hub != "" {
var err error
if hub, err = CheckHub(hub); err != nil {
b, _ := json.Marshal(map[string]string{"error": err.Error()})
x.Response = proxy.LocalResponse(400, "application/json", b)
return nil
}
}
m.SignIn(hubURL)
if hub == m.Default {
hub = "" // the default stays the default, should it change
}
if err := SaveChoice(m.ChoicePath, hub); err != nil {
m.Log.Printf("hub: saving the choice: %v", err)
}
case x.URL.Path == "/hub/signout" && method == "POST":
m.SignOut()
case x.URL.Path == "/badges" && method == "POST":
+5 -17
View File
@@ -94,7 +94,7 @@ func TestSignInAndOut(t *testing.T) {
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),
m := &Manager{Path: path, Hub: srv.URL, Default: 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 {
@@ -122,22 +122,10 @@ func TestSignInAndOut(t *testing.T) {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("hub.json: %v", err)
}
// Signing in again goes to the same hub.
m.DefaultHub = "https://elsewhere.test"
if s := ask(t, m, "GET", "/hub", ""); s.Hub != srv.URL {
t.Fatalf("hub after signing out: %q", s.Hub)
}
// Only https hubs (or this computer's); other requests to the proxy's address are refused.
u, _ := url.Parse("http://" + Host + "/hub/signin")
// Other requests to the proxy's address are refused.
u, _ := url.Parse("http://" + Host + "/other")
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}
x := &proxy.Exchange{URL: u, Request: req}
m.Intercept(x)
if x.Response.StatusCode != 404 {
t.Fatalf("other path: %d", x.Response.StatusCode)
@@ -154,7 +142,7 @@ func TestSignInAndOut(t *testing.T) {
func TestBadges(t *testing.T) {
var signedOut atomic.Bool
srv := fakeHub(t, &signedOut)
m := &Manager{Path: filepath.Join(t.TempDir(), "hub.json"), DefaultHub: srv.URL, Log: log.New(io.Discard, "", 0)}
m := &Manager{Path: filepath.Join(t.TempDir(), "hub.json"), Hub: srv.URL, Log: log.New(io.Discard, "", 0)}
badges := func(body string) string {
t.Helper()
u, _ := url.Parse("http://" + Host + "/badges")
+114
View File
@@ -0,0 +1,114 @@
package control
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
)
// T5DE's hubs.
const (
Production = "https://hub.t5de.dev"
Staging = "https://hub.staging.t5de.dev"
)
// ChannelDefault is the hub an install uses unless the person chooses one: nightlies and builds outside an
// install (channel "nightly", "dev") use staging, releases T5DE's hub.
func ChannelDefault(channel string) string {
if channel == "nightly" || channel == "dev" {
return Staging
}
return Production
}
// Server is the hub for this run: env ($T5DE_HUB) if set, else the person's choice (the client's Settings, kept
// in choicePath), else the channel's.
func Server(choicePath, channel, env string) string {
if env != "" {
return strings.TrimSuffix(env, "/")
}
if c := LoadChoice(choicePath); c != "" {
return c
}
return ChannelDefault(channel)
}
// LoadChoice is the hub the person chose, "" for the channel's.
func LoadChoice(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
var c struct {
Hub string `json:"hub"`
}
if json.Unmarshal(b, &c) != nil {
return ""
}
hub, err := CheckHub(c.Hub)
if err != nil {
return ""
}
return hub
}
// SaveChoice remembers the hub the person chose ("" for the channel's).
func SaveChoice(path, hub string) error {
if hub == "" {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
b, _ := json.Marshal(map[string]string{"hub": hub})
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)
}
// CheckHub is a hub's address without a trailing slash, if it's https (or this computer's, for development).
func CheckHub(s string) (string, error) {
s = strings.TrimSuffix(strings.TrimSpace(s), "/")
u, err := url.Parse(s)
if err != nil || u.Host == "" || u.RawQuery != "" || u.Fragment != "" {
return "", fmt.Errorf("not a hub address: %q", s)
}
local := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"
if u.Scheme != "https" && !(u.Scheme == "http" && local) {
return "", errors.New("the hub needs an https address")
}
return s, nil
}
// StateDir is where the proxy keeps what belongs to a hub (its sign-in, product IDs, products): the data folder
// itself for T5DE's hub, hubs/<name> in it for any other, so a hub never gets another's sign-in and product IDs
// from two hubs never clash.
func StateDir(data, hub string) string {
if hub == Production {
return data
}
u, err := url.Parse(hub)
name := hub
if err == nil {
name = u.Host + u.Path
}
name = strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '.' || r == '-' {
return r
}
if r >= 'A' && r <= 'Z' {
return r + 'a' - 'A'
}
return '_'
}, name)
return filepath.Join(data, "hubs", name)
}
+108
View File
@@ -0,0 +1,108 @@
package control
import (
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
)
func TestServer(t *testing.T) {
choice := filepath.Join(t.TempDir(), "server.json")
for channel, want := range map[string]string{"release": Production, "": Production, "nightly": Staging, "dev": Staging} {
if got := Server(choice, channel, ""); got != want {
t.Errorf("channel %q: %s", channel, got)
}
}
if err := SaveChoice(choice, "https://hub.example.org"); err != nil {
t.Fatal(err)
}
if got := Server(choice, "nightly", ""); got != "https://hub.example.org" {
t.Errorf("chosen: %s", got)
}
if got := Server(choice, "nightly", "http://localhost:4101/"); got != "http://localhost:4101" {
t.Errorf("$T5DE_HUB: %s", got)
}
if err := SaveChoice(choice, ""); err != nil {
t.Fatal(err)
}
if got := Server(choice, "release", ""); got != Production {
t.Errorf("default again: %s", got)
}
// A choice that isn't a hub address is ignored.
os.WriteFile(choice, []byte(`{"hub": "http://evil.test"}`), 0o600)
if got := Server(choice, "release", ""); got != Production {
t.Errorf("bad choice: %s", got)
}
data := `C:\data`
if got := StateDir(data, Production); got != data {
t.Errorf("production state %s", got)
}
if got := StateDir(data, Staging); got != filepath.Join(data, "hubs", "hub.staging.t5de.dev") {
t.Errorf("staging state %s", got)
}
if got := StateDir(data, "http://LocalHost:4101/x"); got != filepath.Join(data, "hubs", "localhost_4101_x") {
t.Errorf("local state %s", got)
}
for _, bad := range []string{"http://evil.test", "ftp://x", "hub.t5de.dev", "https://", "https://x/?a=1"} {
if _, err := CheckHub(bad); err == nil {
t.Errorf("accepted %q", bad)
}
}
}
func TestChoosingServer(t *testing.T) {
dir := t.TempDir()
m := &Manager{Path: filepath.Join(dir, "hub.json"), Hub: Staging, Default: Staging, ChoicePath: filepath.Join(dir, "server.json"),
Log: log.New(io.Discard, "", 0)}
if s := ask(t, m, "GET", "/hub", ""); s.Hub != Staging || s.Default != Staging || s.Choice != "" || s.Restart {
t.Fatalf("status %+v", s)
}
if s := ask(t, m, "POST", "/hub/server", "hub="+url.QueryEscape(Production+"/")); s.Choice != Production || !s.Restart || s.Hub != Staging {
t.Fatalf("chose production: %+v", s)
}
// Choosing the default again forgets the choice.
if s := ask(t, m, "POST", "/hub/server", "hub="+url.QueryEscape(Staging)); s.Choice != "" || s.Restart {
t.Fatalf("back to the default: %+v", s)
}
if _, err := os.Stat(m.ChoicePath); !os.IsNotExist(err) {
t.Fatalf("server.json kept: %v", err)
}
u, _ := url.Parse("http://" + Host + "/hub/server")
body := "hub=http://evil.test"
req, _ := http.NewRequest("POST", u.String(), strings.NewReader(body))
x := &proxy.Exchange{URL: u, Request: req, Body: []byte(body)}
m.Intercept(x)
if x.Response.StatusCode != 400 {
t.Fatalf("plain http hub: %d", x.Response.StatusCode)
}
// Set by $T5DE_HUB: never a restart to suggest.
m.Fixed = true
ask(t, m, "POST", "/hub/server", "hub="+url.QueryEscape(Production))
if s := ask(t, m, "GET", "/hub", ""); s.Restart || !s.Fixed {
t.Fatalf("fixed: %+v", s)
}
}
func TestSignInForAnotherHub(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "hub.json")
if err := (&hub.Saved{URL: "http://localhost:4101", Token: "old"}).Save(path); err != nil {
t.Fatal(err)
}
attached := 0
m := &Manager{Path: path, Hub: Production, Log: log.New(io.Discard, "", 0), Attach: func(*hub.Client) { attached++ }}
m.Start()
if s := ask(t, m, "GET", "/hub", ""); s.SignedIn || attached != 0 {
t.Fatalf("used another hub's sign-in: %+v", s)
}
}