diff --git a/cmd/openfsd-client/gui/clients_test.go b/cmd/openfsd-client/gui/clients_test.go index dd2ede3..3ceb272 100644 --- a/cmd/openfsd-client/gui/clients_test.go +++ b/cmd/openfsd-client/gui/clients_test.go @@ -14,13 +14,16 @@ func TestBuildClientSlots_WithDefaultAdapters(t *testing.T) { } slots := BuildClientSlots(eng.Adapters) var enabled, coming int - var hasVPilot bool + var hasVPilot, hasXPilot bool for _, s := range slots { if s.Enabled { enabled++ if s.ID == "vpilot" { hasVPilot = true } + if s.ID == "xpilot" { + hasXPilot = true + } if strings.Contains(SlotLabel(s), "Coming soon") { t.Fatalf("enabled slot labeled coming soon: %+v", s) } @@ -29,12 +32,18 @@ func TestBuildClientSlots_WithDefaultAdapters(t *testing.T) { if !strings.Contains(SlotLabel(s), "Coming soon") { t.Fatalf("disabled label: %q", SlotLabel(s)) } + if s.ID == "xpilot" { + t.Fatal("xpilot registered as adapter must not stay Coming soon") + } } } if !hasVPilot { t.Fatal("expected vpilot enabled") } - if enabled < 1 || coming < 1 { + if !hasXPilot { + t.Fatal("expected xpilot enabled (adapter registered)") + } + if enabled < 2 || coming < 1 { t.Fatalf("enabled=%d coming=%d slots=%d", enabled, coming, len(slots)) } } diff --git a/cmd/openfsd-client/run.go b/cmd/openfsd-client/run.go index 96a5117..592547d 100644 --- a/cmd/openfsd-client/run.go +++ b/cmd/openfsd-client/run.go @@ -15,6 +15,7 @@ import ( "github.com/renorris/openfsd/internal/clientinject" "github.com/renorris/openfsd/internal/clientinject/adapters" "github.com/renorris/openfsd/internal/clientinject/adapters/vpilot" + "github.com/renorris/openfsd/internal/clientinject/adapters/xpilot" ) // Exit codes (design: docs/design/client-runtime-injector.md). @@ -35,7 +36,7 @@ const usageText = `openfsd-client — openfsd Client Setup (GUI + headless CLI) Usage: openfsd-client Launch GUI when a display is available; else this help openfsd-client list-profiles - openfsd-client detect --client vpilot + openfsd-client detect --client vpilot|xpilot openfsd-client plan|apply|revert|health|launch [flags] Shared flags for plan|apply|health|launch: @@ -44,10 +45,10 @@ Shared flags for plan|apply|health|launch: --fsd-port PORT FSD TCP port (default 6809, omitted from server list when default) --fsd-server-name NAME CachedServers label (default OPENFSD) --afv-base URL AFV REST public base (optional) - --force-disable-afv Always launch with -novoice - --prefer-short-jwt Prefer short JWT paths (/j, /fsd-jwt, …) for #US budget - --install DIR vPilot install directory (required when not auto-detected) - --client ID Client adapter id (default vpilot) + --force-disable-afv Always launch with -novoice (vPilot) + --prefer-short-jwt Prefer short JWT paths (/j, /fsd-jwt, …) for #US budget (vPilot) + --install DIR Client install directory (required when not auto-detected) + --client ID Client adapter id (vpilot|xpilot; default vpilot) --profiles-dir DIR Optional override profile directory (YAML) Launch-only flags: @@ -58,12 +59,14 @@ Launch-only flags: preserves relpath under temp (Phase 1 typically PE-only). Readiness honesty: - Default JWT path /api/v1/fsd-jwt allows max host 12 characters for in-place #US - patch (budget 35 runes). Use --prefer-short-jwt for /j (max host 25) if the - server has fixed short JWT routes (direct POST /j, not a 302 redirect). + vPilot: default JWT path /api/v1/fsd-jwt allows max host 12 characters for + in-place #US patch (budget 35 runes). Use --prefer-short-jwt for /j (max host + 25) if the server has fixed short JWT routes (direct POST /j, not a 302). Long hostnames without short paths or free-slot remap are plan blockers. AFV PE ret-disable is out of scope until research gate R2; over-budget AFV falls back to -novoice. + xPilot 3.0.1: PE padded_string + LEA fixups for status.json + fsd-jwt; large + slot budgets; AFV not retargeted; unknown PE hashes refused. Exit codes: 0 ok @@ -410,11 +413,23 @@ func buildInstall(eng *clientinject.Engine, clientID, root string) clientinject. switch clientID { case "vpilot": install = vpilot.InstallFromDir(root) + case "xpilot": + install = xpilot.InstallFromDir(root) default: + // Fall back to profile primary binary name when available. + primary := "client.exe" + if eng != nil && eng.Profiles != nil { + for _, p := range eng.Profiles.ForClient(clientID) { + if p.PrimaryBinary.RelativePath != "" { + primary = p.PrimaryBinary.RelativePath + break + } + } + } install = clientinject.Install{ ClientID: clientID, RootDir: root, - PrimaryPE: filepath.Join(root, "vPilot.exe"), + PrimaryPE: filepath.Join(root, primary), } } seedInstallFromManifest(eng, &install) diff --git a/cmd/openfsd-client/run_test.go b/cmd/openfsd-client/run_test.go index 2cfd63d..650d166 100644 --- a/cmd/openfsd-client/run_test.go +++ b/cmd/openfsd-client/run_test.go @@ -39,6 +39,9 @@ func TestRun_ListProfiles(t *testing.T) { if !strings.Contains(out.String(), "vpilot-3.12.1") { t.Fatalf("expected embedded profile: %s", out.String()) } + if !strings.Contains(out.String(), "xpilot-3.0.1") { + t.Fatalf("expected xpilot profile: %s", out.String()) + } } func TestRun_PlanUsageMissingInstall(t *testing.T) { diff --git a/docs/client-injector/research/xpilot.md b/docs/client-injector/research/xpilot.md new file mode 100644 index 0000000..a029cd3 --- /dev/null +++ b/docs/client-injector/research/xpilot.md @@ -0,0 +1,112 @@ +# xPilot 3.0.1 — reverse-engineering research notes + +**Date:** 2026-07-28 +**Client:** xPilot 3.0.1 (`xPilot.exe`) +**Legal posture:** Research notes + hashes only. **Never commit or redistribute** xPilot binaries. Users install from the official xPilot distribution. + +Tracked profile: `third_party/client-profiles/xpilot-3.0.1.yaml` +Embed plan profile: `internal/clientinject/profiles/xpilot-3.0.1.yaml` + +## Fingerprints + +| Artifact | SHA-1 | +|----------|-------| +| `xPilot.exe` (3.0.1) | `1ae61e1d4a624751124a49cd992c90f948c31d37` | + +Default install path (prior art): `C:\Program Files\xPilot\xPilot.exe`. + +**SHA-256 / installer size:** not recorded in prior art; leave blank until a maintainer re-hashes a user-owned install. + +## Stack + +- Native Windows PE64 (not CLR / not #US). Prior-art patches target `.text` / `.idata` / `.data` section VAs. +- Built-in AFV voice path (separate from FSD). **Prior-art 3.0.1 patchfile does not retarget AFV** — openfsd AFV requires a future research pass or an external AFV client (TrackAudio, etc.). +- Network surfaces of interest: status JSON URL, fsd-jwt URL, residual `fsd.vatsim.net` string. + +## Connection surfaces (what the 3.0.1 prior-art path redirects) + +| Surface | Stock role | openfsd target | Patch family | +|---------|------------|----------------|--------------| +| Network status | VATSIM status JSON | `https://{host}/api/v1/data/status.json` | padded_string (UTF-8) + LEA RIP + length imm | +| FSD JWT auth | VATSIM fsd-jwt | `https://{host}/api/v1/fsd-jwt` | padded_string (UTF-16LE) + LEA RIP×2 + length imm×2 | +| FSD auto host | `fsd.vatsim.net` (or related) | break / neutralise | raw_overwrite 3 bytes (`foo`) in `.idata` | +| AFV REST | (not in prior art) | — | **out of scope** for 3.0.1 adapter | + +Unlike vPilot, there is **no obfuscated config XML** in the 3.0.1 path: server list comes from the retargeted status JSON feed. + +## Section map (3.0.1 prior art) + +| Section | Raw file offset | Virtual start | +|---------|-----------------|---------------| +| `.text` | `0x400` | `0x140001000` | +| `.idata` | `0x01A5A200` | `0x141A5B000` | +| `.data` | `0x027F9000` | `0x1427FA000` | + +File offset conversion (same as openfsd-client-patch-utility): + +```text +file_offset = section.raw_offset + (section_address_va - section.virtual_start) +``` + +### Computed file offsets (ported) + +| Logical site | Section | VA | File offset | +|--------------|---------|----|-------------| +| status.json LEA RIP | `.text` | `0x140028D0E` | `0x2810E` | +| status.json length imm | `.text` | `0x140028D07` | `0x28107` | +| fsd-jwt LEA RIP (site 1) | `.text` | `0x140035BFE` | `0x34FFE` | +| fsd-jwt length (site 1) | `.text` | `0x140035C0A` | `0x3500A` | +| fsd-jwt LEA RIP (site 2) | `.text` | `0x14006BE08` | `0x6B208` | +| fsd-jwt length (site 2) | `.text` | `0x14006BE14` | `0x6B214` | +| break `fsd.vatsim.net` | `.idata` | `0x141CBF240` | `0x1CBE440` | +| status.json string slot | `.idata` | `0x141AAF5AE` | `0x1AAE7AE` | +| fsd-jwt string slot | `.idata` | `0x141A9662C` | `0x1A9582C` | + +### Slot budgets + +| Slot | Encoding | `available_bytes` | Notes | +|------|----------|-------------------|-------| +| status.json URL | UTF-8 (+ NUL pad) | `0x3B5` (949) | Length imm is **single byte** (max URL length 255) | +| fsd-jwt URL | UTF-16LE (+ U+0000 pad) | `0x3B4` (948) | Length imm is **character count**, single byte | + +LEA RIP displacements in prior art point at the **relocated** `.idata` slots (not the stock string sites). They are **content-independent** for a fixed slot address. Length immediates **must** match the new URL length at Apply time. + +Example prior-art URLs (length reference only): + +- `https://yourfsdserver.com/api/v1/data/status.json` → length `49` +- `https://yourfsdserver.com/api/v1/fsd-jwt` → length `40` + +## Prior art source + +| Repo / path | Role | +|-------------|------| +| `renorris/openfsd-client-patch-utility` `example_patchfiles/xpilot/xpilot-3.0.1.yaml` | Section overwrite + padded string patchfile for sum `1ae61e1d…` | +| Same repo `patch/section_*.go` | VA→raw conversion + padded write semantics | +| openfsd wiki `Client-Connection.md` | Points operators at that utility for xPilot | + +**This PR ports those offsets into schema v2 + `internal/clientinject` adapter.** Offsets were **derived** from the published prior-art YAML (VA + section map → file offset). They were **not re-verified against a live `xPilot.exe` binary in this environment** (binaries are never committed). The adapter **refuses** any PE whose SHA-1 ≠ profile stock. + +## Adapter strategy (openfsd Client Setup) + +1. **Discover** — `Program Files\xPilot`, `Program Files (x86)\xPilot`, Wine equivalents. +2. **Verify** — primary PE SHA-1 must match profile (or stock bak after re-apply). +3. **Plan / Apply** — `padded_string` for status + JWT; `raw_overwrite` for LEA RIP fixups, dynamic length bytes, and break-fsd; transactional `.openfsd-bak` via engine. +4. **HealthCheck** — decode padded slots; assert URLs match planned endpoints; assert break site is not stock prefix when patched. +5. **LaunchArgs** — empty (no documented xPilot CLI server override in prior art). +6. **AFV** — warn only; not patched. Operators may set AFV base in an external client or await a future profile. + +## Honesty / known gaps + +- [ ] Re-hash a maintainer-owned 3.0.1 install; add SHA-256 + `size_bytes` when confirmed. +- [ ] Confirm live connect path after status.json + fsd-jwt retarget (server list fields xPilot expects). +- [ ] Inventory AFV / voice base URL sites in the same PE (and companion DLLs if any). +- [ ] Confirm single-byte length immediates for non-ASCII hosts (openfsd URLs are ASCII). +- [ ] Antivirus / code-signing interaction when rewriting signed `xPilot.exe`. +- [ ] Newer xPilot versions need **new** version-pinned profiles — do not reuse 3.0.1 offsets. + +## Legal / product constraints + +- Do not redistribute xPilot or derivative binaries. +- Track **hashes + profiles + research** in git only. +- Product framing: **private openfsd networks** the operator is authorized to use. +- Prefer reversible Apply/Revert; never delete user model/aircraft data outside the PE/config targets. diff --git a/internal/clientinject/adapters/registry.go b/internal/clientinject/adapters/registry.go index c62da05..a91782d 100644 --- a/internal/clientinject/adapters/registry.go +++ b/internal/clientinject/adapters/registry.go @@ -4,12 +4,14 @@ package adapters import ( "github.com/renorris/openfsd/internal/clientinject" "github.com/renorris/openfsd/internal/clientinject/adapters/vpilot" + "github.com/renorris/openfsd/internal/clientinject/adapters/xpilot" ) -// DefaultAdapters returns the built-in client adapters (vPilot first). +// DefaultAdapters returns the built-in client adapters (vPilot first, then xPilot). func DefaultAdapters() []clientinject.Adapter { return []clientinject.Adapter{ vpilot.New(), + xpilot.New(), } } diff --git a/internal/clientinject/adapters/registry_test.go b/internal/clientinject/adapters/registry_test.go index abdf968..2071b0d 100644 --- a/internal/clientinject/adapters/registry_test.go +++ b/internal/clientinject/adapters/registry_test.go @@ -6,12 +6,19 @@ import ( func TestDefaultAdapters(t *testing.T) { as := DefaultAdapters() - if len(as) == 0 { - t.Fatal("empty") + if len(as) < 2 { + t.Fatalf("want ≥2 adapters, got %d", len(as)) } if as[0].ClientID() != "vpilot" { t.Fatalf("got %q", as[0].ClientID()) } + ids := map[string]bool{} + for _, a := range as { + ids[a.ClientID()] = true + } + if !ids["vpilot"] || !ids["xpilot"] { + t.Fatalf("ids=%v", ids) + } } func TestDefaultEngine(t *testing.T) { @@ -22,7 +29,17 @@ func TestDefaultEngine(t *testing.T) { if eng.Adapters["vpilot"] == nil { t.Fatal("missing vpilot") } + if eng.Adapters["xpilot"] == nil { + t.Fatal("missing xpilot") + } if _, ok := eng.Profiles.Get("vpilot-3.12.1"); !ok { - t.Fatal("missing profile") + t.Fatal("missing vpilot profile") + } + if _, ok := eng.Profiles.Get("xpilot-3.0.1"); !ok { + t.Fatal("missing xpilot profile") + } + // Multi-client: registry is not vPilot-only. + if len(eng.Adapters) < 2 { + t.Fatalf("adapters=%d", len(eng.Adapters)) } } diff --git a/internal/clientinject/adapters/xpilot/adapter.go b/internal/clientinject/adapters/xpilot/adapter.go new file mode 100644 index 0000000..94a5898 --- /dev/null +++ b/internal/clientinject/adapters/xpilot/adapter.go @@ -0,0 +1,273 @@ +// Package xpilot implements the clientinject.Adapter for xPilot 3.0.1. +// +// Strategy (prior art openfsd-client-patch-utility xpilot-3.0.1.yaml): +// padded_string slots for status.json (UTF-8) and fsd-jwt (UTF-16LE), +// raw_overwrite for LEA RIP fixups, dynamic length immediates, and a +// break of residual fsd.vatsim.net. AFV retarget is out of scope until +// research inventories voice base sites. +// +// Offsets are version-pinned by primary PE SHA-1; unknown hashes are refused. +package xpilot + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/renorris/openfsd/internal/clientinject" +) + +const ( + clientID = "xpilot" + displayName = "xPilot" + primaryName = "xPilot.exe" + profileID = "xpilot-3.0.1" + + // Single-byte length immediates in the 3.0.1 prior-art path. + maxLengthImm = 255 +) + +// Adapter is the xPilot client strategy. +type Adapter struct { + // Writer is used by HealthCheck / Discover when non-nil; default OSFileWriter. + Writer clientinject.FileWriter + // Profiles overrides the embedded profile store (tests / --profiles-dir). + Profiles *clientinject.ProfileStore +} + +// New returns an xPilot adapter. +func New() *Adapter { + return &Adapter{} +} + +// NewWithProfiles returns an adapter that resolves profiles from store. +func NewWithProfiles(store *clientinject.ProfileStore) *Adapter { + return &Adapter{Profiles: store} +} + +// ClientID implements clientinject.Adapter. +func (a *Adapter) ClientID() string { return clientID } + +// DisplayName implements clientinject.Adapter. +func (a *Adapter) DisplayName() string { return displayName } + +// SupportedProfiles implements clientinject.Adapter. +func (a *Adapter) SupportedProfiles() []string { + return []string{profileID} +} + +func (a *Adapter) writer() clientinject.FileWriter { + if a != nil && a.Writer != nil { + return a.Writer + } + return clientinject.OSFileWriter{} +} + +// Discover looks for xPilot installs (Program Files + Wine prefixes). +func (a *Adapter) Discover(ctx context.Context) ([]clientinject.InstallCandidate, error) { + _ = ctx + var cands []clientinject.InstallCandidate + seen := make(map[string]struct{}) + + add := func(root, hint string) { + root = filepath.Clean(root) + if root == "" || root == "." { + return + } + if _, ok := seen[root]; ok { + return + } + pe := filepath.Join(root, primaryName) + if _, err := a.writer().Stat(pe); err != nil { + return + } + seen[root] = struct{}{} + cands = append(cands, clientinject.InstallCandidate{ + ClientID: clientID, + RootDir: root, + PrimaryPE: pe, + ConfigPaths: nil, + DisplayHint: hint, + }) + } + + switch runtime.GOOS { + case "windows": + for _, env := range []string{"ProgramFiles", "ProgramFiles(x86)"} { + if base := os.Getenv(env); base != "" { + add(filepath.Join(base, "xPilot"), env+" install") + } + } + // Some installs may live under LocalAppData (defensive). + if local := os.Getenv("LOCALAPPDATA"); local != "" { + add(filepath.Join(local, "xPilot"), "LocalAppData install") + } + default: + home, _ := os.UserHomeDir() + if home != "" { + user := os.Getenv("USER") + if user == "" { + user = os.Getenv("USERNAME") + } + wineRoots := []string{ + filepath.Join(home, ".wine", "drive_c", "Program Files", "xPilot"), + filepath.Join(home, ".wine", "drive_c", "Program Files (x86)", "xPilot"), + } + if user != "" { + wineRoots = append(wineRoots, + filepath.Join(home, ".wine", "drive_c", "users", user, "AppData", "Local", "xPilot"), + ) + } + for _, r := range wineRoots { + add(r, "Wine install") + } + } + } + return cands, nil +} + +// Verify checks primary PE SHA-1 against the profile (stock bak allowed post-Apply). +func (a *Adapter) Verify(install clientinject.Install, profile *clientinject.Profile) error { + if profile == nil { + return fmt.Errorf("xpilot: nil profile") + } + if profile.ClientID != clientID { + return fmt.Errorf("xpilot: profile client_id %q is not xpilot", profile.ClientID) + } + pe := clientinject.AbsPrimaryPE(install) + if pe == "" { + return fmt.Errorf("xpilot: empty primary PE path") + } + w := a.writer() + data, err := w.ReadFile(pe) + if err != nil { + return fmt.Errorf("xpilot: read PE: %w", err) + } + sum := sha1.Sum(data) + got := hex.EncodeToString(sum[:]) + want := strings.ToLower(strings.TrimSpace(profile.PrimaryBinary.SHA1)) + if want == "" { + return fmt.Errorf("xpilot: profile has no primary_binary.sha1") + } + if !strings.EqualFold(got, want) { + bak := clientinject.BackupPath(pe) + if _, bakErr := w.Stat(bak); bakErr == nil { + if install.HashSHA1 != "" && strings.EqualFold(install.HashSHA1, want) { + return nil + } + if bakData, rerr := w.ReadFile(bak); rerr == nil { + bakSum := sha1.Sum(bakData) + if strings.EqualFold(hex.EncodeToString(bakSum[:]), want) { + return nil + } + } + } + return fmt.Errorf("xpilot: PE sha1 %s does not match profile %s (stock %s); refuse unknown hash", + got, profile.ProfileID, want) + } + if wantSize := profile.PrimaryBinary.SizeBytes; wantSize > 0 && int64(len(data)) != wantSize { + return fmt.Errorf("xpilot: PE size %d does not match profile %d", len(data), wantSize) + } + return nil +} + +// EndpointConstraints returns slot budget hints from the profile. +func (a *Adapter) EndpointConstraints(profile *clientinject.Profile) []clientinject.Constraint { + if profile == nil { + return nil + } + var out []clientinject.Constraint + if s, ok := profile.Strings["status_json"]; ok && s.PayloadBudgetBytes > 0 { + // UTF-8: budget is byte slot; max URL runes ≈ budget-1 (NUL). + maxRunes := s.PayloadBudgetBytes - 1 + if maxRunes > maxLengthImm { + maxRunes = maxLengthImm + } + out = append(out, clientinject.Constraint{ + Field: "StatusJSONURL", + MaxRunes: maxRunes, + Strategy: "padded_string", + Description: fmt.Sprintf("UTF-8 padded slot %d bytes; length imm is 1 byte (max %d)", s.PayloadBudgetBytes, maxLengthImm), + }) + } + if s, ok := profile.Strings["fsd_jwt"]; ok && s.PayloadBudgetBytes > 0 { + // UTF-16LE: encoded (runes+NUL)*2 must fit slot; length imm is char count ≤255. + maxRunes := (s.PayloadBudgetBytes / 2) - 1 + if maxRunes > maxLengthImm { + maxRunes = maxLengthImm + } + out = append(out, clientinject.Constraint{ + Field: "JWTURL", + MaxRunes: maxRunes, + Strategy: "padded_string", + Description: fmt.Sprintf("UTF-16LE padded slot %d bytes (~%d runes); length imm max %d", s.PayloadBudgetBytes, maxRunes, maxLengthImm), + }) + } + out = append(out, clientinject.Constraint{ + Field: "AFVBaseURL", + MaxRunes: 0, + Strategy: "n/a", + Description: "xPilot 3.0.1 prior art does not retarget AFV; configure voice separately or await a future profile", + }) + return out +} + +// LaunchArgs returns CLI flags for launching the client (none known for 3.0.1). +func (a *Adapter) LaunchArgs(install clientinject.Install, ep clientinject.Endpoints) []string { + _ = install + _ = ep + return nil +} + +// InstallFromDir builds an Install for an explicit --install directory. +func InstallFromDir(root string) clientinject.Install { + return New().InstallFromDir(root) +} + +// InstallFromDir builds an Install using this adapter's FileWriter. +func (a *Adapter) InstallFromDir(root string) clientinject.Install { + root = filepath.Clean(root) + return clientinject.Install{ + ClientID: clientID, + RootDir: root, + PrimaryPE: filepath.Join(root, primaryName), + } +} + +func (a *Adapter) loadProfileForInstall(install clientinject.Install) (*clientinject.Profile, error) { + var store *clientinject.ProfileStore + if a != nil && a.Profiles != nil { + store = a.Profiles + } else { + var err error + store, err = clientinject.LoadEmbedded() + if err != nil { + return nil, err + } + } + if install.ProfileID != "" { + if p, ok := store.Get(install.ProfileID); ok { + return p, nil + } + } + if install.HashSHA1 != "" { + if p, ok := store.LookupByHash(install.ClientID, install.HashSHA1); ok { + return p, nil + } + } + list := store.ForClient(clientID) + if len(list) == 0 { + return nil, fmt.Errorf("xpilot: no xpilot profiles loaded") + } + for _, p := range list { + if strings.HasPrefix(p.ProfileID, "xpilot") { + return p, nil + } + } + return list[0], nil +} diff --git a/internal/clientinject/adapters/xpilot/adapter_test.go b/internal/clientinject/adapters/xpilot/adapter_test.go new file mode 100644 index 0000000..a555359 --- /dev/null +++ b/internal/clientinject/adapters/xpilot/adapter_test.go @@ -0,0 +1,384 @@ +package xpilot + +import ( + "bytes" + "context" + "crypto/sha1" + "encoding/binary" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf16" + + "github.com/renorris/openfsd/internal/clientinject" + "github.com/renorris/openfsd/internal/clientinject/pepatch" +) + +func sha1hex(b []byte) string { + s := sha1.Sum(b) + return hex.EncodeToString(s[:]) +} + +// Compact synthetic layout — not production offsets (those need ~30MB PE). +const ( + testStatusOff = 0x100 + testJWTOff = 0x200 + testStatusLEA = 0x50 + testStatusLen = 0x54 + testJWTLEA = 0x58 + testJWTLen = 0x5C + testBreakOff = 0x60 + testStatusSlot = 64 + testJWTSlot = 80 +) + +func fo(v int64) *clientinject.FlexibleInt64 { + f := clientinject.FlexibleInt64(v) + return &f +} + +func testProfile(pe []byte) *clientinject.Profile { + return &clientinject.Profile{ + SchemaVersion: 2, + ProfileID: "xpilot-test-synth", + ClientID: clientID, + DisplayName: "xPilot", + SupportedClientVersion: "3.0.1-test", + PrimaryBinary: clientinject.PrimaryBinarySpec{ + RelativePath: primaryName, + SHA1: sha1hex(pe), + SizeBytes: int64(len(pe)), + }, + Strings: map[string]clientinject.StringSpec{ + "status_json": {PayloadBudgetBytes: testStatusSlot, Template: "{{.StatusJSONURL}}"}, + "fsd_jwt": {PayloadBudgetBytes: testJWTSlot, Template: "{{.JWTURL}}"}, + }, + Mutations: []clientinject.ProfileMutationSpec{ + { + ID: "write_status_json", Kind: "padded_string", Description: "status", + FileOffset: fo(testStatusOff), AvailableBytes: fo(testStatusSlot), + Encoding: "utf8", EndpointKey: "status_json", + }, + { + ID: "write_fsd_jwt", Kind: "padded_string", Description: "jwt", + FileOffset: fo(testJWTOff), AvailableBytes: fo(testJWTSlot), + Encoding: "utf16le", EndpointKey: "fsd_jwt", + }, + { + ID: "patch_status_lea", Kind: "raw_overwrite", Description: "status lea", + FileOffset: fo(testStatusLEA), NewBytes: []byte{0x9C, 0x68, 0xA8, 0x01}, + }, + { + ID: "patch_status_len", Kind: "raw_overwrite", Description: "status len", + FileOffset: fo(testStatusLen), LengthOf: "status_json", + }, + { + ID: "patch_jwt_lea", Kind: "raw_overwrite", Description: "jwt lea", + FileOffset: fo(testJWTLEA), NewBytes: []byte{0x2A, 0x0A, 0xA6, 0x01}, + }, + { + ID: "patch_jwt_len", Kind: "raw_overwrite", Description: "jwt len", + FileOffset: fo(testJWTLen), LengthOf: "fsd_jwt", + }, + { + ID: "break_fsd", Kind: "raw_overwrite", Description: "break fsd", + FileOffset: fo(testBreakOff), NewBytes: []byte{0x66, 0x6F, 0x6F}, + }, + }, + } +} + +func buildSynthPE(t *testing.T) []byte { + t.Helper() + // Large enough for JWT slot end. + size := testJWTOff + testJWTSlot + 16 + data := make([]byte, size) + // Stock-like placeholders. + copy(data[testStatusOff:], []byte("https://status.vatsim.net/old.json")) + u := utf16.Encode([]rune("https://auth.vatsim.net/api/fsd-jwt")) + for i, c := range u { + binary.LittleEndian.PutUint16(data[testJWTOff+i*2:], c) + } + // break site stock-ish + copy(data[testBreakOff:], []byte("fsd.vatsim.net")) + return data +} + +func setupInstall(t *testing.T) (root string, pe []byte, install clientinject.Install, profile *clientinject.Profile, a *Adapter) { + t.Helper() + root = t.TempDir() + pe = buildSynthPE(t) + pePath := filepath.Join(root, primaryName) + if err := os.WriteFile(pePath, pe, 0o644); err != nil { + t.Fatal(err) + } + profile = testProfile(pe) + store := clientinject.NewProfileStore() + if err := store.Add(profile); err != nil { + t.Fatal(err) + } + a = NewWithProfiles(store) + install = clientinject.Install{ + ClientID: clientID, + RootDir: root, + PrimaryPE: pePath, + HashSHA1: profile.PrimaryBinary.SHA1, + ProfileID: profile.ProfileID, + } + return root, pe, install, profile, a +} + +func TestClientIDDisplay(t *testing.T) { + a := New() + if a.ClientID() != "xpilot" || a.DisplayName() != "xPilot" { + t.Fatal(a.ClientID(), a.DisplayName()) + } + if len(a.SupportedProfiles()) != 1 || a.SupportedProfiles()[0] != profileID { + t.Fatalf("profiles=%v", a.SupportedProfiles()) + } +} + +func TestPlan_OK(t *testing.T) { + _, _, install, profile, a := setupInstall(t) + ep := clientinject.Endpoints{ + WebBaseURL: "https://fsd.ex.co", + FSDHost: "fsd.ex.co", + } + plan, err := a.Plan(install, profile, ep) + if err != nil { + t.Fatal(err) + } + if len(plan.Blockers) != 0 { + t.Fatalf("blockers: %v", plan.Blockers) + } + var padded, raw int + for _, m := range plan.Mutations { + switch m.Kind { + case clientinject.MutPaddedString: + padded++ + case clientinject.MutRawOverwrite: + raw++ + } + } + if padded != 2 { + t.Fatalf("padded=%d", padded) + } + if raw < 4 { + t.Fatalf("raw=%d", raw) + } + // Dynamic length for status URL. + statusURL := ep.StatusJSONURL() + var sawLen bool + for _, m := range plan.Mutations { + if m.ID != "patch_status_len" { + continue + } + d, ok := rawDetail(m.Detail) + if !ok || len(d.NewBytes) != 1 { + t.Fatalf("detail=%+v", m.Detail) + } + if d.NewBytes[0] != byte(len([]rune(statusURL))) { + t.Fatalf("len byte=%d want %d", d.NewBytes[0], len([]rune(statusURL))) + } + sawLen = true + } + if !sawLen { + t.Fatal("missing status len mutation") + } +} + +func TestPlan_MissingWebBaseBlocker(t *testing.T) { + _, _, install, profile, a := setupInstall(t) + plan, err := a.Plan(install, profile, clientinject.Endpoints{FSDHost: "x"}) + if err != nil { + t.Fatal(err) + } + if len(plan.Blockers) == 0 { + t.Fatal("expected blockers") + } +} + +func TestPlan_RefuseUnknownMutationKind(t *testing.T) { + _, _, install, profile, a := setupInstall(t) + profile.Mutations = append(profile.Mutations, clientinject.ProfileMutationSpec{ + ID: "bad", Kind: "cil_us_string", FileOffset: fo(1), + }) + plan, err := a.Plan(install, profile, clientinject.Endpoints{ + WebBaseURL: "https://fsd.ex.co", + FSDHost: "fsd.ex.co", + }) + if err != nil { + t.Fatal(err) + } + found := false + for _, b := range plan.Blockers { + if strings.Contains(b, "unsupported kind") || strings.Contains(b, "cil_us_string") { + found = true + } + } + if !found { + t.Fatalf("blockers=%v", plan.Blockers) + } +} + +func TestVerify_RefuseUnknownHash(t *testing.T) { + _, _, install, profile, a := setupInstall(t) + // Corrupt PE. + if err := os.WriteFile(install.PrimaryPE, []byte("not-the-pe"), 0o644); err != nil { + t.Fatal(err) + } + err := a.Verify(install, profile) + if err == nil || !strings.Contains(err.Error(), "refuse unknown hash") { + t.Fatalf("err=%v", err) + } +} + +func TestApplyHealth_RoundTrip(t *testing.T) { + _, stock, install, profile, a := setupInstall(t) + ep := clientinject.Endpoints{ + WebBaseURL: "https://fsd.ex.co", + FSDHost: "fsd.ex.co", + } + plan, err := a.Plan(install, profile, ep) + if err != nil { + t.Fatal(err) + } + if len(plan.Blockers) != 0 { + t.Fatalf("blockers: %v", plan.Blockers) + } + w := clientinject.OSFileWriter{} + if err := a.Apply(context.Background(), plan, w); err != nil { + t.Fatal(err) + } + if err := a.HealthCheck(install, ep); err != nil { + t.Fatal(err) + } + // Ensure stock was actually changed. + got, err := os.ReadFile(install.PrimaryPE) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(got, stock) { + t.Fatal("PE unchanged after apply") + } + // Status URL present as UTF-8. + status := ep.StatusJSONURL() + if !bytes.Contains(got[testStatusOff:testStatusOff+testStatusSlot], []byte(status)) { + t.Fatalf("status URL missing in slot") + } + // JWT as UTF-16LE. + u := utf16.Encode([]rune(ep.JWTURL())) + pat := make([]byte, len(u)*2) + for i, c := range u { + binary.LittleEndian.PutUint16(pat[i*2:], c) + } + if !bytes.Contains(got[testJWTOff:testJWTOff+testJWTSlot], pat) { + t.Fatal("jwt utf16 missing") + } + // Break site. + if !bytes.Equal(got[testBreakOff:testBreakOff+3], []byte("foo")) { + t.Fatalf("break=%q", got[testBreakOff:testBreakOff+3]) + } +} + +func TestEngine_ApplyRevert_WithXPilot(t *testing.T) { + root, stock, install, profile, a := setupInstall(t) + store := clientinject.NewProfileStore() + if err := store.Add(profile); err != nil { + t.Fatal(err) + } + eng := clientinject.NewEngine(store, a) + ep := clientinject.Endpoints{ + WebBaseURL: "https://fsd.ex.co", + FSDHost: "fsd.ex.co", + } + plan, err := eng.Plan(context.Background(), install, ep) + if err != nil { + t.Fatal(err) + } + if len(plan.Blockers) != 0 { + t.Fatalf("blockers: %v", plan.Blockers) + } + res, err := eng.Apply(context.Background(), plan) + if err != nil { + t.Fatal(err) + } + if res.ManifestPath == "" { + t.Fatal("empty manifest") + } + // Health via engine already ran inside Apply. + got, _ := os.ReadFile(install.PrimaryPE) + if bytes.Equal(got, stock) { + t.Fatal("not patched") + } + if err := eng.Revert(context.Background(), root); err != nil { + t.Fatal(err) + } + restored, _ := os.ReadFile(install.PrimaryPE) + if !bytes.Equal(restored, stock) { + t.Fatal("revert did not restore stock PE") + } +} + +func TestInstallFromDir(t *testing.T) { + inst := InstallFromDir("/tmp/xpilot-install") + if inst.ClientID != clientID { + t.Fatal(inst.ClientID) + } + if !strings.HasSuffix(inst.PrimaryPE, primaryName) { + t.Fatal(inst.PrimaryPE) + } +} + +func TestDiscover_EmptyOnUnixWithoutWine(t *testing.T) { + // Should not error; may return empty. + cands, err := New().Discover(context.Background()) + if err != nil { + t.Fatal(err) + } + _ = cands +} + +func TestPaddedString_pepatchEncodingParity(t *testing.T) { + // Sanity: pepatch accepts encodings used by profile. + var buf struct { + data []byte + pos int64 + } + buf.data = make([]byte, 32) + // Use a small WriteSeeker via temp file. + dir := t.TempDir() + path := filepath.Join(dir, "p.bin") + if err := os.WriteFile(path, make([]byte, 64), 0o644); err != nil { + t.Fatal(err) + } + f, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := pepatch.PaddedStringOverwrite(f, 0, "https://x.test/j", 32, "utf8"); err != nil { + t.Fatal(err) + } + // UTF-16LE needs (runes+1)*2 bytes; use a short string that fits remaining 32. + if err := pepatch.PaddedStringOverwrite(f, 32, "https://x.t/j", 32, "utf16le"); err != nil { + t.Fatal(err) + } +} + +func TestEndpointConstraints(t *testing.T) { + _, _, _, profile, a := setupInstall(t) + cs := a.EndpointConstraints(profile) + if len(cs) < 2 { + t.Fatalf("constraints=%v", cs) + } +} + +func TestLaunchArgsEmpty(t *testing.T) { + a := New() + if args := a.LaunchArgs(clientinject.Install{}, clientinject.Endpoints{}); len(args) != 0 { + t.Fatalf("args=%v", args) + } +} diff --git a/internal/clientinject/adapters/xpilot/apply.go b/internal/clientinject/adapters/xpilot/apply.go new file mode 100644 index 0000000..2065879 --- /dev/null +++ b/internal/clientinject/adapters/xpilot/apply.go @@ -0,0 +1,98 @@ +package xpilot + +import ( + "context" + "fmt" + + "github.com/renorris/openfsd/internal/clientinject" + "github.com/renorris/openfsd/internal/clientinject/pepatch" +) + +// Apply writes plan mutations via FileWriter. +func (a *Adapter) Apply(ctx context.Context, plan *clientinject.Plan, w clientinject.FileWriter) error { + _ = ctx + if plan == nil { + return fmt.Errorf("xpilot: nil plan") + } + if w == nil { + return fmt.Errorf("xpilot: nil FileWriter") + } + pePath := clientinject.AbsPrimaryPE(plan.Install) + if pePath == "" { + return fmt.Errorf("xpilot: PE mutation planned but PrimaryPE empty") + } + + needPE := false + for _, m := range plan.Mutations { + switch m.Kind { + case clientinject.MutPaddedString, clientinject.MutRawOverwrite: + needPE = true + } + } + if !needPE { + return nil + } + + peFile, err := w.OpenReadWrite(pePath) + if err != nil { + return fmt.Errorf("xpilot: open PE: %w", err) + } + defer peFile.Close() + + for _, m := range plan.Mutations { + switch m.Kind { + case clientinject.MutPaddedString: + d, ok := paddedDetail(m.Detail) + if !ok { + return fmt.Errorf("xpilot: mutation %s: bad PaddedStringDetail", m.ID) + } + if err := pepatch.PaddedStringOverwrite(peFile, d.FileOffset, d.NewString, d.SlotLen, d.Encoding); err != nil { + return fmt.Errorf("xpilot: %s: %w", m.ID, err) + } + case clientinject.MutRawOverwrite: + d, ok := rawDetail(m.Detail) + if !ok { + return fmt.Errorf("xpilot: mutation %s: bad RawOverwriteDetail", m.ID) + } + if len(d.NewBytes) == 0 { + continue + } + if err := pepatch.OverwriteAt(peFile, d.FileOffset, d.NewBytes); err != nil { + return fmt.Errorf("xpilot: %s: %w", m.ID, err) + } + case clientinject.MutLaunchFlag: + continue + default: + return fmt.Errorf("xpilot: unsupported mutation kind %q (id=%s)", m.Kind, m.ID) + } + } + return nil +} + +func paddedDetail(d any) (clientinject.PaddedStringDetail, bool) { + switch v := d.(type) { + case clientinject.PaddedStringDetail: + return v, true + case *clientinject.PaddedStringDetail: + if v == nil { + return clientinject.PaddedStringDetail{}, false + } + return *v, true + default: + return clientinject.PaddedStringDetail{}, false + } +} + +func rawDetail(d any) (clientinject.RawOverwriteDetail, bool) { + switch v := d.(type) { + case clientinject.RawOverwriteDetail: + return v, true + case *clientinject.RawOverwriteDetail: + if v == nil { + return clientinject.RawOverwriteDetail{}, false + } + return *v, true + default: + return clientinject.RawOverwriteDetail{}, false + } +} diff --git a/internal/clientinject/adapters/xpilot/health.go b/internal/clientinject/adapters/xpilot/health.go new file mode 100644 index 0000000..38976f4 --- /dev/null +++ b/internal/clientinject/adapters/xpilot/health.go @@ -0,0 +1,133 @@ +package xpilot + +import ( + "bytes" + "encoding/binary" + "fmt" + "strings" + "unicode/utf16" + + "github.com/renorris/openfsd/internal/clientinject" +) + +// HealthCheck validates padded-string slots and raw length/break sites after Apply. +func (a *Adapter) HealthCheck(install clientinject.Install, ep clientinject.Endpoints) error { + ep = ep.Normalize() + w := a.writer() + pe := clientinject.AbsPrimaryPE(install) + if pe == "" { + return fmt.Errorf("xpilot: healthcheck: empty PrimaryPE") + } + data, err := w.ReadFile(pe) + if err != nil { + return fmt.Errorf("xpilot: healthcheck read PE: %w", err) + } + + profile, err := a.loadProfileForInstall(install) + if err != nil { + return fmt.Errorf("xpilot: healthcheck profile: %w", err) + } + + statusURL := ep.StatusJSONURL() + jwtURL := ep.JWTURL() + endpoints := map[string]string{ + "status_json": statusURL, + "fsd_jwt": jwtURL, + "status": statusURL, + } + + for _, m := range profile.Mutations { + if m.FileOffset == nil { + continue + } + off := m.FileOffset.Int64() + kind, err := clientinject.MapYAMLKind(m.Kind) + if err != nil { + continue + } + switch kind { + case clientinject.MutPaddedString: + key := strings.TrimSpace(m.EndpointKey) + if key == "" { + key = strings.TrimSpace(m.StringRef) + } + want := endpoints[key] + if want == "" { + continue + } + slotLen := 0 + if m.AvailableBytes != nil { + slotLen = int(m.AvailableBytes.Int64()) + } + if slotLen <= 0 { + if s, ok := profile.Strings[key]; ok { + slotLen = s.PayloadBudgetBytes + } + } + if slotLen <= 0 || off < 0 || int(off)+slotLen > len(data) { + return fmt.Errorf("xpilot: healthcheck: padded slot %s @%#x out of range", m.ID, off) + } + got, err := decodePadded(data[off:int(off)+slotLen], m.Encoding) + if err != nil { + return fmt.Errorf("xpilot: healthcheck %s: %w", m.ID, err) + } + if got != want { + return fmt.Errorf("xpilot: healthcheck %s @%#x = %q, want %q", m.ID, off, got, want) + } + case clientinject.MutRawOverwrite: + if lo := strings.TrimSpace(m.LengthOf); lo != "" { + url := endpoints[lo] + if url == "" { + continue + } + want := byte(len([]rune(url))) + if off < 0 || int(off) >= len(data) { + return fmt.Errorf("xpilot: healthcheck: length site %s out of range", m.ID) + } + if data[off] != want { + return fmt.Errorf("xpilot: healthcheck %s @%#x = %d, want %d", m.ID, off, data[off], want) + } + continue + } + if len(m.NewBytes) == 0 { + continue + } + if off < 0 || int(off)+len(m.NewBytes) > len(data) { + return fmt.Errorf("xpilot: healthcheck: raw site %s out of range", m.ID) + } + got := data[off : int(off)+len(m.NewBytes)] + if !bytes.Equal(got, m.NewBytes) { + return fmt.Errorf("xpilot: healthcheck %s @%#x = %x, want %x", m.ID, off, got, m.NewBytes) + } + } + } + return nil +} + +func decodePadded(slot []byte, encoding string) (string, error) { + enc := strings.ToLower(strings.TrimSpace(encoding)) + switch enc { + case "utf16le", "utf-16le", "utf16": + if len(slot) < 2 { + return "", fmt.Errorf("utf16 slot too short") + } + // Read until U+0000 or end. + nUnits := len(slot) / 2 + units := make([]uint16, 0, nUnits) + for i := 0; i < nUnits; i++ { + u := binary.LittleEndian.Uint16(slot[i*2:]) + if u == 0 { + break + } + units = append(units, u) + } + return string(utf16.Decode(units)), nil + default: + // utf8/ascii: until first 0x00. + i := bytes.IndexByte(slot, 0) + if i < 0 { + return string(slot), nil + } + return string(slot[:i]), nil + } +} diff --git a/internal/clientinject/adapters/xpilot/plan.go b/internal/clientinject/adapters/xpilot/plan.go new file mode 100644 index 0000000..1bfa136 --- /dev/null +++ b/internal/clientinject/adapters/xpilot/plan.go @@ -0,0 +1,223 @@ +package xpilot + +import ( + "fmt" + "strings" + "unicode/utf8" + + "github.com/renorris/openfsd/internal/clientinject" +) + +// Plan builds a dry-run mutation plan from the profile. Does not write disk. +func (a *Adapter) Plan(install clientinject.Install, profile *clientinject.Profile, ep clientinject.Endpoints) (*clientinject.Plan, error) { + if profile == nil { + return nil, fmt.Errorf("xpilot: nil profile") + } + ep = ep.Normalize() + plan := &clientinject.Plan{ + Install: install, + Endpoints: ep, + Constraints: a.EndpointConstraints(profile), + } + + if ep.WebBaseURL == "" { + plan.Blockers = append(plan.Blockers, "WebBaseURL is required") + } + // FSDHost is not written into the PE (status.json feed supplies servers), + // but keep the form contract: require it so operators configure a coherent pair. + if ep.FSDHost == "" { + plan.Blockers = append(plan.Blockers, "FSDHost is required (used with openfsd status feed / operator checklist)") + } + + statusURL := ep.StatusJSONURL() + jwtURL := ep.JWTURL() + // Prefer full /api/v1/fsd-jwt; PreferShortJWTPath still works via Endpoints.JWTURL. + if ep.PreferShortJWTPath && jwtURL != "" { + plan.Warnings = append(plan.Warnings, + "PreferShortJWTPath set — ensure server has fixed short JWT routes; xPilot slots are large so default /api/v1/fsd-jwt usually fits") + } + + endpoints := map[string]string{ + "status_json": statusURL, + "fsd_jwt": jwtURL, + "status": statusURL, + } + + if statusURL == "" && ep.WebBaseURL != "" { + plan.Blockers = append(plan.Blockers, "StatusJSONURL empty after normalize") + } + if jwtURL == "" && ep.WebBaseURL != "" { + plan.Blockers = append(plan.Blockers, "JWTURL empty after normalize") + } + + // Validate endpoint lengths against slots + single-byte length imm. + if statusURL != "" { + if err := checkURLFits(statusURL, profile, "status_json", "utf8"); err != nil { + plan.Blockers = append(plan.Blockers, err.Error()) + updateConstraintStrategy(plan, "StatusJSONURL", "blocker") + } + } + if jwtURL != "" { + if err := checkURLFits(jwtURL, profile, "fsd_jwt", "utf16le"); err != nil { + plan.Blockers = append(plan.Blockers, err.Error()) + updateConstraintStrategy(plan, "JWTURL", "blocker") + } + } + + if len(profile.Mutations) == 0 { + plan.Blockers = append(plan.Blockers, "profile has no mutations (research incomplete)") + return plan, nil + } + + for _, m := range profile.Mutations { + if m.FileOffset == nil { + plan.Blockers = append(plan.Blockers, fmt.Sprintf("mutation %s: missing file_offset", m.ID)) + continue + } + off := m.FileOffset.Int64() + if off <= 0 { + plan.Blockers = append(plan.Blockers, fmt.Sprintf("mutation %s: invalid file_offset %d", m.ID, off)) + continue + } + kind, err := clientinject.MapYAMLKind(m.Kind) + if err != nil { + plan.Blockers = append(plan.Blockers, fmt.Sprintf("mutation %s: %v", m.ID, err)) + continue + } + + switch kind { + case clientinject.MutPaddedString: + key := strings.TrimSpace(m.EndpointKey) + if key == "" { + key = strings.TrimSpace(m.StringRef) + } + url := endpoints[key] + if url == "" { + plan.Blockers = append(plan.Blockers, fmt.Sprintf("mutation %s: unknown/empty endpoint_key %q", m.ID, key)) + continue + } + slotLen := 0 + if m.AvailableBytes != nil { + slotLen = int(m.AvailableBytes.Int64()) + } + if slotLen <= 0 { + if s, ok := profile.Strings[key]; ok && s.PayloadBudgetBytes > 0 { + slotLen = s.PayloadBudgetBytes + } + } + if slotLen <= 0 { + plan.Blockers = append(plan.Blockers, fmt.Sprintf("mutation %s: missing available_bytes", m.ID)) + continue + } + enc := m.Encoding + if enc == "" { + enc = "utf8" + } + plan.Mutations = append(plan.Mutations, clientinject.Mutation{ + ID: m.ID, + Kind: clientinject.MutPaddedString, + Description: m.Description, + TargetRel: profile.PrimaryBinary.RelativePath, + Detail: clientinject.PaddedStringDetail{ + FileOffset: off, + NewString: url, + SlotLen: slotLen, + Encoding: enc, + }, + }) + + case clientinject.MutRawOverwrite: + if lo := strings.TrimSpace(m.LengthOf); lo != "" { + url := endpoints[lo] + if url == "" { + plan.Blockers = append(plan.Blockers, fmt.Sprintf("mutation %s: length_of %q empty", m.ID, lo)) + continue + } + n := utf8.RuneCountInString(url) + if n > maxLengthImm { + plan.Blockers = append(plan.Blockers, fmt.Sprintf("mutation %s: URL length %d exceeds single-byte imm max %d", m.ID, n, maxLengthImm)) + continue + } + plan.Mutations = append(plan.Mutations, clientinject.Mutation{ + ID: m.ID, + Kind: clientinject.MutRawOverwrite, + Description: m.Description, + TargetRel: profile.PrimaryBinary.RelativePath, + Detail: clientinject.RawOverwriteDetail{ + FileOffset: off, + NewBytes: []byte{byte(n)}, + }, + }) + continue + } + if len(m.NewBytes) == 0 { + plan.Blockers = append(plan.Blockers, fmt.Sprintf("mutation %s: raw_overwrite needs new_bytes or length_of", m.ID)) + continue + } + plan.Mutations = append(plan.Mutations, clientinject.Mutation{ + ID: m.ID, + Kind: clientinject.MutRawOverwrite, + Description: m.Description, + TargetRel: profile.PrimaryBinary.RelativePath, + Detail: clientinject.RawOverwriteDetail{ + FileOffset: off, + NewBytes: append([]byte(nil), m.NewBytes...), + }, + }) + + default: + plan.Blockers = append(plan.Blockers, fmt.Sprintf("mutation %s: unsupported kind %q for xpilot adapter", m.ID, m.Kind)) + } + } + + if ep.AFVBaseURL != "" { + plan.Warnings = append(plan.Warnings, + "AFVBaseURL set but xPilot 3.0.1 adapter does not retarget voice; configure AFV outside this PE patch or use a future profile") + } + if ep.ForceDisableAFV { + plan.Warnings = append(plan.Warnings, + "ForceDisableAFV has no PE/launch effect for xPilot 3.0.1 (no -novoice equivalent in prior art)") + } + + return plan, nil +} + +func checkURLFits(url string, profile *clientinject.Profile, stringKey, encoding string) error { + n := utf8.RuneCountInString(url) + if n > maxLengthImm { + return fmt.Errorf("%s URL length %d exceeds single-byte length immediate max %d", stringKey, n, maxLengthImm) + } + budget := 0 + if s, ok := profile.Strings[stringKey]; ok { + budget = s.PayloadBudgetBytes + } + if budget <= 0 { + return nil + } + enc := strings.ToLower(encoding) + var need int + switch enc { + case "utf16le", "utf-16le", "utf16": + need = (n + 1) * 2 // runes + NUL + default: + // utf8/ascii: bytes + NUL; openfsd URLs are ASCII so runes==bytes. + need = len(url) + 1 + } + if need > budget { + return fmt.Errorf("%s URL needs %d encoded bytes > slot budget %d", stringKey, need, budget) + } + return nil +} + +func updateConstraintStrategy(plan *clientinject.Plan, field, strategy string) { + for i := range plan.Constraints { + if plan.Constraints[i].Field == field { + plan.Constraints[i].Strategy = strategy + return + } + } + plan.Constraints = append(plan.Constraints, clientinject.Constraint{ + Field: field, + Strategy: strategy, + }) +} diff --git a/internal/clientinject/profile.go b/internal/clientinject/profile.go index f1cacb4..fed2ef8 100644 --- a/internal/clientinject/profile.go +++ b/internal/clientinject/profile.go @@ -20,28 +20,37 @@ const RequiredSchemaVersion = 2 // Profile is a loaded schema_version 2 document (fingerprint + mutations). type Profile struct { - SchemaVersion int `yaml:"schema_version"` - ClientID string `yaml:"client_id"` - DisplayName string `yaml:"display_name"` - Vendor string `yaml:"vendor"` - LicenseNote string `yaml:"license_note"` - ProfileVersion string `yaml:"profile_version"` - SupportedClientVersion string `yaml:"supported_client_version"` - Released string `yaml:"released"` - DownloadPage string `yaml:"download_page"` - Installer *InstallerSpec `yaml:"installer"` - PrimaryBinary PrimaryBinarySpec `yaml:"primary_binary"` - ConfigFiles []ConfigFileSpec `yaml:"config_files"` - RelatedBinaries []string `yaml:"related_binaries"` - CLR *CLRSpec `yaml:"clr"` - Strings map[string]StringSpec `yaml:"strings"` - USFreeSlots []USFreeSlot `yaml:"us_free_slots"` - Mutations []ProfileMutationSpec `yaml:"mutations"` - Launch LaunchSpec `yaml:"launch"` + SchemaVersion int `yaml:"schema_version"` + ClientID string `yaml:"client_id"` + DisplayName string `yaml:"display_name"` + Vendor string `yaml:"vendor"` + LicenseNote string `yaml:"license_note"` + ProfileVersion string `yaml:"profile_version"` + SupportedClientVersion string `yaml:"supported_client_version"` + Released string `yaml:"released"` + DownloadPage string `yaml:"download_page"` + Installer *InstallerSpec `yaml:"installer"` + PrimaryBinary PrimaryBinarySpec `yaml:"primary_binary"` + ConfigFiles []ConfigFileSpec `yaml:"config_files"` + RelatedBinaries []string `yaml:"related_binaries"` + // PESections documents native PE section maps (xPilot-class VA→file conversion). + PESections []PESectionSpec `yaml:"pe_sections"` + CLR *CLRSpec `yaml:"clr"` + Strings map[string]StringSpec `yaml:"strings"` + USFreeSlots []USFreeSlot `yaml:"us_free_slots"` + Mutations []ProfileMutationSpec `yaml:"mutations"` + Launch LaunchSpec `yaml:"launch"` // ProfileID is the stem of the YAML file (e.g. "vpilot-3.12.1"), set by loader. ProfileID string `yaml:"-"` } +// PESectionSpec is a PE section raw/virtual base for VA→file offset conversion. +type PESectionSpec struct { + Name string `yaml:"name"` + RawOffset FlexibleInt64 `yaml:"raw_offset"` + VirtualStart FlexibleInt64 `yaml:"virtual_start"` +} + // InstallerSpec describes the upstream installer (metadata only). type InstallerSpec struct { Filename string `yaml:"filename"` @@ -115,10 +124,33 @@ type ProfileMutationSpec struct { FileOffset *FlexibleInt64 `yaml:"file_offset"` NewBytes []byte `yaml:"new_bytes"` OnlyIf string `yaml:"only_if"` + // AvailableBytes is the padded-string slot size (padded_string kind). + AvailableBytes *FlexibleInt64 `yaml:"available_bytes"` + // Encoding is "utf8" / "ascii" / "utf16le" for padded_string. + Encoding string `yaml:"encoding"` + // EndpointKey selects a planned URL: "status_json", "fsd_jwt", "status", "afv_base", … + EndpointKey string `yaml:"endpoint_key"` + // LengthOf, when set on raw_overwrite, writes a single-byte length of that endpoint URL. + LengthOf string `yaml:"length_of"` // Fields is free-form for config_rewrite / vpilot_config. Fields map[string]any `yaml:"fields"` } +// FileOffsetOf returns the VA→file conversion for a section-relative virtual address, +// or (-1, false) if the section is unknown. +func (p *Profile) FileOffsetOf(sectionName string, virtualAddr int64) (int64, bool) { + if p == nil { + return -1, false + } + for _, sec := range p.PESections { + if sec.Name != sectionName { + continue + } + return sec.RawOffset.Int64() + (virtualAddr - sec.VirtualStart.Int64()), true + } + return -1, false +} + // LaunchSpec holds default launch flag templates. type LaunchSpec struct { ServerAddressFlag string `yaml:"server_address_flag"` diff --git a/internal/clientinject/profile_test.go b/internal/clientinject/profile_test.go index f7034b6..93120c2 100644 --- a/internal/clientinject/profile_test.go +++ b/internal/clientinject/profile_test.go @@ -51,6 +51,45 @@ func TestLoadEmbedded(t *testing.T) { if len(list) < 1 { t.Fatal("ForClient empty") } + + // xPilot 3.0.1 embed profile (second client). + xp, ok := store.Get("xpilot-3.0.1") + if !ok { + t.Fatal("missing xpilot-3.0.1") + } + if xp.ClientID != "xpilot" { + t.Fatalf("client=%q", xp.ClientID) + } + if xp.PrimaryBinary.SHA1 != "1ae61e1d4a624751124a49cd992c90f948c31d37" { + t.Fatalf("xpilot sha1=%q", xp.PrimaryBinary.SHA1) + } + if len(xp.PESections) < 2 { + t.Fatalf("pe_sections=%d", len(xp.PESections)) + } + // status string VA → file offset from research notes. + off, ok := xp.FileOffsetOf(".idata", 0x141AAF5AE) + if !ok || off != 0x1AAE7AE { + t.Fatalf("status file offset=%#x ok=%v", off, ok) + } + // Ensure padded_string mutations have file offsets. + var sawPadded, sawLen bool + for _, m := range xp.Mutations { + if m.Kind == "padded_string" { + sawPadded = true + if m.FileOffset == nil || m.AvailableBytes == nil { + t.Fatalf("padded mutation incomplete: %+v", m) + } + } + if m.LengthOf != "" { + sawLen = true + } + } + if !sawPadded || !sawLen { + t.Fatalf("padded=%v length_of=%v mutations=%d", sawPadded, sawLen, len(xp.Mutations)) + } + if _, ok := store.LookupByHash("xpilot", "1AE61E1D4A624751124A49CD992C90F948C31D37"); !ok { + t.Fatal("xpilot lookup by hash failed") + } } func TestParseProfile_RejectsV1(t *testing.T) { diff --git a/internal/clientinject/profiles/xpilot-3.0.1.yaml b/internal/clientinject/profiles/xpilot-3.0.1.yaml new file mode 100644 index 0000000..50540ac --- /dev/null +++ b/internal/clientinject/profiles/xpilot-3.0.1.yaml @@ -0,0 +1,116 @@ +# openfsd client profile — schema v2. Do NOT ship xPilot binaries. +# Offsets from docs/client-injector/research/xpilot.md (prior-art VA→file conversion). +# Version-pinned by primary PE SHA-1; refuse unknown hashes. + +schema_version: 2 +client_id: xpilot +display_name: xPilot +vendor: Justin Shannon / xPilot project +license_note: Proprietary; not redistributable by openfsd +profile_version: "2" +supported_client_version: "3.0.1" +download_page: https://github.com/xpilot-project/xpilot/releases + +primary_binary: + relative_path: xPilot.exe + default_install_globs: + windows: + - "%ProgramFiles%\\xPilot\\xPilot.exe" + - "%ProgramFiles(x86)%\\xPilot\\xPilot.exe" + sha1: 1ae61e1d4a624751124a49cd992c90f948c31d37 + pe: + clr: false + architecture: x86_64 + +# No client-local config rewrite in the 3.0.1 prior-art path (status JSON feed only). +config_files: [] + +# PE section map from prior art (documentation + VA→file helper tests). +pe_sections: + - name: .text + raw_offset: 0x400 + virtual_start: 0x140001000 + - name: .idata + raw_offset: 0x01A5A200 + virtual_start: 0x141A5B000 + - name: .data + raw_offset: 0x027F9000 + virtual_start: 0x1427FA000 + +# Logical string budgets (padded .idata slots). Stock bodies not required for plan. +strings: + status_json: + stock: "" + payload_budget_bytes: 0x3B5 + template: "{{.StatusJSONURL}}" + fsd_jwt: + stock: "" + payload_budget_bytes: 0x3B4 + template: "{{.JWTURL}}" + +us_free_slots: [] + +mutations: + # --- Relocated string slots (padded) --- + - id: write_status_json + kind: padded_string + description: Write openfsd status.json URL into .idata free slot (UTF-8) + file_offset: 0x1AAE7AE + available_bytes: 0x3B5 + encoding: utf8 + endpoint_key: status_json + + - id: write_fsd_jwt + kind: padded_string + description: Write openfsd fsd-jwt URL into .idata free slot (UTF-16LE) + file_offset: 0x1A9582C + available_bytes: 0x3B4 + encoding: utf16le + endpoint_key: fsd_jwt + + # --- LEA RIP fixups (content-independent; point at relocated slots) --- + - id: patch_status_lea + kind: raw_overwrite + description: Overwrite status.json LEA RIP displacement + file_offset: 0x2810E + new_bytes: [0x9C, 0x68, 0xA8, 0x01] + + - id: patch_fsd_jwt_lea_1 + kind: raw_overwrite + description: Overwrite fsd-jwt LEA RIP (site 1) + file_offset: 0x34FFE + new_bytes: [0x2A, 0x0A, 0xA6, 0x01] + + - id: patch_fsd_jwt_lea_2 + kind: raw_overwrite + description: Overwrite fsd-jwt LEA RIP (site 2) + file_offset: 0x6B208 + new_bytes: [0x20, 0xA8, 0xA2, 0x01] + + # --- Dynamic single-byte length immediates (Apply fills from endpoint URL len) --- + - id: patch_status_len + kind: raw_overwrite + description: status.json URL length immediate (1 byte = rune/byte count) + file_offset: 0x28107 + length_of: status_json + + - id: patch_fsd_jwt_len_1 + kind: raw_overwrite + description: fsd-jwt URL length immediate site 1 + file_offset: 0x3500A + length_of: fsd_jwt + + - id: patch_fsd_jwt_len_2 + kind: raw_overwrite + description: fsd-jwt URL length immediate site 2 + file_offset: 0x6B214 + length_of: fsd_jwt + + # --- Neutralise stock FSD auto host --- + - id: break_fsd_vatsim_net + kind: raw_overwrite + description: Overwrite fsd.vatsim.net residual with "foo" + file_offset: 0x1CBE440 + new_bytes: [0x66, 0x6F, 0x6F] + +launch: {} diff --git a/third_party/client-profiles/xpilot-3.0.1.yaml b/third_party/client-profiles/xpilot-3.0.1.yaml new file mode 100644 index 0000000..455c4e3 --- /dev/null +++ b/third_party/client-profiles/xpilot-3.0.1.yaml @@ -0,0 +1,22 @@ +# openfsd client profile — metadata only. Do NOT ship xPilot binaries. +# Prior art: openfsd-client-patch-utility example_patchfiles/xpilot/xpilot-3.0.1.yaml +# Legal: user must install xPilot themselves; openfsd never redistributes it. + +client_id: xpilot +display_name: xPilot +vendor: Justin Shannon / xPilot project +license_note: Proprietary; not redistributable by openfsd +profile_version: "1" +supported_client_version: "3.0.1" +download_page: https://github.com/xpilot-project/xpilot/releases +primary_binary: + relative_path: xPilot.exe + default_install_globs: + windows: + - "%ProgramFiles%\\xPilot\\xPilot.exe" + - "%ProgramFiles(x86)%\\xPilot\\xPilot.exe" + sha1: 1ae61e1d4a624751124a49cd992c90f948c31d37 + pe: + clr: false + architecture: x86_64 +prior_art_profile: xpilot-3.0.1 # openfsd-client-patch-utility