diff --git a/README.md b/README.md index d965749..8920736 100644 --- a/README.md +++ b/README.md @@ -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\` 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. diff --git a/cmd/t5de-proxy/main.go b/cmd/t5de-proxy/main.go index e58631b..d539ff5 100644 --- a/cmd/t5de-proxy/main.go +++ b/cmd/t5de-proxy/main.go @@ -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- 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.") diff --git a/internal/control/control.go b/internal/control/control.go index 6d652e7..eaf4679 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -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= 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=, 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,9 +30,12 @@ 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 + 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) // OpenBrowser opens a page in the person's browser. @@ -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": diff --git a/internal/control/control_test.go b/internal/control/control_test.go index 9c0ec2e..2fca9c7 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -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") diff --git a/internal/control/server.go b/internal/control/server.go new file mode 100644 index 0000000..818cbd2 --- /dev/null +++ b/internal/control/server.go @@ -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/ 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) +} diff --git a/internal/control/server_test.go b/internal/control/server_test.go new file mode 100644 index 0000000..fd8034f --- /dev/null +++ b/internal/control/server_test.go @@ -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) + } +}