From 48ec62c4dba7b27816859b66005738716770b7a7 Mon Sep 17 00:00:00 2001 From: Mikeycallin1 <184832397+Mikeycallin1@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:51:48 +0100 Subject: [PATCH] Make the addresses wire-pod uses for robots configurable wire-pod assumes it can discover its own reachable address, and that the address a robot appears to come from is the robot's. Both hold on a flat LAN and neither holds behind a reverse proxy, a container bridge or a router, where every robot arrives at the same gateway address and that address is what gets stored and later dialled. Two separate addresses become configurable, kept deliberately distinct: robot_facing_address inbound - what robots dial to reach wire-pod. Goes into the certificate SANs and server_config.json, which must agree or the robot's pinned-leaf handshake fails. robot_lan_addresses outbound - per robot, what wire-pod dials to reach that robot for the SDK app, camera stream and jdocs. Behind a proxy the observed peer address is also useless as an identity key, so a robot is identified by the ESN it reports on the connection check, or by its own token's requestor_id on refresh. All of this is off unless proxy_mode is set. A default install writes a byte-identical apiConfig.json, regenerates no certificate and changes no on-disk shape - the new fields are omitempty and absent when unset. Identity resolution is gated too, deliberately: on a direct LAN the peer address is the better signal, and preferring the reported ESN there would retire the mDNS browse that repairs a robot's stored address after DHCP moves it. The first-run wizard gains a checkbox and an address field. Bot Setup gains a per-robot address table, shown only when proxy_mode is on. An address can also be captured automatically during BLE adoption. Tests cover the paths that could damage an existing install: config byte-identity, write-then-reload round trips, both gates asserted at the level of their consequences, and certificate/server_config coherence. The repo has no test CI, so they run only when invoked: cd chipper && go test ./pkg/vars/... ./pkg/servers/token/... \ ./pkg/wirepod/setup/... ./pkg/wirepod/sdkapp/... --- chipper/pkg/initwirepod/startserver.go | 12 + chipper/pkg/initwirepod/web.go | 15 + chipper/pkg/servers/jdocs/server.go | 4 +- chipper/pkg/servers/token/requestesn_test.go | 124 +++++++ .../pkg/servers/token/requestidentity_test.go | 144 ++++++++ chipper/pkg/servers/token/token.go | 74 +++- chipper/pkg/servers/token/token_test.go | 138 ++++++++ chipper/pkg/servers/token/tokenpath_test.go | 149 ++++++++ chipper/pkg/vars/botinfo_test.go | 66 ++++ chipper/pkg/vars/config.go | 146 ++++++++ chipper/pkg/vars/config_test.go | 122 +++++++ chipper/pkg/vars/dialtarget_test.go | 114 ++++++ chipper/pkg/vars/normalize_test.go | 168 +++++++++ chipper/pkg/vars/upgrade_test.go | 330 ++++++++++++++++++ chipper/pkg/vars/vars.go | 83 ++++- chipper/pkg/wirepod/config-ws/webserver.go | 71 ++++ chipper/pkg/wirepod/sdkapp/conncheck_test.go | 153 ++++++++ chipper/pkg/wirepod/sdkapp/connpath_test.go | 131 +++++++ chipper/pkg/wirepod/sdkapp/jdocspinger.go | 65 +++- chipper/pkg/wirepod/sdkapp/pinger_test.go | 132 +++++++ chipper/pkg/wirepod/sdkapp/robot.go | 4 +- chipper/pkg/wirepod/setup/ble.go | 36 ++ chipper/pkg/wirepod/setup/certs.go | 82 ++++- chipper/pkg/wirepod/setup/certs_test.go | 189 ++++++++++ chipper/pkg/wirepod/setup/coherence_test.go | 91 +++++ chipper/pkg/wirepod/setup/lanaddress.go | 45 +++ chipper/pkg/wirepod/setup/lanaddress_test.go | 131 +++++++ chipper/pkg/wirepod/setup/robotfacing_test.go | 199 +++++++++++ chipper/pkg/wirepod/ttr/intentparam.go | 2 +- chipper/pkg/wirepod/ttr/kgsim.go | 4 +- chipper/pkg/wirepod/ttr/matchIntentSend.go | 2 +- chipper/webroot/css/style.css | 40 +++ chipper/webroot/index.html | 30 ++ chipper/webroot/initial.html | 33 ++ chipper/webroot/js/ble.js | 201 +++++++++++ chipper/webroot/js/initial.js | 25 +- 36 files changed, 3316 insertions(+), 39 deletions(-) create mode 100644 chipper/pkg/servers/token/requestesn_test.go create mode 100644 chipper/pkg/servers/token/requestidentity_test.go create mode 100644 chipper/pkg/servers/token/token_test.go create mode 100644 chipper/pkg/servers/token/tokenpath_test.go create mode 100644 chipper/pkg/vars/botinfo_test.go create mode 100644 chipper/pkg/vars/config_test.go create mode 100644 chipper/pkg/vars/dialtarget_test.go create mode 100644 chipper/pkg/vars/normalize_test.go create mode 100644 chipper/pkg/vars/upgrade_test.go create mode 100644 chipper/pkg/wirepod/sdkapp/conncheck_test.go create mode 100644 chipper/pkg/wirepod/sdkapp/connpath_test.go create mode 100644 chipper/pkg/wirepod/sdkapp/pinger_test.go create mode 100644 chipper/pkg/wirepod/setup/certs_test.go create mode 100644 chipper/pkg/wirepod/setup/coherence_test.go create mode 100644 chipper/pkg/wirepod/setup/lanaddress.go create mode 100644 chipper/pkg/wirepod/setup/lanaddress_test.go create mode 100644 chipper/pkg/wirepod/setup/robotfacing_test.go diff --git a/chipper/pkg/initwirepod/startserver.go b/chipper/pkg/initwirepod/startserver.go index a9226f04e..fa51b0237 100644 --- a/chipper/pkg/initwirepod/startserver.go +++ b/chipper/pkg/initwirepod/startserver.go @@ -21,6 +21,7 @@ import ( wpweb "github.com/kercre123/wire-pod/chipper/pkg/wirepod/config-ws" wp "github.com/kercre123/wire-pod/chipper/pkg/wirepod/preqs" sdkWeb "github.com/kercre123/wire-pod/chipper/pkg/wirepod/sdkapp" + botsetup "github.com/kercre123/wire-pod/chipper/pkg/wirepod/setup" "github.com/soheilhy/cmux" // grpclog "github.com/digital-dream-labs/hugh/grpc/interceptors/logger" @@ -171,6 +172,17 @@ func StartChipper() { } } + // Read-only coherence check: regenerating to fix a mismatch would de-pair + // every robot, so this only reports. Skipped for the unspecified address, + // which is what GetOutboundIP returns with no route -- warning that a good + // certificate fails to cover 0.0.0.0 is noise. ParseIP is nil for a + // hostname and nil.IsUnspecified is false, so hostnames still get checked. + if !vars.APIConfig.Server.EPConfig { + if addr := vars.GetRobotFacingAddress(); !net.ParseIP(addr).IsUnspecified() { + botsetup.WarnIfCertMissesAddress(addr) + } + } + logger.Println("Initiating TLS listener, cmux, gRPC handler, and REST handler") cert, err := tls.X509KeyPair(certPub, certPriv) if err != nil { diff --git a/chipper/pkg/initwirepod/web.go b/chipper/pkg/initwirepod/web.go index 37478a54a..bdfacfe59 100644 --- a/chipper/pkg/initwirepod/web.go +++ b/chipper/pkg/initwirepod/web.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "strconv" + "strings" "github.com/kercre123/wire-pod/chipper/pkg/logger" "github.com/kercre123/wire-pod/chipper/pkg/vars" @@ -29,6 +30,16 @@ func ChipperHTTPApi(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "error: port is invalid") return } + // Parse the proxy fields BEFORE generating anything: the certificate + // bakes in the robot-facing address, so the config must be populated + // first or the cert would cover the wrong name. + robotFacing := strings.TrimSpace(r.FormValue("robot_facing_address")) + if err := vars.ValidateRobotFacingAddress(robotFacing); err != nil { + fmt.Fprint(w, "error: robot-facing address is invalid: "+err.Error()) + return + } + vars.APIConfig.Server.ProxyMode = r.FormValue("proxy_mode") == "true" + vars.APIConfig.Server.RobotFacingAddress = robotFacing vars.APIConfig.Server.EPConfig = false vars.APIConfig.Server.Port = port err = botsetup.CreateCertCombo() @@ -44,6 +55,10 @@ func ChipperHTTPApi(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "done") return case r.URL.Path == "/api-chipper/use_ep": + // proxy_mode is meaningful in EP mode too -- it gates the per-robot + // address UI, which is exactly what a proxied EP install needs. The + // robot-facing address is not: EP mode hardcodes escapepod.local. + vars.APIConfig.Server.ProxyMode = r.FormValue("proxy_mode") == "true" vars.APIConfig.Server.EPConfig = true vars.APIConfig.Server.Port = "443" vars.APIConfig.PastInitialSetup = true diff --git a/chipper/pkg/servers/jdocs/server.go b/chipper/pkg/servers/jdocs/server.go index 9f984ef10..db40f1b62 100755 --- a/chipper/pkg/servers/jdocs/server.go +++ b/chipper/pkg/servers/jdocs/server.go @@ -112,7 +112,7 @@ func (s *JdocServer) ReadDocs(ctx context.Context, req *jdocspb.ReadDocsReq) (*j os.WriteFile(fullPath, tokenserver.SessionWriteStoreCerts[num], 0755) // export to ./session-certs os.WriteFile(vars.SessionCertPath+"/"+esn, tokenserver.SessionWriteStoreCerts[num], 0755) - WriteToIniPrimary(pair[1], esn, botGUID, ipAddr) + WriteToIniPrimary(pair[1], esn, botGUID, vars.GetRobotDialTarget(esn, ipAddr)) vars.AddToRInfo(esn, pair[1], ipAddr) tokenserver.RemoveFromSessionStore(num) logger.Println("Session certificate successfully output") @@ -128,7 +128,7 @@ func (s *JdocServer) ReadDocs(ctx context.Context, req *jdocspb.ReadDocsReq) (*j // creates apptoken jdoc file tokenserver.WriteTokenHash(esn, hash) if !sessionMatched { - WriteToIniSecondary(esn, guid, ipAddr) + WriteToIniSecondary(esn, guid, vars.GetRobotDialTarget(esn, ipAddr)) } // bot is not authenticated yet, do not write to botinfo json tokenJdoc, _ := vars.GetJdoc(req.Thing, "vic.AppToken") diff --git a/chipper/pkg/servers/token/requestesn_test.go b/chipper/pkg/servers/token/requestesn_test.go new file mode 100644 index 000000000..17a419053 --- /dev/null +++ b/chipper/pkg/servers/token/requestesn_test.go @@ -0,0 +1,124 @@ +package tokenserver + +import ( + "context" + "testing" + + "github.com/kercre123/wire-pod/chipper/pkg/vars" +) + +// withProxyMode sets server.proxy_mode for the duration of a test. The token +// claim is only preferred behind a proxy. +func withProxyMode(t *testing.T, on bool) { + t.Helper() + restore := vars.APIConfig + t.Cleanup(func() { vars.APIConfig = restore }) + vars.APIConfig.Server.ProxyMode = on +} + +// Two robots behind one gateway is the case the preference exists for: the peer +// address resolves to whichever is stored first, so without the token claim a +// refresh from one robot is attributed to the other. +func TestProxyModePrefersTheESNTheTokenNames(t *testing.T) { + withProxyMode(t, true) + seedBotInfo(t, sharedPeerBotInfo) + withKnownRobots(t, "00e20145", "00e20146") + + for _, want := range []string{"00e20145", "00e20146"} { + got, err := resolveTokenRequestESN(ctxWithToken(issueJWT(t, "vic:"+want)), "172.19.0.1") + if err != nil { + t.Fatalf("resolveTokenRequestESN: unexpected error %v", err) + } + if got != want { + t.Errorf("resolveTokenRequestESN = %q, want %q; behind a proxy the "+ + "token is the only thing that tells two robots apart", got, want) + } + } +} + +// Without proxy_mode the peer address wins even when a token names someone +// else. This is the guard that matters: preferring the claim also clears the +// error, and that is what routes CreateJWT into the branch which persists +// SetBotGUID and ChangeGUIDInIni. The claim cannot be verified, so ungated it +// would let anyone who knows an ESN rotate that robot's GUID. +func TestWithoutProxyModeThePeerAddressWins(t *testing.T) { + withProxyMode(t, false) + seedBotInfo(t, twoRobotBotInfo) + withKnownRobots(t, "00e20145", "00e20146") + + // A request genuinely from .150, carrying a token that claims to be .151's robot. + got, err := resolveTokenRequestESN(ctxWithToken(issueJWT(t, "vic:00e20146")), "192.168.1.150") + if err != nil { + t.Fatalf("resolveTokenRequestESN: unexpected error %v", err) + } + if got != "00e20145" { + t.Errorf("resolveTokenRequestESN = %q, want %q; on a direct LAN a token "+ + "claim must not override the address the request actually came from", + got, "00e20145") + } +} + +// An unknown peer address must keep returning an error when there is no proxy. +// CreateJWT branches on that error, so swallowing it here would silently start +// persisting GUID changes for requests upstream treated as new robots. +func TestWithoutProxyModeAnUnknownAddressStillErrors(t *testing.T) { + withProxyMode(t, false) + seedBotInfo(t, twoRobotBotInfo) + withKnownRobots(t, "00e20145") + + if _, err := resolveTokenRequestESN(ctxWithToken(issueJWT(t, "vic:00e20145")), "10.0.0.99"); err == nil { + t.Error("an unrecognised peer address must still report an error; CreateJWT " + + "branches on it to decide whether to persist a new GUID") + } +} + +// Behind a proxy the address is expected not to resolve, and the token is what +// rescues the request. Clearing the error here is deliberate. +func TestProxyModeResolvesEvenWhenTheAddressDoesNot(t *testing.T) { + withProxyMode(t, true) + seedBotInfo(t, twoRobotBotInfo) + withKnownRobots(t, "00e20145") + + got, err := resolveTokenRequestESN(ctxWithToken(issueJWT(t, "vic:00e20145")), "172.19.0.1") + if err != nil { + t.Fatalf("resolveTokenRequestESN: unexpected error %v", err) + } + if got != "00e20145" { + t.Errorf("resolveTokenRequestESN = %q, want %q", got, "00e20145") + } +} + +// A token naming a robot wire-pod has never stored must not be trusted even +// behind a proxy: it would otherwise clear the error and invent an identity. +func TestProxyModeIgnoresATokenNamingAnUnknownRobot(t *testing.T) { + withProxyMode(t, true) + seedBotInfo(t, twoRobotBotInfo) + withKnownRobots(t, "00e20145") + + if _, err := resolveTokenRequestESN(ctxWithToken(issueJWT(t, "vic:00ffffff")), "10.0.0.99"); err == nil { + t.Error("an unknown ESN in the token must fall through to the address path, " + + "which here cannot resolve either") + } +} + +// No token at all is the primary-association case, and must behave exactly as +// it did before any of this. +func TestNoTokenFallsBackToTheAddress(t *testing.T) { + withProxyMode(t, true) + seedBotInfo(t, twoRobotBotInfo) + withKnownRobots(t, "00e20145") + + got, err := resolveTokenRequestESN(context.Background(), "192.168.1.150") + if err != nil { + t.Fatalf("resolveTokenRequestESN: unexpected error %v", err) + } + if got != "00e20145" { + t.Errorf("resolveTokenRequestESN = %q, want %q from the peer address", got, "00e20145") + } +} + +// Two robots whose stored ip_address is the gateway, which is what wire-pod +// records for every robot behind a proxy. +const sharedPeerBotInfo = `{"global_guid":"g","robots":[` + + `{"esn":"00e20145","ip_address":"172.19.0.1","guid":"g1","activated":true},` + + `{"esn":"00e20146","ip_address":"172.19.0.1","guid":"g2","activated":true}]}` diff --git a/chipper/pkg/servers/token/requestidentity_test.go b/chipper/pkg/servers/token/requestidentity_test.go new file mode 100644 index 000000000..8f0fd98ae --- /dev/null +++ b/chipper/pkg/servers/token/requestidentity_test.go @@ -0,0 +1,144 @@ +package tokenserver + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "testing" + + "github.com/golang-jwt/jwt" + "github.com/kercre123/wire-pod/chipper/pkg/vars" + "google.golang.org/grpc/metadata" +) + +// issueJWT mints a token the way CreateJWT does: signed with a throwaway key, +// which is exactly why the claims can only ever be parsed unverified. +func issueJWT(t *testing.T, requestorID string) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodRS512, jwt.MapClaims{ + "requestor_id": requestorID, + "token_type": "user+robot", + "user_id": UserId, + }) + key, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + t.Fatalf("generating key: %v", err) + } + signed, err := token.SignedString(key) + if err != nil { + t.Fatalf("signing: %v", err) + } + return signed +} + +func ctxWithToken(jwtToken string) context.Context { + return metadata.NewIncomingContext(context.Background(), + metadata.Pairs("anki-app-key", "oDoa0quieSeir6goowai7f", "anki-access-token", jwtToken)) +} + +func withKnownRobots(t *testing.T, esns ...string) { + t.Helper() + restore := vars.BotInfo + t.Cleanup(func() { vars.BotInfo = restore }) + + vars.BotInfo = vars.RobotInfoStore{GlobalGUID: "g"} + for _, esn := range esns { + vars.BotInfo.Robots = append(vars.BotInfo.Robots, struct { + Esn string `json:"esn"` + IPAddress string `json:"ip_address"` + GUID string `json:"guid"` + Activated bool `json:"activated"` + }{Esn: esn, IPAddress: "172.19.0.1", GUID: "guid-" + esn, Activated: true}) + } +} + +// The point of the change: two robots share a peer address, and the token is +// what tells them apart on a refresh. +func TestESNFromRequestTokenIdentifiesTheRightRobot(t *testing.T) { + withKnownRobots(t, "00e20145", "00e20146") + + for _, want := range []string{"00e20145", "00e20146"} { + got := esnFromRequestToken(ctxWithToken(issueJWT(t, "vic:"+want))) + if got != want { + t.Errorf("esnFromRequestToken = %q, want %q; the token must disambiguate "+ + "robots sharing a peer address", got, want) + } + } +} + +func TestESNFromRequestTokenReturnsEmptyWithoutMetadata(t *testing.T) { + withKnownRobots(t, "00e20145") + + if got := esnFromRequestToken(context.Background()); got != "" { + t.Errorf("esnFromRequestToken = %q, want \"\" when there is no metadata", got) + } + empty := metadata.NewIncomingContext(context.Background(), metadata.Pairs("anki-app-key", "x")) + if got := esnFromRequestToken(empty); got != "" { + t.Errorf("esnFromRequestToken = %q, want \"\" when no token is present", got) + } +} + +// An unparseable token must fall through to the address path, not error out. +func TestESNFromRequestTokenIgnoresGarbage(t *testing.T) { + withKnownRobots(t, "00e20145") + + if got := esnFromRequestToken(ctxWithToken("not-a-jwt")); got != "" { + t.Errorf("esnFromRequestToken = %q, want \"\" for an unparseable token", got) + } +} + +// A token naming a robot we have never stored must not select anything. This +// is what stops the claim from inventing a robot, and it is also what makes +// the pre-association placeholder requestor_id harmless. +func TestESNFromRequestTokenRejectsUnknownRobot(t *testing.T) { + withKnownRobots(t, "00e20145") + + if got := esnFromRequestToken(ctxWithToken(issueJWT(t, "vic:00ffffff"))); got != "" { + t.Errorf("esnFromRequestToken = %q, want \"\" for a robot not in the store", got) + } + // The placeholder wire-pod issues before a robot is associated. + if got := esnFromRequestToken(ctxWithToken(issueJWT(t, "vic:00601b50"))); got != "" { + t.Errorf("esnFromRequestToken = %q, want \"\" for the pre-association "+ + "placeholder requestor_id", got) + } +} + +func TestESNFromRequestTokenHandlesMalformedRequestorID(t *testing.T) { + withKnownRobots(t, "00e20145") + + for _, requestor := range []string{"", "vic:", " ", "00e20145"} { + got := esnFromRequestToken(ctxWithToken(issueJWT(t, requestor))) + // A bare ESN with no "vic:" prefix is still usable; the others are not. + want := "" + if requestor == "00e20145" { + want = "00e20145" + } + if got != want { + t.Errorf("requestor_id %q: esnFromRequestToken = %q, want %q", requestor, got, want) + } + } +} + +func TestESNFromRequestTokenMatchesCaseInsensitively(t *testing.T) { + withKnownRobots(t, "00e20145") + + if got := esnFromRequestToken(ctxWithToken(issueJWT(t, "vic:00E20145"))); got != "00e20145" { + t.Errorf("esnFromRequestToken = %q, want the stored casing %q", got, "00e20145") + } +} + +// Primary association must be untouched: it carries no usable token, and its +// branch ignores the resolved ESN anyway. This pins that the resolver stays +// silent there rather than relying on the branch to save us. +func TestPrimaryAssociationCarriesNoUsableToken(t *testing.T) { + withKnownRobots(t, "00e20145", "00e20146") + + // What queue.go:119-131 actually sends on a primary association. + primary := metadata.NewIncomingContext(context.Background(), + metadata.Pairs("anki-app-key", "oDoa0quieSeir6goowai7f", "anki-user-session", "sess-abc")) + + if got := esnFromRequestToken(primary); got != "" { + t.Errorf("esnFromRequestToken = %q, want \"\"; a primary association sends no "+ + "access token and must not resolve to any robot", got) + } +} diff --git a/chipper/pkg/servers/token/token.go b/chipper/pkg/servers/token/token.go index 36d6d13a3..8b0c45162 100755 --- a/chipper/pkg/servers/token/token.go +++ b/chipper/pkg/servers/token/token.go @@ -17,6 +17,7 @@ import ( "github.com/google/uuid" "github.com/kercre123/wire-pod/chipper/pkg/logger" "github.com/kercre123/wire-pod/chipper/pkg/vars" + "google.golang.org/grpc/metadata" "google.golang.org/grpc/peer" "gopkg.in/ini.v1" ) @@ -163,7 +164,7 @@ func ChangeGUIDInIni(esn string) { for _, section := range userIniData.Sections() { if strings.EqualFold(section.Name(), esn) { matched = true - section.Key("ip").SetValue(robot.IPAddress) + section.Key("ip").SetValue(vars.GetRobotDialTarget(robot.Esn, robot.IPAddress)) if robot.GUID == "" { section.Key("guid").SetValue(vars.BotInfo.GlobalGUID) } else { @@ -183,6 +184,75 @@ func GenerateUUID() string { return uuid.String() } +// esnFromRequestToken returns the ESN that the robot's own token claims, or "" +// when it cannot be determined. +// +// The robot presents its JWT as gRPC metadata "anki-access-token", carrying +// requestor_id = "vic:" that wire-pod itself wrote when it issued the +// token. Behind a proxy the peer address is shared by every robot, so this is +// the only per-robot signal available on a refresh. +// +// It is a disambiguation hint, not an authentication check: wire-pod signs +// every JWT with a freshly generated RSA key it then discards, so there is +// nothing to verify a signature against. The result is used only when it names +// a robot already in the store, so it can select among known robots but never +// invent one, and its one caller is gated on proxy_mode. +func esnFromRequestToken(ctx context.Context) string { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return "" + } + values := md.Get("anki-access-token") + if len(values) == 0 { + return "" + } + + claims := jwt.MapClaims{} + parser := jwt.Parser{SkipClaimsValidation: true} + if _, _, err := parser.ParseUnverified(strings.TrimSpace(values[0]), claims); err != nil { + return "" + } + requestor, _ := claims["requestor_id"].(string) + esn := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(requestor), "vic:")) + if esn == "" { + return "" + } + + // Only trust it if it names a robot we already know. A token issued before + // association carries the hardcoded placeholder requestor_id, which will + // not match a stored robot and so falls through to the address path. + for _, robot := range vars.BotInfo.Robots { + if strings.EqualFold(strings.TrimSpace(robot.Esn), esn) { + return robot.Esn + } + } + return "" +} + +// resolveTokenRequestESN decides which robot a token request belongs to. The +// peer address wins, except behind a proxy where every robot shares one and the +// token's claim is the only thing telling them apart. +// +// Gated because preferring the claim also clears the error, and that is what +// routes CreateJWT into persisting SetBotGUID and ChangeGUIDInIni. The claim is +// unverifiable, so ungated it would let anyone knowing an ESN rotate that +// robot's GUID. +func resolveTokenRequestESN(ctx context.Context, ipAddr string) (string, error) { + esn, err := GetEsnFromTarget(ipAddr) + if !vars.APIConfig.Server.ProxyMode { + return esn, err + } + tokenESN := esnFromRequestToken(ctx) + if tokenESN == "" { + return esn, err + } + if err != nil || !strings.EqualFold(tokenESN, esn) { + logger.Println("Token names robot " + tokenESN + " for request from " + ipAddr + + "; using it in preference to the peer address") + } + return tokenESN, nil +} + func CreateJWT(ctx context.Context, skipGuid bool, isPrimary bool) *tokenpb.TokenBundle { // defaults requestorId := "vic:00601b50" @@ -201,7 +271,7 @@ func CreateJWT(ctx context.Context, skipGuid bool, isPrimary bool) *tokenpb.Toke // get esn using ip address of request p, _ := peer.FromContext(ctx) ipAddr := strings.TrimSpace(strings.Split(p.Addr.String(), ":")[0]) - esn, err := GetEsnFromTarget(ipAddr) + esn, err := resolveTokenRequestESN(ctx, ipAddr) // secondary handler if err == nil { diff --git a/chipper/pkg/servers/token/token_test.go b/chipper/pkg/servers/token/token_test.go new file mode 100644 index 000000000..ca2ac1e5d --- /dev/null +++ b/chipper/pkg/servers/token/token_test.go @@ -0,0 +1,138 @@ +package tokenserver + +import ( + "os" + "path/filepath" + "testing" + + "github.com/kercre123/wire-pod/chipper/pkg/vars" +) + +// GetEsnFromTarget maps an inbound peer address back to an ESN by matching +// ip_address in botSdkInfo.json. This is invariant I1(b): ip_address is an +// identity key, not just a dial target. +// +// The whole proxy-networking design exists to avoid perturbing this. Feature B +// stores the dial override in apiConfig.json specifically so that no admin +// action can change what this function sees. +func seedBotInfo(t *testing.T, contents string) { + t.Helper() + path := filepath.Join(t.TempDir(), "botSdkInfo.json") + if err := os.WriteFile(path, []byte(contents), 0644); err != nil { + t.Fatalf("seeding botSdkInfo: %v", err) + } + restore := vars.BotInfoPath + t.Cleanup(func() { vars.BotInfoPath = restore }) + vars.BotInfoPath = path +} + +const twoRobotBotInfo = `{"global_guid":"tni1TRsTRTaNSapjo0Y+Sw==","robots":[` + + `{"esn":"00e20145","ip_address":"192.168.1.150","guid":"g1","activated":true},` + + `{"esn":"00e20146","ip_address":"192.168.1.151","guid":"g2","activated":true}]}` + +func TestGetEsnFromTargetMatchesObservedAddress(t *testing.T) { + seedBotInfo(t, twoRobotBotInfo) + + for _, tc := range []struct{ target, wantESN string }{ + {"192.168.1.150", "00e20145"}, + {"192.168.1.151", "00e20146"}, + } { + got, err := GetEsnFromTarget(tc.target) + if err != nil { + t.Errorf("GetEsnFromTarget(%q) returned error: %v", tc.target, err) + continue + } + if got != tc.wantESN { + t.Errorf("GetEsnFromTarget(%q) = %q, want %q", tc.target, got, tc.wantESN) + } + } +} + +func TestGetEsnFromTargetFailsForUnknownAddress(t *testing.T) { + seedBotInfo(t, twoRobotBotInfo) + + if _, err := GetEsnFromTarget("10.0.0.99"); err == nil { + t.Error("GetEsnFromTarget accepted an address that is in no robot record; " + + "token auth depends on this failing so it can fall into its new-robot branch") + } +} + +// The proxied deployment relies on this: every robot presents as the bridge +// gateway, the stored ip_address equals that peer address, and identity +// matching therefore still succeeds even though dialing that address fails. +// Feature B must not disturb it. +func TestGetEsnFromTargetWorksWhenAddressIsAProxyPeer(t *testing.T) { + seedBotInfo(t, `{"global_guid":"g","robots":[`+ + `{"esn":"00e20145","ip_address":"172.19.0.1","guid":"g1","activated":true}]}`) + + got, err := GetEsnFromTarget("172.19.0.1") + if err != nil { + t.Fatalf("GetEsnFromTarget on a proxy peer address failed: %v", err) + } + if got != "00e20145" { + t.Errorf("GetEsnFromTarget = %q, want %q", got, "00e20145") + } +} + +// Documents the known limitation in §10 so a future change that appears to +// "fix" it is a deliberate decision rather than an accident: with multiple +// robots behind one proxy, the first match wins. +func TestGetEsnFromTargetReturnsFirstMatchForSharedPeerAddress(t *testing.T) { + seedBotInfo(t, `{"global_guid":"g","robots":[`+ + `{"esn":"00e20145","ip_address":"172.19.0.1","guid":"g1","activated":true},`+ + `{"esn":"00e20146","ip_address":"172.19.0.1","guid":"g2","activated":true}]}`) + + got, err := GetEsnFromTarget("172.19.0.1") + if err != nil { + t.Fatalf("GetEsnFromTarget failed: %v", err) + } + if got != "00e20145" { + t.Errorf("GetEsnFromTarget = %q, want first match %q "+ + "(multi-robot inbound identity is a known limitation, §10)", got, "00e20145") + } +} + +// §7.2, the regression this whole design exists to prevent. +// +// Feature B lets an admin redirect where wire-pod dials a robot. That must be +// invisible to identity matching: GetEsnFromTarget reads botSdkInfo.json from +// disk and matches the observed peer address, and no amount of admin +// configuration may change what it returns. +// +// The naive alternative — pinning ip_address itself — would break exactly this, +// sending token auth into its "act as if this is a new robot" branch. +func TestConfiguredDialTargetDoesNotPerturbGetEsnFromTarget(t *testing.T) { + seedBotInfo(t, `{"global_guid":"g","robots":[`+ + `{"esn":"00e20145","ip_address":"172.19.0.1","guid":"g1","activated":true}]}`) + + restore := vars.APIConfig + t.Cleanup(func() { vars.APIConfig = restore }) + + // Baseline: the proxied deployment resolves by peer address. + before, err := GetEsnFromTarget("172.19.0.1") + if err != nil { + t.Fatalf("baseline GetEsnFromTarget failed: %v", err) + } + + // Now point dialing at the robot's real LAN address. + vars.SetRobotLANAddress("00e20145", "192.0.2.13") + + after, err := GetEsnFromTarget("172.19.0.1") + if err != nil { + t.Fatalf("GetEsnFromTarget failed after configuring a dial override: %v", err) + } + if after != before { + t.Errorf("configuring a dial override changed identity resolution: %q -> %q", before, after) + } + + // The configured LAN address must NOT become an identity key. + if _, err := GetEsnFromTarget("192.0.2.13"); err == nil { + t.Error("the configured dial address resolved as an identity key; " + + "only the observed peer address may do that") + } + + // And the dial target really did change, so the test is not vacuous. + if got := vars.GetRobotDialTarget("00e20145", "172.19.0.1"); got != "192.0.2.13" { + t.Errorf("dial target = %q, want the configured %q", got, "192.0.2.13") + } +} diff --git a/chipper/pkg/servers/token/tokenpath_test.go b/chipper/pkg/servers/token/tokenpath_test.go new file mode 100644 index 000000000..83d9ac06f --- /dev/null +++ b/chipper/pkg/servers/token/tokenpath_test.go @@ -0,0 +1,149 @@ +package tokenserver + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "testing" + + "github.com/kercre123/wire-pod/chipper/pkg/vars" + "google.golang.org/grpc/peer" +) + +// withPersistencePaths redirects everything CreateJWT can write. SDKIniPath is +// pointed at an empty directory on purpose: ChangeGUIDInIni logs and returns +// when the ini is absent, which keeps the test off the real SDK config while +// still exercising the branch that calls it. +func withPersistencePaths(t *testing.T, botInfo string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "botSdkInfo.json") + if err := os.WriteFile(path, []byte(botInfo), 0644); err != nil { + t.Fatalf("seeding botSdkInfo: %v", err) + } + restorePath, restoreIni, restoreInfo := vars.BotInfoPath, vars.SDKIniPath, vars.BotInfo + t.Cleanup(func() { + vars.BotInfoPath, vars.SDKIniPath, vars.BotInfo = restorePath, restoreIni, restoreInfo + }) + vars.BotInfoPath = path + vars.SDKIniPath = dir + string(os.PathSeparator) + if err := json.Unmarshal([]byte(botInfo), &vars.BotInfo); err != nil { + t.Fatalf("loading botSdkInfo: %v", err) + } + return path +} + +// storedGUID reads a robot's GUID back off disk, which is what the next process +// and the SDK will use. +func storedGUID(t *testing.T, path, esn string) string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading botSdkInfo: %v", err) + } + var store struct { + Robots []struct { + Esn string `json:"esn"` + GUID string `json:"guid"` + } `json:"robots"` + } + if err := json.Unmarshal(raw, &store); err != nil { + t.Fatalf("parsing botSdkInfo: %v", err) + } + for _, r := range store.Robots { + if r.Esn == esn { + return r.GUID + } + } + return "" +} + +// ctxFrom builds the context CreateJWT actually receives: a peer address, plus +// the robot's own token as metadata. +func ctxFrom(peerIP, jwtToken string) context.Context { + base := context.Background() + if jwtToken != "" { + base = ctxWithToken(jwtToken) + } + return peer.NewContext(base, &peer.Peer{ + Addr: &net.TCPAddr{IP: net.ParseIP(peerIP), Port: 41234}, + }) +} + +const twoRobotsForPersistence = `{"global_guid":"g","robots":[` + + `{"esn":"00e20145","ip_address":"192.0.2.150","guid":"guid-A","activated":true},` + + `{"esn":"00e20146","ip_address":"192.0.2.151","guid":"guid-B","activated":true}]}` + +// The consequence that matters, tested end to end rather than at the resolver. +// +// On a stock install a request arriving from an address wire-pod does not +// recognise is treated as a new robot: nothing is persisted. If the token's +// claim were honoured here it would clear the error, select the other branch, +// and rewrite a stored robot's GUID -- which invalidates that robot's existing +// SDK and app pairing. Knowing an eight-character ESN would be enough to do it +// to someone else's robot. +func TestStockInstallDoesNotRewriteAGUIDFromATokenClaim(t *testing.T) { + withProxyMode(t, false) + path := withPersistencePaths(t, twoRobotsForPersistence) + before := storedGUID(t, path, "00e20146") + + // Request from an address belonging to no robot, carrying a token that + // claims to be robot B. + CreateJWT(ctxFrom("192.0.2.99", issueJWT(t, "vic:00e20146")), false, false) + + if after := storedGUID(t, path, "00e20146"); after != before { + t.Errorf("robot B's stored GUID changed from %q to %q on a stock install. "+ + "An unverifiable token claim was allowed to re-pair a robot the request "+ + "did not come from", before, after) + } +} + +// Behind a proxy the same request must be honoured, because there the peer +// address is the gateway and the claim is the only way to tell robots apart. +// This is the other half: the gate must not be so tight that the feature stops +// working where it is needed. +func TestProxyInstallDoesRewriteTheGUIDTheTokenNames(t *testing.T) { + withProxyMode(t, true) + path := withPersistencePaths(t, twoRobotsForPersistence) + before := storedGUID(t, path, "00e20146") + + CreateJWT(ctxFrom("172.28.0.1", issueJWT(t, "vic:00e20146")), false, false) + + if after := storedGUID(t, path, "00e20146"); after == before { + t.Errorf("robot B's GUID was not updated behind a proxy (still %q). The "+ + "token is the only per-robot signal there, so a refresh cannot complete "+ + "without it", before) + } +} + +// A robot refreshing from its own recognised address is the ordinary case and +// must keep working with no proxy configured at all. +func TestStockInstallStillRefreshesFromAKnownAddress(t *testing.T) { + withProxyMode(t, false) + path := withPersistencePaths(t, twoRobotsForPersistence) + before := storedGUID(t, path, "00e20145") + + CreateJWT(ctxFrom("192.0.2.150", ""), false, false) + + if after := storedGUID(t, path, "00e20145"); after == before { + t.Errorf("a robot refreshing from its own stored address %q did not get a "+ + "new GUID; the ordinary path is broken", before) + } +} + +// The claim must never be able to name a robot wire-pod has never seen, even +// behind a proxy, or a token alone could conjure an identity. +func TestATokenNamingAnUnknownRobotPersistsNothing(t *testing.T) { + withProxyMode(t, true) + path := withPersistencePaths(t, twoRobotsForPersistence) + beforeA := storedGUID(t, path, "00e20145") + beforeB := storedGUID(t, path, "00e20146") + + CreateJWT(ctxFrom("192.0.2.99", issueJWT(t, "vic:00ffffff")), false, false) + + if storedGUID(t, path, "00e20145") != beforeA || storedGUID(t, path, "00e20146") != beforeB { + t.Error("a token naming an unknown ESN disturbed a stored robot's GUID") + } +} diff --git a/chipper/pkg/vars/botinfo_test.go b/chipper/pkg/vars/botinfo_test.go new file mode 100644 index 000000000..9fa14e269 --- /dev/null +++ b/chipper/pkg/vars/botinfo_test.go @@ -0,0 +1,66 @@ +package vars + +import ( + "encoding/json" + "testing" +) + +// botSdkInfo.json is machine-observed state and its on-disk shape must not +// change. Admin intent lives in apiConfig.json precisely so this file, the +// anonymous struct literal at jdocs/botInfoStorer.go:144, and token.go's +// duplicate RobotInfoStore all stay untouched. +// +// GetEsnFromTarget matches ip_address in these bytes to identify a robot, so a +// changed key here changes identity resolution, not just storage. +const baselineBotInfoJSON = `{"global_guid":"tni1TRsTRTaNSapjo0Y+Sw==",` + + `"robots":[{"esn":"00e20145","ip_address":"192.168.1.150","guid":"abc123","activated":true}]}` + +func TestBotInfoRoundTripsUnchanged(t *testing.T) { + var info RobotInfoStore + if err := json.Unmarshal([]byte(baselineBotInfoJSON), &info); err != nil { + t.Fatalf("unmarshalling baseline botSdkInfo: %v", err) + } + got, err := json.Marshal(info) + if err != nil { + t.Fatalf("re-marshalling baseline botSdkInfo: %v", err) + } + if string(got) != baselineBotInfoJSON { + t.Errorf("botSdkInfo.json did not round-trip. A field was added to "+ + "RobotInfoStore; it belongs in apiConfig.json instead.\n got: %s\nwant: %s", + got, baselineBotInfoJSON) + } +} + +func TestBotInfoHasNoAddressOverrideField(t *testing.T) { + var info RobotInfoStore + if err := json.Unmarshal([]byte(baselineBotInfoJSON), &info); err != nil { + t.Fatalf("unmarshalling baseline botSdkInfo: %v", err) + } + raw, err := json.Marshal(info) + if err != nil { + t.Fatalf("marshalling: %v", err) + } + + var decoded struct { + Robots []map[string]json.RawMessage `json:"robots"` + } + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("decoding robots: %v", err) + } + if len(decoded.Robots) != 1 { + t.Fatalf("expected 1 robot, got %d", len(decoded.Robots)) + } + + want := map[string]bool{"esn": true, "ip_address": true, "guid": true, "activated": true} + for key := range decoded.Robots[0] { + if !want[key] { + t.Errorf("unexpected key %q in a robot record; per-robot admin state "+ + "belongs in apiConfig.json, not botSdkInfo.json", key) + } + } + for key := range want { + if _, ok := decoded.Robots[0][key]; !ok { + t.Errorf("baseline key %q disappeared from the robot record", key) + } + } +} diff --git a/chipper/pkg/vars/config.go b/chipper/pkg/vars/config.go index 40b60e6f7..2e708acc4 100644 --- a/chipper/pkg/vars/config.go +++ b/chipper/pkg/vars/config.go @@ -2,7 +2,13 @@ package vars import ( "encoding/json" + "errors" + "fmt" + "net" "os" + "sort" + "strconv" + "strings" "github.com/kercre123/wire-pod/chipper/pkg/logger" ) @@ -45,13 +51,147 @@ type apiConfig struct { // false for ip, true for escape pod EPConfig bool `json:"epconfig"` Port string `json:"port"` + // Gates the proxy-related UI and the BLE address capture. Set once + // during first-run setup. Does not gate the accessors: a configured + // address is always honored, so it can never silently stop applying. + ProxyMode bool `json:"proxy_mode,omitempty"` + // Address robots should use to reach wire-pod. Hostname or IP. + // Empty means derive it from GetOutboundIP(), the existing behavior. + // Applies to the IP/unlocked path only; EP mode is hardcoded. + RobotFacingAddress string `json:"robot_facing_address,omitempty"` + // ESN -> the LAN address wire-pod should dial for that robot. Empty or + // absent means use the observed peer address, the existing behavior. + // Kept here rather than on RobotInfo so botSdkInfo.json, which is + // machine-observed state, keeps its exact on-disk shape. + RobotLANAddresses map[string]string `json:"robot_lan_addresses,omitempty"` } `json:"server"` HasReadFromEnv bool `json:"hasreadfromenv"` PastInitialSetup bool `json:"pastinitialsetup"` } +// NormalizeConfig trims the admin-supplied networking fields and drops entries +// that carry no information. An empty RobotLANAddresses map is reduced to nil +// so that omitempty keeps a default install's apiConfig.json byte-identical. +// +// It also clears a robot-facing address that would not survive use. The setters +// validate, but a hand-edited file does not go through them, and +// CreateServerConfig concatenates ":" + port onto whatever it finds, so a +// stored "host:443" becomes "host:443:443". +// +// Must run before anything reads these fields and before every write. +func NormalizeConfig() { + APIConfig.Server.RobotFacingAddress = strings.TrimSpace(APIConfig.Server.RobotFacingAddress) + if err := ValidateRobotFacingAddress(APIConfig.Server.RobotFacingAddress); err != nil { + logger.Println("Ignoring the configured robot-facing address " + + strconv.Quote(APIConfig.Server.RobotFacingAddress) + ": " + err.Error() + + ". Falling back to this machine's outbound address.") + APIConfig.Server.RobotFacingAddress = "" + } + + robotLANMu.Lock() + defer robotLANMu.Unlock() + + if len(APIConfig.Server.RobotLANAddresses) == 0 { + APIConfig.Server.RobotLANAddresses = nil + return + } + // Sorted, because lookups are case-insensitive: two entries differing only + // in case would otherwise resolve by map iteration order, so the UI could + // show one address while wire-pod dialled the other, and which one won + // could change on restart. Sorting makes the survivor the same every load. + keys := make([]string, 0, len(APIConfig.Server.RobotLANAddresses)) + for esn := range APIConfig.Server.RobotLANAddresses { + keys = append(keys, esn) + } + sort.Strings(keys) + + normalized := make(map[string]string, len(keys)) + for _, key := range keys { + esn := strings.ToLower(strings.TrimSpace(key)) + addr := strings.TrimSpace(APIConfig.Server.RobotLANAddresses[key]) + // A blank ESN or address carries no intent; dropping it here is what + // makes "set to empty" clear an entry. + if esn == "" || addr == "" { + continue + } + if existing, dup := normalized[esn]; dup && existing != addr { + logger.Println("Duplicate robot LAN address entries for ESN " + esn + + "; discarding " + existing + " and keeping " + addr) + } + normalized[esn] = addr + } + if len(normalized) == 0 { + normalized = nil + } + APIConfig.Server.RobotLANAddresses = normalized +} + +// ValidateRobotFacingAddress rejects anything that is not a bare host or IPv4 +// address. This is not cosmetic: CreateServerConfig concatenates +// addr + ":" + port, so an input of "192.168.1.10:443" would silently yield +// "192.168.1.10:443:443" and produce a robot that cannot connect. +func ValidateRobotFacingAddress(addr string) error { + if addr == "" { + // Empty is the default and means "derive from GetOutboundIP()". + return nil + } + if strings.ContainsAny(addr, " \t\r\n") { + return errors.New("address must not contain whitespace") + } + if strings.Contains(addr, "://") { + return fmt.Errorf("address must be a bare host or IP, not a URL: %q", addr) + } + if strings.Contains(addr, "/") { + return fmt.Errorf("address must not contain a path: %q", addr) + } + if strings.Contains(addr, "@") { + return fmt.Errorf("address must not contain userinfo: %q", addr) + } + if ip := net.ParseIP(addr); ip != nil { + if ip.To4() == nil { + return fmt.Errorf("address must be IPv4 or a hostname; server_config.json "+ + "has no bracket syntax for IPv6: %q", addr) + } + return nil + } + if strings.Contains(addr, ":") { + return fmt.Errorf("address must not include a port, that comes from "+ + "the server port setting: %q", addr) + } + return validateHostname(addr) +} + +// validateHostname accepts a DNS name of the kind that can appear as a +// certificate DNS SAN, e.g. "escapepod.local" or "wirepod.lan". +func validateHostname(host string) error { + if len(host) > 253 { + return fmt.Errorf("hostname is longer than 253 characters: %q", host) + } + labels := strings.Split(strings.TrimSuffix(host, "."), ".") + for _, label := range labels { + if label == "" { + return fmt.Errorf("hostname has an empty label: %q", host) + } + if len(label) > 63 { + return fmt.Errorf("hostname label %q is longer than 63 characters", label) + } + if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { + return fmt.Errorf("hostname label %q must not start or end with a hyphen", label) + } + for _, r := range label { + isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') + isDigit := r >= '0' && r <= '9' + if !isLetter && !isDigit && r != '-' { + return fmt.Errorf("hostname %q contains an invalid character %q", host, r) + } + } + } + return nil +} + func WriteConfigToDisk() { logger.Println("Configuration changed, writing to disk") + NormalizeConfig() writeBytes, _ := json.Marshal(APIConfig) os.WriteFile(ApiConfigPath, writeBytes, 0644) } @@ -103,6 +243,10 @@ func ReadConfig() { APIConfig.Weather.Enable = false logger.Println("Failed to read API config file") logger.Println(err) + // Normalize on the error paths too: APIConfig may already hold a + // partially populated value, and no un-normalized field may reach + // an accessor. + NormalizeConfig() return } err = json.Unmarshal(configBytes, &APIConfig) @@ -111,8 +255,10 @@ func ReadConfig() { APIConfig.Weather.Enable = false logger.Println("Failed to unmarshal API config JSON") logger.Println(err) + NormalizeConfig() return } + NormalizeConfig() // stt service is the only thing controlled by shell if APIConfig.STT.Service != os.Getenv("STT_SERVICE") { WriteSTT() diff --git a/chipper/pkg/vars/config_test.go b/chipper/pkg/vars/config_test.go new file mode 100644 index 000000000..5cd783325 --- /dev/null +++ b/chipper/pkg/vars/config_test.go @@ -0,0 +1,122 @@ +package vars + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// Baseline shape of apiConfig as marshalled with every field at its zero value, +// captured at commit 1f33b63 before any proxy-networking work. +// +// This is the I4 guarantee in its strongest form: a default install's +// apiConfig.json must stay byte-identical. Any new field that is NOT tagged +// omitempty will break this test, which is the entire point — ReadConfig +// rewrites this file on every startup (config.go:132-133), so a new key here +// silently rewrites the config of every existing install on first boot. +const baselineZeroConfigJSON = `{"weather":{"enable":false,"provider":"","key":"","unit":""},` + + `"knowledge":{"enable":false,"provider":"","key":"","id":"","model":"","intentgraph":false,` + + `"robotName":"","openai_prompt":"","openai_voice":"","openai_voice_with_english":false,` + + `"save_chat":false,"commands_enable":false,"endpoint":"","top_p":0,"temp":0},` + + `"STT":{"provider":"","language":""},` + + `"server":{"epconfig":false,"port":""},` + + `"hasreadfromenv":false,"pastinitialsetup":false}` + +func TestZeroConfigMarshalsToBaselineBytes(t *testing.T) { + var cfg apiConfig + got, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshalling zero apiConfig: %v", err) + } + if string(got) != baselineZeroConfigJSON { + t.Errorf("zero-valued apiConfig no longer marshals to the baseline bytes.\n"+ + "A new field is missing `omitempty`, or an existing field changed.\n"+ + " got: %s\nwant: %s", got, baselineZeroConfigJSON) + } +} + +// A baseline config file must survive read-then-write untouched. ReadConfig +// unconditionally rewrites the file, so this is the round-trip an existing +// install actually experiences on upgrade. +func TestBaselineConfigRoundTripsUnchanged(t *testing.T) { + original := []byte(baselineZeroConfigJSON) + + var cfg apiConfig + if err := json.Unmarshal(original, &cfg); err != nil { + t.Fatalf("unmarshalling baseline config: %v", err) + } + got, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("re-marshalling baseline config: %v", err) + } + if string(got) != string(original) { + t.Errorf("baseline apiConfig.json did not round-trip.\n got: %s\nwant: %s", got, original) + } +} + +// A populated config must also round-trip, so the guarantee is not an artifact +// of everything being zero. +func TestPopulatedConfigRoundTripsUnchanged(t *testing.T) { + original := `{"weather":{"enable":true,"provider":"openweathermap","key":"abc","unit":"F"},` + + `"knowledge":{"enable":true,"provider":"openai","key":"redacted","id":"","model":"gpt-4",` + + `"intentgraph":false,"robotName":"Vector","openai_prompt":"hi","openai_voice":"nova",` + + `"openai_voice_with_english":true,"save_chat":true,"commands_enable":false,` + + `"endpoint":"https://api.openai.com/v1","top_p":0.9,"temp":0.7},` + + `"STT":{"provider":"vosk","language":"en-US"},` + + `"server":{"epconfig":true,"port":"443"},` + + `"hasreadfromenv":true,"pastinitialsetup":true}` + + var cfg apiConfig + if err := json.Unmarshal([]byte(original), &cfg); err != nil { + t.Fatalf("unmarshalling populated config: %v", err) + } + got, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("re-marshalling populated config: %v", err) + } + if string(got) != original { + t.Errorf("populated apiConfig.json did not round-trip.\n got: %s\nwant: %s", got, original) + } +} + +// ReadConfig is the real upgrade path: it reads, then unconditionally rewrites. +// An existing install's file must come back out identical. +func TestReadConfigDoesNotRewriteBaselineFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "apiConfig.json") + + // Port must match DDL_RPC_PORT or ReadConfig flips HasReadFromEnv / + // PastInitialSetup (config.go:120-125). Pin both so the only thing under + // test is field-shape stability. + t.Setenv("DDL_RPC_PORT", "443") + t.Setenv("STT_SERVICE", "vosk") + + original := `{"weather":{"enable":false,"provider":"","key":"","unit":""},` + + `"knowledge":{"enable":false,"provider":"","key":"","id":"","model":"","intentgraph":false,` + + `"robotName":"","openai_prompt":"","openai_voice":"","openai_voice_with_english":false,` + + `"save_chat":false,"commands_enable":false,"endpoint":"","top_p":0,"temp":0},` + + `"STT":{"provider":"vosk","language":"en-US"},` + + `"server":{"epconfig":true,"port":"443"},` + + `"hasreadfromenv":true,"pastinitialsetup":true}` + + if err := os.WriteFile(path, []byte(original), 0644); err != nil { + t.Fatalf("seeding config file: %v", err) + } + + restorePath, restoreCfg := ApiConfigPath, APIConfig + t.Cleanup(func() { ApiConfigPath, APIConfig = restorePath, restoreCfg }) + ApiConfigPath = path + APIConfig = apiConfig{} + + ReadConfig() + + after, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading config back: %v", err) + } + if string(after) != original { + t.Errorf("ReadConfig rewrote an existing install's apiConfig.json.\n"+ + "before: %s\n after: %s", original, after) + } +} diff --git a/chipper/pkg/vars/dialtarget_test.go b/chipper/pkg/vars/dialtarget_test.go new file mode 100644 index 000000000..f08da1735 --- /dev/null +++ b/chipper/pkg/vars/dialtarget_test.go @@ -0,0 +1,114 @@ +package vars + +import ( + "encoding/json" + "testing" +) + +// The fallback is the whole safety property: a call site that previously read +// bot.IPAddress directly must never end up with a worse value than before. +func TestGetRobotDialTargetFallsBackToObserved(t *testing.T) { + withCleanConfig(t) + + for _, tc := range []struct { + name string + m map[string]string + }{ + {"nil map", nil}, + {"empty map", map[string]string{}}, + {"other robot only", map[string]string{"00e29999": "192.168.1.99"}}, + {"blank value", map[string]string{"00e20145": ""}}, + {"whitespace-only value", map[string]string{"00e20145": " "}}, + } { + APIConfig.Server.RobotLANAddresses = tc.m + if got := GetRobotDialTarget("00e20145", "172.19.0.1"); got != "172.19.0.1" { + t.Errorf("%s: GetRobotDialTarget = %q, want the observed address %q", + tc.name, got, "172.19.0.1") + } + } +} + +func TestGetRobotDialTargetReturnsConfiguredAddress(t *testing.T) { + withCleanConfig(t) + APIConfig.Server.RobotLANAddresses = map[string]string{"00e20145": "192.0.2.13"} + + if got := GetRobotDialTarget("00e20145", "172.19.0.1"); got != "192.0.2.13" { + t.Errorf("GetRobotDialTarget = %q, want the configured address %q", got, "192.0.2.13") + } +} + +// ESNs appear in mixed case across the codebase, so matching must not depend +// on how the admin happened to type it. +func TestGetRobotDialTargetMatchesESNCaseInsensitively(t *testing.T) { + withCleanConfig(t) + APIConfig.Server.RobotLANAddresses = map[string]string{"00E20145": " 192.0.2.13 "} + + for _, esn := range []string{"00e20145", "00E20145", " 00e20145 "} { + if got := GetRobotDialTarget(esn, "172.19.0.1"); got != "192.0.2.13" { + t.Errorf("GetRobotDialTarget(%q) = %q, want %q", esn, got, "192.0.2.13") + } + } +} + +func TestSetRobotLANAddressSetsAndClears(t *testing.T) { + withCleanConfig(t) + + SetRobotLANAddress("00e20145", "192.0.2.13") + if got := GetRobotDialTarget("00e20145", "obs"); got != "192.0.2.13" { + t.Fatalf("after set, dial target = %q, want %q", got, "192.0.2.13") + } + + SetRobotLANAddress("00e20145", "") + if got := GetRobotDialTarget("00e20145", "obs"); got != "obs" { + t.Errorf("after clear, dial target = %q, want the observed address", got) + } + if APIConfig.Server.RobotLANAddresses != nil { + t.Error("clearing the only entry should leave a nil map so the field stays omitted") + } +} + +// One robot must never accumulate two entries that disagree. +func TestSetRobotLANAddressReplacesCaseVariantKey(t *testing.T) { + withCleanConfig(t) + + SetRobotLANAddress("00E20145", "192.0.2.13") + SetRobotLANAddress("00e20145", "192.0.2.99") + + if n := len(APIConfig.Server.RobotLANAddresses); n != 1 { + t.Errorf("map holds %d entries %v, want 1", n, APIConfig.Server.RobotLANAddresses) + } + if got := GetRobotDialTarget("00e20145", "obs"); got != "192.0.2.99" { + t.Errorf("dial target = %q, want the most recently set %q", got, "192.0.2.99") + } +} + +func TestSetRobotLANAddressIgnoresBlankESN(t *testing.T) { + withCleanConfig(t) + SetRobotLANAddress(" ", "192.0.2.13") + if APIConfig.Server.RobotLANAddresses != nil { + t.Errorf("a blank ESN created an entry: %v", APIConfig.Server.RobotLANAddresses) + } +} + +// Feature B must stay invisible to a default install. +func TestConfiguringDialTargetsKeepsDefaultConfigByteIdentical(t *testing.T) { + withCleanConfig(t) + + SetRobotLANAddress("00e20145", "192.0.2.13") + SetRobotLANAddress("00e20145", "") + NormalizeConfig() + + raw, err := marshalConfig() + if err != nil { + t.Fatalf("marshalling: %v", err) + } + if raw != baselineZeroConfigJSON { + t.Errorf("setting then clearing an address left the config changed.\n got: %s\nwant: %s", + raw, baselineZeroConfigJSON) + } +} + +func marshalConfig() (string, error) { + b, err := json.Marshal(APIConfig) + return string(b), err +} diff --git a/chipper/pkg/vars/normalize_test.go b/chipper/pkg/vars/normalize_test.go new file mode 100644 index 000000000..b7d497546 --- /dev/null +++ b/chipper/pkg/vars/normalize_test.go @@ -0,0 +1,168 @@ +package vars + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func withCleanConfig(t *testing.T) { + t.Helper() + restore := APIConfig + t.Cleanup(func() { APIConfig = restore }) + APIConfig = apiConfig{} +} + +// The three new fields must vanish entirely from a default install's config. +// This is the same guarantee as TestZeroConfigMarshalsToBaselineBytes, stated +// against the fields directly so a failure points at the cause. +func TestNewServerFieldsAreOmittedWhenUnset(t *testing.T) { + withCleanConfig(t) + NormalizeConfig() + + raw, err := json.Marshal(APIConfig) + if err != nil { + t.Fatalf("marshalling: %v", err) + } + var decoded struct { + Server map[string]json.RawMessage `json:"server"` + } + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("decoding server block: %v", err) + } + + for _, key := range []string{"proxy_mode", "robot_facing_address", "robot_lan_addresses"} { + if _, present := decoded.Server[key]; present { + t.Errorf("%q appears in a default config; it must be omitempty", key) + } + } + if len(decoded.Server) != 2 { + t.Errorf("server block has keys %v, want exactly epconfig and port", decoded.Server) + } +} + +func TestNormalizeTrimsRobotFacingAddress(t *testing.T) { + withCleanConfig(t) + APIConfig.Server.RobotFacingAddress = " escapepod.local\t" + NormalizeConfig() + + if got := APIConfig.Server.RobotFacingAddress; got != "escapepod.local" { + t.Errorf("RobotFacingAddress = %q, want %q", got, "escapepod.local") + } +} + +func TestNormalizeTrimsMapEntriesAndDropsBlanks(t *testing.T) { + withCleanConfig(t) + APIConfig.Server.RobotLANAddresses = map[string]string{ + " 00e20145 ": " 192.168.1.150 ", + "00e20146": " ", + " ": "192.168.1.152", + "00e20147": "192.168.1.153", + } + NormalizeConfig() + + got := APIConfig.Server.RobotLANAddresses + want := map[string]string{"00e20145": "192.168.1.150", "00e20147": "192.168.1.153"} + + if len(got) != len(want) { + t.Fatalf("normalized map = %v, want %v", got, want) + } + for esn, addr := range want { + if got[esn] != addr { + t.Errorf("entry %q = %q, want %q", esn, got[esn], addr) + } + } +} + +// An allocated-but-empty map must be reduced to nil, otherwise it still +// marshals as "robot_lan_addresses":{} and breaks byte-identity. +func TestNormalizeReducesEmptyMapToNil(t *testing.T) { + withCleanConfig(t) + APIConfig.Server.RobotLANAddresses = map[string]string{} + NormalizeConfig() + if APIConfig.Server.RobotLANAddresses != nil { + t.Error("empty map was not reduced to nil") + } + + APIConfig.Server.RobotLANAddresses = map[string]string{"00e20145": " "} + NormalizeConfig() + if APIConfig.Server.RobotLANAddresses != nil { + t.Error("map holding only blank values was not reduced to nil") + } + + raw, err := json.Marshal(APIConfig) + if err != nil { + t.Fatalf("marshalling: %v", err) + } + if string(raw) != baselineZeroConfigJSON { + t.Errorf("a config whose only content was blank entries is not byte-identical "+ + "to a default one.\n got: %s\nwant: %s", raw, baselineZeroConfigJSON) + } +} + +func TestValidateRobotFacingAddressAccepts(t *testing.T) { + for _, addr := range []string{ + "", + "escapepod.local", + "wirepod.lan", + "wire-pod.example.com", + "192.0.2.12", + "10.0.0.1", + "a", + "xn--80ak6aa92e.com", + } { + if err := ValidateRobotFacingAddress(addr); err != nil { + t.Errorf("ValidateRobotFacingAddress(%q) rejected a valid address: %v", addr, err) + } + } +} + +func TestValidateRobotFacingAddressRejects(t *testing.T) { + for _, tc := range []struct{ addr, why string }{ + {"http://escapepod.local", "scheme"}, + {"https://192.0.2.12", "scheme"}, + {"escapepod.local:443", "embedded port — would produce host:443:443"}, + {"192.0.2.12:443", "embedded port"}, + {"escapepod.local/ok", "path"}, + {"192.0.2.12/ok", "path"}, + {"escape pod.local", "whitespace"}, + {"escapepod.local\n", "trailing newline"}, + {"user@escapepod.local", "userinfo"}, + {"-badlabel.local", "label starts with a hyphen"}, + {"badlabel-.local", "label ends with a hyphen"}, + {"double..dot", "empty label"}, + {"under_score.local", "invalid character for a DNS SAN"}, + {"fe80::1", "IPv6 has no bracket syntax in server_config.json"}, + } { + if err := ValidateRobotFacingAddress(tc.addr); err == nil { + t.Errorf("ValidateRobotFacingAddress(%q) accepted an invalid address (%s)", + tc.addr, tc.why) + } + } +} + +// Normalization must survive the read path, including the error branches, +// so no un-normalized value can reach an accessor. +func TestReadConfigNormalizesOnUnmarshalFailure(t *testing.T) { + path := filepath.Join(t.TempDir(), "apiConfig.json") + if err := os.WriteFile(path, []byte("{ this is not json"), 0644); err != nil { + t.Fatalf("seeding bad config: %v", err) + } + + restorePath, restoreCfg := ApiConfigPath, APIConfig + t.Cleanup(func() { ApiConfigPath, APIConfig = restorePath, restoreCfg }) + ApiConfigPath = path + APIConfig = apiConfig{} + APIConfig.Server.RobotFacingAddress = " spaced.local " + APIConfig.Server.RobotLANAddresses = map[string]string{} + + ReadConfig() + + if got := APIConfig.Server.RobotFacingAddress; got != "spaced.local" { + t.Errorf("address not normalized on the unmarshal-error path: %q", got) + } + if APIConfig.Server.RobotLANAddresses != nil { + t.Error("empty map not reduced to nil on the unmarshal-error path") + } +} diff --git a/chipper/pkg/vars/upgrade_test.go b/chipper/pkg/vars/upgrade_test.go new file mode 100644 index 000000000..b05123bf2 --- /dev/null +++ b/chipper/pkg/vars/upgrade_test.go @@ -0,0 +1,330 @@ +package vars + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// withConfigFile points ApiConfigPath at a temp file seeded with contents and +// restores both the path and APIConfig afterwards. Package-level state, so +// nothing here may run in parallel. +func withConfigFile(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "apiConfig.json") + if err := os.WriteFile(path, []byte(contents), 0666); err != nil { + t.Fatalf("seeding config file: %v", err) + } + restorePath, restoreCfg := ApiConfigPath, APIConfig + t.Cleanup(func() { ApiConfigPath, APIConfig = restorePath, restoreCfg }) + ApiConfigPath, APIConfig = path, apiConfig{} + return path +} + +// The on-disk form of the three new fields, pinned. The unset case is covered +// several times over; this is the other half. Without it a renamed json tag +// would silently orphan every proxy install's settings on upgrade and no test +// would notice, because the byte-identity tests only ever see them omitted. +func TestConfiguredNetworkingFieldsMarshalToTheirDocumentedKeys(t *testing.T) { + withCleanConfig(t) + APIConfig.Server.ProxyMode = true + APIConfig.Server.RobotFacingAddress = "wirepod.lan" + APIConfig.Server.RobotLANAddresses = map[string]string{"00e20145": "192.0.2.13"} + NormalizeConfig() + + raw, err := json.Marshal(APIConfig) + if err != nil { + t.Fatalf("marshalling: %v", err) + } + var probe struct { + Server map[string]json.RawMessage `json:"server"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + t.Fatalf("unmarshalling: %v", err) + } + + for key, want := range map[string]string{ + "proxy_mode": `true`, + "robot_facing_address": `"wirepod.lan"`, + "robot_lan_addresses": `{"00e20145":"192.0.2.13"}`, + } { + got, ok := probe.Server[key] + if !ok { + t.Errorf("server.%s is missing; a configured install would lose this setting", key) + continue + } + if string(got) != want { + t.Errorf("server.%s = %s, want %s", key, got, want) + } + } +} + +// The round trip an existing install actually performs when an admin sets an +// address and then clears it again: the file must gain exactly one key and then +// return to its original bytes. WriteConfigToDisk is the only persistence path +// for all of this and is otherwise untested. +func TestSettingAndClearingALANAddressLeavesTheFileAsItWas(t *testing.T) { + path := withConfigFile(t, baselineZeroConfigJSON) + original, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading seed: %v", err) + } + + if err := json.Unmarshal(original, &APIConfig); err != nil { + t.Fatalf("unmarshalling seed: %v", err) + } + + SetRobotLANAddress("00e20145", "192.0.2.13") + WriteConfigToDisk() + + withAddress, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading after set: %v", err) + } + if string(withAddress) == string(original) { + t.Fatal("setting a LAN address did not change the file at all") + } + var probe struct { + Server map[string]json.RawMessage `json:"server"` + } + if err := json.Unmarshal(withAddress, &probe); err != nil { + t.Fatalf("unmarshalling after set: %v", err) + } + if len(probe.Server) != 3 { + t.Errorf("server block has %d keys after setting one address, want 3 "+ + "(epconfig, port, robot_lan_addresses); got %v", len(probe.Server), probe.Server) + } + + SetRobotLANAddress("00e20145", "") + WriteConfigToDisk() + + cleared, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading after clear: %v", err) + } + if string(cleared) != string(original) { + t.Errorf("clearing the only LAN address did not restore the original bytes.\n"+ + " got: %s\nwant: %s", cleared, original) + } +} + +// Configuring one robot must not disturb another. Every other dial-target test +// uses a single-entry map, so nothing else would catch a setter that rebuilt +// the map instead of editing it. +func TestSettingOneRobotLeavesTheOthersAlone(t *testing.T) { + withCleanConfig(t) + SetRobotLANAddress("00e20145", "192.0.2.13") + SetRobotLANAddress("00e20146", "192.0.2.14") + + SetRobotLANAddress("00e20145", "192.0.2.99") + + for _, tc := range []struct{ esn, want string }{ + {"00e20145", "192.0.2.99"}, + {"00e20146", "192.0.2.14"}, + } { + if got := GetRobotDialTarget(tc.esn, "observed"); got != tc.want { + t.Errorf("GetRobotDialTarget(%q) = %q, want %q; editing one robot "+ + "disturbed another", tc.esn, got, tc.want) + } + } +} + +// Lookups are case-insensitive, so two keys differing only in case would +// otherwise resolve by map iteration order: the UI could show one address while +// wire-pod dialled the other, and the winner could change on restart. A +// hand-edited config is the only way to produce this, which is exactly why the +// setter's own de-duplication does not cover it. +func TestNormalizeCollapsesCaseVariantESNKeysDeterministically(t *testing.T) { + const seed = `{"server":{"robot_lan_addresses":` + + `{"00E20145":"192.0.2.13","00e20145":"192.0.2.99"}}}` + + first := "" + for attempt := 0; attempt < 20; attempt++ { + withCleanConfig(t) + if err := json.Unmarshal([]byte(seed), &APIConfig); err != nil { + t.Fatalf("unmarshalling seed: %v", err) + } + NormalizeConfig() + + if n := len(APIConfig.Server.RobotLANAddresses); n != 1 { + t.Fatalf("case-variant keys collapsed to %d entries, want 1: %v", + n, APIConfig.Server.RobotLANAddresses) + } + got := GetRobotDialTarget("00e20145", "observed") + if first == "" { + first = got + } + if got != first { + t.Fatalf("dial target changed between loads of an identical config: "+ + "%q then %q; the survivor must not depend on map iteration order", first, got) + } + } +} + +// The setters validate, but a hand-edited apiConfig.json does not go through +// them. CreateServerConfig concatenates ":" + port onto whatever it finds, so a +// stored "host:443" would become "host:443:443" and produce a robot that cannot +// connect. Clearing it falls back to the outbound IP: wrong, but reachable. +func TestNormalizeClearsAnUnusableRobotFacingAddressFromDisk(t *testing.T) { + for _, tc := range []struct{ stored, why string }{ + {"192.168.1.10:443", "host:port would be concatenated into host:port:port"}, + {"https://wirepod.lan", "a URL is not a bare host"}, + {"wirepod.lan/ok", "a path is not a bare host"}, + {"fe80::1", "IPv6 cannot be concatenated with :port"}, + } { + withCleanConfig(t) + APIConfig.Server.RobotFacingAddress = tc.stored + NormalizeConfig() + + if APIConfig.Server.RobotFacingAddress != "" { + t.Errorf("a stored %q survived normalization (%s); it would reach "+ + "server_config.json unusable", tc.stored, tc.why) + } + } +} + +// The counterpart: a legitimate stored value must not be discarded. +func TestNormalizeKeepsAUsableRobotFacingAddressFromDisk(t *testing.T) { + for _, stored := range []string{"wirepod.lan", "escapepod.local", "192.0.2.12"} { + withCleanConfig(t) + APIConfig.Server.RobotFacingAddress = stored + NormalizeConfig() + + if APIConfig.Server.RobotFacingAddress != stored { + t.Errorf("normalization discarded the valid stored address %q (got %q)", + stored, APIConfig.Server.RobotFacingAddress) + } + } +} + +// A realistic pre-fork config: past setup, port and STT matching the env below, +// so ReadConfig's env reconciliation does not mutate anything and the only +// variable under test is the networking field. +const preforkConfigJSON = `{"weather":{"enable":false,"provider":"","key":"","unit":""},` + + `"knowledge":{"enable":false,"provider":"","key":"","id":"","model":"","intentgraph":false,` + + `"robotName":"","openai_prompt":"","openai_voice":"","openai_voice_with_english":false,` + + `"save_chat":false,"commands_enable":false,"endpoint":"","top_p":0,"temp":0},` + + `"STT":{"provider":"vosk","language":"en-US"},` + + `"server":{"epconfig":true,"port":"443"},` + + `"hasreadfromenv":true,"pastinitialsetup":true}` + +// The path a restart actually takes: an address is set through the setter, the +// config is written, the process comes back with nothing in memory, ReadConfig +// loads from disk, and the dial target must still be the configured address. +// +// The write and the read are each covered above but never joined, and a robot +// only stays reachable across a restart if the whole path holds. A serialiser +// that wrote the field under the wrong key, or a loader that dropped it, would +// satisfy every individual test here and still lose the setting on reboot. +func TestAConfiguredAddressSurvivesAWriteAndReload(t *testing.T) { + t.Setenv("DDL_RPC_PORT", "443") + t.Setenv("STT_SERVICE", "vosk") + withConfigFile(t, preforkConfigJSON) + + ReadConfig() + if got := GetRobotDialTarget("00e20145", "172.28.0.1"); got != "172.28.0.1" { + t.Fatalf("precondition: an unconfigured robot should dial its observed "+ + "address, got %q", got) + } + + SetRobotLANAddress("00e20145", "192.0.2.13") + WriteConfigToDisk() + + // The restart: nothing survives in memory. + APIConfig = apiConfig{} + ReadConfig() + + if got := GetRobotDialTarget("00e20145", "172.28.0.1"); got != "192.0.2.13" { + t.Errorf("after write and reload the dial target is %q, want %q. The "+ + "address was lost somewhere between the setter and the loader, so a "+ + "robot behind a proxy becomes unreachable on the next restart", got, "192.0.2.13") + } +} + +// Clearing must survive a reload too, or an address an admin removed comes back +// from disk and wire-pod keeps dialling somewhere the robot no longer is. +func TestAClearedAddressStaysClearedAcrossAReload(t *testing.T) { + t.Setenv("DDL_RPC_PORT", "443") + t.Setenv("STT_SERVICE", "vosk") + withConfigFile(t, preforkConfigJSON) + + ReadConfig() + SetRobotLANAddress("00e20145", "192.0.2.13") + WriteConfigToDisk() + + SetRobotLANAddress("00e20145", "") + WriteConfigToDisk() + + APIConfig = apiConfig{} + ReadConfig() + + if got := GetRobotDialTarget("00e20145", "172.28.0.1"); got != "172.28.0.1" { + t.Errorf("after clearing and reloading the dial target is %q, want the "+ + "observed address %q", got, "172.28.0.1") + } +} + +// WriteConfigToDisk normalises before marshalling, and that is load-bearing +// rather than belt-and-braces. Other paths legitimately mutate APIConfig +// without going through SetRobotLANAddress -- the first-run wizard, the config +// handlers, a future caller -- and whatever reaches disk is what the next +// process reads back and dials. +// +// Found by mutation: deleting the NormalizeConfig call from WriteConfigToDisk +// previously broke no test at all, because the only covered write path happened +// to normalise itself on the way in. +func TestWriteNormalisesWhatReachesDisk(t *testing.T) { + path := withConfigFile(t, preforkConfigJSON) + + // Assign directly, as code outside the setter does. + APIConfig.Server.RobotFacingAddress = " wirepod.lan " + APIConfig.Server.RobotLANAddresses = map[string]string{"00E20145": " 192.0.2.13 "} + WriteConfigToDisk() + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading written config: %v", err) + } + var cfg struct { + Server struct { + RobotFacingAddress string `json:"robot_facing_address"` + RobotLANAddresses map[string]string `json:"robot_lan_addresses"` + } `json:"server"` + } + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("parsing written config: %v", err) + } + + if cfg.Server.RobotFacingAddress != "wirepod.lan" { + t.Errorf("robot_facing_address written as %q, want it trimmed to %q; "+ + "CreateServerConfig concatenates \":\"+port onto this", + cfg.Server.RobotFacingAddress, "wirepod.lan") + } + if got, ok := cfg.Server.RobotLANAddresses["00e20145"]; !ok || got != "192.0.2.13" { + t.Errorf("robot_lan_addresses written as %v, want the key lowercased and "+ + "the value trimmed; lookups are case-insensitive, so a stored variant "+ + "resolves by map iteration order", cfg.Server.RobotLANAddresses) + } +} + +// An emptied map must be omitted, not written as {}. omitempty elides a nil map +// but not an empty non-nil one, so without the normalise-on-write a default +// install that set and removed an address would no longer match upstream byte +// for byte -- the guarantee the whole feature rests on. +func TestAnEmptiedMapIsOmittedFromDisk(t *testing.T) { + path := withConfigFile(t, preforkConfigJSON) + + APIConfig.Server.RobotLANAddresses = map[string]string{} + WriteConfigToDisk() + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading written config: %v", err) + } + if strings.Contains(string(raw), "robot_lan_addresses") { + t.Errorf("an empty map was written to disk as a key; a default install's "+ + "config must stay byte-identical to upstream.\n got: %s", raw) + } +} diff --git a/chipper/pkg/vars/vars.go b/chipper/pkg/vars/vars.go index 73dab193c..6ef2a049b 100644 --- a/chipper/pkg/vars/vars.go +++ b/chipper/pkg/vars/vars.go @@ -12,6 +12,7 @@ import ( "runtime" "strconv" "strings" + "sync" "github.com/fforchino/vector-go-sdk/pkg/vector" "github.com/kercre123/wire-pod/chipper/pkg/logger" @@ -415,6 +416,86 @@ func AddToRInfo(esn string, id string, ip string) { RecurringInfo = append(RecurringInfo, rinfo) } +// robotLANMu guards RobotLANAddresses, the only map in APIConfig. It is read +// from request goroutines while the Bot Setup handler, the BLE capture and +// NormalizeConfig mutate it, and a concurrent map read and write is a fatal +// runtime error rather than a recoverable panic. +var robotLANMu sync.RWMutex + +// GetRobotFacingAddress returns the address robots should use to reach +// wire-pod. Empty configuration falls back to the outbound IP, which is +// exactly today's behavior. +// +// This must appear in the generated certificate's SANs: the robot pins that +// leaf and derives ServerName from the dial authority, so server_config.json +// and the certificate have to agree or the handshake fails. +func GetRobotFacingAddress() string { + if addr := strings.TrimSpace(APIConfig.Server.RobotFacingAddress); addr != "" { + return addr + } + return GetOutboundIP().String() +} + +// GetRobotDialTarget returns the address wire-pod should dial for esn. +// +// observed is the caller's existing bot.IPAddress and is returned whenever no +// override is configured, so a call site can never end up with an empty target. +// +// This affects outbound dialing only. ip_address keeps its meaning as the +// passively observed peer address and remains the identity key. +func GetRobotDialTarget(esn, observed string) string { + if addr := GetConfiguredLANAddress(esn); addr != "" { + return addr + } + return observed +} + +// GetConfiguredLANAddress returns the admin-configured LAN address for esn, or +// "" when none is set. Exported so callers outside this package never touch the +// map directly, which is what keeps robotLANMu able to do its job. +func GetConfiguredLANAddress(esn string) string { + esn = strings.TrimSpace(esn) + robotLANMu.RLock() + defer robotLANMu.RUnlock() + for k, v := range APIConfig.Server.RobotLANAddresses { + if strings.EqualFold(strings.TrimSpace(k), esn) { + if v = strings.TrimSpace(v); v != "" { + return v + } + } + } + return "" +} + +// SetRobotLANAddress records the LAN address wire-pod should dial for esn. +// An empty addr clears the entry. The caller persists via WriteConfigToDisk. +func SetRobotLANAddress(esn, addr string) { + esn = strings.TrimSpace(esn) + addr = strings.TrimSpace(addr) + if esn == "" { + return + } + robotLANMu.Lock() + defer robotLANMu.Unlock() + // Drop any case-variant of this ESN so one robot cannot end up with two + // entries that disagree. + for k := range APIConfig.Server.RobotLANAddresses { + if strings.EqualFold(strings.TrimSpace(k), esn) { + delete(APIConfig.Server.RobotLANAddresses, k) + } + } + if addr == "" { + if len(APIConfig.Server.RobotLANAddresses) == 0 { + APIConfig.Server.RobotLANAddresses = nil + } + return + } + if APIConfig.Server.RobotLANAddresses == nil { + APIConfig.Server.RobotLANAddresses = make(map[string]string) + } + APIConfig.Server.RobotLANAddresses[esn] = addr +} + func GetRobot(esn string) (*vector.Vector, error) { var guid string var target string @@ -422,7 +503,7 @@ func GetRobot(esn string) (*vector.Vector, error) { for _, bot := range BotInfo.Robots { if esn == bot.Esn { guid = bot.GUID - target = bot.IPAddress + ":443" + target = GetRobotDialTarget(bot.Esn, bot.IPAddress) + ":443" matched = true break } diff --git a/chipper/pkg/wirepod/config-ws/webserver.go b/chipper/pkg/wirepod/config-ws/webserver.go index 72a42a3d7..1f356b491 100755 --- a/chipper/pkg/wirepod/config-ws/webserver.go +++ b/chipper/pkg/wirepod/config-ws/webserver.go @@ -63,6 +63,10 @@ func apiHandler(w http.ResponseWriter, r *http.Request) { handleGetOTA(w, r) case "get_version_info": handleGetVersionInfo(w) + case "get_robots": + handleGetRobots(w) + case "set_robot_lan_address": + handleSetRobotLANAddress(w, r) case "generate_certs": handleGenerateCerts(w) case "is_api_v3": @@ -371,11 +375,78 @@ func handleGetVersionInfo(w http.ResponseWriter) { json.NewEncoder(w).Encode(verInfo) } +// RobotDialInfo is one row of the Bot Setup address list. It deliberately +// separates the observed peer address from the configured override so the +// distinction stays visible to the admin: ip_address is what wire-pod saw, +// and behind a proxy that is the gateway rather than the robot. +type RobotDialInfo struct { + Esn string `json:"esn"` + // Passively observed peer address from botSdkInfo.json. Never written here. + ObservedAddress string `json:"observed_address"` + // Admin-configured LAN address, empty when none is set. + ConfiguredAddress string `json:"configured_address"` + // What wire-pod will actually dial: configured if set, else observed. + EffectiveTarget string `json:"effective_target"` + Activated bool `json:"activated"` +} + +func handleGetRobots(w http.ResponseWriter) { + robots := make([]RobotDialInfo, 0, len(vars.BotInfo.Robots)) + for _, bot := range vars.BotInfo.Robots { + robots = append(robots, RobotDialInfo{ + Esn: bot.Esn, + ObservedAddress: bot.IPAddress, + ConfiguredAddress: vars.GetConfiguredLANAddress(bot.Esn), + EffectiveTarget: vars.GetRobotDialTarget(bot.Esn, bot.IPAddress), + Activated: bot.Activated, + }) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(robots) +} + +func handleSetRobotLANAddress(w http.ResponseWriter, r *http.Request) { + var req struct { + Esn string `json:"esn"` + Address string `json:"address"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Esn) == "" { + http.Error(w, "must provide an esn", http.StatusBadRequest) + return + } + // Same validation as the robot-facing address: a bare host or IPv4, no + // port. GetRobotDialTarget appends ":443", so an embedded port here would + // produce host:443:443 exactly as it would in server_config.json. + if err := vars.ValidateRobotFacingAddress(strings.TrimSpace(req.Address)); err != nil { + http.Error(w, "invalid address: "+err.Error(), http.StatusBadRequest) + return + } + vars.SetRobotLANAddress(req.Esn, req.Address) + vars.WriteConfigToDisk() + fmt.Fprint(w, "Changes successfully applied.") +} + func handleGenerateCerts(w http.ResponseWriter) { + // The robot appends wire-pod's leaf certificate to its own root pool, so + // a new certificate shares no trust with the old one. Every robot that + // pinned the previous certificate must be set up again. + if n := len(vars.BotInfo.Robots); n > 0 { + logger.Println(fmt.Sprint("WARNING: regenerating the certificate while ", n, + " robot(s) are known to wire-pod. Each pinned the previous certificate ", + "and will fail to connect until it is set up again.")) + } if err := botsetup.CreateCertCombo(); err != nil { http.Error(w, "error: "+err.Error(), http.StatusInternalServerError) return } + // Keep the pair coherent. The robot verifies the address in + // server_config.json against this certificate's SANs, so the two must + // never be written independently. + botsetup.CreateServerConfig() fmt.Fprint(w, "done") } diff --git a/chipper/pkg/wirepod/sdkapp/conncheck_test.go b/chipper/pkg/wirepod/sdkapp/conncheck_test.go new file mode 100644 index 000000000..286091d25 --- /dev/null +++ b/chipper/pkg/wirepod/sdkapp/conncheck_test.go @@ -0,0 +1,153 @@ +package sdkapp + +import ( + "net/http/httptest" + "testing" + + "github.com/kercre123/wire-pod/chipper/pkg/vars" +) + +func withRobots(t *testing.T, robots ...struct{ esn, ip string }) { + t.Helper() + restore := vars.BotInfo + t.Cleanup(func() { vars.BotInfo = restore }) + + vars.BotInfo = vars.RobotInfoStore{GlobalGUID: "g"} + for _, r := range robots { + vars.BotInfo.Robots = append(vars.BotInfo.Robots, struct { + Esn string `json:"esn"` + IPAddress string `json:"ip_address"` + GUID string `json:"guid"` + Activated bool `json:"activated"` + }{Esn: r.esn, IPAddress: r.ip, GUID: "guid-" + r.esn, Activated: true}) + } +} + +// withProxyMode sets server.proxy_mode for the duration of a test. The reported +// ESN is only preferred behind a proxy, so anything exercising emresn needs it. +func withProxyMode(t *testing.T, on bool) { + t.Helper() + restore := vars.APIConfig + t.Cleanup(func() { vars.APIConfig = restore }) + vars.APIConfig.Server.ProxyMode = on +} + +// The whole point: behind a proxy every robot presents the same peer address, +// and the ESN the robot itself reports is what disambiguates them. +func TestResolveConnCheckESNPrefersReportedESN(t *testing.T) { + withProxyMode(t, true) + withRobots(t, + struct{ esn, ip string }{"00e20145", "172.19.0.1"}, + struct{ esn, ip string }{"00e20146", "172.19.0.1"}, + ) + + for _, want := range []string{"00e20145", "00e20146"} { + r := httptest.NewRequest("HEAD", "/ok?emresn="+want+"&ankiversion=1.8.1", nil) + r.RemoteAddr = "172.19.0.1:41234" + + if got := resolveConnCheckESN(r); got != want { + t.Errorf("two robots behind one gateway: resolveConnCheckESN = %q, want %q "+ + "(the reported ESN must win over the shared peer address)", got, want) + } + } +} + +// Without emresn the peer address is all there is, and the first match wins -- +// the pre-existing behavior this change does not alter. +func TestResolveConnCheckESNFallsBackToPeerAddress(t *testing.T) { + withRobots(t, struct{ esn, ip string }{"00e20145", "192.168.1.150"}) + + r := httptest.NewRequest("HEAD", "/ok", nil) + r.RemoteAddr = "192.168.1.150:41234" + + if got := resolveConnCheckESN(r); got != "00e20145" { + t.Errorf("resolveConnCheckESN = %q, want %q from the peer address", got, "00e20145") + } +} + +// An ESN we have never stored must not short-circuit the address path; a +// connection check is not an adoption. +func TestResolveConnCheckESNIgnoresUnknownESN(t *testing.T) { + withProxyMode(t, true) + withRobots(t, struct{ esn, ip string }{"00e20145", "192.168.1.150"}) + + r := httptest.NewRequest("HEAD", "/ok?emresn=00ffffff", nil) + r.RemoteAddr = "192.168.1.150:41234" + + if got := resolveConnCheckESN(r); got != "00e20145" { + t.Errorf("resolveConnCheckESN = %q, want the peer-address match %q; an unknown "+ + "reported ESN must fall through rather than invent a robot", got, "00e20145") + } +} + +func TestResolveConnCheckESNMatchesESNCaseInsensitively(t *testing.T) { + withProxyMode(t, true) + withRobots(t, struct{ esn, ip string }{"00e20145", "172.19.0.1"}) + + r := httptest.NewRequest("HEAD", "/ok?emresn=00E20145", nil) + r.RemoteAddr = "172.19.0.1:41234" + + if got := resolveConnCheckESN(r); got != "00e20145" { + t.Errorf("resolveConnCheckESN = %q, want %q", got, "00e20145") + } +} + +// Returning "" is what preserves the existing mDNS-browse branch. +func TestResolveConnCheckESNReturnsEmptyWhenUnidentifiable(t *testing.T) { + withRobots(t, struct{ esn, ip string }{"00e20145", "192.168.1.150"}) + + r := httptest.NewRequest("HEAD", "/ok", nil) + r.RemoteAddr = "10.0.0.99:41234" + + if got := resolveConnCheckESN(r); got != "" { + t.Errorf("resolveConnCheckESN = %q, want \"\" so the mDNS fallback still runs", got) + } +} + +// Without proxy_mode the peer address stays authoritative even when the robot +// reports an ESN. That is what keeps the mDNS self-heal alive on a stock +// install: returning "" here is what makes connCheck fall through to the browse +// that rewrites a robot's stored ip_address after DHCP moves it. Preferring +// emresn unconditionally would retire that path without anyone noticing. +func TestResolveConnCheckESNIgnoresReportedESNWithoutProxyMode(t *testing.T) { + withProxyMode(t, false) + withRobots(t, struct{ esn, ip string }{"00e20145", "192.168.1.150"}) + + // The robot moved; the address on file is stale. + r := httptest.NewRequest("HEAD", "/ok?emresn=00e20145", nil) + r.RemoteAddr = "192.168.1.77:41234" + + if got := resolveConnCheckESN(r); got != "" { + t.Errorf("resolveConnCheckESN = %q, want \"\" so connCheck still reaches the "+ + "mDNS browse that repairs the stored address", got) + } +} + +// A proxied robot whose stored ip_address is its real LAN address (because +// Feature B or mDNS recorded it) would previously fail the peer-address match +// entirely and trigger an mDNS browse on every check. emresn fixes that. +func TestReportedESNResolvesWhenPeerAddressDoesNotMatch(t *testing.T) { + withProxyMode(t, true) + withRobots(t, struct{ esn, ip string }{"00e20145", "192.0.2.13"}) + + r := httptest.NewRequest("HEAD", "/ok?emresn=00e20145", nil) + r.RemoteAddr = "172.19.0.1:41234" // the gateway, not the robot + + if got := resolveConnCheckESN(r); got != "00e20145" { + t.Errorf("resolveConnCheckESN = %q, want %q; the robot is identifiable even "+ + "though its stored address does not match the peer", got, "00e20145") + } +} + +func TestESNForAddressIgnoresPortAndCase(t *testing.T) { + withRobots(t, struct{ esn, ip string }{"00e20145", "192.168.1.150"}) + + for _, addr := range []string{"192.168.1.150", "192.168.1.150:41234", " 192.168.1.150 "} { + if got := esnForAddress(addr); got != "00e20145" { + t.Errorf("esnForAddress(%q) = %q, want %q", addr, got, "00e20145") + } + } + if got := esnForAddress("10.0.0.1"); got != "" { + t.Errorf("esnForAddress on an unknown address = %q, want \"\"", got) + } +} diff --git a/chipper/pkg/wirepod/sdkapp/connpath_test.go b/chipper/pkg/wirepod/sdkapp/connpath_test.go new file mode 100644 index 000000000..98315968d --- /dev/null +++ b/chipper/pkg/wirepod/sdkapp/connpath_test.go @@ -0,0 +1,131 @@ +package sdkapp + +import ( + "net/http/httptest" + "testing" +) + +// withMDNSAlreadyRun pre-registers an address so RunMDNS returns immediately +// instead of performing a real browse. Without it these tests would fire a live +// mDNS query into the network. +func withMDNSAlreadyRun(t *testing.T, ip string) { + t.Helper() + restore := MDNSAlreadyRun + t.Cleanup(func() { MDNSAlreadyRun = restore }) + MDNSAlreadyRun = append(MDNSAlreadyRun, ip) +} + +// trackRobotAsChecking puts esn in the pinger list in the state a robot that is +// already checking in normally would be: tracked, not stopped. ShouldPingJdocs- +// ForESN then returns false, so the jdocs pull -- which would dial the robot -- +// never happens, while still resetting the timer. That reset is the observable +// this file uses to tell which branch of connCheck ran. +func trackRobotAsChecking(t *testing.T, esn string, marker int) { + t.Helper() + ShouldPingJdocsForESN(esn) + JdocsPingerBots.mu.Lock() + defer JdocsPingerBots.mu.Unlock() + for i := range JdocsPingerBots.Robots { + if JdocsPingerBots.Robots[i].ESN == esn { + JdocsPingerBots.Robots[i].TimeSinceLastCheck = marker + return + } + } + t.Fatalf("failed to seed pinger state for %s", esn) +} + +// okRequest builds the request a Vector actually sends: a bare HEAD /ok, with +// ?emresn= only on the newer firmware path. +func okRequest(emresn, peer string) *httptest.ResponseRecorder { + path := "/ok" + if emresn != "" { + path += "?emresn=" + emresn + } + r := httptest.NewRequest("HEAD", path, nil) + r.RemoteAddr = peer + ":41234" + w := httptest.NewRecorder() + connCheck(w, r) + return w +} + +// Behind a proxy the peer address is the gateway and matches no robot, so +// without the reported ESN the request would fall to the mDNS branch on every +// single check. This asserts the whole path -- request in, pinger updated -- +// rather than just the resolver's return value. +func TestConnCheckReachesThePingerViaReportedESNBehindAProxy(t *testing.T) { + withProxyMode(t, true) + withRobots(t, struct{ esn, ip string }{"00e20145", "192.0.2.13"}) + withEmptyPingerList(t) + withMDNSAlreadyRun(t, "172.28.0.1") + trackRobotAsChecking(t, "00e20145", 9) + + w := okRequest("00e20145", "172.28.0.1") // the gateway, not the robot + + if body := w.Body.String(); body != "ok" { + t.Errorf("connCheck body = %q, want \"ok\"; the robot treats anything else as unreachable", body) + } + if _, since, _ := pingerState("00e20145"); since != 0 { + t.Errorf("TimeSinceLastCheck = %d, want 0. The request never reached the "+ + "pinger, so the robot is not being counted as checking in", since) + } +} + +// The regression the gating exists to prevent. On a stock install the peer +// address is authoritative, so a robot whose stored address is stale must NOT +// resolve -- because not resolving is what sends connCheck to the mDNS browse +// that rewrites the stale address. If this starts passing through to the pinger, +// the self-heal is gone and a robot that changes IP stays unreachable forever. +func TestConnCheckFallsToMDNSWhenTheStoredAddressIsStaleAndProxyModeIsOff(t *testing.T) { + withProxyMode(t, false) + withRobots(t, struct{ esn, ip string }{"00e20145", "192.0.2.13"}) + withEmptyPingerList(t) + withMDNSAlreadyRun(t, "192.0.2.77") + trackRobotAsChecking(t, "00e20145", 9) + + // The robot moved to .77; botSdkInfo.json still says .13. + w := okRequest("00e20145", "192.0.2.77") + + if body := w.Body.String(); body != "ok" { + t.Errorf("connCheck body = %q, want \"ok\"", body) + } + if _, since, _ := pingerState("00e20145"); since != 9 { + t.Errorf("TimeSinceLastCheck = %d, want the marker 9 left untouched. The "+ + "request reached the pinger, which means it did NOT take the mDNS "+ + "branch -- the path that repairs a stale stored address", since) + } +} + +// With proxy mode off and the address matching, the ordinary path must still +// work end to end. Guards against a gate that fixes the stale case by breaking +// the common one. +func TestConnCheckReachesThePingerViaPeerAddressOnAStockInstall(t *testing.T) { + withProxyMode(t, false) + withRobots(t, struct{ esn, ip string }{"00e20145", "192.0.2.13"}) + withEmptyPingerList(t) + withMDNSAlreadyRun(t, "192.0.2.13") + trackRobotAsChecking(t, "00e20145", 9) + + okRequest("", "192.0.2.13") + + if _, since, _ := pingerState("00e20145"); since != 0 { + t.Errorf("TimeSinceLastCheck = %d, want 0; a robot checking in from its "+ + "own stored address must still be recognised", since) + } +} + +// An address belonging to no known robot has nothing to resolve to, on any +// configuration, and must reach the mDNS branch so the robot can be discovered. +func TestConnCheckFallsToMDNSForAnUnknownRobot(t *testing.T) { + withProxyMode(t, true) + withRobots(t, struct{ esn, ip string }{"00e20145", "192.0.2.13"}) + withEmptyPingerList(t) + withMDNSAlreadyRun(t, "192.0.2.99") + trackRobotAsChecking(t, "00e20145", 9) + + okRequest("", "192.0.2.99") + + if _, since, _ := pingerState("00e20145"); since != 9 { + t.Errorf("TimeSinceLastCheck = %d, want the marker 9; an unknown address "+ + "must not be attributed to an existing robot", since) + } +} diff --git a/chipper/pkg/wirepod/sdkapp/jdocspinger.go b/chipper/pkg/wirepod/sdkapp/jdocspinger.go index 43f23c629..cd75cb225 100644 --- a/chipper/pkg/wirepod/sdkapp/jdocspinger.go +++ b/chipper/pkg/wirepod/sdkapp/jdocspinger.go @@ -37,21 +37,30 @@ type JdocsPingerRobot struct { // setting JDOCS_PINGER_ENABLED=false will disable jdocs pinger var PingerEnabled bool = true -func pingJdocs(target string) { - ctx := context.Background() - target = strings.Split(target, ":")[0] - var serial string - matched := false +// esnForAddress resolves an observed peer address to an ESN. Behind a proxy +// every robot presents the same address, so this returns the first match -- +// which is why callers that can identify the robot directly should do so. +func esnForAddress(target string) string { + target = strings.TrimSpace(strings.ToLower(strings.Split(target, ":")[0])) for _, robot := range vars.BotInfo.Robots { - if strings.TrimSpace(strings.ToLower(robot.IPAddress)) == strings.TrimSpace(strings.ToLower(target)) { - matched = true - serial = robot.Esn + if strings.TrimSpace(strings.ToLower(robot.IPAddress)) == target { + return robot.Esn } } - if !matched { + return "" +} + +func pingJdocs(target string) { + serial := esnForAddress(target) + if serial == "" { logger.Println("jdocs pinger error: serial did not match any bot in bot json") return } + pingJdocsForESN(serial) +} + +func pingJdocsForESN(serial string) { + ctx := context.Background() robotTmp, err := NewWP(serial, false) if err != nil { logger.Println(err) @@ -113,11 +122,11 @@ func InitJdocsPinger() { }() } -func ShouldPingJdocs(target string) bool { - var esn, guid, botip string +func ShouldPingJdocsForESN(esn string) bool { + var guid, botip string matched := false for _, bot := range vars.BotInfo.Robots { - if target == bot.IPAddress { + if strings.EqualFold(strings.TrimSpace(bot.Esn), strings.TrimSpace(esn)) { esn = bot.Esn guid = bot.GUID botip = bot.IPAddress @@ -154,6 +163,28 @@ func ShouldPingJdocs(target string) bool { return true } +// resolveConnCheckESN identifies the robot behind a connCheck request, or "" +// when it cannot. Behind a proxy every robot shares one peer address, so +// identity comes from the robot instead: it sends HEAD /ok?emresn=. +// +// Gated because "" is what makes connCheck fall through to the mDNS browse that +// repairs a stale stored address after DHCP moves a robot. Preferring emresn +// everywhere would retire that self-heal on direct-LAN installs. +func resolveConnCheckESN(r *http.Request) string { + if vars.APIConfig.Server.ProxyMode { + if reported := strings.TrimSpace(r.FormValue("emresn")); reported != "" { + for _, bot := range vars.BotInfo.Robots { + if strings.EqualFold(strings.TrimSpace(bot.Esn), reported) { + return bot.Esn + } + } + // A robot we have never stored. Fall through rather than inventing + // a record: adoption is what creates those, not a connection check. + } + } + return esnForAddress(r.RemoteAddr) +} + func connCheck(w http.ResponseWriter, r *http.Request) { switch { default: @@ -167,13 +198,9 @@ func connCheck(w http.ResponseWriter, r *http.Request) { } if PingerEnabled { //logger.Println("connCheck request from " + r.RemoteAddr) - robotTarget := strings.Split(r.RemoteAddr, ":")[0] - jsonB, _ := json.Marshal(vars.BotInfo) - json := string(jsonB) - if strings.Contains(json, strings.TrimSpace(robotTarget)) { - ping := ShouldPingJdocs(robotTarget) - if ping { - pingJdocs(robotTarget) + if esn := resolveConnCheckESN(r); esn != "" { + if ShouldPingJdocsForESN(esn) { + pingJdocsForESN(esn) } } else { go RunMDNS(strings.Split(r.RemoteAddr, ":")[0]) diff --git a/chipper/pkg/wirepod/sdkapp/pinger_test.go b/chipper/pkg/wirepod/sdkapp/pinger_test.go new file mode 100644 index 000000000..e32d04116 --- /dev/null +++ b/chipper/pkg/wirepod/sdkapp/pinger_test.go @@ -0,0 +1,132 @@ +package sdkapp + +import ( + "testing" +) + +// withEmptyPingerList clears the tracked-robot list and restores it afterwards. +// JdocsPingerBots is a package-level global with its own mutex, so tests +// touching it must not run in parallel. +func withEmptyPingerList(t *testing.T) { + t.Helper() + JdocsPingerBots.mu.Lock() + restore := JdocsPingerBots.Robots + JdocsPingerBots.Robots = nil + JdocsPingerBots.mu.Unlock() + + t.Cleanup(func() { + JdocsPingerBots.mu.Lock() + JdocsPingerBots.Robots = restore + JdocsPingerBots.mu.Unlock() + }) +} + +// pingerState reports the tracked state for esn, or ok=false when untracked. +func pingerState(esn string) (stopped bool, since int, ok bool) { + JdocsPingerBots.mu.Lock() + defer JdocsPingerBots.mu.Unlock() + for _, bot := range JdocsPingerBots.Robots { + if bot.ESN == esn { + return bot.Stopped, bot.TimeSinceLastCheck, true + } + } + return false, 0, false +} + +// The first check for a robot starts tracking it and pulls jdocs, which is what +// appends the escape-pod CA to the cert store. +func TestFirstCheckStartsTrackingAndPings(t *testing.T) { + withEmptyPingerList(t) + withRobots(t, struct{ esn, ip string }{"00e20145", "192.168.1.150"}) + + if !ShouldPingJdocsForESN("00e20145") { + t.Fatal("the first check for an untracked robot must ping jdocs") + } + stopped, _, ok := pingerState("00e20145") + if !ok { + t.Fatal("the robot was not added to the tracked list") + } + if stopped { + t.Error("a freshly tracked robot must not be marked stopped") + } +} + +// A robot checking in normally does not need a jdocs pull on every check; the +// call exists to reset its timer. +func TestSteadyCheckResetsTheTimerWithoutPinging(t *testing.T) { + withEmptyPingerList(t) + withRobots(t, struct{ esn, ip string }{"00e20145", "192.168.1.150"}) + + ShouldPingJdocsForESN("00e20145") + + JdocsPingerBots.mu.Lock() + JdocsPingerBots.Robots[0].TimeSinceLastCheck = 9 + JdocsPingerBots.mu.Unlock() + + if ShouldPingJdocsForESN("00e20145") { + t.Error("a robot already checking in must not trigger a jdocs pull every time") + } + if _, since, _ := pingerState("00e20145"); since != 0 { + t.Errorf("TimeSinceLastCheck = %d, want 0; the check must reset the timer", since) + } +} + +// The case the whole mechanism exists for: the ticker marks a silent robot +// stopped, and the next check has to revive it and pull jdocs. +func TestCheckAfterAGapRevivesTheRobotAndPings(t *testing.T) { + withEmptyPingerList(t) + withRobots(t, struct{ esn, ip string }{"00e20145", "192.168.1.150"}) + + ShouldPingJdocsForESN("00e20145") + JdocsPingerBots.mu.Lock() + JdocsPingerBots.Robots[0].Stopped = true + JdocsPingerBots.Robots[0].TimeSinceLastCheck = 16 + JdocsPingerBots.mu.Unlock() + + if !ShouldPingJdocsForESN("00e20145") { + t.Fatal("a robot that had gone quiet must pull jdocs when it returns") + } + stopped, since, _ := pingerState("00e20145") + if stopped { + t.Error("the robot should no longer be marked stopped") + } + if since != 0 { + t.Errorf("TimeSinceLastCheck = %d, want 0", since) + } +} + +// An ESN that names no stored robot must not create tracking state. connCheck +// is not adoption, and the list is keyed by ESN, so inventing entries here +// would leak an unbounded set of them. +func TestUnknownESNIsNotTracked(t *testing.T) { + withEmptyPingerList(t) + withRobots(t, struct{ esn, ip string }{"00e20145", "192.168.1.150"}) + + if ShouldPingJdocsForESN("00ffffff") { + t.Error("an unknown ESN must not trigger a jdocs pull") + } + if _, _, ok := pingerState("00ffffff"); ok { + t.Error("an unknown ESN must not be added to the tracked list") + } +} + +// Entries are matched case-sensitively against the tracked list (== at the +// lookup) but resolved case-insensitively against BotInfo (EqualFold). That +// only holds together because the stored spelling is canonicalised on the way +// in; if it ever stops being, a robot gains a duplicate entry per case variant +// and every check pulls jdocs afresh. +func TestCaseVariantESNResolvesToTheSameTrackedRobot(t *testing.T) { + withEmptyPingerList(t) + withRobots(t, struct{ esn, ip string }{"00e20145", "192.168.1.150"}) + + ShouldPingJdocsForESN("00e20145") + ShouldPingJdocsForESN("00E20145") + + JdocsPingerBots.mu.Lock() + n := len(JdocsPingerBots.Robots) + JdocsPingerBots.mu.Unlock() + if n != 1 { + t.Errorf("tracked list holds %d entries for one robot, want 1; a case "+ + "variant created a duplicate", n) + } +} diff --git a/chipper/pkg/wirepod/sdkapp/robot.go b/chipper/pkg/wirepod/sdkapp/robot.go index 028d5f0a5..935fda40c 100644 --- a/chipper/pkg/wirepod/sdkapp/robot.go +++ b/chipper/pkg/wirepod/sdkapp/robot.go @@ -45,7 +45,7 @@ func newRobot(serial string) (Robot, int, error) { for _, robot := range vars.BotInfo.Robots { if strings.EqualFold(serial, robot.Esn) { RobotObj.ESN = strings.TrimSpace(strings.ToLower(serial)) - RobotObj.Target = robot.IPAddress + ":443" + RobotObj.Target = vars.GetRobotDialTarget(robot.Esn, robot.IPAddress) + ":443" matched = true if robot.GUID == "" { robot.GUID = vars.BotInfo.GlobalGUID @@ -190,7 +190,7 @@ func NewWP(serial string, useGlobal bool) (*vector.Vector, error) { for _, robot := range vars.BotInfo.Robots { if strings.EqualFold(serial, robot.Esn) { matched = true - target = robot.IPAddress + ":443" + target = vars.GetRobotDialTarget(robot.Esn, robot.IPAddress) + ":443" guid = robot.GUID break } diff --git a/chipper/pkg/wirepod/setup/ble.go b/chipper/pkg/wirepod/setup/ble.go index 12524d362..c46b5f640 100644 --- a/chipper/pkg/wirepod/setup/ble.go +++ b/chipper/pkg/wirepod/setup/ble.go @@ -460,6 +460,7 @@ func BluetoothSetupAPI(w http.ResponseWriter, r *http.Request) { } if resp { time.Sleep(time.Second) + captureRobotLANAddress(BleClient) fmt.Fprint(w, "success") return } else { @@ -515,6 +516,41 @@ func BluetoothSetupAPI(w http.ResponseWriter, r *http.Request) { } } +// captureRobotLANAddress records the robot's real LAN address at the one +// moment wire-pod is talking to it over a channel the proxy cannot distort. +// +// Only runs in proxy mode, where the address wire-pod observes over the +// network is the proxy's rather than the robot's. Every failure is logged +// and swallowed: a BLE hiccup must never turn an otherwise-successful +// authentication into an error. +func captureRobotLANAddress(client *ble.VectorBLE) { + if !vars.APIConfig.Server.ProxyMode { + return + } + defer func() { + if r := recover(); r != nil { + logger.Println("Proxy mode: recovered while capturing robot LAN address:", r) + } + }() + + status, err := client.GetStatus() + if err != nil { + logger.Println("Proxy mode: could not read robot status to capture its address: " + err.Error()) + return + } + // Wi-Fi is connected before authentication runs, so this returns a real + // address by the time we get here. + wifi, err := client.WifiIP() + if err != nil { + logger.Println("Proxy mode: could not read Wi-Fi address for " + + strings.TrimSpace(status.ESN) + ": " + err.Error()) + return + } + + // Everything past this point is in lanaddress.go, which carries no build + // tag and so is covered by the test suite. + RecordRobotLANAddress(status.ESN, wifi.IPv4) +} func RegisterBLEAPI() { http.HandleFunc("/api-ble/", BluetoothSetupAPI) } diff --git a/chipper/pkg/wirepod/setup/certs.go b/chipper/pkg/wirepod/setup/certs.go index 651381c7d..8b905c263 100644 --- a/chipper/pkg/wirepod/setup/certs.go +++ b/chipper/pkg/wirepod/setup/certs.go @@ -8,9 +8,11 @@ import ( "crypto/x509/pkix" "encoding/json" "encoding/pem" + "errors" "math/big" "net" "os" + "strings" "time" "github.com/kercre123/wire-pod/chipper/pkg/logger" @@ -28,8 +30,29 @@ type ClientServerConfig struct { // creates and exports a priv/pub key combo generated with IP address func CreateCertCombo() error { - // get preferred IP address of machine - ipAddr := vars.GetOutboundIP() + // Build the SAN set. The robot pins this leaf into its root pool and + // derives ServerName from the dial authority, so whatever address ends up + // in server_config.json must appear here or the handshake fails. + host := vars.GetRobotFacingAddress() + var ips []net.IP + var dnsNames []string + + // Retain the outbound IP so an unconfigured install generates exactly the + // SAN set it always has. Skip it when the UDP probe failed and returned + // the unspecified address, which would only pin a useless SAN. + if outbound := vars.GetOutboundIP(); !outbound.IsUnspecified() { + ips = append(ips, outbound) + } + if parsed := net.ParseIP(host); parsed != nil { + if len(ips) == 0 || !parsed.Equal(ips[0]) { + ips = append(ips, parsed) + } + } else { + dnsNames = append(dnsNames, host) + } + if len(ips) == 0 && len(dnsNames) == 0 { + return errors.New("cannot generate a certificate with no SANs: no outbound IP and no robot-facing address configured") + } // ca certificate ca := &x509.Certificate{ @@ -51,7 +74,8 @@ func CreateCertCombo() error { cert := &x509.Certificate{ SerialNumber: big.NewInt(1658), Subject: pkix.Name{}, - IPAddresses: []net.IP{ipAddr}, + IPAddresses: ips, + DNSNames: dnsNames, NotBefore: time.Now(), NotAfter: time.Now().AddDate(10, 0, 0), SubjectKeyId: []byte{1, 2, 3, 4, 6}, @@ -96,6 +120,47 @@ func CreateCertCombo() error { return nil } +// WarnIfCertMissesAddress is a read-only coherence check. The robot pins +// wire-pod's leaf certificate and verifies the hostname it dials against that +// leaf's SANs, so a server_config.json that names an address the certificate +// does not cover produces a robot which cannot complete a TLS handshake. +// +// This only warns. Regenerating the certificate to fix the mismatch would +// de-pair every already-provisioned robot, so that has to stay a deliberate +// admin action via /api/generate_certs. +func WarnIfCertMissesAddress(host string) { + certBytes, err := os.ReadFile(vars.CertPath) + if err != nil { + // No certificate yet is normal during first-run setup. + return + } + block, _ := pem.Decode(certBytes) + if block == nil { + logger.Println("WARNING: " + vars.CertPath + " is not valid PEM, cannot check it covers " + host) + return + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + logger.Println("WARNING: could not parse " + vars.CertPath + " to check it covers " + host + ": " + err.Error()) + return + } + if cert.VerifyHostname(host) == nil { + return + } + + var covered []string + for _, ip := range cert.IPAddresses { + covered = append(covered, ip.String()) + } + covered = append(covered, cert.DNSNames...) + logger.Println("Note: the existing certificate does not cover " + host + + " (it covers " + strings.Join(covered, ", ") + "). Robots already paired " + + "are unaffected and keep working. A robot set up against " + host + " from " + + "now on would fail the TLS handshake, which regenerating the certificate " + + "would fix -- but that de-pairs every already-paired robot, so only do it " + + "if you are prepared to set them all up again.") +} + // outputs a server config to ../certs/server_config.json func CreateServerConfig() { os.MkdirAll(vars.Certs, 0777) @@ -109,13 +174,16 @@ func CreateServerConfig() { config.Logfiles = "s3://anki-device-logs-prod/victor" config.Appkey = "oDoa0quieSeir6goowai7f" } else { - ip := vars.GetOutboundIP() - ipString := ip.String() - url := ipString + ":" + vars.APIConfig.Server.Port + // Robot-facing address: the configured value when set, else the + // outbound IP, which is exactly the previous behavior. + host := vars.GetRobotFacingAddress() + url := host + ":" + vars.APIConfig.Server.Port config.Jdocs = url config.Token = url config.Chipper = url - config.Check = ipString + "/ok" + // The check endpoint is plaintext HTTP on port 80 and carries no port. + config.Check = host + "/ok" + WarnIfCertMissesAddress(host) config.Logfiles = "s3://anki-device-logs-prod/victor" config.Appkey = "oDoa0quieSeir6goowai7f" } diff --git a/chipper/pkg/wirepod/setup/certs_test.go b/chipper/pkg/wirepod/setup/certs_test.go new file mode 100644 index 000000000..7f7d92b3a --- /dev/null +++ b/chipper/pkg/wirepod/setup/certs_test.go @@ -0,0 +1,189 @@ +package botsetup + +import ( + "crypto/x509" + "encoding/json" + "encoding/pem" + "os" + "path/filepath" + "testing" + + "github.com/kercre123/wire-pod/chipper/pkg/vars" +) + +// redirectCertPaths points the package's write targets at a temp dir and +// restores them afterwards. These are package-level vars in vars, so tests in +// this file must not run in parallel. +func redirectCertPaths(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + certs, certPath, keyPath, serverCfg := vars.Certs, vars.CertPath, vars.KeyPath, vars.ServerConfigPath + cfg := vars.APIConfig + t.Cleanup(func() { + vars.Certs, vars.CertPath, vars.KeyPath, vars.ServerConfigPath = certs, certPath, keyPath, serverCfg + vars.APIConfig = cfg + }) + + vars.Certs = dir + vars.CertPath = filepath.Join(dir, "cert.crt") + vars.KeyPath = filepath.Join(dir, "cert.key") + vars.ServerConfigPath = filepath.Join(dir, "server_config.json") + return dir +} + +func readServerConfig(t *testing.T) string { + t.Helper() + b, err := os.ReadFile(vars.ServerConfigPath) + if err != nil { + t.Fatalf("reading server_config.json: %v", err) + } + return string(b) +} + +// I4: with no proxy configuration, the non-EP server config must be exactly +// what the outbound-IP formula produces today. After Phase 5 this same +// assertion holds because GetRobotFacingAddress falls back to GetOutboundIP. +func TestCreateServerConfigIPModeMatchesOutboundIP(t *testing.T) { + redirectCertPaths(t) + vars.APIConfig.Server.EPConfig = false + vars.APIConfig.Server.Port = "443" + + CreateServerConfig() + + ip := vars.GetOutboundIP().String() + want := `{"jdocs":"` + ip + `:443","tms":"` + ip + `:443","chipper":"` + ip + `:443",` + + `"check":"` + ip + `/ok","logfiles":"s3://anki-device-logs-prod/victor",` + + `"appkey":"oDoa0quieSeir6goowai7f"}` + + if got := readServerConfig(t); got != want { + t.Errorf("IP-mode server_config.json changed.\n got: %s\nwant: %s", got, want) + } +} + +// EP mode is explicitly out of scope for the robot-facing address, so its +// output is fully static and must never move. +func TestCreateServerConfigEPModeIsUnchanged(t *testing.T) { + redirectCertPaths(t) + vars.APIConfig.Server.EPConfig = true + vars.APIConfig.Server.Port = "443" + + CreateServerConfig() + + const want = `{"jdocs":"escapepod.local:443","tms":"escapepod.local:443",` + + `"chipper":"escapepod.local:443","check":"escapepod.local/ok",` + + `"logfiles":"s3://anki-device-logs-prod/victor","appkey":"oDoa0quieSeir6goowai7f"}` + + if got := readServerConfig(t); got != want { + t.Errorf("EP-mode server_config.json changed; EP mode must stay out of scope.\n"+ + " got: %s\nwant: %s", got, want) + } +} + +// A custom port must land exactly once. CreateServerConfig concatenates +// addr + ":" + port, which is why the robot-facing address needs validation +// against embedded ports. +func TestCreateServerConfigHonorsCustomPort(t *testing.T) { + redirectCertPaths(t) + vars.APIConfig.Server.EPConfig = false + vars.APIConfig.Server.Port = "8443" + + CreateServerConfig() + + ip := vars.GetOutboundIP().String() + var cfg ClientServerConfig + if err := json.Unmarshal([]byte(readServerConfig(t)), &cfg); err != nil { + t.Fatalf("parsing server config: %v", err) + } + if want := ip + ":8443"; cfg.Jdocs != want { + t.Errorf("jdocs = %q, want %q", cfg.Jdocs, want) + } + if want := ip + "/ok"; cfg.Check != want { + t.Errorf("check = %q, want %q (the check endpoint carries no port)", cfg.Check, want) + } +} + +func parseGeneratedCert(t *testing.T) *x509.Certificate { + t.Helper() + pemBytes, err := os.ReadFile(vars.CertPath) + if err != nil { + t.Fatalf("reading generated cert: %v", err) + } + block, _ := pem.Decode(pemBytes) + if block == nil { + t.Fatal("generated cert is not valid PEM") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("parsing generated cert: %v", err) + } + return cert +} + +// I4 for the certificate: with no robot-facing address configured, the SAN set +// is exactly one IP — the outbound IP — and no DNS names at all. +// +// This is the assertion that matters most in the whole suite. The robot pins +// this leaf into its root pool (vector-cloud/cloud/main.go:84), so the SAN set +// is the entire name policy, and changing it de-pairs every robot (I3). +func TestCreateCertComboBaselineSANs(t *testing.T) { + redirectCertPaths(t) + + if err := CreateCertCombo(); err != nil { + t.Fatalf("CreateCertCombo: %v", err) + } + cert := parseGeneratedCert(t) + + if len(cert.DNSNames) != 0 { + t.Errorf("baseline cert gained DNS SANs %v; today it has none", cert.DNSNames) + } + if len(cert.IPAddresses) != 1 { + t.Fatalf("baseline cert has %d IP SANs %v, want exactly 1", + len(cert.IPAddresses), cert.IPAddresses) + } + if want := vars.GetOutboundIP(); !cert.IPAddresses[0].Equal(want) { + t.Errorf("cert IP SAN = %v, want the outbound IP %v", cert.IPAddresses[0], want) + } +} + +// The leaf is what gets pinned, so it must be the thing written to CertPath, +// and it must be usable as a self-standing trust anchor the way the robot +// uses it. Guards the I3 reasoning against a well-meaning refactor that starts +// writing the CA instead. +func TestCreateCertComboWritesTheLeafNotTheCA(t *testing.T) { + redirectCertPaths(t) + + if err := CreateCertCombo(); err != nil { + t.Fatalf("CreateCertCombo: %v", err) + } + cert := parseGeneratedCert(t) + + if cert.IsCA { + t.Error("CertPath now holds a CA certificate; the robot pins whatever is " + + "here as a trust anchor, so this changes the pairing model") + } + if len(cert.IPAddresses) == 0 && len(cert.DNSNames) == 0 { + t.Error("written certificate has no SANs at all; no robot could verify it") + } +} + +// Regenerating mints a fresh key every time, which is exactly why cert +// generation must never be added to the SSH path or a settings write (I3). +func TestCreateCertComboRegeneratesADifferentKey(t *testing.T) { + redirectCertPaths(t) + + if err := CreateCertCombo(); err != nil { + t.Fatalf("first CreateCertCombo: %v", err) + } + first := parseGeneratedCert(t) + + if err := CreateCertCombo(); err != nil { + t.Fatalf("second CreateCertCombo: %v", err) + } + second := parseGeneratedCert(t) + + if first.Equal(second) { + t.Error("two CreateCertCombo calls produced the same certificate; the " + + "de-pairing consequence in I3 rests on them differing") + } +} diff --git a/chipper/pkg/wirepod/setup/coherence_test.go b/chipper/pkg/wirepod/setup/coherence_test.go new file mode 100644 index 000000000..c964f0ac3 --- /dev/null +++ b/chipper/pkg/wirepod/setup/coherence_test.go @@ -0,0 +1,91 @@ +package botsetup + +import ( + "os" + "strings" + "testing" + + "github.com/kercre123/wire-pod/chipper/pkg/logger" + "github.com/kercre123/wire-pod/chipper/pkg/vars" +) + +// captureLog returns whatever WarnIfCertMissesAddress wrote to the log tray. +func captureLog(t *testing.T, fn func()) string { + t.Helper() + before := len(logger.LogTrayArray) + fn() + return strings.Join(logger.LogTrayArray[before:], "\n") +} + +func TestWarnsWhenCertDoesNotCoverConfiguredAddress(t *testing.T) { + redirectCertPaths(t) + + // Generate a certificate for the outbound IP only... + vars.APIConfig.Server.RobotFacingAddress = "" + if err := CreateCertCombo(); err != nil { + t.Fatalf("CreateCertCombo: %v", err) + } + // ...then point the config at a name it cannot possibly cover. + out := captureLog(t, func() { WarnIfCertMissesAddress("wirepod.lan") }) + + if !strings.Contains(out, "does not cover") || !strings.Contains(out, "wirepod.lan") { + t.Errorf("expected a warning naming the uncovered address, got: %s", out) + } + if !strings.Contains(out, "de-pairs") { + t.Errorf("the warning should say regenerating de-pairs paired robots, got: %s", out) + } +} + +func TestDoesNotWarnWhenCertCoversConfiguredAddress(t *testing.T) { + redirectCertPaths(t) + + vars.APIConfig.Server.RobotFacingAddress = "wirepod.lan" + if err := CreateCertCombo(); err != nil { + t.Fatalf("CreateCertCombo: %v", err) + } + out := captureLog(t, func() { WarnIfCertMissesAddress("wirepod.lan") }) + + if strings.Contains(out, "WARNING") { + t.Errorf("warned about an address the certificate does cover: %s", out) + } +} + +// A missing certificate is normal during first-run setup and must stay quiet. +func TestDoesNotWarnWhenNoCertExists(t *testing.T) { + redirectCertPaths(t) + os.Remove(vars.CertPath) + + out := captureLog(t, func() { WarnIfCertMissesAddress("wirepod.lan") }) + if strings.Contains(out, "WARNING") { + t.Errorf("warned when no certificate exists yet: %s", out) + } +} + +func TestWarnsWhenCertIsUnparseable(t *testing.T) { + redirectCertPaths(t) + if err := os.WriteFile(vars.CertPath, []byte("not a certificate"), 0644); err != nil { + t.Fatalf("writing junk cert: %v", err) + } + + out := captureLog(t, func() { WarnIfCertMissesAddress("wirepod.lan") }) + if !strings.Contains(out, "WARNING") { + t.Errorf("stayed silent about an unparseable certificate: %s", out) + } +} + +// Valid PEM framing wrapping meaningless DER. TestWarnsWhenCertIsUnparseable +// writes bytes that are not PEM at all, so it stops at pem.Decode and never +// reaches x509.ParseCertificate; this is the only case that does. +func TestWarnsWhenTheCertificateBodyCannotBeParsed(t *testing.T) { + redirectCertPaths(t) + + const notACert = "-----BEGIN CERTIFICATE-----\nZm9vYmFy\n-----END CERTIFICATE-----\n" + if err := os.WriteFile(vars.CertPath, []byte(notACert), 0644); err != nil { + t.Fatalf("writing cert: %v", err) + } + + out := captureLog(t, func() { WarnIfCertMissesAddress("wirepod.lan") }) + if !strings.Contains(out, "could not parse") { + t.Errorf("expected the parse failure to be reported, got: %s", out) + } +} diff --git a/chipper/pkg/wirepod/setup/lanaddress.go b/chipper/pkg/wirepod/setup/lanaddress.go new file mode 100644 index 000000000..67d18b5fe --- /dev/null +++ b/chipper/pkg/wirepod/setup/lanaddress.go @@ -0,0 +1,45 @@ +package botsetup + +import ( + "strconv" + "strings" + + "github.com/kercre123/wire-pod/chipper/pkg/logger" + "github.com/kercre123/wire-pod/chipper/pkg/vars" +) + +// RecordRobotLANAddress stores the address a robot reported for itself during +// BLE adoption, if it is usable. Reports whether one was recorded. +// +// Deliberately in an untagged file, unlike its only caller: ble.go needs the +// inbuiltble tag and libsodium to compile, so anything left in there is +// invisible to the test suite and absent from every shipped image. This half +// needs neither, so it is the half that can be tested. +// +// The robot supplies this address over BLE, which is why it goes through the +// same validation as an admin-entered one. A malformed value would be +// concatenated with ":" + port when wire-pod dials, and a robot that reports +// the proxy's own address would make wire-pod dial itself. +func RecordRobotLANAddress(esn, addr string) bool { + esn = strings.TrimSpace(esn) + addr = strings.TrimSpace(addr) + + if esn == "" { + logger.Println("Proxy mode: robot reported an empty ESN, not capturing an address") + return false + } + if addr == "" { + logger.Println("Proxy mode: robot " + esn + " reported no Wi-Fi address, not capturing one") + return false + } + if err := vars.ValidateRobotFacingAddress(addr); err != nil { + logger.Println("Proxy mode: robot " + esn + " reported an unusable Wi-Fi address " + + strconv.Quote(addr) + " (" + err.Error() + "), not capturing it") + return false + } + + vars.SetRobotLANAddress(esn, addr) + vars.WriteConfigToDisk() + logger.Println("Proxy mode: captured LAN address " + addr + " for robot " + esn) + return true +} diff --git a/chipper/pkg/wirepod/setup/lanaddress_test.go b/chipper/pkg/wirepod/setup/lanaddress_test.go new file mode 100644 index 000000000..0bc6de71f --- /dev/null +++ b/chipper/pkg/wirepod/setup/lanaddress_test.go @@ -0,0 +1,131 @@ +package botsetup + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/kercre123/wire-pod/chipper/pkg/vars" +) + +// withCapturedConfig points the config path at a temp file and clears APIConfig, +// restoring both afterwards. Package-level state in vars, so no t.Parallel. +func withCapturedConfig(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "apiConfig.json") + restorePath, restoreCfg := vars.ApiConfigPath, vars.APIConfig + t.Cleanup(func() { vars.ApiConfigPath, vars.APIConfig = restorePath, restoreCfg }) + vars.ApiConfigPath = path + vars.APIConfig.Server.RobotLANAddresses = nil + vars.APIConfig.Server.ProxyMode = true + return path +} + +// configuredAddress reads back what was persisted, so the assertion covers the +// write rather than just the in-memory map. +func configuredAddress(t *testing.T, path, esn string) string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + return "" + } + var cfg struct { + Server struct { + RobotLANAddresses map[string]string `json:"robot_lan_addresses"` + } `json:"server"` + } + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("parsing written config: %v", err) + } + return cfg.Server.RobotLANAddresses[esn] +} + +// The happy path: the robot names itself and its address over BLE, and both +// end up on disk so wire-pod can dial it after a restart. +func TestRecordsTheAddressTheRobotReported(t *testing.T) { + path := withCapturedConfig(t) + + if !RecordRobotLANAddress("00e20145", "192.0.2.13") { + t.Fatal("a valid ESN and address should have been recorded") + } + if got := vars.GetRobotDialTarget("00e20145", "172.28.0.1"); got != "192.0.2.13" { + t.Errorf("dial target = %q, want the captured address", got) + } + if got := configuredAddress(t, path, "00e20145"); got != "192.0.2.13" { + t.Errorf("persisted address = %q, want %q; a capture that is not written "+ + "is lost on restart", got, "192.0.2.13") + } +} + +// The address arrives from the robot over BLE, so it gets the same validation +// as an admin-typed one. A bad value would be concatenated with ":" + port at +// dial time, or point wire-pod at something that is not the robot. +func TestRefusesAnUnusableReportedAddress(t *testing.T) { + for _, tc := range []struct{ addr, why string }{ + {"", "no address reported at all"}, + {" ", "whitespace only"}, + {"192.0.2.13:443", "host:port would be concatenated into host:port:port"}, + {"http://192.0.2.13", "a URL is not a bare host"}, + {"fe80::1", "IPv6 cannot be concatenated with :port"}, + } { + path := withCapturedConfig(t) + + if RecordRobotLANAddress("00e20145", tc.addr) { + t.Errorf("recorded %q (%s); it would reach the dialler unusable", tc.addr, tc.why) + } + if got := configuredAddress(t, path, "00e20145"); got != "" { + t.Errorf("persisted %q for a rejected address %q", got, tc.addr) + } + if got := vars.GetRobotDialTarget("00e20145", "172.28.0.1"); got != "172.28.0.1" { + t.Errorf("dial target = %q, want the observed address to stand when "+ + "the reported one is rejected", got) + } + } +} + +// A robot that reports no ESN cannot be keyed, and inventing one would attach +// the address to the wrong robot. +func TestRefusesAnEmptyESN(t *testing.T) { + path := withCapturedConfig(t) + + for _, esn := range []string{"", " "} { + if RecordRobotLANAddress(esn, "192.0.2.13") { + t.Errorf("recorded an address against ESN %q", esn) + } + } + if _, err := os.Stat(path); err == nil { + t.Error("nothing should have been written for an unidentifiable robot") + } +} + +// Re-adopting a robot that has moved must replace its address, not accumulate +// entries: lookups take the first case-insensitive match, so a stale duplicate +// could win. +func TestReAdoptingReplacesTheStoredAddress(t *testing.T) { + withCapturedConfig(t) + + RecordRobotLANAddress("00e20145", "192.0.2.13") + RecordRobotLANAddress("00E20145", "192.0.2.77") + + if n := len(vars.APIConfig.Server.RobotLANAddresses); n != 1 { + t.Errorf("stored %d entries for one robot, want 1: %v", + n, vars.APIConfig.Server.RobotLANAddresses) + } + if got := vars.GetRobotDialTarget("00e20145", "172.28.0.1"); got != "192.0.2.77" { + t.Errorf("dial target = %q, want the address from the most recent adoption", got) + } +} + +// Whitespace around either value is the robot's, not the admin's, and must not +// become part of the key or the dial target. +func TestTrimsWhatTheRobotReported(t *testing.T) { + withCapturedConfig(t) + + if !RecordRobotLANAddress(" 00e20145 ", " 192.0.2.13 ") { + t.Fatal("padded but valid values should be recorded") + } + if got := vars.GetRobotDialTarget("00e20145", "172.28.0.1"); got != "192.0.2.13" { + t.Errorf("dial target = %q, want the trimmed address", got) + } +} diff --git a/chipper/pkg/wirepod/setup/robotfacing_test.go b/chipper/pkg/wirepod/setup/robotfacing_test.go new file mode 100644 index 000000000..da6640b2e --- /dev/null +++ b/chipper/pkg/wirepod/setup/robotfacing_test.go @@ -0,0 +1,199 @@ +package botsetup + +import ( + "encoding/json" + "net" + "testing" + + "github.com/kercre123/wire-pod/chipper/pkg/vars" +) + +func configureRobotFacing(t *testing.T, addr string) { + t.Helper() + vars.APIConfig.Server.EPConfig = false + vars.APIConfig.Server.Port = "443" + vars.APIConfig.Server.RobotFacingAddress = addr +} + +func TestGetRobotFacingAddressFallsBackToOutboundIP(t *testing.T) { + redirectCertPaths(t) + vars.APIConfig.Server.RobotFacingAddress = "" + + if got, want := vars.GetRobotFacingAddress(), vars.GetOutboundIP().String(); got != want { + t.Errorf("GetRobotFacingAddress = %q, want the outbound IP %q", got, want) + } +} + +// A hostname is the case that matters for sub-domain routing: Go only sends +// SNI when ServerName is not an IP literal. +func TestCreateServerConfigUsesConfiguredHostname(t *testing.T) { + redirectCertPaths(t) + configureRobotFacing(t, "escapepod.local") + + CreateServerConfig() + + const want = `{"jdocs":"escapepod.local:443","tms":"escapepod.local:443",` + + `"chipper":"escapepod.local:443","check":"escapepod.local/ok",` + + `"logfiles":"s3://anki-device-logs-prod/victor","appkey":"oDoa0quieSeir6goowai7f"}` + if got := readServerConfig(t); got != want { + t.Errorf("server config with a hostname.\n got: %s\nwant: %s", got, want) + } +} + +func TestCreateServerConfigUsesConfiguredIP(t *testing.T) { + redirectCertPaths(t) + configureRobotFacing(t, "192.0.2.12") + + CreateServerConfig() + + var cfg ClientServerConfig + if err := json.Unmarshal([]byte(readServerConfig(t)), &cfg); err != nil { + t.Fatalf("parsing: %v", err) + } + if cfg.Jdocs != "192.0.2.12:443" { + t.Errorf("jdocs = %q, want %q", cfg.Jdocs, "192.0.2.12:443") + } + if cfg.Check != "192.0.2.12/ok" { + t.Errorf("check = %q, want %q (no port on the check endpoint)", cfg.Check, "192.0.2.12/ok") + } +} + +// The port must land exactly once. This is the doubled-port footgun that +// ValidateRobotFacingAddress exists to prevent reaching here at all. +func TestCreateServerConfigDoesNotDoubleThePort(t *testing.T) { + redirectCertPaths(t) + configureRobotFacing(t, "wirepod.lan") + vars.APIConfig.Server.Port = "8443" + + CreateServerConfig() + + var cfg ClientServerConfig + if err := json.Unmarshal([]byte(readServerConfig(t)), &cfg); err != nil { + t.Fatalf("parsing: %v", err) + } + if cfg.Chipper != "wirepod.lan:8443" { + t.Errorf("chipper = %q, want exactly one port", cfg.Chipper) + } +} + +// EP mode is out of scope: it serves the bundled ep.crt whose only SAN is +// DNS:escapepod.local, so honoring a robot-facing address there would produce +// a config the certificate cannot satisfy. +func TestCreateServerConfigIgnoresRobotFacingAddressInEPMode(t *testing.T) { + redirectCertPaths(t) + vars.APIConfig.Server.EPConfig = true + vars.APIConfig.Server.Port = "443" + vars.APIConfig.Server.RobotFacingAddress = "wirepod.lan" + + CreateServerConfig() + + const want = `{"jdocs":"escapepod.local:443","tms":"escapepod.local:443",` + + `"chipper":"escapepod.local:443","check":"escapepod.local/ok",` + + `"logfiles":"s3://anki-device-logs-prod/victor","appkey":"oDoa0quieSeir6goowai7f"}` + if got := readServerConfig(t); got != want { + t.Errorf("EP mode honored a robot-facing address.\n got: %s\nwant: %s", got, want) + } +} + +func TestCreateCertComboPutsHostnameInDNSNames(t *testing.T) { + redirectCertPaths(t) + configureRobotFacing(t, "escapepod.local") + + if err := CreateCertCombo(); err != nil { + t.Fatalf("CreateCertCombo: %v", err) + } + cert := parseGeneratedCert(t) + + if len(cert.DNSNames) != 1 || cert.DNSNames[0] != "escapepod.local" { + t.Errorf("DNSNames = %v, want [escapepod.local]", cert.DNSNames) + } + // The outbound IP must still be retained, or an install that previously + // worked by IP would stop working. + found := false + for _, ip := range cert.IPAddresses { + if ip.Equal(vars.GetOutboundIP()) { + found = true + } + } + if !found { + t.Errorf("outbound IP %v was dropped from the SANs %v", + vars.GetOutboundIP(), cert.IPAddresses) + } + + // And the cert must actually verify for the configured name, which is what + // the robot's gRPC client does. + if err := cert.VerifyHostname("escapepod.local"); err != nil { + t.Errorf("generated cert does not verify for its own configured name: %v", err) + } +} + +func TestCreateCertComboPutsIPInIPAddresses(t *testing.T) { + redirectCertPaths(t) + configureRobotFacing(t, "192.0.2.12") + + if err := CreateCertCombo(); err != nil { + t.Fatalf("CreateCertCombo: %v", err) + } + cert := parseGeneratedCert(t) + + if len(cert.DNSNames) != 0 { + t.Errorf("an IP address produced DNS SANs %v", cert.DNSNames) + } + want := net.ParseIP("192.0.2.12") + found := false + for _, ip := range cert.IPAddresses { + if ip.Equal(want) { + found = true + } + } + if !found { + t.Errorf("configured IP %v missing from SANs %v", want, cert.IPAddresses) + } + if err := cert.VerifyHostname("192.0.2.12"); err != nil { + t.Errorf("generated cert does not verify for its configured IP: %v", err) + } +} + +// Configuring the outbound IP explicitly must not produce it twice. +func TestCreateCertComboDoesNotDuplicateTheOutboundIP(t *testing.T) { + redirectCertPaths(t) + configureRobotFacing(t, vars.GetOutboundIP().String()) + + if err := CreateCertCombo(); err != nil { + t.Fatalf("CreateCertCombo: %v", err) + } + cert := parseGeneratedCert(t) + + if len(cert.IPAddresses) != 1 { + t.Errorf("IP SANs = %v, want exactly one entry", cert.IPAddresses) + } +} + +// The certificate and the server config must name the same thing, since the +// robot verifies one against the other. +func TestCertAndServerConfigAgree(t *testing.T) { + for _, addr := range []string{"escapepod.local", "192.0.2.12", ""} { + func() { + redirectCertPaths(t) + configureRobotFacing(t, addr) + + if err := CreateCertCombo(); err != nil { + t.Fatalf("CreateCertCombo(%q): %v", addr, err) + } + CreateServerConfig() + + var cfg ClientServerConfig + if err := json.Unmarshal([]byte(readServerConfig(t)), &cfg); err != nil { + t.Fatalf("parsing: %v", err) + } + host, _, err := net.SplitHostPort(cfg.Chipper) + if err != nil { + t.Fatalf("server config chipper %q is not host:port: %v", cfg.Chipper, err) + } + if err := parseGeneratedCert(t).VerifyHostname(host); err != nil { + t.Errorf("robot-facing address %q: server config names %q but the "+ + "certificate does not cover it: %v", addr, host, err) + } + }() + } +} diff --git a/chipper/pkg/wirepod/ttr/intentparam.go b/chipper/pkg/wirepod/ttr/intentparam.go index ba130f905..89f55b15b 100755 --- a/chipper/pkg/wirepod/ttr/intentparam.go +++ b/chipper/pkg/wirepod/ttr/intentparam.go @@ -182,7 +182,7 @@ func ParamChecker(req interface{}, intent string, speechText string, botSerial s for _, bot := range vars.BotInfo.Robots { if botSerial == bot.Esn { guid = bot.GUID - target = bot.IPAddress + ":443" + target = vars.GetRobotDialTarget(bot.Esn, bot.IPAddress) + ":443" matched = true break } diff --git a/chipper/pkg/wirepod/ttr/kgsim.go b/chipper/pkg/wirepod/ttr/kgsim.go index 585afa2b5..a5e2ca9ee 100644 --- a/chipper/pkg/wirepod/ttr/kgsim.go +++ b/chipper/pkg/wirepod/ttr/kgsim.go @@ -203,7 +203,7 @@ func StreamingKGSim(req interface{}, esn string, transcribedText string, isKG bo for _, bot := range vars.BotInfo.Robots { if esn == bot.Esn { guid = bot.GUID - target = bot.IPAddress + ":443" + target = vars.GetRobotDialTarget(bot.Esn, bot.IPAddress) + ":443" matched = true break } @@ -515,7 +515,7 @@ func KGSim(esn string, textToSay string) error { for _, bot := range vars.BotInfo.Robots { if esn == bot.Esn { guid = bot.GUID - target = bot.IPAddress + ":443" + target = vars.GetRobotDialTarget(bot.Esn, bot.IPAddress) + ":443" matched = true break } diff --git a/chipper/pkg/wirepod/ttr/matchIntentSend.go b/chipper/pkg/wirepod/ttr/matchIntentSend.go index b13c5bf3d..09dc0f8e3 100755 --- a/chipper/pkg/wirepod/ttr/matchIntentSend.go +++ b/chipper/pkg/wirepod/ttr/matchIntentSend.go @@ -202,7 +202,7 @@ func pluginFunctionHandler(req interface{}, voiceText string, botSerial string) for _, bot := range vars.BotInfo.Robots { if bot.Esn == botSerial { guid = bot.GUID - target = bot.IPAddress + ":443" + target = vars.GetRobotDialTarget(bot.Esn, bot.IPAddress) + ":443" } } intent, pluginResponse = PluginFunctions[num](voiceText, botSerial, guid, target) diff --git a/chipper/webroot/css/style.css b/chipper/webroot/css/style.css index f03ebf140..c139b0324 100755 --- a/chipper/webroot/css/style.css +++ b/chipper/webroot/css/style.css @@ -593,3 +593,43 @@ input[type='checkbox']:checked:after { margin-left: 5px; word-break: break-word; } + +/* Robot LAN address table (proxy mode only). + Scoped to the section so the shared .table rules keep serving every other + table unchanged. .table is width:100% and left-aligned, which on this page -- + centred throughout, 950px wide, 1.8em body text -- left a single robot's row + stranded across the full column. This sizes to its content and centres, and + drops the type down so the rows read as a compact list rather than a grid. */ +#proxyRobotList table { + width: auto; + margin: 0 auto 0.8em auto; +} + +#proxyRobotList th, +#proxyRobotList td { + /* td and th are absent from the h1..label rule that gives everything else the + page's font and white text, and neither html nor body sets a colour, so + cells fell back to the browser default in wing.css's -apple-system stack: + a different typeface in a different colour from the text around them. + 0.8em is what resolves to, which is the neighbouring copy. */ + font-family: var(--body-font-family); + color: var(--text-color); + font-size: 0.8em; + /* .table sets text-align:left; the rest of this page is centred. */ + text-align: center; + padding: 5px 16px; + white-space: nowrap; + border-bottom: 1px solid var(--bg-color-alt); +} + +/* The shared rule uses #333030, which is invisible against this background. */ +#proxyRobotList thead th { + border-bottom: 2px solid var(--fg-color); +} + +/* wing.css pads every input by 1.1rem, which on its own made each row nearly + three lines tall. */ +#proxyRobotList input[type="text"] { + padding: 7px 10px; + width: 185px; +} diff --git a/chipper/webroot/index.html b/chipper/webroot/index.html index 4e183310d..99b53bb72 100755 --- a/chipper/webroot/index.html +++ b/chipper/webroot/index.html @@ -120,6 +120,33 @@

diff --git a/chipper/webroot/js/ble.js b/chipper/webroot/js/ble.js index 316a2e7b7..0ac7c6150 100644 --- a/chipper/webroot/js/ble.js +++ b/chipper/webroot/js/ble.js @@ -11,6 +11,7 @@ function showBotAuth() { GetLog = false; toggleSections("section-botauth", "icon-BotAuth"); checkBLECapability(); + checkProxyMode(); } function toggleSections(showSection, icon) { @@ -340,3 +341,203 @@ function doOnboard(withAnim) { `; }); } + +// --- Robot LAN addresses (proxy mode only) --------------------------------- +// +// Gated on server.proxy_mode via the existing /api/get_config, which already +// returns the whole APIConfig, so no extra endpoint is needed for the gate. +// A default install never sets the flag and never sees any of this. + +function checkProxyMode() { + fetch("/api/get_config") + .then((response) => response.json()) + .then((config) => { + const enabled = config && config.server && config.server.proxy_mode === true; + document.getElementById("proxyRobotSection").style.display = enabled ? "block" : "none"; + if (enabled) { + loadProxyRobots(); + } + }) + .catch(() => { + // A failed gate check must leave the section hidden rather than + // exposing controls that would not apply. + document.getElementById("proxyRobotSection").style.display = "none"; + }); +} + +// `note` carries a message from a preceding save. Without it the refresh this +// function performs would immediately wipe that confirmation off the screen. +function loadProxyRobots(note) { + const list = document.getElementById("proxyRobotList"); + const status = document.getElementById("proxyRobotStatus"); + // Deliberately no "Loading..." placeholder when the table is already on + // screen. Emptying the list first collapsed it to a single line and dropped + // everything below it up the page, then put it back when the fetch landed + // ~10ms later -- a jolt that reads as a full page reload. Build the + // replacement off-DOM and swap it in one go, so the height never changes. + if (!list.hasChildNodes()) { + list.innerHTML = "

Loading robots...

"; + } + + fetch("/api/get_robots") + .then((response) => response.json()) + .then((robots) => { + if (!robots || robots.length === 0) { + list.innerHTML = "

No robots known to wire-pod yet. Authenticate a robot first.

"; + } else { + const parts = document.createDocumentFragment(); + parts.appendChild(buildProxyRobotTable(robots)); + + // One Save under the whole table, centred like the rest of the page. + const saveRow = document.createElement("div"); + const save = document.createElement("button"); + save.type = "button"; + save.innerHTML = "Save"; + save.onclick = saveProxyRobotAddresses; + saveRow.appendChild(save); + parts.appendChild(saveRow); + + list.replaceChildren(parts); + prefillSSHIp(robots); + } + // The list is rebuilt identically when nothing has changed, and on a LAN + // the fetch is too quick to see, so without a timestamp the button looks + // dead and the serial looks hard-coded. Both are the same missing signal. + const count = robots ? robots.length : 0; + status.innerHTML = + (note ? "

" + note + "

" : "") + + "Checked " + new Date().toLocaleTimeString() + + " — " + count + (count === 1 ? " robot" : " robots") + + " known to wire-pod."; + }) + .catch((err) => { + list.innerHTML = ""; + status.innerHTML = "

Error loading robots: " + err + "

"; + }); +} + +// The OSKR flow below asks for the same address this section configures. Fill +// it in from what is already known rather than asking twice. +// +// Only ever from configured_address: effective_target falls back to the +// observed peer address, which behind a proxy is the proxy itself -- prefilling +// that would point an SSH setup at the wrong host entirely. The field stays +// editable because a robot being set up for the first time is not yet known. +function prefillSSHIp(robots) { + const sshIp = document.getElementById("sshIp"); + const hint = document.getElementById("sshIpHint"); + if (!sshIp || sshIp.value) { + return; + } + const known = robots.find((robot) => robot.configured_address); + if (!known) { + return; + } + sshIp.value = known.configured_address; + if (hint) { + hint.innerHTML = + "Filled in from the LAN address set above for " + + known.esn + ". Change it if you are setting up a different robot."; + } +} + +// One row per robot. A table because the interesting comparison is down the +// columns -- every robot is seen at the same proxy address, and that repetition +// is only visible when the values line up under each other. +function buildProxyRobotTable(robots) { + const table = document.createElement("table"); + table.className = "table"; + + const head = table.createTHead().insertRow(); + ["Robot", "Seen at", "Dialling", "LAN address"].forEach((text) => { + const th = document.createElement("th"); + th.innerHTML = text; + head.appendChild(th); + }); + + const body = table.createTBody(); + robots.forEach((robot) => body.appendChild(buildProxyRobotRow(robot))); + return table; +} + +// One line per robot. The proxy address is explained once in the section +// description, so it is not repeated under every row -- that sub-line was what +// made the rows double height. +function buildProxyRobotRow(robot) { + const row = document.createElement("tr"); + + const esn = row.insertCell(); + esn.innerHTML = + "" + robot.esn + "" + + (robot.activated ? "" : " (not authenticated)"); + + row.insertCell().innerHTML = robot.observed_address; + row.insertCell().innerHTML = robot.effective_target; + + const input = document.createElement("input"); + input.type = "text"; + input.id = "lanAddr-" + robot.esn; + input.placeholder = "blank to clear"; + input.value = robot.configured_address || ""; + // The original is kept on the element so Save can write only what changed, + // rather than re-POSTing every row on every press. + input.dataset.esn = robot.esn; + input.dataset.original = robot.configured_address || ""; + input.onkeydown = (e) => { + if (e.key === "Enter") { + e.preventDefault(); + saveProxyRobotAddresses(); + } + }; + row.insertCell().appendChild(input); + + return row; +} + +// A single Save for the whole table, so several robots can be corrected in one +// pass. Only changed rows are sent. +// +// The API takes one robot per call, so this fans out and uses allSettled rather +// than all: a partial failure must still report which rows did save, or the +// operator cannot tell what state they are in. +function saveProxyRobotAddresses() { + const status = document.getElementById("proxyRobotStatus"); + const inputs = Array.from( + document.querySelectorAll("#proxyRobotList input[data-esn]") + ); + const changed = inputs.filter((i) => i.value.trim() !== i.dataset.original); + + if (changed.length === 0) { + status.innerHTML = "

No changes to save.

"; + return; + } + + Promise.allSettled( + changed.map((input) => + fetch("/api/set_robot_lan_address", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ esn: input.dataset.esn, address: input.value.trim() }), + }).then(async (response) => { + const text = (await response.text()).trim(); + if (!response.ok) { + throw new Error(input.dataset.esn + ": " + text); + } + return input.dataset.esn; + }) + ) + ).then((results) => { + const saved = results.filter((r) => r.status === "fulfilled").map((r) => r.value); + const failed = results.filter((r) => r.status === "rejected").map((r) => r.reason.message); + let note = ""; + if (saved.length) { + note += "Saved " + saved.join(", ") + "."; + } + if (failed.length) { + note += (note ? " " : "") + "Failed — " + failed.join("; "); + } + // Handed to the refresh rather than set here, or the refresh that follows + // would overwrite it before it could be read. + loadProxyRobots(note); + }); +} diff --git a/chipper/webroot/js/initial.js b/chipper/webroot/js/initial.js index d1b912185..3bb49c95b 100644 --- a/chipper/webroot/js/initial.js +++ b/chipper/webroot/js/initial.js @@ -74,7 +74,12 @@ function sendSetupInfo() { function checkConn() { const connValue = document.getElementById("connSelection").value; + const proxyMode = document.getElementById("proxyModeInput").checked; document.getElementById("portViz").style.display = connValue === "ip" ? "block" : "none"; + // The robot-facing address only applies to the IP path. EP mode serves the + // bundled certificate and a hardcoded escapepod.local address. + document.getElementById("robotFacingViz").style.display = + connValue === "ip" && proxyMode ? "block" : "none"; } function setConn() { @@ -82,12 +87,28 @@ function setConn() { const connValue = document.getElementById("connSelection").value; let port = document.getElementById("portInput").value; port = port ? port : "443"; - const url = connValue === "ep" ? "/api-chipper/use_ep" : `/api-chipper/use_ip?port=${port}`; + const proxyMode = document.getElementById("proxyModeInput").checked; + + const params = new URLSearchParams(); + params.set("proxy_mode", proxyMode ? "true" : "false"); + if (connValue === "ip") { + params.set("port", port); + if (proxyMode) { + params.set("robot_facing_address", document.getElementById("robotFacingInput").value.trim()); + } + } + const base = connValue === "ep" ? "/api-chipper/use_ep" : "/api-chipper/use_ip"; + const url = base + "?" + params.toString(); fetch(url) .then((response) => response.text()) .then((response) => { - if (response) { + // Validation failures come back as a plain "error: ..." body with a 200, + // so the text has to be inspected rather than the status code. + if (response && response.startsWith("error")) { + updateSetupStatus(response); + document.getElementById("config-options").style.display = "block"; + } else if (response) { updateSetupStatus("Setup is complete! Wire-pod has started. Redirecting to main page..."); setTimeout(() => window.location.href = "/", 3000); } else {