Choose the hub: Settings' choice, else the install's channel's; state kept per hub
CI / build (push) Successful in 18s
CI / build (push) Successful in 18s
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:
1 parent
8daf91ede3
commit
8ae45fc07a
6 files changed
+291
-52
No files matched your search
@@ -60,10 +60,17 @@ last check, `products.Guard` refuses any request to IMVU that still names a loca
|
|||||||
|
|
||||||
## The hub
|
## 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`,
|
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
|
`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 `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
|
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
|
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.
|
session (signed out everywhere, banned), the proxy stops connecting until `signin` is run again.
|
||||||
|
|||||||
+15
-11
@@ -62,6 +62,7 @@ func main() {
|
|||||||
packs := flag.String("packs", "", "community product packs: directories of packs, separated by "+string(filepath.ListSeparator)+
|
packs := flag.String("packs", "", "community product packs: directories of packs, separated by "+string(filepath.ListSeparator)+
|
||||||
" (default: %APPDATA%/T5DE/packs and $T5DE_PACKS)")
|
" (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)")
|
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")
|
version := flag.Bool("version", false, "print the version and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
if *version {
|
if *version {
|
||||||
@@ -83,7 +84,7 @@ func main() {
|
|||||||
out = f
|
out = f
|
||||||
}
|
}
|
||||||
logger := log.New(out, "", log.Ldate|log.Ltime|log.Lmicroseconds)
|
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)
|
logger.Printf("t5de-proxy: %v", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -114,7 +115,7 @@ func dataDir(flagValue string) string {
|
|||||||
return "."
|
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)
|
secret := make([]byte, 24)
|
||||||
if _, err := rand.Read(secret); err != nil {
|
if _, err := rand.Read(secret); err != nil {
|
||||||
return err
|
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}
|
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.
|
// 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 {
|
if err := os.MkdirAll(hubProducts, 0o755); err != nil {
|
||||||
logger.Printf("hub products: %v", err)
|
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 {
|
if err != nil {
|
||||||
logger.Printf("community products are off: %v", err)
|
logger.Printf("community products are off: %v", err)
|
||||||
} else {
|
} 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).
|
// 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),
|
manager := &control.Manager{Path: filepath.Join(state, "hub.json"), Hub: hubURL, Default: control.ChannelDefault(channel),
|
||||||
Log: logger, OpenBrowser: openBrowser}
|
ChoicePath: choice, Fixed: os.Getenv("T5DE_HUB") != "", Log: logger, OpenBrowser: openBrowser}
|
||||||
manager.Attach = func(client *hub.Client) {
|
manager.Attach = func(client *hub.Client) {
|
||||||
hubNow.set(client)
|
hubNow.set(client)
|
||||||
client.OnRoom, client.OnConnect = rooms.FromHub, rooms.Reconnected
|
client.OnRoom, client.OnConnect = rooms.FromHub, rooms.Reconnected
|
||||||
@@ -272,12 +279,9 @@ func writeReady(path, address, token string) error {
|
|||||||
return os.Rename(tmp, path)
|
return os.Rename(tmp, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultHub is T5DE's hub.
|
|
||||||
const defaultHub = "https://hub.t5de.dev"
|
|
||||||
|
|
||||||
func signin(args []string) error {
|
func signin(args []string) error {
|
||||||
fs := flag.NewFlagSet("signin", flag.ExitOnError)
|
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)")
|
data := fs.String("data", "", "where the proxy keeps its state (default: %APPDATA%/T5DE/proxy)")
|
||||||
fs.Parse(args)
|
fs.Parse(args)
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
@@ -289,7 +293,7 @@ func signin(args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println("Signed in. T5DE connects to the hub the next time the client starts.")
|
fmt.Println("Signed in. T5DE connects to the hub the next time the client starts.")
|
||||||
|
|||||||
+41
-23
@@ -2,9 +2,10 @@
|
|||||||
// change it: requests to http://t5de-proxy.local/ (only the client can make them: they need the proxy's token)
|
// change it: requests to http://t5de-proxy.local/ (only the client can make them: they need the proxy's token)
|
||||||
// are answered here.
|
// are answered here.
|
||||||
//
|
//
|
||||||
// GET /hub {signedIn, hub, username, signingIn, code, page, error}
|
// GET /hub {signedIn, hub, username, signingIn, code, page, error, choice, default, fixed, restart}
|
||||||
// POST /hub/signin starts signing in (hub=<URL> optional): opens the hub's page for the code in the browser
|
// 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/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
|
// 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
|
// T5DE users opted in), from the hub, remembered for a while
|
||||||
package control
|
package control
|
||||||
@@ -29,8 +30,11 @@ const Host = "t5de-proxy.local"
|
|||||||
|
|
||||||
// Manager keeps the hub connection running and answers the client's settings page.
|
// Manager keeps the hub connection running and answers the client's settings page.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
Path string // hub.json
|
Path string // hub.json, in the hub's state folder (StateDir)
|
||||||
DefaultHub string
|
// 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
|
Log *log.Logger
|
||||||
// Attach is called with each new hub client before it runs (to connect it to the rest of the proxy).
|
// 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)
|
Attach func(c *hub.Client)
|
||||||
@@ -42,7 +46,6 @@ type Manager struct {
|
|||||||
saved *hub.Saved
|
saved *hub.Saved
|
||||||
stop context.CancelFunc
|
stop context.CancelFunc
|
||||||
username string
|
username string
|
||||||
lastHub string // the hub last signed in to, for signing in again after signing out
|
|
||||||
signin *signin
|
signin *signin
|
||||||
badges map[int64]badgeAnswer
|
badges map[int64]badgeAnswer
|
||||||
}
|
}
|
||||||
@@ -125,6 +128,11 @@ func (m *Manager) Start() {
|
|||||||
}
|
}
|
||||||
return
|
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)
|
m.run(saved)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,17 +176,23 @@ type status struct {
|
|||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Page string `json:"page"`
|
Page string `json:"page"`
|
||||||
Error string `json:"error"`
|
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 {
|
func (m *Manager) status() status {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
s := status{Hub: m.DefaultHub}
|
s := status{Hub: m.Hub, Default: m.Default, Fixed: m.Fixed, Choice: LoadChoice(m.ChoicePath)}
|
||||||
if m.lastHub != "" {
|
next := s.Choice
|
||||||
s.Hub = m.lastHub
|
if next == "" {
|
||||||
|
next = m.Default
|
||||||
}
|
}
|
||||||
|
s.Restart = !m.Fixed && next != m.Hub
|
||||||
if m.saved != nil {
|
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 {
|
if si := m.signin; si != nil {
|
||||||
s.SigningIn, s.Code, s.Page, s.Error = si.running, si.code, si.page, si.err
|
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
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// SignIn starts signing in to a hub; the code to enter shows in the status, and the browser opens the page.
|
// SignIn starts signing in to this run's hub; the code to enter shows in the status, and the browser opens the
|
||||||
func (m *Manager) SignIn(hubURL string) {
|
// page.
|
||||||
if hubURL == "" {
|
func (m *Manager) SignIn() {
|
||||||
hubURL = m.status().Hub
|
hubURL := m.Hub
|
||||||
}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
si := &signin{running: true, cancel: cancel}
|
si := &signin{running: true, cancel: cancel}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -230,9 +243,6 @@ func (m *Manager) SignIn(hubURL string) {
|
|||||||
func (m *Manager) SignOut() {
|
func (m *Manager) SignOut() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
c, stop := m.client, m.stop
|
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.client, m.saved, m.stop, m.username, m.signin, m.badges = nil, nil, nil, "", nil, nil
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
if c != nil {
|
if c != nil {
|
||||||
@@ -256,16 +266,24 @@ func (m *Manager) Intercept(x *proxy.Exchange) error {
|
|||||||
switch {
|
switch {
|
||||||
case x.URL.Path == "/hub" && method == "GET":
|
case x.URL.Path == "/hub" && method == "GET":
|
||||||
case x.URL.Path == "/hub/signin" && method == "POST":
|
case x.URL.Path == "/hub/signin" && method == "POST":
|
||||||
|
m.SignIn()
|
||||||
|
case x.URL.Path == "/hub/server" && method == "POST":
|
||||||
form, _ := url.ParseQuery(string(x.Body))
|
form, _ := url.ParseQuery(string(x.Body))
|
||||||
hubURL := strings.TrimSuffix(form.Get("hub"), "/")
|
hub := form.Get("hub")
|
||||||
if hubURL != "" {
|
if hub != "" {
|
||||||
u, err := url.Parse(hubURL)
|
var err error
|
||||||
if err != nil || (u.Scheme != "https" && u.Hostname() != "localhost" && u.Hostname() != "127.0.0.1") {
|
if hub, err = CheckHub(hub); err != nil {
|
||||||
x.Response = proxy.LocalResponse(400, "application/json", []byte(`{"error":"the hub needs an https address"}`))
|
b, _ := json.Marshal(map[string]string{"error": err.Error()})
|
||||||
|
x.Response = proxy.LocalResponse(400, "application/json", b)
|
||||||
return nil
|
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":
|
case x.URL.Path == "/hub/signout" && method == "POST":
|
||||||
m.SignOut()
|
m.SignOut()
|
||||||
case x.URL.Path == "/badges" && method == "POST":
|
case x.URL.Path == "/badges" && method == "POST":
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func TestSignInAndOut(t *testing.T) {
|
|||||||
path := filepath.Join(t.TempDir(), "hub.json")
|
path := filepath.Join(t.TempDir(), "hub.json")
|
||||||
var opened []string
|
var opened []string
|
||||||
attached := 0
|
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++ }}
|
OpenBrowser: func(p string) { opened = append(opened, p) }, Attach: func(*hub.Client) { attached++ }}
|
||||||
m.Start()
|
m.Start()
|
||||||
if s := ask(t, m, "GET", "/hub", ""); s.SignedIn || s.Hub != srv.URL {
|
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) {
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
t.Fatalf("hub.json: %v", err)
|
t.Fatalf("hub.json: %v", err)
|
||||||
}
|
}
|
||||||
// Signing in again goes to the same hub.
|
// Other requests to the proxy's address are refused.
|
||||||
m.DefaultHub = "https://elsewhere.test"
|
u, _ := url.Parse("http://" + Host + "/other")
|
||||||
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")
|
|
||||||
req, _ := http.NewRequest("POST", u.String(), nil)
|
req, _ := http.NewRequest("POST", u.String(), nil)
|
||||||
x := &proxy.Exchange{URL: u, Request: req, Body: []byte("hub=http://evil.test")}
|
x := &proxy.Exchange{URL: u, Request: req}
|
||||||
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)
|
m.Intercept(x)
|
||||||
if x.Response.StatusCode != 404 {
|
if x.Response.StatusCode != 404 {
|
||||||
t.Fatalf("other path: %d", x.Response.StatusCode)
|
t.Fatalf("other path: %d", x.Response.StatusCode)
|
||||||
@@ -154,7 +142,7 @@ func TestSignInAndOut(t *testing.T) {
|
|||||||
func TestBadges(t *testing.T) {
|
func TestBadges(t *testing.T) {
|
||||||
var signedOut atomic.Bool
|
var signedOut atomic.Bool
|
||||||
srv := fakeHub(t, &signedOut)
|
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 {
|
badges := func(body string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
u, _ := url.Parse("http://" + Host + "/badges")
|
u, _ := url.Parse("http://" + Host + "/badges")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user