Sign in and out of the hub from the client: the proxy answers t5de-proxy.local
CI / build (push) Successful in 28s
CI / build (push) Successful in 28s
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 <[email protected]>
This commit is contained in:
1 parent
5b4e44c4e7
commit
5eca0b57a6
4 files changed
+368
-23
No files matched your search
@@ -56,7 +56,8 @@ leaves the machine.
|
|||||||
|
|
||||||
## The hub
|
## 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
|
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 `%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
|
running proxy keeps a WebSocket to the hub (`internal/hub`) with the token as a bearer token, reporting which IMVU
|
||||||
|
|||||||
+28
-22
@@ -17,7 +17,6 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -33,6 +32,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.t5de.dev/t5de/t5de-proxy/internal/buildinfo"
|
"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/fetch"
|
||||||
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||||
"git.t5de.dev/t5de/t5de-proxy/internal/parent"
|
"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
|
handler.Interceptor, handler.IMQFilter = prods, prods
|
||||||
logger.Printf("community products from %s", strings.Join(packs, ", "))
|
logger.Printf("community products from %s", strings.Join(packs, ", "))
|
||||||
}
|
}
|
||||||
if saved, err := hub.Load(filepath.Join(data, "hub.json")); err == nil {
|
// The hub connection, which the client's T5DE settings can sign in and out (internal/control).
|
||||||
client := hub.New(filepath.Join(data, "hub.json"), saved, logger)
|
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)
|
fetcher := fetch.New(client, hubProducts, logger.Printf)
|
||||||
if prods != nil {
|
client.OnLibrary = func() { fetcher.SyncLibrary(context.Background()) }
|
||||||
prods.Observer = client
|
if prods == nil {
|
||||||
client.OnChat = prods.Others
|
return
|
||||||
fetcher.OnReady = prods.Ready
|
}
|
||||||
fetcher.OnLibrary = prods.RefreshInventory
|
prods.Observer = client
|
||||||
prods.Want = fetcher.Want
|
client.OnChat = prods.Others
|
||||||
prods.HubDir, prods.Owned = hubProducts, fetcher.Owned
|
fetcher.OnReady = prods.Ready
|
||||||
prods.OnHide = func(id string) {
|
fetcher.OnLibrary = prods.RefreshInventory
|
||||||
if fetch.IsHubID(id) {
|
prods.Want = fetcher.Want
|
||||||
go func() {
|
prods.HubDir, prods.Owned = hubProducts, fetcher.Owned
|
||||||
if err := client.Call(context.Background(), "DELETE", "/api/v1/library/"+id, nil, nil); err != nil {
|
prods.OnHide = func(id string) {
|
||||||
logger.Printf("hub: removing %s from the library: %v", id, err)
|
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())
|
manager.Start()
|
||||||
} else if !errors.Is(err, hub.ErrSignedOut) {
|
if prods != nil {
|
||||||
logger.Printf("hub: %v", err)
|
handler.Interceptor = proxy.Interceptors{manager, prods}
|
||||||
|
} else {
|
||||||
|
handler.Interceptor = manager
|
||||||
}
|
}
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
|
|||||||
@@ -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=<URL> 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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user