19 Commits
Author SHA1 Message Date
Andy Dixon e1381e66d7 IRC support 2026-06-17 08:55:56 +01:00
Andy Dixon 0e6f54901b Stop github from asking to close 2026-06-16 18:56:18 +01:00
Andy Dixon cb2fbf70eb fix: harden local proxy and import boundaries 2026-06-16 17:09:20 +01:00
Andy Dixon 739651d548 Documentation 2026-06-16 16:15:47 +01:00
Andy Dixon 3577b800d1 Final tweaks for v3 2026-06-16 16:08:05 +01:00
Andy Dixon d5295772cb Updates to xterm 2026-06-16 15:59:28 +01:00
Andy Dixon 58e916ecf8 v3 fixes and styling 2026-06-16 15:43:25 +01:00
Andy Dixon 493bfed599 v3 candidate 2026-06-16 11:51:07 +01:00
Andy Dixon fcb19c96c8 Security Fixes 2026-06-16 09:41:51 +01:00
Andy Dixon 9bbd9ceccf fix: harden profile imports and release dependencies 2026-06-15 19:39:16 +01:00
andy b2fc84846c Styling 2026-06-06 19:25:59 +01:00
Andy Dixon 18aa193039 Tightening security 2026-06-02 12:49:24 +01:00
Andy Dixon ea6a9743d1 fix: harden SSH trust and dependency security 2026-06-02 12:25:20 +01:00
Andy Dixon fb230ee35d More paste fixes 2026-06-02 11:56:18 +01:00
Andy Dixon 74f16bfd0d Fix for pasting 2026-06-02 10:47:59 +01:00
Andy Dixon 2eed4ea548 Fix for pasting 2026-06-02 10:47:31 +01:00
Andy Dixon 759485fc47 fix: ensure session state is removed correctly when closing tabs 2026-05-22 15:29:29 +01:00
Andy Dixon cb76b77f1b Tab hightligh 2026-05-21 20:24:10 +01:00
Andy Dixon c2aa0349fc Tab hightligh 2026-05-21 20:08:22 +01:00
163 changed files with 40917 additions and 1171 deletions
+2 -2
View File
@@ -64,10 +64,10 @@ jobs:
node-version: "20"
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
run: go install github.com/wailsapp/wails/v2/cmd/wails@$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v2)
- name: Install frontend dependencies
run: npm install
run: npm ci
working-directory: frontend
- name: Install Linux build dependencies
+2
View File
@@ -11,6 +11,8 @@ dist/
# ── Go ────────────────────────────────────────────────────────────────────
vendor/
.tools/
.cache/
*.exe
*.exe~
*.dll
+787
View File
@@ -0,0 +1,787 @@
# Security Audit Report
## 1. Executive Summary
This audit reviewed Nexus Terminal, a Wails desktop SSH/Telnet/RAW/VNC/SFTP client with a Go backend and JavaScript frontend. Fix mode was requested, so confirmed issues were patched, regression-tested, and prepared for commit.
The highest-risk issues were SSH trust failures: regular SSH sessions were not wired to the existing known-host verification flow, jump-host SSH connections ignored host keys, and VNC SSH tunnels also ignored host keys. These could allow machine-in-the-middle attacks against SSH credentials and terminal/VNC traffic. This run wired all SSH paths to known-host verification and prompt handling.
Other fixed issues include a local noVNC WebSocket proxy access-control weakness, SSH key-name path traversal in public-key reads, unsafe update-result DOM rendering, inline secret leakage in non-secret profile exports, and current vulnerable dependency versions in `golang.org/x/crypto` and `esbuild`.
Residual risk remains in user-selected insecure modes such as direct unencrypted VNC, user-configured `HostKeyInsecure`, and large local file transfer workflows that still process complete files in memory.
## 2. Scope Reviewed
Reviewed:
- Go backend packages under `internal/`: API bindings, sessions, SSH, VNC, SFTP, storage, secrets, updater, raw/telnet clients, recording, validation, known hosts, config, app startup.
- Frontend JavaScript under `frontend/src/js`: settings/update UI, host-key event handling, terminal and SFTP/VNC/GitHub surfaces, common DOM helpers.
- Dependency manifests and lockfiles: `go.mod`, `go.sum`, `frontend/package.json`, `frontend/package-lock.json`.
- Build and release scripts: `Makefile`, `build.sh`, `release.sh`, Wails config.
- Tests under `internal/**/*_test.go`.
Not in scope:
- Live network penetration testing of remote hosts.
- Reverse engineering packaged binaries.
- Manual verification of Wails runtime origin values in a built app.
## 3. Methodology
- Static source review of trust boundaries, file operations, SSH/VNC flows, Wails API bindings, frontend DOM sinks, update checks, storage, crypto, and secret handling.
- Dependency review with `govulncheck` and `npm audit`.
- Configuration review of Wails, build scripts, update manifests, and release paths.
- Threat modelling of desktop renderer compromise, local web-origin attacks, remote SSH/VNC attackers, and malicious update metadata.
- Denial-of-service review of network timeouts, WebSocket proxy behavior, transfer paths, and file ingestion.
- Regression tests added for fixed Go issues.
## 4. Risk Rating Method
Severity considered exploitability, attacker position, affected security property, and impact:
- Critical: practical remote compromise or credential disclosure without meaningful preconditions.
- High: credential/session compromise or machine-in-the-middle exposure on primary connection paths.
- Medium: meaningful local, renderer, or network-assisted exploit with constraints.
- Low: hardening issue, local DoS, or user-assisted exposure.
- Informational: checked area or non-security note.
Confidence reflects direct code evidence and verification coverage.
## 5. Findings Summary Table
| ID | Severity | Confidence | Title | Component | Status |
|---|---:|---:|---|---|---|
| SEC-001 | High | High | SSH sessions and jump hosts bypassed known-host verification | SSH sessions | Fixed |
| SEC-002 | High | High | VNC SSH tunnels accepted any SSH host key | VNC over SSH | Fixed |
| SEC-003 | Medium | High | Local noVNC WebSocket proxy used a guessable root endpoint | VNC WebSocket proxy | Fixed |
| SEC-004 | Medium | High | SSH public-key read accepted traversal key names | SSH key API | Fixed |
| SEC-005 | Medium | High | Update metadata rendered through dynamic HTML and inline handler | Settings/update UI | Fixed |
| SEC-006 | Medium | High | Non-secret profile export leaked inline SSH secrets | Profile export | Fixed |
| SEC-007 | High | High | `golang.org/x/crypto` vulnerable in reachable SSH code | Go dependencies | Fixed |
| SEC-008 | Medium | High | `esbuild` dev dependency matched current npm advisory | Frontend dependency | Fixed |
| SEC-009 | Low | Medium | Large local file upload paths remain memory-heavy | File transfer UX | Open |
| SEC-010 | Low | Medium | Direct VNC remains unencrypted by protocol choice | VNC direct mode | Open |
## 6. Detailed Findings
### Finding ID: SEC-001
#### Title
SSH sessions and jump hosts bypassed known-host verification
#### Severity
High
#### Confidence
High
#### Affected Files / Components
- `internal/sessions/manager.go`, SSH connection setup, now fixed at lines 657 and 1450-1502.
- `internal/sshclient/sshclient.go`, jump-host config, now fixed at lines 599 and 646-652.
- `frontend/src/js/app.js`, host-key response correlation, now fixed at lines 334-342.
#### Description
The project already contained known-host storage, a host-key verifier, and frontend prompt UI, but the main SSH session manager previously called the SSH client's default `Connect` path. That default path used `ssh.InsecureIgnoreHostKey()`. Jump hosts also built their own `ssh.ClientConfig` with `ssh.InsecureIgnoreHostKey()`.
#### Evidence
The fix now calls `ConnectWithHostKeyCallback(...)` for regular SSH sessions and propagates the final host-key callback into jump-host configs. A request ID is included in host-key prompt responses to prevent cross-session prompt confusion.
#### Attack Scenario
An attacker on the network path presents a fake SSH server or jump host. Without host-key verification, Nexus Terminal would accept the attacker's key, allowing credential capture, terminal tampering, or traffic interception.
#### Impact
Possible SSH credential theft, terminal command tampering, and loss of server authenticity.
#### Recommendation
Always use the known-host verifier for every SSH hop. Unknown host keys should follow the configured profile policy: strict, ask, accept-new, or explicit insecure.
#### Safer Example
Implemented: `Manager.hostKeyCallback` builds `sshclient.NewHostKeyVerifier(...)`, emits a prompt when needed, and `connectSSH` calls `ConnectWithHostKeyCallback(...)`.
#### Suggested Tests
Added regression coverage:
- `TestHostKeyCallbackAcceptNewStoresHost`
- `TestHostKeyCallbackStrictRejectsUnknownHost`
- `TestJumpHostClientConfigUsesFinalHostKeyCallback`
#### References
- CWE-295: Improper Certificate Validation, analogous host-key trust failure.
- OWASP ASVS 9.1.2: server certificate validation principles.
#### Assumptions / Limitations
Profiles may still explicitly choose `HostKeyInsecure`; that is a user-selected insecure mode and remains visible as residual risk.
### Finding ID: SEC-002
#### Title
VNC SSH tunnels accepted any SSH host key
#### Severity
High
#### Confidence
High
#### Affected Files / Components
- `internal/vncclient/vncclient.go`, VNC SSH config, now fixed at lines 49 and 89-95.
- `internal/sessions/manager.go`, VNC SSH callback wiring, now fixed at line 1016.
#### Description
VNC SSH tunnels established SSH connections with `ssh.InsecureIgnoreHostKey()`, allowing a network attacker to impersonate the SSH tunnel endpoint.
#### Evidence
The VNC client now requires `SSHHostKeyCallback` for tunneled SSH. The session manager supplies the same known-host/prompt path used by SSH sessions.
#### Attack Scenario
An attacker controlling DNS, Wi-Fi, or a gateway presents a fake SSH tunnel host for VNC traffic. The prior client accepted the key and tunneled VNC traffic through the attacker.
#### Impact
Possible VNC credential exposure, remote desktop traffic interception, and remote interaction tampering.
#### Recommendation
Require known-host verification for VNC SSH tunnels and reject tunnel setup if no callback is configured.
#### Safer Example
Implemented: `vncclient.Connect` returns an error if `cfg.SSHHostKeyCallback` is nil, and uses the provided callback in `ssh.ClientConfig`.
#### Suggested Tests
Covered indirectly by session host-key callback tests and VNC package tests.
#### References
- CWE-295.
- OWASP ASVS 9.1.2.
#### Assumptions / Limitations
VNC tunnel host-key policy defaults to ask mode because VNC profiles do not currently expose a separate host-key policy field.
### Finding ID: SEC-003
#### Title
Local noVNC WebSocket proxy used a guessable root endpoint
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `internal/vncclient/vncclient.go`, WebSocket proxy setup, now fixed at lines 171-183 and 255-260.
#### Description
The local VNC WebSocket proxy listened on `127.0.0.1` with a random port, but accepted upgrades on `/` and allowed any Origin. A malicious local web page could scan localhost ports and attempt to connect to an active proxy.
#### Evidence
The proxy now generates a 128-bit random token and exposes only `ws://127.0.0.1:<port>/<token>` to the Wails frontend.
#### Attack Scenario
A user visits a malicious web page while a VNC session is active. The page scans localhost WebSocket ports and connects to the proxy endpoint, potentially reading or injecting VNC traffic.
#### Impact
Possible local cross-origin access to VNC session traffic if the port is discovered.
#### Recommendation
Require an unguessable per-session URL token and continue binding only to loopback.
#### Safer Example
Implemented: `newProxyToken()` uses `crypto/rand` and the proxy route is registered only at the token path.
#### Suggested Tests
Added `TestNewProxyTokenIsRandomHex`.
#### References
- CWE-346: Origin Validation Error.
- OWASP ASVS 14.4.3: cross-origin resource restrictions.
#### Assumptions / Limitations
`CheckOrigin` still returns true because Wails/noVNC origin behavior needs built-app verification. The unguessable path materially reduces exposure.
### Finding ID: SEC-004
#### Title
SSH public-key read accepted traversal key names
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `internal/api/api.go`, SSH key API, now fixed at lines 1653-1708.
#### Description
`GetSSHPublicKey(name)` previously built `~/.ssh/<name>.pub` directly from a renderer-provided name. Unlike key generation, it did not restrict names to simple key basenames. Names such as `../somefile` could read `~/.ssh/../somefile.pub`.
#### Evidence
The fix centralizes key name validation through `sshKeyPairPaths(...)`, allowing only alphanumeric characters, hyphens, and underscores.
#### Attack Scenario
If renderer-side JavaScript is compromised or a malicious plugin/flow can invoke the Wails API, it can request public-key contents outside the intended `.ssh` key namespace if the target path ends in `.pub`.
#### Impact
Local file disclosure for matching `.pub` paths under paths reachable through traversal.
#### Recommendation
Treat key names as basenames, not paths. Apply the same validation to generate, read, and delete operations.
#### Safer Example
Implemented: `GetSSHPublicKey`, `GenerateSSHKey`, and `DeleteSSHKey` now all use `sshKeyPairPaths`.
#### Suggested Tests
Added:
- `TestSSHKeyPairPathsAcceptsSafeName`
- `TestSSHKeyPairPathsRejectsTraversal`
- `TestSSHKeyPairPathsRejectsNestedPath`
#### References
- CWE-22: Path Traversal.
#### Assumptions / Limitations
The API still intentionally lists and reads legitimate SSH public keys in `~/.ssh`.
### Finding ID: SEC-005
#### Title
Update metadata rendered through dynamic HTML and inline handler
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `frontend/src/js/settings.js`, update-check UI, now fixed at lines 940-958.
#### Description
Update metadata from GitHub or a configured update manifest was inserted into `innerHTML`, including an inline `onclick` that interpolated `info.releaseURL`. A malicious or compromised update metadata source could inject script into the Wails renderer.
#### Evidence
The fix constructs DOM nodes, sets text via `textContent`, and attaches a click listener with `addEventListener`.
#### Attack Scenario
An attacker controlling a self-hosted update manifest returns a release URL containing a quote and JavaScript payload. The prior inline handler interpolation could execute renderer JavaScript.
#### Impact
Renderer code execution. In a Wails app, renderer execution can call exposed backend APIs, increasing impact.
#### Recommendation
Never render remote metadata through string-built HTML. Use `textContent` and event listeners.
#### Safer Example
Implemented in `settings.js` with `createElement`, `document.createTextNode`, and `addEventListener`.
#### Suggested Tests
No frontend unit harness exists. Verified with `npm run build`.
#### References
- CWE-79: Cross-Site Scripting.
- OWASP ASVS 5.3: output encoding.
#### Assumptions / Limitations
Other static modal templates still use `innerHTML`; reviewed high-risk dynamic remote metadata paths first.
### Finding ID: SEC-006
#### Title
Non-secret profile export leaked inline SSH secrets
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `internal/api/api.go`, Wails export wrapper, now fixed at lines 683-684.
- `internal/storage/store.go`, storage export, now fixed at lines 356-358 and 726-738.
#### Description
The plain profile export path accepted `includeSecrets=false` but profiles can still contain inline `ssh.proxyPassword` and `ssh.totpSecret` fields. Those fields could be serialized despite a non-secret export request.
#### Evidence
The fix redacts `ProxyPassword` and `TOTPSecret` for non-secret exports in both API and storage layers.
#### Attack Scenario
A user creates a plain profile export believing secrets are excluded. The exported JSON contains proxy credentials or TOTP secrets that are then shared, backed up, or stored insecurely.
#### Impact
Credential leakage and potential 2FA bypass for affected profiles.
#### Recommendation
Redact inline secret fields whenever `includeSecrets=false`. Prefer encrypted export for any export that intentionally includes secrets.
#### Safer Example
Implemented `redactProfileSecrets(...)` in storage and API packages.
#### Suggested Tests
Added:
- `TestExportProfilesRedactsInlineSSHSecrets`
- `TestRedactProfileSecretsClearsInlineSSHSecrets`
#### References
- CWE-200: Exposure of Sensitive Information.
- OWASP ASVS 8.3: sensitive data protection.
#### Assumptions / Limitations
The encrypted export path intentionally includes selected profile data and is password-protected.
### Finding ID: SEC-007
#### Title
`golang.org/x/crypto` vulnerable in reachable SSH code
#### Severity
High
#### Confidence
High
#### Affected Files / Components
- `go.mod`, `golang.org/x/crypto`, now fixed at line 14.
- `go.mod`, indirect `golang.org/x/sys`, now updated at line 45.
- Reachable SSH code in `internal/sshclient`, `internal/vncclient`, `internal/sftpclient`.
#### Description
`govulncheck` reported seven reachable vulnerabilities in `golang.org/x/crypto@v0.51.0`, including SSH DoS/panic issues, FIDO/U2F security-key interaction bypass risk, and SSH agent forwarding constraint handling issues.
#### Evidence
`govulncheck` identified GO-2026-5033, GO-2026-5020, GO-2026-5019, GO-2026-5018, GO-2026-5017, GO-2026-5013, and GO-2026-5006 as reachable before the update. The dependency is now `v0.52.0`, and rerun output reports: "No vulnerabilities found. Your code is affected by 0 vulnerabilities."
#### Attack Scenario
A malicious or compromised SSH server, SSH agent, key material, or protocol peer triggers vulnerable SSH library behavior during connection, channel use, key parsing, or agent forwarding.
#### Impact
Possible client panic, denial of service, weakened security-key interaction, or agent forwarding constraint mishandling depending on the CVE path.
#### Recommendation
Keep `golang.org/x/crypto` at or above `v0.52.0` and run `govulncheck` in CI.
#### Safer Example
Implemented: `go get golang.org/x/crypto@v0.52.0`.
#### Suggested Tests
Ran `govulncheck` and `go test ./...`.
#### References
- https://pkg.go.dev/vuln/GO-2026-5033
- https://pkg.go.dev/vuln/GO-2026-5020
- https://pkg.go.dev/vuln/GO-2026-5019
- https://pkg.go.dev/vuln/GO-2026-5018
- https://pkg.go.dev/vuln/GO-2026-5017
- https://pkg.go.dev/vuln/GO-2026-5013
- https://pkg.go.dev/vuln/GO-2026-5006
#### Assumptions / Limitations
`govulncheck` also noted vulnerabilities in required modules that are not called by this code.
### Finding ID: SEC-008
#### Title
`esbuild` dev dependency matched current npm advisory
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `frontend/package.json`, `esbuild`, now fixed at line 22.
- `frontend/package-lock.json`.
#### Description
`npm audit` reported GHSA-67mh-4wv8-2f99 for `esbuild <=0.24.2`: any website could send requests to the dev server and read responses under affected versions.
#### Evidence
The frontend previously resolved `esbuild@0.24.2`. It now uses `^0.28.0`, and `npm audit --json` reports zero vulnerabilities.
#### Attack Scenario
During local development, a malicious website could interact with the esbuild dev server and read responses if a developer runs an affected dev server.
#### Impact
Possible local development data exposure.
#### Recommendation
Use a fixed esbuild release and keep npm audit in CI.
#### Safer Example
Implemented: `npm install esbuild@0.28.0`.
#### Suggested Tests
Ran `npm audit --json` and `npm run build`.
#### References
- https://github.com/advisories/GHSA-67mh-4wv8-2f99
#### Assumptions / Limitations
This is a devDependency, so production exploitability is lower than runtime dependencies.
### Finding ID: SEC-009
#### Title
Large local file upload paths remain memory-heavy
#### Severity
Low
#### Confidence
Medium
#### Affected Files / Components
- `frontend/src/js/terminal.js`, dropped-file paste/upload flows.
- `internal/api/api.go`, `SFTPUploadData`.
#### Description
Some upload paths read whole files into memory and send base64 payloads to Go before uploading. This can be expensive for very large local files.
#### Evidence
`SFTPUploadData` decodes the entire base64 payload before calling session upload. The frontend drag/drop paste path also reads file contents into strings.
#### Attack Scenario
A local user or compromised renderer submits very large files, causing high memory use and potential application unresponsiveness.
#### Impact
Local denial of service.
#### Recommendation
Add size limits for paste-as-text, prefer path-based streaming upload for large files, and surface user-facing warnings for files above a threshold.
#### Safer Example
Use file path streaming upload where possible and reject in-memory uploads above a configured maximum.
#### Suggested Tests
Add boundary tests for maximum accepted in-memory upload size and UI tests for large-file warnings.
#### References
- CWE-400: Uncontrolled Resource Consumption.
#### Assumptions / Limitations
This is primarily local/user-assisted. It was left open to avoid changing UX and transfer semantics during the security fix pass.
### Finding ID: SEC-010
#### Title
Direct VNC remains unencrypted by protocol choice
#### Severity
Low
#### Confidence
Medium
#### Affected Files / Components
- `internal/vncclient/vncclient.go`, direct TCP VNC mode.
- VNC profile settings.
#### Description
Direct VNC connections use raw TCP to the VNC server. VNC is commonly unencrypted unless tunneled or separately protected.
#### Evidence
The direct branch calls `net.DialTimeout` to the VNC endpoint. SSH tunnel mode is available and now host-key verified.
#### Attack Scenario
On an untrusted network, direct VNC traffic may expose screen contents or credentials depending on server configuration.
#### Impact
Sensitive remote desktop data exposure.
#### Recommendation
Warn users before direct VNC connections and prefer SSH tunnel mode by default.
#### Safer Example
Default new VNC profiles to SSH tunnel where feasible, or show an explicit "unencrypted direct VNC" warning.
#### Suggested Tests
Add UI tests that direct VNC profiles show a warning before connection.
#### References
- OWASP ASVS 9.1: communications security.
#### Assumptions / Limitations
Some VNC servers may provide their own encryption; this audit did not test remote server configurations.
## 7. Denial-of-Service Review
Fixed:
- Updated `golang.org/x/crypto` to resolve reachable SSH panic/deadlock/DoS vulnerabilities.
- Added random-token access control to the local VNC WebSocket proxy, reducing local cross-origin abuse.
Reviewed and not changed:
- HTTP clients for GitHub and update checks use finite timeouts.
- SSH/Telnet/RAW connection setup uses timeouts.
- VNC WebSocket bridge is intentionally long-lived and uses no read/write timeout.
- Large in-memory file upload/paste paths remain a local DoS risk; see SEC-009.
## 8. Dependency and Supply-Chain Review
Checks run:
- `govulncheck`: fixed reachable `golang.org/x/crypto` vulnerabilities by updating to `v0.52.0`; rerun found zero reachable vulnerabilities.
- `npm audit --json`: fixed `esbuild` advisory by updating to `0.28.0`; rerun found zero vulnerabilities.
- `npm run build`: passed after dependency update.
Notes:
- `xterm` packages are deprecated upstream in favor of `@xterm/*`, but no current audit advisory was reported in this run.
- `go.mod` still has transitive modules with vulnerabilities that `govulncheck` reports as not called by this code.
## 9. Secrets and Sensitive Data Review
Fixed:
- Non-secret profile export now redacts inline `ProxyPassword` and `TOTPSecret`.
- SSH host-key verification now protects credential submission from basic MITM paths.
Reviewed:
- Vault uses Argon2id and AES-256-GCM with random salts/nonces.
- Vault file is written with `0600` permissions and store directories with `0700`.
- OS keychain is preferred where available.
Residual:
- Secret values still exist in process memory while sessions need them.
- Proxy password is still present in profile model state when entered inline.
## 10. Authentication and Authorisation Review
Fixed:
- SSH, jump host, and VNC-over-SSH authentication now has host authenticity checks before credential use.
- SSH host-key prompt responses are request-correlated.
Reviewed:
- SSH/TOTP flows use profile-specific secret callbacks.
- Agent forwarding is explicit profile configuration.
- Known-host policies include strict, ask, accept-new, and insecure.
Residual:
- `HostKeyInsecure` remains available for user-selected insecure connections.
## 11. Input Validation and Output Encoding Review
Fixed:
- SSH key names now reject traversal and nested path separators.
- Update metadata is rendered with DOM APIs instead of string-built HTML.
- Non-secret export redacts inline secret fields.
Reviewed:
- SQL operations use parameterized queries.
- Profile validation checks host, port, username, protocol-specific fields, and port-forwarding basics.
- Frontend frequently uses `textContent` or `escapeHtml`; remaining `innerHTML` static templates should still be reviewed during future frontend hardening.
## 12. Infrastructure and Deployment Review
Reviewed:
- Wails app binds Go services directly to the renderer. This makes frontend XSS more impactful than in a browser-only app.
- Build scripts use `set -euo pipefail`.
- Update checking is opt-in and does not auto-install binaries.
Residual:
- No CI configuration was present in the reviewed file list, so dependency scanning is not currently enforced by repository automation.
## 13. Logging, Monitoring, and Error Handling Review
Reviewed:
- Audit logger records profile create/update/delete, connection events, secret access, import/export.
- Errors are generally returned to the frontend with enough context for troubleshooting.
Residual:
- Some error messages include file paths, which is acceptable for a local desktop tool but should be considered if telemetry is later added.
## 14. Test Coverage and Security Regression Gaps
Added tests:
- API SSH key path validation and export redaction.
- Session host-key callback storage and strict rejection behavior.
- Jump-host host-key callback propagation.
- Storage export redaction.
- VNC proxy token generation.
Remaining gaps:
- No frontend unit test harness for DOM injection regressions.
- No integration test for full Wails host-key prompt round trip.
- No VNC WebSocket integration test verifying token-only connection behavior.
- No large-file upload boundary tests.
## 15. Prioritised Remediation Plan
Immediate:
- Keep the fixes in this audit and ensure they are merged.
- Add dependency scanning to CI: `govulncheck ./...` and `npm audit --audit-level=moderate`.
Short-term:
- Add a frontend test harness for update UI and other dynamic DOM surfaces.
- Add direct VNC warning UX and default to SSH tunnel where possible.
- Add explicit in-memory upload size limits.
Medium-term:
- Expose host-key policy for VNC SSH tunnels instead of always using ask mode.
- Review all Wails-exposed methods under a compromised-renderer threat model.
- Migrate deprecated `xterm` packages to maintained `@xterm/*` packages.
Long-term:
- Consider capability gating or confirmation for high-risk renderer-callable APIs such as file deletion, SSH key deletion, local shell selection, and profile import/export.
- Add signed update metadata if self-hosted update manifests become a production channel.
## 16. False Positives / Needs Manual Verification
- Wails/noVNC Origin behavior in a packaged app should be manually verified. Tokenized VNC WebSocket paths are in place, but Origin policy may still be tightened later.
- Direct VNC encryption depends on remote VNC server configuration and network placement.
- `HostKeyInsecure` is intentionally user-configurable; confirm UI wording makes the risk clear.
- Large-file memory usage thresholds need product decisions before enforcement.
## 17. Final Notes
Fix mode completed with code changes, tests, and dependency updates. The strongest residual concern is the general Wails threat model: a frontend XSS can call powerful local backend APIs. This audit fixed the confirmed dynamic update UI injection path and several backend trust-boundary issues, but future work should continue reducing string-built HTML and adding capability checks around sensitive local operations.
+22 -2
View File
@@ -1,9 +1,13 @@
.PHONY: all build dev clean test test-cover lint install deps frontend-deps run package release help
.PHONY: all build build-compressed dev clean test test-cover lint install deps frontend-deps upx-check run package release help
# Resolve the Wails binary. Prefer wails on PATH; fall back to GOPATH/bin.
WAILS := $(or $(shell which wails 2>/dev/null),$(shell go env GOPATH)/bin/wails)
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null || echo dev)-$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
LD_UPDATE_URL := $(if $(NEXUS_UPDATE_URL),-X github.com/andydixon/nexus/internal/api.appUpdateURL=$(NEXUS_UPDATE_URL),)
UPX_FLAGS ?=
NEXUS_FORCE_MACOS_UPX ?= 0
GOBIN_DIR := $(shell go env GOPATH)/bin
export PATH := $(GOBIN_DIR):$(PATH)
# Default target
all: build
@@ -12,6 +16,17 @@ all: build
deps: frontend-deps
go mod download
upx-check:
@command -v upx >/dev/null 2>&1 || { echo "UPX is required for compressed builds. Install it first, for example: brew install upx"; exit 1; }
@if [ "$$(uname -s)" = "Darwin" ] && [ "$(NEXUS_FORCE_MACOS_UPX)" != "1" ]; then \
echo "UPX compression is disabled for macOS by default. Use ./release.sh for a zipped .app, or run NEXUS_FORCE_MACOS_UPX=1 UPX_FLAGS='--force-macos --best --no-color --no-progress' make build-compressed."; \
exit 1; \
fi
@if [ "$$(uname -s)" = "Darwin" ] && [ "$(NEXUS_FORCE_MACOS_UPX)" = "1" ] && ! printf '%s\n' "$(UPX_FLAGS)" | grep -q -- '--force-macos'; then \
echo "macOS UPX builds must pass --force-macos in UPX_FLAGS."; \
exit 1; \
fi
# Install frontend dependencies
frontend-deps:
cd frontend && npm install
@@ -22,7 +37,11 @@ dev: deps
# Production build
build: deps
$(WAILS) build -ldflags "-s -w -X github.com/andydixon/nexus/internal/api.appVersion=$(VERSION) $(LD_UPDATE_URL)"
CGO_CFLAGS="$${CGO_CFLAGS:--O2 -g0}" CGO_CXXFLAGS="$${CGO_CXXFLAGS:--O2 -g0}" $(WAILS) build -trimpath -ldflags "-s -w -buildid= -X github.com/andydixon/nexus/internal/api.appVersion=$(VERSION) $(LD_UPDATE_URL)"
# Production build with UPX compression
build-compressed: deps upx-check
CGO_CFLAGS="$${CGO_CFLAGS:--O2 -g0}" CGO_CXXFLAGS="$${CGO_CXXFLAGS:--O2 -g0}" $(WAILS) build -trimpath -upx $(if $(UPX_FLAGS),-upxflags "$(UPX_FLAGS)",) -ldflags "-s -w -buildid= -X github.com/andydixon/nexus/internal/api.appVersion=$(VERSION) $(LD_UPDATE_URL)"
# Run tests
test:
@@ -60,6 +79,7 @@ help:
@echo " make deps — Install all dependencies"
@echo " make dev — Run in development mode with hot reload"
@echo " make build — Production build"
@echo " make build-compressed — Production build with UPX compression"
@echo " make test — Run tests"
@echo " make test-cover — Run tests with coverage report"
@echo " make clean — Clean build artefacts"
+3 -2
View File
@@ -1,14 +1,15 @@
# Nexus Terminal
**A desktop client for SSH, Telnet, RAW TCP, and GitHub — with secure credential handling and a focused, multi-session workflow.**
**A desktop client for SSH, Telnet, RAW TCP, VNC, GitHub, local terminals, and Leviathan binary analysis — with secure credential handling and a focused, multi-session workflow.**
Nexus Terminal helps you manage many connections at once, keep terminal work organised, and move files with confidence.
## Highlights
- SSH, Telnet, RAW TCP, and GitHub sessions in one app
- SSH, Telnet, RAW TCP, VNC, GitHub, and local terminal sessions in one app
- Tabbed sessions with split panes, pinning, and reordering
- SFTP browser with drag-and-drop uploads and a directory picker
- Leviathan binary workbench with hex editing, entropy, carving, BinSnoop, structure parsers, transforms, and spectrogram tools
- Disconnected overlay with one-click reconnect
- Broadcast input, snippets, and terminal search
- Session recording and playback
@@ -0,0 +1,126 @@
# Functionality and Next Steps Report
## 1. Executive Summary
This fix-mode audit improved immediate security and robustness around profile validation, imports, drag-drop upload, frontend encoding, dependencies, and release reproducibility.
The most useful next work is to turn these fixes into durable product quality: add frontend interaction tests, make large file upload streaming and consistent, warn users about intentionally insecure protocol choices, and strengthen release provenance.
## 2. Current Functional Risks
Direct VNC can still be used without SSH tunnelling. That is valid for trusted networks, but the UI should make the risk visible when profiles are saved or connected.
Drag-drop SFTP upload is now capped at 25 MiB because it still uses an in-memory browser-to-Go base64 path. Larger files should use the existing native SFTP upload flow or a future streaming drag-drop implementation.
Profile import now rejects malformed or unsafe profiles before storage. That is safer, but older third-party exports with missing defaults may need a clearer import error UI or a migration/repair preview.
RAW session history and recordings can still grow over long sessions. Users need limits, retention controls, and clear storage management.
## 3. Code Quality Improvements
Profile validation is now centralized enough for create/update/import, but profile normalization is still split between frontend defaults, Go defaults, and import-time cleanup. A schema-versioned import normalizer would make future protocol additions safer.
Frontend still has many `innerHTML` templates. Some are static or escaped, but a small helper pattern for safe modal construction would reduce future XSS mistakes.
Release scripts and CI now use pinned Wails and `npm ci`; follow-up work should avoid duplicate release logic between `build.sh`, `release.sh`, and GitHub Actions.
## 4. Frontend Interaction and Error-Handling Improvements
Add Wails/browser interaction tests for:
- Dropping a file with HTML metacharacters in its filename.
- Dropping a file over 25 MiB.
- Importing malformed and oversized profile bundles.
- Saving GitHub profiles with HTTP and HTTPS API base URLs.
- Creating direct VNC profiles and seeing a warning.
Improve import UX by showing which profile entry failed validation without exposing secret values.
## 5. Testing Roadmap
Immediate tests to add:
- Frontend DOM regression for file-drop title rendering.
- API tests for successful valid GitHub Enterprise HTTPS profile creation.
- Import tests for mixed valid/invalid profile arrays.
Short-term tests:
- End-to-end Wails smoke tests for create profile, connect prompt flow, import/export, SFTP upload, and VNC tunnel setup.
- Load tests for large SFTP directories, RAW captures, and recordings.
- CI security job with `npm audit`, govulncheck, and secret scanning.
Longer-term tests:
- Release workflow dry-run or artifact validation tests.
- Migration tests for profile schema changes.
## 6. Recommended New Functionality
Add a direct-VNC safety warning and a per-profile “prefer SSH tunnel” nudge.
Add streaming drag-drop SFTP upload for larger files, or route drag-drop files above 25 MiB into the native upload flow.
Add import preview: list profile names, protocols, validation warnings, and duplicate handling before writing.
Add recording retention settings: max recording size, max age, and one-click cleanup.
Add security diagnostics in settings: dependency check status, known-host summary, direct insecure profile count, and last audit-log events.
## 7. Next-Level Opportunities
Release hardening:
- Sign and notarize release artifacts where platform-appropriate.
- Publish checksums and provenance.
- Pin or cache dependency sources in CI.
Observability:
- Add structured non-sensitive debug logs for import rejection, update checks, VNC proxy lifecycle, and SFTP transfer failures.
- Add audit entries for validation failures that indicate suspicious imports, without storing payload contents.
Developer experience:
- Consolidate release/build dependency resolution into one reusable script.
- Add a frontend lint rule or custom check for unsafe `innerHTML` with dynamic values.
Accessibility/product quality:
- Ensure modal error states are keyboard reachable and announced.
- Add clearer status messages for blocked uploads and import validation failures.
## 8. Prioritised Action Plan
Immediate:
- Add direct VNC warning copy and tests.
- Add frontend file-drop XSS and oversize tests.
- Add CI jobs for `go test ./...`, `npm run build`, `npm audit`, and govulncheck.
Short-term:
- Replace deprecated `xterm` packages with maintained `@xterm/*` packages.
- Build import preview and validation repair UI.
- Add recording and RAW capture storage limits.
Medium-term:
- Implement streaming drag-drop upload.
- Add release signing, checksums, and provenance.
- Add schema-versioned profile import/export.
Long-term:
- Build a security/settings dashboard for insecure profiles, known hosts, audit status, and update health.
- Add comprehensive Wails end-to-end smoke coverage for core workflows.
## 9. Caveats and Validation Needed
The fixes were verified with automated local checks, but not with live Wails UI automation.
The new import validation may reject malformed legacy profiles that were previously accepted. That is intentional for safety, but the UX should help users repair old data.
The 25 MiB drag-drop upload cap is a guardrail, not a final large-file strategy. Streaming support would be the better long-term user experience.
Direct VNC was left open because some users need it on trusted networks; the next step is product warning and safer defaults, not silent removal.
+752
View File
@@ -0,0 +1,752 @@
# Security Audit Report
## 1. Executive Summary
This fix-mode audit reviewed Nexus Terminal, a Wails desktop client with a Go backend and JavaScript frontend for SSH, Telnet, RAW TCP, VNC, SFTP, GitHub, recordings, local shells, encrypted secrets, and profile import/export.
The highest-risk issues fixed in this run were unsafe profile persistence/import validation, a local DOM XSS trigger in the file-drop modal, memory-exhaustion paths in import and drag-drop upload flows, vulnerable dependency versions, and release tooling that installed unpinned build dependencies. I also removed the stale insecure SSH connection helper so future code cannot silently bypass host-key verification.
Post-fix verification passed: `go test ./...`, focused Go tests, `npm run build`, `npm audit --json`, `go run golang.org/x/vuln/cmd/govulncheck@latest ./...`, `bash -n build.sh release.sh`, and `git diff --check`.
Residual risk remains where users intentionally choose insecure protocols or workflows, especially direct VNC without SSH tunnelling. No high-confidence secrets or private keys were found in the repository search performed during this audit.
## 2. Scope Reviewed
Reviewed code and configuration:
- Go backend under `internal/`: API bindings, sessions, SSH, VNC, SFTP, storage, secrets, export/import, validation, updater, recordings, local shell, GitHub client, raw and telnet clients.
- Frontend JavaScript under `frontend/src/js`, especially file drop, terminal, settings, GitHub, profile, SFTP, VNC, notification, and import/export flows.
- Dependency manifests and lockfiles: `go.mod`, `go.sum`, `frontend/package.json`, `frontend/package-lock.json`.
- Build/release configuration: `build.sh`, `release.sh`, `.github/workflows/release-builds.yml`, `wails.json`.
- Existing tests under `internal/**/*_test.go`.
Not reviewed with live external testing:
- Real SSH/VNC/GitHub endpoints.
- Packaged binaries and platform installers.
- Manual UI testing inside a running Wails app.
## 3. Methodology
I used a two-pass review:
- Attack-surface mapping of Wails API methods, import/export paths, file handling, DOM sinks, dependency/build chain, protocol clients, local proxy behavior, and credential flows.
- Defensive validation from source and tests, followed by smallest safe fixes with regression tests.
Tools/checks used:
- Static review with `rg`, direct source reads, and line evidence.
- `go test ./...`.
- Focused tests for API, storage, export/import, validation, SSH client, and sessions.
- `npm audit --json`.
- `npm run build`.
- `go run golang.org/x/vuln/cmd/govulncheck@latest ./...`.
- `bash -n build.sh release.sh`.
- `git diff --check`.
## 4. Risk Rating Method
Severity considered attacker position, exploit preconditions, likely impact, and whether sensitive credentials or code execution could be affected.
- Critical: practical remote compromise or credential disclosure with minimal preconditions.
- High: credible dependency, credential, or supply-chain compromise.
- Medium: user-assisted or renderer/local attack that can affect confidentiality, integrity, or availability.
- Low: hardening, defense-in-depth, or intentionally insecure user-selected modes.
- Informational: verified area, non-exploitable issue, or test gap.
Confidence is High where source evidence and regression checks directly support the finding.
## 5. Attack Surface Map
Entry points:
- Wails-bound Go API methods in `internal/api/api.go`.
- Profile create/update/import/export and encrypted `.nexus` imports.
- Drag-and-drop file upload/paste in `frontend/src/js/terminal.js`.
- SSH, Telnet, RAW TCP, VNC, SFTP, GitHub API, and local shell sessions.
- Local VNC WebSocket proxy on loopback.
- Build/release automation and dependency installation.
Trust boundaries:
- Frontend renderer to Wails API.
- User-selected/imported local files to backend parsers.
- Saved/imported profiles to outbound network clients.
- Remote hosts and GitHub/GitHub Enterprise APIs to frontend rendering.
- Local filesystem/keychain/vault to application process.
- CI/release environment to dependency registries and Go module proxy.
Attacker starting positions considered:
- Malicious imported profile bundle.
- Crafted local file or filename dropped into the terminal.
- Compromised renderer or malicious frontend input reaching Wails APIs.
- Network attacker on SSH/VNC/GitHub paths.
- Dependency registry or release-time supply-chain manipulation.
## 6. Findings Summary Table
| ID | Severity | Confidence | Title | Component | Status |
|---|---:|---:|---|---|---|
| SEC-20260615-001 | Medium | High | Profile persistence and imports accepted unsafe or malformed profiles | API, validation, import | Fixed |
| SEC-20260615-002 | Medium | High | Import and drag-drop upload paths allowed memory exhaustion | Import/export, SFTP upload | Fixed |
| SEC-20260615-003 | Medium | High | Dropped filenames were rendered through `innerHTML` | Frontend terminal file-drop modal | Fixed |
| SEC-20260615-004 | Medium | High | Unused SSH helper still defaulted to insecure host-key verification | SSH client | Fixed |
| SEC-20260615-005 | High | High | Dependency advisories affected `esbuild` and required `x/net` module versions | Dependencies | Fixed |
| SEC-20260615-006 | Medium | High | Release automation installed unpinned build dependencies | CI/release supply chain | Fixed |
| SEC-20260615-007 | Low | High | Direct VNC remains unencrypted when SSH tunnelling is disabled | VNC client | Open |
## 7. Detailed Findings
### Finding ID: SEC-20260615-001
#### Title
Profile persistence and imports accepted unsafe or malformed profiles
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `internal/api/api.go`, `CreateProfile`, `UpdateProfile`, `ImportProfiles`, `ImportProfilesEncrypted`, lines 94-131, 715-728, 1176-1189, 1754-1791.
- `internal/validation/validation.go`, profile, GitHub, and VNC validation, lines 51-140 and 325-410.
- `internal/storage/store.go`, `ImportProfiles`, lines 365-380.
#### Description
The API comment said profile creation validates input, but the Wails API previously persisted profiles without calling the validation package. Plain JSON import also decoded into `[]*models.Profile` and dereferenced entries without rejecting `null`. A malicious or corrupt import could therefore crash the app or store profiles with unsafe GitHub API base URLs that would later receive a saved or prompted token.
#### Evidence
The fixed code now calls `validation.ValidateProfile` before create/update and validates every imported profile before writing. GitHub API base URLs must be HTTPS unless loopback, must be absolute, and must not include URL credentials. Import strategy and null entries are rejected before persistence.
Regression tests were added in `internal/api/api_test.go` and `internal/storage/storage_test.go`.
#### Attack Scenario
A user imports a profile JSON containing a GitHub profile with `http://evil.example/api/v3` as the API base URL, or a `null` profile entry. Before the fix, malformed data could be saved or crash import handling. If the malicious GitHub profile were later used, the app could send a token to an attacker-controlled endpoint.
#### Impact
Possible token exposure through malicious profile configuration, app crash during import, and fragile persisted state.
#### Recommendation
Validate all Wails-bound persistence inputs and imported profiles before storage. Reject non-HTTPS remote GitHub API URLs and malformed entries.
#### Safer Example
Implemented:
- `CreateProfile` and `UpdateProfile` call `validation.ValidateProfile`.
- `ValidateGitHubConfig` rejects remote HTTP and URL credentials.
- Import helpers reject oversized, null, or invalid entries before writes.
#### Suggested Tests
Implemented:
- Unsafe GitHub base URL rejection.
- Null import entry rejection.
- Oversized JSON import rejection.
- Storage-level null import entry rejection.
- GitHub and VNC validation cases.
#### References
- CWE-20: Improper Input Validation.
- CWE-200: Exposure of Sensitive Information to an Unauthorized Actor.
- OWASP API Security Top 10: API3 Broken Object Property Level Authorization / unsafe object properties.
#### Assumptions / Limitations
GitHub Enterprise over non-loopback HTTP is now rejected. If a legitimate deployment needs HTTP, it should be placed behind local loopback or HTTPS.
### Finding ID: SEC-20260615-002
#### Title
Import and drag-drop upload paths allowed memory exhaustion
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `internal/api/api.go`, plain import and SFTP drag-drop upload, lines 715-718 and 1336-1350.
- `internal/exportimport/exportimport.go`, encrypted import file read, lines 37 and 131-144.
- `frontend/src/js/terminal.js`, drag-drop file upload cap, lines 10 and 829-849.
#### Description
Several local file paths loaded attacker-controlled or user-selected content fully into memory without a size cap. Plain profile JSON import accepted arbitrary string size from the renderer. Encrypted `.nexus` import used `os.ReadFile` on the selected path. Drag-drop SFTP upload read each browser `File` into an `ArrayBuffer`, converted it to base64, and sent it to the backend, where it was decoded into another byte slice.
#### Evidence
The fixed code adds:
- 5 MiB cap for plain profile JSON import.
- 10 MiB cap for encrypted `.nexus` import files.
- 25 MiB frontend and backend caps for inline drag-drop SFTP uploads.
Regression tests cover oversized plain import, encrypted import, and backend upload payload rejection.
#### Attack Scenario
A user drops or imports a very large crafted file. Before the fix, the renderer and backend could allocate multiple full copies, causing high memory pressure or an app crash.
#### Impact
Local denial of service against the desktop application.
#### Recommendation
Keep bounded inline payload paths and prefer streaming APIs for larger transfers. The existing native SFTP upload path streams local files and should remain the recommended path for large uploads.
#### Safer Example
Implemented size checks before decode/read where practical:
- `base64.StdEncoding.DecodedLen` guard before decoding drag-drop upload payloads.
- `os.Stat` size check before encrypted import `os.ReadFile`.
#### Suggested Tests
Implemented:
- `TestImportProfilesRejectsOversizedJSON`.
- `TestDecryptRejectsOversizedFile`.
- `TestSFTPUploadDataRejectsOversizedPayloadBeforeSessionLookup`.
#### References
- CWE-400: Uncontrolled Resource Consumption.
- OWASP ASVS 12.4: file and resource limits.
#### Assumptions / Limitations
The caps are conservative application-level limits. Larger profile bundles or drag-drop uploads now require a streaming implementation or the existing native SFTP upload flow.
### Finding ID: SEC-20260615-003
#### Title
Dropped filenames were rendered through `innerHTML`
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `frontend/src/js/terminal.js`, file-drop modal, lines 720-742.
#### Description
The file-drop modal interpolated the dropped filename into an `innerHTML` template. Local files can have names containing HTML metacharacters. In a Wails desktop app, DOM script execution is higher impact than a normal static page because the renderer can call privileged Wails APIs.
#### Evidence
The fixed modal escapes the file-drop title with `escapeHtml(title)` before injecting it into the template.
#### Attack Scenario
A user downloads or receives a file with a crafted name such as an HTML tag with an event handler, then drops it into a terminal. Before the fix, the modal could parse the filename as markup.
#### Impact
Potential renderer-side script execution and access to Wails-bound APIs from malicious DOM content.
#### Recommendation
Do not put user-controlled strings into `innerHTML`. Use `textContent` or escape before insertion.
#### Safer Example
Implemented `escapeHtml(title)` in the file-drop modal.
#### Suggested Tests
Add a frontend interaction test that drops a file named with `<img ...>`-style metacharacters and asserts the modal text is rendered literally. Manual Wails/browser verification was not run in this audit.
#### References
- CWE-79: Improper Neutralization of Input During Web Page Generation.
- OWASP ASVS 5.3: output encoding.
#### Assumptions / Limitations
This fix covers the reviewed file-drop title sink. Other `innerHTML` templates were spot-checked; future work should add an automated DOM-sink lint or test pass.
### Finding ID: SEC-20260615-004
#### Title
Unused SSH helper still defaulted to insecure host-key verification
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `internal/sshclient/sshclient.go`, `Connect`, lines 72-75.
- `internal/sshclient/sshclient_test.go`, regression test, lines 12-19.
#### Description
Current production session code uses explicit host-key callbacks, and no remaining reviewed SSH/VNC path calls `ssh.InsecureIgnoreHostKey`. However, the internal `sshclient.Connect` helper still defaulted to an insecure host-key callback. That made future misuse easy.
#### Evidence
The helper now fails closed and instructs callers to use `ConnectWithHostKeyCallback`. `rg` found no remaining `InsecureIgnoreHostKey` references after the change.
#### Attack Scenario
A future feature calls the convenience method instead of `ConnectWithHostKeyCallback`. Before the fix, that new path would silently accept any SSH host key.
#### Impact
Potential SSH machine-in-the-middle exposure if the helper were reused.
#### Recommendation
Keep host-key policy explicit on every SSH path. Do not provide insecure convenience APIs in internal packages.
#### Safer Example
Implemented: `Connect` returns `sshclient: host key callback is required`.
#### Suggested Tests
Implemented `TestConnectRequiresHostKeyCallback`.
#### References
- CWE-295: Improper Certificate Validation, analogous to SSH host-key validation.
- OWASP ASVS 9.1.2: server authenticity validation.
#### Assumptions / Limitations
This is a preventive fix. The reviewed production paths already used explicit callbacks.
### Finding ID: SEC-20260615-005
#### Title
Dependency advisories affected `esbuild` and required `x/net` module versions
#### Severity
High
#### Confidence
High
#### Affected Files / Components
- `frontend/package.json`, `esbuild`, line 22.
- `frontend/package-lock.json`, `esbuild` and platform packages.
- `go.mod`, `golang.org/x/net`, line 15.
- `go.sum`.
#### Description
`npm audit` reported `esbuild 0.28.0` affected by a high-severity advisory and a low-severity advisory. `govulncheck -show verbose` reported six vulnerabilities in required `golang.org/x/net v0.54.0` modules, though no reachable symbols were called by this code.
#### Evidence
The fixed versions are:
- `esbuild` bumped to `^0.28.1`.
- `golang.org/x/net` bumped to `v0.55.0`.
Post-fix `npm audit --json` reports zero vulnerabilities and `govulncheck` reports `No vulnerabilities found.`
#### Attack Scenario
For `esbuild`, a compromised or manipulated package installation environment could exploit known build-tool weaknesses. For `x/net`, future code changes could start calling vulnerable symbols if the module stayed on an affected version.
#### Impact
Supply-chain compromise risk during build/development and latent dependency risk.
#### Recommendation
Keep audited dependency patch versions current and include `npm audit` and `govulncheck` in regular release checks.
#### Safer Example
Implemented package/module upgrades and lockfile updates.
#### Suggested Tests
Implemented verification:
- `npm audit --json`.
- `go run golang.org/x/vuln/cmd/govulncheck@latest ./...`.
- `go test ./...`.
- `npm run build`.
#### References
- CWE-494: Download of Code Without Integrity Check.
- CWE-1104: Use of Unmaintained Third Party Components.
- Go vulnerability database advisories for `golang.org/x/net`.
- GitHub Advisory Database for `esbuild`.
#### Assumptions / Limitations
The `x/net` advisories were not reachable before the bump, according to govulncheck. The upgrade removes the latent module-level finding anyway.
### Finding ID: SEC-20260615-006
#### Title
Release automation installed unpinned build dependencies
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `.github/workflows/release-builds.yml`, Wails and npm install steps, lines 66-70.
- `release.sh`, Wails and npm install logic, lines 76-99.
#### Description
The release workflow installed the Wails CLI with `@latest` and used `npm install` rather than the lockfile-enforcing `npm ci`. Local release scripting also instructed or performed unpinned dependency installation in some paths.
#### Evidence
The fixed workflow installs the Wails CLI at the version resolved from `go.mod` and uses `npm ci`. `release.sh` now installs the pinned Wails CLI version from `go.mod` when needed and uses `npm ci` when `frontend/package-lock.json` exists.
#### Attack Scenario
A release build runs after a compromised or breaking upstream release, or after lockfile drift. Before the fix, release outputs could include unreviewed dependency changes.
#### Impact
Build reproducibility loss and supply-chain risk in release artifacts.
#### Recommendation
Use pinned module versions and lockfile-enforced installs in CI/release paths.
#### Safer Example
Implemented:
- `go install ...@$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v2)`.
- `npm ci` in CI and release script.
#### Suggested Tests
Implemented:
- `bash -n build.sh release.sh`.
- Frontend build after `npm ci`.
#### References
- SLSA build integrity principles.
- CWE-494.
#### Assumptions / Limitations
The workflow still allows network dependency retrieval during CI. Stronger controls would include dependency caching, provenance, and artifact signing.
### Finding ID: SEC-20260615-007
#### Title
Direct VNC remains unencrypted when SSH tunnelling is disabled
#### Severity
Low
#### Confidence
High
#### Affected Files / Components
- `internal/sessions/manager.go`, direct VNC branch, lines 1012-1062.
- `internal/vncclient/vncclient.go`, direct TCP dial, lines 139-144.
#### Description
When VNC SSH tunnelling is disabled, the client makes a direct TCP VNC connection. Classic VNC deployments often provide weak or no transport encryption.
#### Evidence
The VNC client intentionally supports both SSH-tunnelled and direct modes. The direct branch uses `net.DialTimeout` to the VNC address.
#### Attack Scenario
A user connects directly to VNC over an untrusted network. A network attacker observes or tampers with remote desktop traffic, depending on the VNC server's negotiated security type.
#### Impact
Potential confidentiality and integrity loss for remote desktop sessions.
#### Recommendation
Keep direct VNC available for trusted networks, but clearly warn users when SSH tunnelling is disabled and consider requiring explicit confirmation for saved direct-VNC profiles.
#### Safer Example
Prefer enabling `vnc.sshTunnel` and verifying the SSH host key for the tunnel host.
#### Suggested Tests
Add a UI test that direct VNC profiles show a warning, and an integration test that tunnelled VNC requires a host-key callback.
#### References
- OWASP ASVS 9.1: secure channel requirements.
#### Assumptions / Limitations
This was not changed in this run because direct VNC can be a deliberate user choice for isolated networks.
## 8. Blackhat-Style Abuse Paths
Path A: malicious profile import to token exfiltration, now blocked.
1. Provide a profile bundle with a GitHub API base URL pointing to attacker infrastructure.
2. User imports and connects.
3. App prompts for or retrieves a token and sends it to the configured endpoint.
4. Defensive breakpoints now added: profile validation, HTTPS requirement for remote GitHub APIs, import validation before storage.
Path B: crafted dropped filename to renderer script execution, now blocked.
1. User receives a file with HTML metacharacters in the filename.
2. User drops it into a terminal.
3. Filename is rendered in the file-drop modal.
4. Defensive breakpoint now added: title is escaped before `innerHTML` insertion.
Path C: oversized file to app memory exhaustion, now blocked for reviewed inline paths.
1. User imports or drops an extremely large file.
2. Renderer/backend allocate full file contents and base64 copies.
3. App becomes slow or crashes.
4. Defensive breakpoints now added: import and inline upload size caps.
Path D: direct VNC over hostile network, still open by design.
1. User disables SSH tunnel and connects to a VNC server over an untrusted network.
2. Network attacker observes/tampers with VNC traffic if protocol security is weak.
3. Defensive breakpoint: use SSH tunnel; future UI warning recommended.
## 9. Denial-of-Service Review
Fixed DoS controls:
- Plain profile import now rejects payloads over 5 MiB.
- Encrypted profile import now rejects files over 10 MiB before `os.ReadFile`.
- Drag-drop SFTP upload now rejects files over 25 MiB in the frontend and backend before base64 decode.
- Null profile entries no longer panic import paths.
Checked and not found:
- SQL queries use parameters for profile and audit operations reviewed.
- GitHub repo fetch has timeouts and caps repository listing at 5000 repos.
- SSH/Telnet/RAW connection setup uses dial timeouts.
- VNC local proxy binds to loopback and uses a per-session token in the current code.
Residual DoS risks:
- RAW session history and terminal recordings can grow with long sessions.
- SFTP directory listing still depends on remote server behavior and could return very large directories.
- Direct VNC proxy bridges long-lived streams by design.
## 10. Dependency and Supply-Chain Review
Fixed:
- `esbuild` upgraded to `^0.28.1`; `npm audit --json` now reports zero vulnerabilities.
- `golang.org/x/net` upgraded to `v0.55.0`; govulncheck now reports no vulnerabilities.
- Release workflow uses `npm ci` and pinned Wails CLI version.
- `release.sh` uses `npm ci` when a lockfile exists and installs the Wails CLI at the version from `go.mod`.
Checked:
- `go.mod`, `go.sum`, `frontend/package.json`, `frontend/package-lock.json`.
- `.github/workflows/release-builds.yml`, `build.sh`, `release.sh`.
Notes:
- npm warns that `xterm` packages are deprecated in favor of the newer `@xterm/*` packages. That is not an active vulnerability from `npm audit`, but it is a maintenance risk.
## 11. Secrets and Sensitive Data Review
Checked:
- Repository search for private key markers, common cloud tokens, GitHub token prefixes, and obvious password assignments.
- Secret store uses AES-GCM with Argon2id-derived keys and writes vault data with `0600`.
- Profile export redacts inline SSH proxy/TOTP secrets when non-secret export mode is used.
No high-confidence committed secrets or private keys were found.
Residual notes:
- GitHub and remote connection tokens are still available to the running app process by design.
- Audit logging should continue to avoid logging raw secrets; reviewed audit messages do not include secret values.
## 12. Authentication and Authorisation Review
This is a desktop app, so there are no multi-user server authz roles in the reviewed code.
Checked/fixed:
- SSH host-key bypass helper removed by making `sshclient.Connect` fail closed.
- Current SSH and VNC tunnel paths use explicit host-key callbacks.
- GitHub API base URL validation now reduces token leakage to remote HTTP endpoints.
Residual:
- Users can still choose insecure protocols/modes such as direct VNC or legacy Telnet/RAW TCP.
## 13. Input Validation and Output Encoding Review
Fixed:
- API create/update now validates profiles before persistence.
- Plain and encrypted import validate every profile before storage.
- GitHub API URLs now require HTTPS except loopback and reject URL credentials.
- VNC validation supports VNC-specific host fallback and validates tunnel fields.
- File-drop filename is escaped before template insertion.
Checked:
- SSH key management already rejects path traversal key names with alphanumeric, hyphen, and underscore constraints.
- SFTP dropped filenames use `filepath.Base` before remote path construction.
## 14. Frontend HTML/JavaScript Robustness Review
Fixed:
- File-drop modal filename sink now uses `escapeHtml`.
- Drag-drop upload rejects files over 25 MiB before FileReader allocation.
Checked:
- Notifications render messages with `textContent`.
- GitHub repository card fields reviewed use `textContent` or escaping for remote metadata.
Not fully exercised:
- No live Wails UI/browser automation was run in this audit. Add frontend interaction tests for file-drop, import/export, and update UI in future work.
## 15. Infrastructure and Deployment Review
Fixed:
- GitHub Actions release workflow now uses pinned Wails CLI and `npm ci`.
- Local `release.sh` now uses pinned Wails CLI and `npm ci`.
Checked:
- No Docker, Kubernetes, Terraform, or cloud deployment config was present in the reviewed file list.
- Wails config was reviewed for frontend build/install commands.
Residual:
- Release artifacts are not signed/notarized in the reviewed workflow.
- CI workflow has `contents: write`, required for release attachment, but should remain limited to release jobs.
## 16. Logging, Monitoring, and Error Handling Review
Checked:
- Audit log records profile changes, import/export counts, connects/disconnects, and secret access without raw secret values.
- Fixed import validation returns actionable errors rather than panics.
- Frontend upload errors now surface a user-safe oversized-file message.
Residual:
- Some protocol errors are emitted directly to the UI. Continue avoiding credentials in error strings from remote services.
- Consider adding structured debug logs for rejected imports without dumping payloads.
## 17. Test Coverage and Security Regression Gaps
Added/updated tests:
- API rejects unsafe GitHub base URL.
- API rejects null and oversized imports.
- API rejects oversized inline SFTP upload before session lookup.
- Export/import rejects oversized encrypted files.
- Storage import rejects null profile entries and invalid strategy before looping.
- Validation covers GitHub URL and VNC host/tunnel cases.
- SSH client `Connect` requires explicit host-key callback.
Remaining gaps:
- Browser/Wails interaction tests for file-drop XSS and upload cap.
- Load tests for very large SFTP directories, RAW history, and recordings.
- End-to-end import/export tests through the actual Wails UI.
- Release workflow provenance/signing tests.
## 18. Prioritised Remediation Plan
Immediate:
- Keep the fixed tests in CI.
- Add `npm audit` and govulncheck to CI if not already present.
- Warn users when saving or connecting direct VNC profiles without SSH tunnelling.
Short-term:
- Replace deprecated `xterm` packages with maintained `@xterm/*` packages.
- Add frontend tests for DOM injection via filenames and imported profile metadata.
- Add streaming drag-drop upload support or route large drops through the native file upload path.
Medium-term:
- Add release artifact signing/notarization and checksums with provenance.
- Add application-level limits for RAW history, terminal recording size, SFTP listing size, and GitHub error body reads.
Long-term:
- Add a security-focused CI job: tests, `npm audit`, govulncheck, secret scanning, and static DOM-sink checks.
- Introduce a profile schema version and migration/normalization layer for imports.
## 19. False Positives / Needs Manual Verification
- `govulncheck -show verbose` initially reported module-level `x/net` advisories that were not reachable. The module was still upgraded to remove latent risk.
- Direct VNC is intentionally supported; risk depends on network trust and VNC server security type.
- File-drop XSS was fixed by code inspection; manual Wails UI verification should still be added.
- No live external service testing was performed.
## 20. Final Notes
The repo is in a materially better state after this run: profile data is validated before storage, local resource-consumption paths are bounded, the file-drop DOM sink is escaped, dependency scanners are clean, and release dependency installation is more reproducible.
The main residual security posture issue is user-selected insecure connectivity: Telnet/RAW/direct VNC remain useful but should be presented with clear safety context.
@@ -0,0 +1,81 @@
# Functionality and Next Steps Report
## 1. Executive Summary
This fix pass moves Nexus closer to predictable large-file behavior: file-backed Leviathan documents stay file-backed for save/export, recordings parse incrementally, and unavoidable in-memory parsers now advertise and enforce clear limits.
The most useful next work is to surface these limits in the Leviathan UI and add user-facing guidance when an operation needs a smaller selection or a streaming implementation.
## 2. Current Functional Risks
Leviathan spectrogram conversion still uses bounded in-memory audio/image buffers because the current encoder/decoder APIs operate on byte slices. Very large images, MP3s, WAVs, or decoded MP3 output will now be rejected with an error rather than processed.
PDF, certificate, and key scanners still operate on bounded whole-document buffers for structural parsing. The cap avoids memory denial of service, but users working with very large firmware or document corpora may need range-based scans in future.
Recording playback is now line-streamed, but the returned API shape still collects parsed events before sending them to the frontend. Very long recordings are still protected by the playback cap; true progressive playback would require an API contract change.
## 3. Code Quality Improvements Completed
Chunk-first behavior:
- Leviathan working-copy save now writes with `Document.ForEachChunk`.
- Leviathan case export now streams `working.bin` into the zip file on disk.
- Recording playback parses line-by-line instead of reading and splitting the whole file.
Bounded read behavior:
- Spectrogram image/audio inputs reject oversize data before decode.
- Image decode checks dimensions before allocating decoded pixels.
- HTTP response helpers cap JSON and error bodies.
- SSH key, VNC key, vault, public-key, PDF, and BinSnoop file reads have explicit limits.
Responsiveness:
- Heavy Leviathan spectrogram/export operations no longer hold the engine lock while processing or showing native save dialogs.
## 4. Frontend Interaction and Error Handling
Recommended UI follow-up:
- Show limit-aware messages for spectrogram tools, such as image max size, audio max size, and decoded image dimension limits.
- For PDF/cert/key tools, show “scan a smaller file or selected range” guidance when caps are hit.
- Add a small status line in Leviathan for whether an operation is streaming, bounded-buffer, or selection-based.
- For recording playback, consider progressive playback loading so long recordings do not need all parsed events returned at once.
## 5. Testing Roadmap
Completed in this pass:
- Oversized spectrogram image dimensions are rejected before full decode.
- Leviathan bounded document reads reject oversize documents.
- Patched working-file writes preserve changed bytes.
- Oversized GitHub/update responses are rejected.
- Oversized SSH public keys, vault files, PDFs, and BinSnoop files are rejected.
Recommended next tests:
- Wails UI tests for Leviathan oversized image/audio/PDF error messages.
- A case-export test that opens a sparse file-backed document and verifies the zip is produced without materializing `working.bin`.
- A recording playback test with malformed lines and a long valid event stream.
- Browser interaction tests for the Leviathan tab while a long analysis is running.
## 6. Recommended New Functionality
Add range-based versions of whole-document Leviathan tools:
- `keys.scanRange`
- `certs.scanRange`
- `pdf.quickScan` for headers/xref/trailer without full structural recovery
- `spectrogram.renderRange` for selected audio windows
Add streaming media support later:
- Decode audio from file/range readers instead of complete byte slices.
- Stream WAV output directly to disk for spectrogram generation.
- Stream image insertion output to disk when carrier audio is large.
## 7. Caveats
The new caps trade permissiveness for stability. Files above the limits now fail fast instead of attempting best-effort processing.
The frontend build and regression checks passed, but I did not run a packaged Wails app manually in this pass.
+135
View File
@@ -0,0 +1,135 @@
# Security Audit Report
## 1. Executive Summary
This fix-mode audit focused on file-reading and response-reading paths that could allocate complete files or unbounded remote bodies in memory.
The main risk fixed was local denial of service from large user-selected files, imported Leviathan artefacts, recordings, SSH key files, vault files, and remote update/GitHub API responses. Where the codebase already had a chunked document model, saves and exports now use it. Where a parser still requires a complete byte slice, the code now rejects oversized inputs before allocation.
Verification passed with targeted package tests, `go test ./...`, frontend regression tests, frontend build, and `git diff --check`.
## 2. Scope Reviewed
Reviewed and changed:
- Leviathan file-backed document save, case export, spectrogram image/audio conversion, PDF parsing, and BinSnoop file helpers.
- Wails API helpers that read SSH public keys and Leviathan image/audio inputs.
- SSH and VNC tunnel private-key loading.
- GitHub client and update-checker HTTP response handling.
- Recording playback parsing.
- Encrypted secrets vault loading.
Not live-tested:
- A packaged Wails desktop binary.
- Real remote SSH/VNC/GitHub endpoints.
- Very large real-world media corpora beyond automated guard tests.
## 3. Methodology
I searched for `ReadFile`, `ReadAll`, and whole-document `Range(0, size)` patterns, then classified each as chunked, bounded, embedded/test-only, or unsafe. Fixes were kept to low-risk local changes:
- Prefer `Document.ForEachChunk` for writing/exporting working file bytes.
- Stream line-oriented recordings with `bufio.Scanner`.
- Limit HTTP response reads with `io.LimitReader`.
- Add `os.Stat` size checks before unavoidable bounded `os.ReadFile` calls.
- Add parser-specific caps before whole-document reads.
## 4. Findings Summary
| ID | Severity | Confidence | Title | Component | Status |
|---|---:|---:|---|---|---|
| SEC-20260616-001 | Medium | High | Leviathan save/export and spectrogram tools could materialize large files | Leviathan API, spectrogram | Fixed |
| SEC-20260616-002 | Medium | High | Remote API/update responses were read without size limits | GitHub client, updater | Fixed |
| SEC-20260616-003 | Medium | High | Local app data/key reads lacked size caps | SSH keys, VNC keys, vault, BinSnoop | Fixed |
| SEC-20260616-004 | Low | High | Recording playback loaded the whole capped recording file | Recording playback | Fixed |
| SEC-20260616-005 | Low | Medium | Some parsers still require complete bounded buffers | PDF, cert/key scanners, media decoders | Accepted with caps |
## 5. Detailed Findings
### SEC-20260616-001: Leviathan full-file allocation paths
Leviathan save/export and spectrogram operations could duplicate entire working files in memory. Case export also built the zip archive in memory before writing it.
Fixed in:
- `internal/api/leviathan.go`
- `internal/leviathan/spectrogram/spectrogram.go`
- `internal/leviathan/pdf/pdf.go`
Changes:
- Working-copy save writes via `Document.ForEachChunk`.
- Case export streams `working.bin` directly into `zip.Writer` backed by the destination file.
- Spectrogram encode/view/insert rejects oversized image/audio documents before bounded decode.
- Image decoding checks compressed byte size and decoded pixel dimensions before full image decode.
- PDF parsing rejects files above 128 MiB before whole-document structural parsing.
- Long-running spectrogram/export tools no longer hold the Leviathan engine lock while doing heavy work or native save dialogs.
### SEC-20260616-002: Unbounded remote response reads
GitHub and update-check response bodies could be read without an explicit maximum.
Fixed in:
- `internal/githubclient/githubclient.go`
- `internal/updater/updater.go`
Changes:
- GitHub JSON responses are capped at 4 MiB.
- GitHub error bodies are capped at 64 KiB.
- Update release/manifest responses are capped at 1 MiB.
- Repository archive downloads continue to stream to disk instead of memory.
### SEC-20260616-003: Local key/vault/helper reads lacked caps
Several local helper paths read files directly even though the expected files are small.
Fixed in:
- `internal/api/api.go`
- `internal/sshclient/sshclient.go`
- `internal/sessions/manager.go`
- `internal/secrets/secrets.go`
- `internal/leviathan/binsnoop/common.go`
- `internal/leviathan/binsnoop/binsnoop.go`
Changes:
- SSH public keys are capped at 64 KiB.
- SSH private keys for SSH and VNC tunnel auth are capped at 1 MiB.
- Secret vault files are capped at 10 MiB.
- BinSnoop direct file helpers are capped at 64 MiB; the UI path already uses a bounded buffer.
### SEC-20260616-004: Recording playback whole-file read
Recording playback had a 100 MiB file cap, but still read and split the full file in memory.
Fixed in:
- `internal/recording/recorder.go`
Changes:
- Playback now opens the recording and scans line-by-line.
- Individual playback lines are capped at 8 MiB.
- Existing malformed-event tolerance is preserved.
## 6. Residual Risk
Some operations still need bounded complete buffers because downstream parsers or decoders require byte slices: image/audio spectrogram conversion, PDF structural parsing, certificate/key scanning, and small SSH/vault/public-key parsing. These now fail before allocation when inputs exceed explicit caps.
Truly streaming versions of every binary parser would be a larger Leviathan architecture project. The current fixes remove the unbounded allocation behavior while preserving tool behavior for normal files.
## 7. Verification
Passed:
- `go test ./internal/api ./internal/leviathan/spectrogram ./internal/leviathan/pdf ./internal/leviathan/binsnoop ./internal/updater ./internal/githubclient ./internal/secrets ./internal/recording ./internal/sshclient ./internal/sessions -count=1`
- `go test ./... -count=1`
- `npm --prefix frontend test`
- `npm --prefix frontend run build`
- `git diff --check`
New tests cover oversized GitHub/update bodies, oversized spectrogram images, bounded Leviathan document reads, chunked patched-file writes, oversized public keys, oversized vault files, oversized PDFs, and BinSnoop file caps.
@@ -0,0 +1,138 @@
# Functionality and Next Steps Report
## 1. Executive Summary
This fix pass improved robustness around local proxying, file imports, remote downloads, SSH key management, and frontend rendering. The app now fails earlier and more predictably when inputs are malformed or too large.
Highest-impact next steps:
- Manually verify packaged VNC connections after the new Origin allowlist.
- Add UI copy for newly enforced limits.
- Add CI coverage for the security checks run in this pass.
- Continue frontend cleanup around dynamic `innerHTML`.
## 2. Current Functional Risks
VNC runtime compatibility:
- The backend now allows missing, `wails://`, and loopback HTTP(S) origins. This should work for Wails/noVNC, but packaged app validation is still needed.
Large-file and large-download behavior:
- GitHub archive downloads now stop at 2 GiB. Very large repositories will fail fast.
- SSH config imports now reject files above 5 MiB or more than 5000 Host blocks.
SSH key generation:
- RSA key generation is limited to 2048, 3072, and 4096 bits and times out after 30 seconds.
- Slow systems should be checked with RSA 4096 to ensure the timeout is not too tight.
Frontend:
- SSH key manager dynamic rendering is safer. Other static `innerHTML` templates remain and should be reviewed when they take dynamic data.
## 3. Code Quality Improvements
Completed:
- Added focused helper functions for VNC Origin validation.
- Centralized GitHub endpoint construction and owner/repo parsing.
- Added reusable archive stream limiting.
- Added backend SSH key generation option validation.
- Added import bounds to the SSH config parser.
- Replaced SSH key manager dynamic modal HTML with DOM construction.
Recommended refactors:
- Move file-size and stream-size caps into named settings or a shared limits package if more subsystems need them.
- Add a small frontend helper for safe modal construction so feature code stops hand-rolling modal `innerHTML`.
- Consider a common backend "safe basename" helper for local and remote file operations.
## 4. Frontend Interaction and Error-Handling Improvements
Recommended:
- Show a specific VNC connection error if the proxy rejects an unexpected Origin in packaged builds.
- Show clear messages for SSH config files that exceed size or Host-block limits.
- Show clear messages for GitHub archives above the cap.
- Add a hostile-filename E2E test for the SSH key delete confirmation modal.
- Continue converting dynamic modal content to DOM nodes or `textContent`.
## 5. Testing Roadmap
Added in this pass:
- VNC Origin allowlist tests.
- SFTP upload parent-directory rejection tests.
- SSH config file and Host-block limit tests.
- SSH key generation option validation tests.
- GitHub path escaping and archive cap tests.
- Frontend regression checks for SSH key manager text rendering.
Next tests:
- Packaged Wails VNC smoke test on macOS, Windows, and Linux.
- Browser/E2E test for SSH key filenames containing `<`, `>`, quotes, and ampersands.
- HTTP integration test for GitHub archive download caps and escaped branch refs.
- UI tests for limit-aware error messages.
- CI job that runs Go tests, frontend tests, frontend build, `npm audit --omit=dev`, `govulncheck`, and `git diff --check`.
## 6. Recommended New Functionality
Useful additions:
- Preferences for maximum GitHub archive download size.
- A pre-download confirmation for large GitHub archives when Content-Length is known.
- Import preview for OpenSSH config showing Host count before persistence.
- A diagnostics panel for VNC proxy status, including local URL redaction, Origin decisions, and connection state.
- A unified "Limits" help page that lists SFTP upload caps, GitHub archive cap, SSH config import cap, and Leviathan file caps.
## 7. Next-Level Opportunities
Architecture:
- Centralize resource limits and expose them to both Go and frontend UI copy.
- Add a secure modal builder and migrate risky frontend dialogs gradually.
- Add structured security regression tests for every Wails method that accepts file paths, filenames, URLs, or base64 payloads.
Operations:
- Generate checksums for release artifacts.
- Generate an SBOM during release.
- Publish a short security hardening checklist in `docs/build-and-release.md`.
Developer experience:
- Add a `make security-check` target that runs the same commands used in this audit.
- Add pre-commit or CI checks for `gofmt`, `go test`, frontend regression tests, and `git diff --check`.
## 8. Prioritised Action Plan
Immediate:
- Manually test packaged VNC on all supported platforms.
- Keep the security patch tests in CI before release.
Short-term:
- Add user-facing messages for the new SSH config, GitHub archive, and SSH keygen limits.
- Add E2E coverage for hostile SSH key filenames.
- Add an HTTP test around GitHub archive download behavior.
Medium-term:
- Centralize app resource limits and expose them to UI/help.
- Migrate high-risk frontend dynamic HTML patterns to safe modal helpers.
- Add release checksums and SBOM generation.
Long-term:
- Add progressive/streaming UX for large GitHub and SFTP transfers.
- Build a cross-platform packaged-app smoke test suite for Wails integrations.
## 9. Caveats and Validation Needed
- VNC Origin compatibility must be validated in packaged builds, not only unit tests.
- The 2 GiB GitHub archive cap may need product tuning for users with very large repositories.
- RSA 4096 key generation timeout should be checked on lower-powered machines.
- The frontend build passed, but this pass did not interactively click through the SSH key modal in a real browser/WebView.
+641
View File
@@ -0,0 +1,641 @@
# Security Audit Report
## 1. Executive Summary
This fix-mode audit reviewed the Wails API surface, local proxy behavior, file import paths, SSH key management, GitHub integration, frontend DOM sinks, dependency manifests, tests, and release workflow.
Six concrete issues were fixed:
- The local VNC WebSocket proxy accepted any browser `Origin` and did not cap client-to-proxy WebSocket message size.
- SFTP browser uploads accepted the special filename `..`, allowing a Wails caller to escape the selected remote directory when the name was joined.
- SSH config import parsed line-by-line but had no total file or Host-block cap.
- Backend SSH key generation trusted caller-supplied key type and RSA bit count and had no process timeout.
- GitHub repo/ref values were interpolated into API paths without escaping, and archive downloads had no explicit stream cap.
- SSH key manager UI interpolated backend errors and filesystem-derived key names into `innerHTML`.
Verification passed with targeted Go tests, full Go tests, frontend regression checks, frontend build, `npm audit --omit=dev`, `govulncheck`, and `git diff --check`.
## 2. Scope Reviewed
Reviewed:
- Go backend packages under `internal/`: API bindings, VNC proxy, GitHub client, SSH config import, SSH key management, SFTP upload helpers, validation, sessions, storage, secrets, updater, recording, Leviathan parser bounds, and tests.
- Browser-facing JavaScript under `frontend/src/js`, especially SSH key management, SFTP upload flows, VNC integration, and dynamic HTML usage.
- Frontend package manifest, lockfile, build scripts, and regression script.
- Go module dependencies and reachable vulnerability state.
- GitHub Actions release workflow.
Not fully live-tested:
- A packaged Wails desktop binary.
- Real noVNC connections inside the packaged WebView after the new Origin policy.
- Real GitHub Enterprise archive downloads above normal repository sizes.
## 3. Methodology
The review used static analysis, targeted source searches, dependency/advisory tooling, and focused regression tests. I mapped attacker-controlled inputs from the renderer and local files into Go methods, network clients, local HTTP/WebSocket services, subprocesses, remote SFTP paths, and DOM sinks. I prioritized issues that could be fixed with small behavior-preserving changes and backed by tests.
Commands run:
- `go test ./internal/vncclient ./internal/api ./internal/sshconfig ./internal/githubclient -count=1`
- `npm --prefix frontend test`
- `git diff --check`
- `go test ./... -count=1`
- `npm --prefix frontend run build`
- `npm --prefix frontend audit --omit=dev`
- `go run golang.org/x/vuln/cmd/govulncheck@latest ./...`
## 4. Risk Rating Method
Severity reflects exploit prerequisite, affected trust boundary, likely impact, and availability/confidentiality/integrity outcome:
- Critical: remote unauthenticated compromise or secret disclosure.
- High: practical compromise of credentials, host access, or arbitrary code execution.
- Medium: meaningful local, renderer, remote service, or connected-server abuse with realistic prerequisites.
- Low: local robustness issues, bounded DoS, or defense-in-depth gaps.
- Informational: observations and hardening opportunities.
Confidence reflects direct code evidence and testability. Findings fixed here are High confidence because they map to concrete files and new regression tests.
## 5. Attack Surface Map
Entry points:
- Wails-bound `api.Service` methods callable from the renderer.
- Local VNC WebSocket proxy bound to `127.0.0.1` on a random port and random token path.
- SSH, Telnet, RAW TCP, VNC, SFTP, GitHub, updater, and Leviathan binary-analysis workflows.
- Profile import/export, encrypted `.nexus` import, OpenSSH config import, recording playback, SSH key generation/deletion, and drag/drop upload flows.
Trust boundaries:
- Renderer to Go backend.
- Local browser/WebView to loopback VNC proxy.
- Local filesystem files selected or scanned by the app.
- Remote SSH/SFTP/VNC/GitHub/update endpoints.
- OS keychain/encrypted vault to profile storage and session runtime.
Attacker starting positions considered:
- Malicious local webpage attempting loopback WebSocket access.
- Malicious or compromised renderer script.
- Malicious local filename or selected config/import file.
- Malicious GitHub Enterprise/API endpoint or unusual repo/ref metadata.
- Malicious connected SSH/SFTP/VNC host.
## 6. Findings Summary Table
| ID | Severity | Confidence | Title | Component | Status |
|---|---|---|---|---|---|
| SEC-20260616-1702-001 | Medium | High | VNC proxy allowed all origins and uncapped WebSocket frames | `internal/vncclient` | Fixed |
| SEC-20260616-1702-002 | Medium | High | SFTP upload name `..` escaped selected remote directory | `internal/api` SFTP upload helpers | Fixed |
| SEC-20260616-1702-003 | Low | High | SSH config import lacked total file and Host-block bounds | `internal/sshconfig` | Fixed |
| SEC-20260616-1702-004 | Low | High | SSH key generation allowed expensive caller-controlled options without timeout | `internal/api` SSH key management | Fixed |
| SEC-20260616-1702-005 | Medium | High | GitHub repo/ref path interpolation and uncapped archive stream | `internal/githubclient` | Fixed |
| SEC-20260616-1702-006 | Medium | High | SSH key manager rendered key names/errors through dynamic `innerHTML` | `frontend/src/js/sshkeys.js` | Fixed |
## 7. Detailed Findings
### Finding ID: SEC-20260616-1702-001
#### Title
VNC proxy allowed all origins and uncapped WebSocket frames
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `internal/vncclient/vncclient.go`
- `internal/vncclient/vncclient_test.go`
- Local noVNC WebSocket proxy
#### Description
The VNC proxy was bound to loopback and protected by a random token path, but its Gorilla WebSocket upgrader accepted every `Origin`. It also read WebSocket messages without an explicit message size limit. If a proxy URL token leaked through logs, UI state, injected renderer code, or another local channel, a non-local browser origin could attempt to drive the active VNC TCP connection or send oversized frames that force memory allocation.
#### Evidence
Before the fix, `websocket.Upgrader.CheckOrigin` returned `true` for every request, and the proxy called `ws.ReadMessage()` without `SetReadLimit`.
#### Attack Scenario
A malicious local webpage or injected renderer script obtains an active `ws://127.0.0.1:<port>/<token>` URL. Because all origins were accepted, it could connect from a non-app origin and send very large WebSocket messages or VNC protocol bytes into the bridged TCP connection.
#### Impact
Potential local cross-origin abuse of active VNC sessions if the token is exposed, and avoidable memory pressure from oversized client-to-proxy WebSocket frames.
#### Recommendation
Allow only Wails and loopback browser origins, keep accepting missing origins for non-browser clients, and cap inbound WebSocket message size.
#### Safer Example
Implemented `allowProxyOrigin`, `isLoopbackHost`, and `ws.SetReadLimit(maxWebSocketMessageBytes)`.
#### Suggested Tests
Added `TestAllowProxyOrigin` for missing, Wails, loopback, remote, lookalike, and malformed origins.
#### References
- CWE-346: Origin Validation Error
- CWE-400: Uncontrolled Resource Consumption
- OWASP ASVS 14.4: HTTP security configuration
#### Assumptions / Limitations
The packaged Wails WebView should be manually tested to confirm its runtime Origin is covered by the `wails://` allow rule.
### Finding ID: SEC-20260616-1702-002
#### Title
SFTP upload name `..` escaped selected remote directory
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `internal/api/api.go`
- `internal/api/api_test.go`
- `SFTPUploadData`
- `SFTPUploadStart`
#### Description
`safeSFTPUploadName` normalized slash variants and used `path.Base`, but did not reject the special basename `..`. `path.Join(remoteDir, "..")` resolves to the parent directory, so a Wails caller could escape the selected upload directory.
#### Evidence
The previous rejection covered empty, `.`, `/`, and NUL, but not `..`. The upload methods then used `path.Join(remoteDir, safeName)`.
#### Attack Scenario
A malicious renderer call invokes chunked upload with `remoteDir="/home/user/upload"` and `fileName=".."`. The destination path resolves to `/home/user`, outside the selected directory.
#### Impact
Unexpected remote writes/truncation attempts outside the user's selected SFTP directory, depending on server permissions and target type.
#### Recommendation
Reject `..` after basename normalization.
#### Safer Example
Implemented `safeName == ".."` rejection.
#### Suggested Tests
Added `TestSafeSFTPUploadNameRejectsParentDirectory`.
#### References
- CWE-22: Path Traversal
- OWASP ASVS 12.3: File path control
#### Assumptions / Limitations
The browser `File.name` API normally does not produce `..`, but Wails API methods are still a trust boundary and should validate independently.
### Finding ID: SEC-20260616-1702-003
#### Title
SSH config import lacked total file and Host-block bounds
#### Severity
Low
#### Confidence
High
#### Affected Files / Components
- `internal/sshconfig/sshconfig.go`
- `internal/sshconfig/sshconfig_test.go`
- `ImportSSHConfig`
#### Description
The OpenSSH config parser scanned files line-by-line, but it did not cap total file size or the number of accumulated Host blocks. A very large selected config could allocate a large slice of parsed blocks and profiles.
#### Evidence
`ParseFile` opened the file and appended Host blocks to an in-memory slice until EOF.
#### Attack Scenario
A user imports a hostile or accidentally huge SSH config containing thousands of Host blocks. The app spends unnecessary CPU and memory parsing and converting entries.
#### Impact
Local denial of service or UI unresponsiveness during import.
#### Recommendation
Reject oversized config files, set a scanner line cap, and reject excessive Host-block counts.
#### Safer Example
Implemented `maxConfigFileBytes`, `maxConfigLineBytes`, and `maxHostBlocks`.
#### Suggested Tests
Added oversized-file and excessive-Host-block tests.
#### References
- CWE-400: Uncontrolled Resource Consumption
#### Assumptions / Limitations
The selected caps are conservative for normal OpenSSH config files. Extremely large enterprise configs may need batch import support later.
### Finding ID: SEC-20260616-1702-004
#### Title
SSH key generation allowed expensive caller-controlled options without timeout
#### Severity
Low
#### Confidence
High
#### Affected Files / Components
- `internal/api/api.go`
- `internal/api/api_test.go`
- `GenerateSSHKey`
#### Description
The frontend exposes fixed key-type and bit-size choices, but the backend method accepted caller-supplied values directly and ran `ssh-keygen` without a context timeout. A malformed renderer call could request unsupported types or excessive RSA bit sizes, tying up the subprocess.
#### Evidence
`GenerateSSHKey` passed `keyType` to `ssh-keygen -t` and only checked `bits > 0` for RSA before adding `-b`.
#### Attack Scenario
A malicious renderer call requests a very large RSA key size. `ssh-keygen` consumes CPU for an extended period or fails slowly.
#### Impact
Local CPU denial of service and poor recovery if the subprocess hangs.
#### Recommendation
Validate allowed key types and RSA sizes server-side, and execute `ssh-keygen` with a timeout.
#### Safer Example
Implemented `validateSSHKeyGenerationOptions` and `exec.CommandContext` with a 30 second timeout.
#### Suggested Tests
Added validation tests for defaults, RSA defaults, oversized RSA sizes, and unsupported key types.
#### References
- CWE-400: Uncontrolled Resource Consumption
- CWE-20: Improper Input Validation
#### Assumptions / Limitations
The timeout is a safety net, not a substitute for platform-level process controls. Real key generation should still be manually tested on slower hardware.
### Finding ID: SEC-20260616-1702-005
#### Title
GitHub repo/ref path interpolation and uncapped archive stream
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `internal/githubclient/githubclient.go`
- `internal/githubclient/githubclient_test.go`
- `FetchLatestCommit`
- `DownloadRepoArchive`
#### Description
Repository full names and refs were interpolated into REST API paths without escaping. The archive download also streamed response bodies to disk without an explicit maximum. A malformed renderer call, unusual branch name, or compromised GitHub Enterprise endpoint could create surprising request paths or consume disk.
#### Evidence
The client built paths with `fmt.Sprintf("%s/repos/%s/commits/%s", ...)` and copied archives with `io.Copy(file, resp.Body)`.
#### Attack Scenario
A renderer calls archive download with a malformed repo full name containing extra path segments, or a malicious GitHub Enterprise endpoint returns an unbounded archive response.
#### Impact
Potential confused API requests using the user's GitHub token and local disk exhaustion from unbounded archive streams.
#### Recommendation
Require `owner/repo` format, URL-escape each API path segment, and cap archive streams.
#### Safer Example
Implemented `splitRepoFullName`, `endpointURL`, `copyLimitedArchive`, and a 2 GiB archive cap.
#### Suggested Tests
Added tests for escaped refs, malformed repo names, archive limit rejection, and at-limit copies.
#### References
- CWE-20: Improper Input Validation
- CWE-22: Path Traversal style path confusion
- CWE-400: Uncontrolled Resource Consumption
#### Assumptions / Limitations
The 2 GiB archive cap may reject unusually large repositories. A future preferences setting could make this user-configurable.
### Finding ID: SEC-20260616-1702-006
#### Title
SSH key manager rendered key names/errors through dynamic `innerHTML`
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
- `frontend/src/js/sshkeys.js`
- `frontend/scripts/test-regressions.js`
- SSH key manager modal
#### Description
The SSH key manager interpolated backend error text and confirmation title/message/button text into `innerHTML`. Key names are derived from local `~/.ssh/*.pub` filenames, so a malicious local filename could inject HTML into the delete confirmation modal.
#### Evidence
The previous code used `list.innerHTML = `<div ...>${err}</div>`` and built confirmation modal HTML with `${title}`, `${message}`, and `${confirmLabel}`.
#### Attack Scenario
A local attacker creates an SSH public key file with an HTML-like filename. When the user opens the key manager and deletes it, the filename is interpolated into modal HTML.
#### Impact
DOM XSS in the Wails renderer, with possible access to app UI actions exposed to the renderer.
#### Recommendation
Render dynamic strings with `textContent` or DOM node construction.
#### Safer Example
Implemented text-node rendering for key load errors and confirmation modal dynamic text.
#### Suggested Tests
Added frontend regression checks to prevent the vulnerable interpolation pattern from returning.
#### References
- CWE-79: Cross-Site Scripting
- OWASP ASVS 5.3: Output encoding
#### Assumptions / Limitations
Other static-template `innerHTML` use remains in the frontend and should continue to be reviewed when it accepts dynamic values.
## 8. Blackhat-Style Abuse Paths
Path 1: Token leak to VNC abuse
- Prerequisite: an active VNC proxy URL leaks to a non-app webpage or injected script.
- Old path: any Origin could connect and send arbitrary WebSocket frames to the VNC TCP bridge.
- Breakpoint added: Wails/loopback Origin allowlist plus message cap.
Path 2: Renderer-to-remote path escape
- Prerequisite: malicious renderer code or direct Wails call.
- Old path: call SFTP upload with filename `..`, causing `path.Join(remoteDir, "..")`.
- Breakpoint added: `..` is rejected after basename normalization.
Path 3: Local filename to renderer script injection
- Prerequisite: crafted key filename in `~/.ssh`.
- Old path: key name appears in confirmation modal HTML.
- Breakpoint added: key names and errors use `textContent`.
Path 4: Malicious GitHub Enterprise response to disk pressure
- Prerequisite: user connects to compromised or malicious API base URL allowed by profile validation.
- Old path: archive stream was copied until EOF.
- Breakpoint added: Content-Length precheck and streaming cap.
## 9. Denial-of-Service Review
Fixed resource-control issues:
- VNC inbound WebSocket message cap.
- SSH config file size, line size, and Host-block caps.
- SSH key generation input validation and subprocess timeout.
- GitHub archive stream cap.
Previously fixed and rechecked from recent audit context:
- GitHub/update JSON response caps.
- Leviathan bounded reads and chunked save/export paths.
- SFTP inline/chunk upload caps.
- Secret vault and SSH key file read caps.
Residual DoS considerations:
- SFTP directory listings depend on remote server behavior and can still return large lists.
- GitHub repository enrichment intentionally caps total repositories but still performs concurrent commit lookups.
- Very large legitimate GitHub archives above 2 GiB now fail fast.
## 10. Dependency and Supply-Chain Review
Results:
- `npm --prefix frontend audit --omit=dev`: 0 vulnerabilities.
- `govulncheck ./...`: no vulnerabilities found.
Reviewed manifests:
- `go.mod`
- `go.sum`
- `frontend/package.json`
- `frontend/package-lock.json`
- `.github/workflows/release-builds.yml`
No high-confidence dependency findings were found in this pass.
## 11. Secrets and Sensitive Data Review
Checked:
- No obvious private keys, GitHub tokens, cloud keys, or hardcoded credentials were found in source outside documentation/examples.
- Non-secret profile export redaction was already covered by tests.
- Secret access logging records secret type, not value.
- GitHub Authorization headers are set only in HTTP requests and not logged by this code.
Residual:
- Plain exports without secrets are safer, but encrypted export remains the recommended path for portable backups.
- Local audit logs should continue to avoid host/user secrets in future features.
## 12. Authentication and Authorisation Review
This is a local desktop application, so there are no server-side user roles or tenant boundaries. Relevant controls reviewed:
- SSH host-key callback requirement for VNC SSH tunnels.
- Known-host storage and verification paths.
- Secret retrieval via keychain/vault and prompt callbacks.
- GitHub token validation before repo fetch.
No missing multi-user authorization checks apply to the current architecture.
## 13. Input Validation and Output Encoding Review
Fixed:
- SFTP upload basename `..`.
- SSH key generation key type and RSA bit sizes.
- GitHub repo full-name format and path segment escaping.
- SSH key manager dynamic output encoding.
Previously present controls:
- Profile validation rejects invalid hosts, ports, secret modes, GitHub HTTP non-loopback base URLs, and malformed protocol configs.
- Import profiles are size-capped and validated before persistence.
## 14. Frontend HTML/JavaScript Robustness Review
Fixed:
- SSH key manager load errors now render via `textContent`.
- SSH key deletion confirmation modal dynamic strings now render via DOM nodes/text.
- Regression script now checks these patterns.
Checked:
- Wails service bridge ordering remains covered by regression checks.
- SFTP drag/drop upload still uses chunked methods.
- Direct VNC warning flow remains covered by regression checks.
Residual:
- Frontend still contains many static `innerHTML` templates. Future reviews should focus on any template that accepts local filesystem, remote host, profile, update, or GitHub data.
## 15. Infrastructure and Deployment Review
Reviewed:
- GitHub Actions release workflow.
- Wails build config.
- Frontend build scripts.
No new high-confidence release workflow finding was fixed. The workflow uses pinned major versions for common actions and default GitHub token permissions scoped to release attachment needs.
Residual:
- Consider adding checksums/SBOM generation for release artifacts.
- Consider a CI job for `go test ./...`, frontend regression tests, `npm audit --omit=dev`, and `govulncheck`.
## 16. Logging, Monitoring, and Error Handling Review
Fixed:
- SSH key manager no longer injects raw error strings into HTML.
- GitHub malformed repo names now fail before request construction.
- SSH keygen timeout returns a specific timeout error.
Checked:
- Error messages added in this pass do not include tokens, passwords, or key material.
- Existing audit logger records profile/session actions and secret access metadata.
## 17. Test Coverage and Security Regression Gaps
Added:
- VNC Origin allowlist tests.
- SFTP parent-directory upload name tests.
- SSH key generation option validation tests.
- SSH config oversized file and Host-block cap tests.
- GitHub URL escaping, malformed full-name, and archive cap tests.
- Frontend static regression checks for SSH key manager safe rendering.
Remaining gaps:
- No packaged Wails/noVNC integration test for Origin behavior.
- No E2E browser test for SSH key modal rendering with hostile filenames.
- No integration test for GitHub archive cap against an HTTP test server.
## 18. Prioritised Remediation Plan
Immediate:
- Manually test VNC connections in a packaged Wails app to verify the Origin allowlist.
- Keep the new security tests in CI.
Short-term:
- Add an E2E test for SSH key manager hostile filenames.
- Add an HTTP integration test for GitHub archive caps and escaped refs.
- Surface user-friendly errors for new caps in the UI.
Medium-term:
- Add CI security checks for `govulncheck`, `npm audit --omit=dev`, and `git diff --check`.
- Add release artifact checksums and an SBOM.
Long-term:
- Continue replacing dynamic frontend `innerHTML` where data provenance is mixed.
- Add configurable large-download limits for GitHub archives and SFTP operations.
## 19. False Positives / Needs Manual Verification
- VNC Origin policy should be validated in macOS, Windows, and Linux packaged Wails builds.
- The 2 GiB GitHub archive cap should be validated against expected user repository sizes.
- The SSH keygen 30 second timeout should be checked on slow hardware, especially for RSA 4096.
## 20. Final Notes
This pass focused on concrete, testable hardening rather than broad speculative rewrites. The remaining risk is mostly runtime validation and UX around newly explicit limits. The repo still has an unrelated sidebar CSS spacing change in the working tree that was not part of this security audit.
+167 -35
View File
@@ -6,6 +6,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
source "$SCRIPT_DIR/scripts/build-common.sh"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
@@ -16,6 +17,103 @@ log() { echo -e "${GREEN}[nexus]${NC} $1"; }
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
MIN_GO_VERSION="1.25.0"
WAILS_VERSION="$(awk '$1 == "github.com/wailsapp/wails/v2" { print $2; exit }' go.mod)"
WAILS_BIN=""
WAILS_USE_GO_RUN=0
BUILT_TARGETS=0
SKIPPED_TARGETS=0
if [ -x "$SCRIPT_DIR/.tools/go/bin/go" ]; then
export PATH="$SCRIPT_DIR/.tools/go/bin:$PATH"
fi
export GOCACHE="${GOCACHE:-$SCRIPT_DIR/.cache/go-build}"
export GOPATH="${GOPATH:-$SCRIPT_DIR/.cache/go}"
mkdir -p "$GOCACHE" "$GOPATH"
export PATH="$GOPATH/bin:$PATH"
version_at_least() {
local have="${1#go}"
local want="$2"
local IFS=.
local have_parts want_parts
read -r -a have_parts <<< "$have"
read -r -a want_parts <<< "$want"
for i in 0 1 2; do
local h="${have_parts[$i]:-0}"
local w="${want_parts[$i]:-0}"
if (( h > w )); then return 0; fi
if (( h < w )); then return 1; fi
done
return 0
}
check_go() {
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
local version
version="$(go env GOVERSION 2>/dev/null || go version | awk '{print $3}')"
if ! version_at_least "$version" "$MIN_GO_VERSION"; then
err "Go ${MIN_GO_VERSION}+ is required by go.mod; found ${version} at $(command -v go)"
err "Install a newer Go, or place it at .tools/go/bin/go for this project."
exit 1
fi
}
resolve_wails() {
if WAILS_BIN="$(command -v wails 2>/dev/null)"; then
WAILS_USE_GO_RUN=0
return
fi
local gopath
gopath="$(go env GOPATH 2>/dev/null || true)"
if [ -n "$gopath" ] && [ -x "$gopath/bin/wails" ]; then
WAILS_BIN="$gopath/bin/wails"
WAILS_USE_GO_RUN=0
return
fi
if [ -n "$WAILS_VERSION" ]; then
WAILS_BIN="github.com/wailsapp/wails/v2/cmd/wails@${WAILS_VERSION}"
WAILS_USE_GO_RUN=1
warn "Wails CLI not found; using pinned ${WAILS_BIN} via go run"
return
fi
err "Wails is not installed and no Wails version was found in go.mod"
exit 1
}
wails_cmd() {
if [ "$WAILS_USE_GO_RUN" -eq 1 ]; then
go run "$WAILS_BIN" "$@"
else
"$WAILS_BIN" "$@"
fi
}
ensure_upx() {
if ! nexus_bool_enabled "${NEXUS_COMPRESS:-0}"; then
return
fi
if command -v upx >/dev/null 2>&1; then
return
fi
warn "UPX is not installed; compressed targets will be skipped unless the target does not use UPX."
}
linux_deps_ok() {
command -v pkg-config >/dev/null 2>&1 || return 1
pkg-config --exists gtk+-3.0 || return 1
if ! pkg-config --exists webkit2gtk-4.1 && ! pkg-config --exists webkit2gtk-4.0; then
return 1
fi
if ! pkg-config --exists ayatana-appindicator3-0.1 && ! pkg-config --exists appindicator3-0.1; then
return 1
fi
}
build_tags() {
if command -v pkg-config >/dev/null 2>&1 \
&& pkg-config --exists webkit2gtk-4.1 \
@@ -42,22 +140,15 @@ build_ldflags() {
}
check_deps() {
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
check_go
command -v node >/dev/null 2>&1 || { err "Node.js is not installed"; exit 1; }
command -v npm >/dev/null 2>&1 || { err "npm is not installed"; exit 1; }
if ! command -v wails >/dev/null 2>&1; then
if [ -f "$HOME/go/bin/wails" ]; then
export PATH="$PATH:$HOME/go/bin"
else
err "Wails is not installed. Run: go install github.com/wailsapp/wails/v2/cmd/wails@latest"
exit 1
fi
fi
resolve_wails
ensure_upx
log "Go: $(go version)"
log "Node: $(node --version)"
log "Wails: $(wails version 2>/dev/null | head -1)"
log "Wails: $(wails_cmd version 2>/dev/null | head -1)"
}
install_deps() {
@@ -65,7 +156,11 @@ install_deps() {
go mod download
log "Installing frontend dependencies…"
cd frontend && npm install && cd ..
if [ -f frontend/package-lock.json ]; then
(cd frontend && npm ci)
else
(cd frontend && npm install)
fi
}
host_os() {
@@ -76,6 +171,14 @@ host_os() {
esac
}
host_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "amd64" ;;
arm64|aarch64) echo "arm64" ;;
*) echo "unknown" ;;
esac
}
pick_cc() {
local target_os="$1"
local target_arch="$2"
@@ -105,37 +208,57 @@ build_target() {
local target_os="$1"
local target_arch="$2"
local host="$3"
local version="$4"
local host_arch_value="$4"
local version="$5"
local platform="${target_os}/${target_arch}"
nexus_configure_release_cgo
local ldflags
ldflags="$(build_ldflags "${version}")"
local tags
tags="$(build_tags)"
local build_args=("-platform" "$target_os/$target_arch" "-trimpath")
if nexus_use_upx_for_target "$target_os"; then
if ! command -v upx >/dev/null 2>&1; then
warn "Skipping ${platform} (NEXUS_COMPRESS=1 requires UPX)"
((SKIPPED_TARGETS += 1))
return 0
fi
build_args+=("-upx")
if [ -n "${NEXUS_UPX_FLAGS:-}" ]; then
build_args+=("-upxflags" "$NEXUS_UPX_FLAGS")
fi
elif nexus_skips_upx_for_target "$target_os"; then
warn "Skipping UPX compression for ${platform}; distribute the zipped .app instead, or set NEXUS_FORCE_MACOS_UPX=1 with suitable NEXUS_UPX_FLAGS."
fi
if [ -n "$tags" ]; then
build_args+=("-tags" "$tags")
fi
build_args+=("-ldflags" "$ldflags")
local platform="${target_os}/${target_arch}"
local cc=""
if [ "$target_os" != "$host" ]; then
if [ "$target_os" = "linux" ] && ! linux_deps_ok; then
warn "Skipping ${platform} (missing Linux GTK/WebKit build dependencies)"
((SKIPPED_TARGETS += 1))
return 0
fi
if [ "$target_os" != "$host" ] || [ "$target_arch" != "$host_arch_value" ]; then
cc="$(pick_cc "$target_os" "$target_arch")"
if [ -n "$cc" ] && ! command -v "$cc" >/dev/null 2>&1; then
warn "Skipping ${platform} (missing C compiler: ${cc})"
((SKIPPED_TARGETS += 1))
return 0
fi
fi
log "Building ${platform}…"
if [ -n "$cc" ]; then
if [ -n "$tags" ]; then
CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -tags "$tags" -ldflags "$ldflags"
else
CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags"
fi
CC="$cc" CGO_ENABLED=1 wails_cmd build "${build_args[@]}"
else
if [ -n "$tags" ]; then
CGO_ENABLED=1 wails build -platform "$platform" -tags "$tags" -ldflags "$ldflags"
else
CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags"
fi
CGO_ENABLED=1 wails_cmd build "${build_args[@]}"
fi
((BUILT_TARGETS += 1))
}
main() {
@@ -147,24 +270,33 @@ main() {
local host
host="$(host_os)"
if [ "$host" = "unknown" ]; then
err "Unsupported host OS for this script. Use macOS or Linux."
local arch
arch="$(host_arch)"
if [ "$host" = "unknown" ] || [ "$arch" = "unknown" ]; then
err "Unsupported host platform: $(uname -s)/$(uname -m)"
exit 1
fi
# Build host targets first
build_target "$host" "amd64" "$host" "$version"
build_target "$host" "arm64" "$host" "$version"
build_target "$host" "$arch" "$host" "$arch" "$version"
# Cross-build Linux and Windows targets where toolchains are available
if [ "$host" = "darwin" ] || [ "$host" = "linux" ]; then
build_target "linux" "amd64" "$host" "$version"
build_target "linux" "arm64" "$host" "$version"
build_target "windows" "amd64" "$host" "$version"
build_target "windows" "arm64" "$host" "$version"
for target in linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
local target_os="${target%%/*}"
local target_arch="${target##*/}"
if [ "$target_os" = "$host" ] && [ "$target_arch" = "$arch" ]; then
continue
fi
build_target "$target_os" "$target_arch" "$host" "$arch" "$version"
done
fi
log "Builds complete. Output: build/bin/"
if [ "$BUILT_TARGETS" -eq 0 ]; then
err "No targets were built; ${SKIPPED_TARGETS} target(s) were skipped because required system toolchains are missing."
exit 1
fi
log "Builds complete (${BUILT_TARGETS} built, ${SKIPPED_TARGETS} skipped). Output: build/bin/"
}
main "$@"
+21 -2
View File
@@ -7,6 +7,10 @@ function Write-Info($Message) { Write-Host "[nexus] $Message" -ForegroundColor G
function Write-Warn($Message) { Write-Host "[nexus] $Message" -ForegroundColor Yellow }
function Write-Err($Message) { Write-Host "[nexus] $Message" -ForegroundColor Red }
function Test-BoolEnabled([string]$Value) {
return $Value -match '^(1|true|TRUE|yes|YES|on|ON)$'
}
function Get-WailsPath {
$wails = Get-Command wails -ErrorAction SilentlyContinue
if ($wails) { return $wails.Source }
@@ -67,7 +71,7 @@ function Get-BuildVersion {
}
function Get-Ldflags([string]$Version) {
$flags = "-s -w -X github.com/andydixon/nexus/internal/api.appVersion=$Version"
$flags = "-s -w -buildid= -X github.com/andydixon/nexus/internal/api.appVersion=$Version"
if ($env:NEXUS_UPDATE_URL) {
$flags += " -X github.com/andydixon/nexus/internal/api.appUpdateURL=$($env:NEXUS_UPDATE_URL)"
}
@@ -88,8 +92,23 @@ function Build {
$ldflags = Get-Ldflags $version
$env:CGO_ENABLED = '1'
if (-not $env:CGO_CFLAGS) { $env:CGO_CFLAGS = '-O2 -g0' }
if (-not $env:CGO_CXXFLAGS) { $env:CGO_CXXFLAGS = '-O2 -g0' }
$buildArgs = @('build', '-trimpath')
if (Test-BoolEnabled $env:NEXUS_COMPRESS) {
if (-not (Get-Command upx -ErrorAction SilentlyContinue)) {
throw 'NEXUS_COMPRESS=1 requires UPX. Install it first.'
}
$buildArgs += '-upx'
if ($env:NEXUS_UPX_FLAGS) {
$buildArgs += @('-upxflags', $env:NEXUS_UPX_FLAGS)
}
}
$buildArgs += @('-ldflags', $ldflags)
Write-Info 'Building production binary (CGO_ENABLED=1)...'
& $wailsPath build -ldflags $ldflags
& $wailsPath @buildArgs
Write-Info 'Build complete: build\bin\'
}
+158 -33
View File
@@ -6,6 +6,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
source "$SCRIPT_DIR/scripts/build-common.sh"
# Colours for output
RED='\033[0;31m'
@@ -17,50 +18,154 @@ log() { echo -e "${GREEN}[nexus]${NC} $1"; }
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
build_tags() {
if command -v pkg-config >/dev/null 2>&1 \
&& pkg-config --exists webkit2gtk-4.1 \
&& ! pkg-config --exists webkit2gtk-4.0; then
echo "webkit2_41"
MIN_GO_VERSION="1.25.0"
WAILS_VERSION="$(nexus_wails_version go.mod)"
WAILS_BIN=""
WAILS_USE_GO_RUN=0
if [ -x "$SCRIPT_DIR/.tools/go/bin/go" ]; then
export PATH="$SCRIPT_DIR/.tools/go/bin:$PATH"
fi
export GOCACHE="${GOCACHE:-$SCRIPT_DIR/.cache/go-build}"
export GOPATH="${GOPATH:-$SCRIPT_DIR/.cache/go}"
mkdir -p "$GOCACHE" "$GOPATH"
export PATH="$GOPATH/bin:$PATH"
version_at_least() {
local have="${1#go}"
local want="$2"
local IFS=.
local have_parts want_parts
read -r -a have_parts <<< "$have"
read -r -a want_parts <<< "$want"
for i in 0 1 2; do
local h="${have_parts[$i]:-0}"
local w="${want_parts[$i]:-0}"
if (( h > w )); then return 0; fi
if (( h < w )); then return 1; fi
done
return 0
}
check_go() {
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
local version
version="$(go env GOVERSION 2>/dev/null || go version | awk '{print $3}')"
if ! version_at_least "$version" "$MIN_GO_VERSION"; then
err "Go ${MIN_GO_VERSION}+ is required by go.mod; found ${version} at $(command -v go)"
err "Install a newer Go, or place it at .tools/go/bin/go for this project."
exit 1
fi
}
resolve_wails() {
if WAILS_BIN="$(command -v wails 2>/dev/null)"; then
WAILS_USE_GO_RUN=0
return
fi
local gopath
gopath="$(go env GOPATH 2>/dev/null || true)"
if [ -n "$gopath" ] && [ -x "$gopath/bin/wails" ]; then
WAILS_BIN="$gopath/bin/wails"
WAILS_USE_GO_RUN=0
return
fi
if [ -n "$WAILS_VERSION" ]; then
WAILS_BIN="github.com/wailsapp/wails/v2/cmd/wails@${WAILS_VERSION}"
WAILS_USE_GO_RUN=1
warn "Wails CLI not found; using pinned ${WAILS_BIN} via go run"
return
fi
err "Wails is not installed and no Wails version was found in go.mod"
exit 1
}
wails_cmd() {
if [ "$WAILS_USE_GO_RUN" -eq 1 ]; then
go run "$WAILS_BIN" "$@"
else
"$WAILS_BIN" "$@"
fi
}
ensure_upx() {
if ! nexus_bool_enabled "${NEXUS_COMPRESS:-0}"; then
return
fi
if nexus_skips_upx_for_target "$(host_os)"; then
return
fi
if command -v upx >/dev/null 2>&1; then
return
fi
err "NEXUS_COMPRESS=1 requires UPX. Install it first, for example: brew install upx"
exit 1
}
check_linux_deps() {
if [ "$(uname -s)" != "Linux" ]; then
return
fi
command -v pkg-config >/dev/null 2>&1 || {
err "pkg-config is required for Linux Wails builds."
err "On Ubuntu/Pop!_OS install: sudo apt-get install -y build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev"
exit 1
}
local missing=()
pkg-config --exists gtk+-3.0 || missing+=("gtk+-3.0")
if ! pkg-config --exists webkit2gtk-4.1 && ! pkg-config --exists webkit2gtk-4.0; then
missing+=("webkit2gtk-4.1 or webkit2gtk-4.0")
fi
if ! pkg-config --exists ayatana-appindicator3-0.1 && ! pkg-config --exists appindicator3-0.1; then
missing+=("ayatana-appindicator3-0.1 or appindicator3-0.1")
fi
if [ "${#missing[@]}" -gt 0 ]; then
err "Missing Linux build dependencies: ${missing[*]}"
err "On Ubuntu/Pop!_OS install: sudo apt-get install -y build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev"
exit 1
fi
}
build_tags() {
nexus_build_tags
}
host_os() {
case "$(uname -s)" in
Darwin) echo "darwin" ;;
Linux) echo "linux" ;;
*) echo "unknown" ;;
esac
}
build_version() {
local tag
local commit
tag="$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")"
commit="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
echo "${tag}-${commit}"
nexus_build_version
}
build_ldflags() {
local version="$1"
local flags="-s -w -X github.com/andydixon/nexus/internal/api.appVersion=${version}"
if [ -n "${NEXUS_UPDATE_URL:-}" ]; then
flags="${flags} -X github.com/andydixon/nexus/internal/api.appUpdateURL=${NEXUS_UPDATE_URL}"
fi
echo "${flags}"
nexus_build_ldflags "$1"
}
# Check prerequisites
check_deps() {
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
check_go
command -v node >/dev/null 2>&1 || { err "Node.js is not installed"; exit 1; }
command -v npm >/dev/null 2>&1 || { err "npm is not installed"; exit 1; }
if ! command -v wails >/dev/null 2>&1; then
if [ -f "$HOME/go/bin/wails" ]; then
export PATH="$PATH:$HOME/go/bin"
else
err "Wails is not installed. Run: go install github.com/wailsapp/wails/v2/cmd/wails@latest"
exit 1
fi
fi
check_linux_deps
resolve_wails
ensure_upx
log "All prerequisites found"
log "Go: $(go version)"
log "Node: $(node --version)"
log "Wails: $(wails version 2>/dev/null | head -1)"
log "Wails: $(wails_cmd version 2>/dev/null | head -1)"
}
# Install dependencies
@@ -69,7 +174,11 @@ install_deps() {
go mod download
log "Installing frontend dependencies…"
cd frontend && npm install && cd ..
if [ -f frontend/package-lock.json ]; then
(cd frontend && npm ci)
else
(cd frontend && npm install)
fi
log "Dependencies installed"
}
@@ -79,7 +188,7 @@ dev() {
check_deps
install_deps
log "Starting development mode…"
wails dev
wails_cmd dev
}
# Production build
@@ -91,11 +200,24 @@ build() {
version="$(build_version)"
local tags
tags="$(build_tags)"
nexus_configure_release_cgo
local build_args=("-trimpath")
if nexus_bool_enabled "${NEXUS_COMPRESS:-0}"; then
if nexus_skips_upx_for_target "$(host_os)"; then
warn "Skipping UPX compression on macOS; distribute the zipped .app instead, or set NEXUS_FORCE_MACOS_UPX=1 with suitable NEXUS_UPX_FLAGS."
else
log "Using UPX compression"
build_args+=("-upx")
if [ -n "${NEXUS_UPX_FLAGS:-}" ]; then
build_args+=("-upxflags" "$NEXUS_UPX_FLAGS")
fi
fi
fi
if [ -n "$tags" ]; then
log "Using Wails build tags: ${tags}"
wails build -tags "$tags" -ldflags "$(build_ldflags "${version}")"
wails_cmd build "${build_args[@]}" -tags "$tags" -ldflags "$(build_ldflags "${version}")"
else
wails build -ldflags "$(build_ldflags "${version}")"
wails_cmd build "${build_args[@]}" -ldflags "$(build_ldflags "${version}")"
fi
# Replace Wails-generated icon with custom app icon on macOS.
@@ -113,8 +235,10 @@ build() {
# Run tests
run_tests() {
check_go
log "Running tests…"
go test ./... -v -count=1
(cd frontend && npm test)
}
# Clean build artefacts
@@ -123,8 +247,9 @@ clean() {
rm -rf build/bin
rm -rf frontend/node_modules
rm -rf frontend/src/js/lib
rm -rf frontend/src/css/lib
go clean -testcache
if command -v go >/dev/null 2>&1; then
go clean -testcache
fi
log "Clean complete"
}
+295 -14
View File
@@ -1,26 +1,307 @@
# Nexus Terminal Overview
Nexus Terminal is a desktop client for managing SSH, Telnet, RAW TCP, and GitHub sessions in one place. It is designed for people who work across multiple servers, environments, or repositories and want a fast, focused workflow with strong credential handling.
Nexus Terminal is a desktop connection and analysis workspace for people who manage servers, network services, remote desktops, repositories, and binary artefacts every day. It brings SSH, Telnet, RAW TCP, VNC, GitHub, local shell, and Leviathan binary-analysis workflows into one focused app with secure credential handling, multi-session organisation, file transfer, automation, recording, and inspection tooling built in.
## What it does
## Product positioning
- Connect to SSH, Telnet, and RAW TCP endpoints with saved profiles or quick connect.
- Manage multiple live sessions with tabs, split panes, and broadcast input.
- Browse and move files on SSH hosts with a built-in SFTP browser.
- Record sessions for later playback.
- Store credentials securely with OS keychain support and an encrypted vault fallback.
Nexus is more than an SSH client. It is a single command centre for remote access, terminal operations, file movement, raw socket inspection, GitHub repository visibility, binary triage, and repeatable operational workflows.
## Highlights
Use Nexus when you need to:
- Multi-session tab bar with pinning, reordering, and split panes.
- SFTP browser with drag-and-drop uploads and a directory picker.
- Clipboard-aware terminal with search, snippets, and right-click actions.
- Auto-reconnect, login scripts, and SSH extras such as jump hosts and port forwarding.
- GitHub view for browsing repositories by organisation.
- Connect quickly to many systems without re-entering credentials.
- Keep multiple live sessions organised with tabs, pinned tabs, split panes, and status indicators.
- Move files over SSH without leaving the terminal workspace.
- Work safely with host key verification, encrypted secrets, TOTP support, and audit logging.
- Reuse commands, automate login flows, record sessions, and react to terminal output.
- Support mixed estates that include SSH servers, Telnet devices, raw TCP services, VNC desktops, GitHub repositories, local shells, firmware images, binaries, archives, and other artefacts.
## Main feature groups
### Multi-protocol connections
- **SSH** for secure remote terminal access.
- **Telnet** for legacy devices and services.
- **RAW TCP** for direct socket interaction, protocol testing, and device troubleshooting.
- **VNC** for remote desktop access.
- **GitHub** for repository browsing and download workflows.
- **Local terminal** for opening a shell on the local machine.
- **Leviathan** for binary inspection, editing, carving, transforms, and spectrogram workflows.
- **Quick connect** for one-off sessions without saving a profile.
- **Saved profiles** for repeat connections.
### Profile management
- Create, edit, duplicate, and delete connection profiles.
- Store display names, descriptions, notes, groups, tags, environment labels, and favourites.
- Search profiles by name, host, or tag.
- Filter profiles by protocol, group, tag, and favourite status.
- Test basic host and port reachability before connecting.
- Import existing OpenSSH config entries from `~/.ssh/config`, including hostnames, usernames, ports, identity files, and jump hosts.
- Export and import profiles using encrypted `.nexus` bundles.
- Choose import behaviour for duplicates: skip, overwrite, or duplicate.
### Multi-session workspace
- Tabbed sessions with connection state indicators.
- Pin tabs to keep important systems visible.
- Rename sessions.
- Reorder tabs.
- Scroll overflowing tabs and hover them to see full names and connection start times.
- Close one session or all sessions.
- Split panes vertically or horizontally.
- Resize split panes.
- Switch tabs by keyboard shortcut.
- Broadcast input to multiple connected sessions.
- Show disconnected overlays with a one-click reconnect action.
- Auto-reconnect support for SSH, Telnet, and RAW TCP profiles.
- Command palette for fast access to connections, settings, terminal actions, security tools, import/export, snippets, triggers, and local terminal.
### Terminal experience
- xterm.js-powered terminal rendering.
- Configurable terminal font family, font size, scrollback, cursor style, and colour theme.
- Search within terminal output.
- Search options for case sensitivity, regular expressions, and whole-word matching.
- Copy, paste, select all, clear, and reset from the terminal context menu.
- Clipboard-aware paste behaviour.
- Clickable web links where supported.
- Alternate-screen scroll handling for tools such as `tmux`, `screen`, and `byobu`.
- Export terminal scrollback as plain text.
- Send break signals where supported.
- Ping active sessions to check responsiveness.
### SSH capabilities
- Password authentication.
- Public key authentication.
- Keyboard-interactive authentication.
- SSH agent authentication.
- SSH agent forwarding.
- Private key path discovery from `~/.ssh`.
- SSH key manager for listing, generating, copying, and deleting key pairs.
- Ed25519, RSA, and ECDSA key generation.
- Optional private key passphrase handling.
- TOTP / 2FA secret storage and code generation for keyboard-interactive login flows.
- Host key verification policies per profile: Strict, Ask, Accept new, and Insecure.
- Known hosts storage and management.
- Jump host support for bastion and multi-hop access.
- SOCKS5 proxy support.
- HTTP CONNECT proxy support.
- Local and remote port forwarding.
- Runtime port forward management for active SSH sessions.
- Startup commands.
- Custom environment variables.
- Terminal type configuration.
- Keepalive interval and missed-keepalive settings.
- Compression option.
- Expect-style login scripts with wait and send steps.
- Built-in SFTP browser for file transfer.
### SFTP file browser
- Open a side panel for SSH file browsing.
- Navigate remote folders with breadcrumbs.
- Go directly to a remote path.
- Bookmark remote locations.
- Refresh remote listings.
- Create folders.
- Upload files.
- Download single files or multiple selected files.
- Rename files and folders.
- Delete files and folders.
- Multi-select support for batch actions.
- Resizable SFTP panel alongside the terminal.
- Drag and drop files into an SSH terminal.
- Choose whether dropped files are uploaded or pasted into the terminal as text.
- Upload directory picker with folder filtering, breadcrumbs, keyboard navigation, and permission badges.
- Permission labels for read-only and inaccessible folders.
### Telnet capabilities
- Telnet option negotiation.
- Terminal type negotiation.
- NAWS window-size updates.
- Local echo option.
- Line mode option.
- Configurable line endings: LF, CR, CRLF, or none.
- Configurable encoding: UTF-8, ASCII, and ISO 8859-1.
- Negotiation debug mode.
- Expect-style login scripts.
- Auto-reconnect with configurable retry count.
- Break signal support.
### RAW TCP capabilities
- Direct TCP socket sessions.
- Text, hex, and split data views.
- Send data as text, hex, or escaped bytes.
- Quick insert controls for common bytes such as NUL, CR, LF, ETX, and EOT.
- Payload presets for reusable test messages.
- Optional timestamps.
- Optional send/receive direction labels.
- Pause and resume display updates.
- Clear the capture buffer.
- Save RAW session captures.
- Configurable encoding and line endings.
- Connect and read timeouts.
- Auto-reconnect option.
### VNC capabilities
- VNC remote desktop sessions.
- noVNC-based viewer inside the Nexus workspace.
- Password prompt and password storage modes.
- View-only mode.
- Scale-to-fit display.
- Shared-session option.
- Quality setting.
- SSH tunnel option for VNC traffic.
- SSH tunnel authentication by password, public key, or SSH agent.
### GitHub capabilities
- GitHub profile type using a personal access token.
- GitHub.com and GitHub Enterprise API base URL support.
- Repository viewer grouped by owner or organisation.
- Repository filtering by name, description, or language.
- Repositories sorted by latest commit activity.
- Shows repository metadata such as language, stars, private/fork/archived state, latest committer, and latest commit time.
- Manual refresh.
- Configurable refresh interval.
- Open repositories in the browser.
- Download repository ZIP archives.
### Leviathan binary workbench
- Switch from **Nexus Terminal** to **Leviathan** from the sidebar header.
- Load a local file into a working document, inspect it with multiple tools, then save the patched working copy.
- Tools open as normal tabs beside connection sessions.
- Leviathan tabs close immediately without connection-disconnect confirmation.
- Shared undo, redo, and revert actions for byte-level edits.
- File-backed document model for large local files, with chunked save/export where supported.
- Clear limits for tools that require bounded in-memory buffers, such as image/audio spectrogram conversion and some parsers.
### Leviathan editor and triage tools
- **Hex Editor** with editable byte cells, live ASCII preview, 16-byte and 32-byte rows, byte search, string/hex search, previous/next match navigation, and range opening from analysis views.
- **Overview** for first-pass summary across hashes, entropy, strings, indicators, and carve candidates.
- **Hashes** for CRC32, MD5, SHA-1, SHA-256, and SHA-512.
- **Entropy** graph over the whole file, with selectable ranges that can open a new hex editor tab.
- **Strings** extraction for ASCII and UTF-16 text.
- **IOCs** for URLs, domains, paths, hashes, and other indicators.
- **Search** and **Multi-search** for text, hex, and regular-expression matching.
- Exportable results from tools that find files, ranges, or structured artefacts.
### Leviathan structure, recovery, and transform tools
- **Structures** parser for common binary formats such as PE, ELF, Mach-O, ZIP, images, RIFF, ASN.1, and certificates.
- **Disasm** heuristic disassembly for common CPU families, with raw byte fallback for unsupported architectures.
- **PDF** object, stream, xref, and suspicious-surface inspection.
- **SquashFS** superblock and filesystem metadata inspection with human-readable bad-magic errors.
- **Carve**, **Keys**, **Timestamps**, **Stego**, and **Metadata** tools for embedded artefacts and recovery workflows.
- **BinSnoop** deep signature scanning across the whole file, with export for discovered files and ranges.
- **Rules** for YARA-like rule scans.
- **Patch Plan** and **Patch Apply** for validating and applying patch scripts.
- **Transforms** for hex, Base64, URL, XOR, inflate, and cipher pipelines.
- **Case Export** for bundling useful artefacts from a working investigation.
### Leviathan spectrogram tools
- Convert an image into WAV audio so the image appears in a spectrogram.
- Insert an encoded image into an existing WAV or MP3 carrier.
- Visualise WAV or MP3 audio as a spectrogram image.
- Control FFT size, height, maximum width, aspect ratio, frequency range, brightness, and contrast.
### Automation and reusable workflows
- Snippet library for saved commands.
- Snippet folders, descriptions, and tags.
- Send snippets to active sessions.
- Snippet variables before sending commands.
- Broadcast input mode for running the same command across connected sessions.
- Trigger rules that watch terminal output.
- Trigger matching by text or regular expression.
- Trigger actions for notifications, tab highlighting, sound, or combined notify/highlight behaviour.
- Expect-style login scripts for SSH and Telnet.
- Startup commands for SSH sessions.
### Session recording
- Start and stop terminal recording per session.
- Recording indicator on active tabs.
- Recordings saved in asciicast v2-compatible format.
- Recording browser for listing saved recordings.
- Playback controls.
- Delete saved recordings.
- Recording metadata includes title, dimensions, duration, size, creation time, and file path.
### Security and credential handling
- OS keychain first where available, including macOS Keychain, Linux Secret Service, and Windows Credential Manager.
- Encrypted vault fallback.
- Vault encryption with AES-256-GCM and Argon2id-derived keys.
- Secret store lock and unlock workflow.
- Per-profile secret modes: Saved, Session only, and Ask every time.
- Encrypted storage for TOTP secrets.
- Known host verification and known host management.
- Audit log for security-relevant operations.
- Audit entries for connections, disconnections, profile changes, secret access, imports, and exports.
- Secrets are not written into audit entries.
- Encrypted profile exports with password-based AES-256-GCM encryption and Argon2id key derivation.
- Plain profile export can redact inline secrets.
### Settings and maintenance
- Application theme setting.
- Terminal theme selection.
- Default protocol preference.
- Default terminal font, size, scrollback, and cursor settings.
- Confirm-on-close and confirm-on-disconnect preferences.
- Status bar visibility setting.
- Sidebar width preference.
- Native paste preference.
- Local shell selection.
- Secret store settings.
- Known hosts management.
- Help and troubleshooting content inside settings.
- Background and manual update checks.
- Update checks report availability, current version, latest version, release notes, release URL, and publish date.
## Website-ready summary
### Suggested headline
Nexus Terminal: one desktop workspace for SSH, files, sockets, remote desktops, repositories, and binary artefacts.
### Suggested subheadline
Manage SSH, Telnet, RAW TCP, VNC, GitHub, local shell, and Leviathan binary-analysis sessions from a fast tabbed interface with secure credentials, SFTP file transfer, split panes, snippets, recordings, automation, and artefact-inspection tools built in.
### Short product description
Nexus Terminal helps developers, operators, support engineers, reverse engineers, firmware analysts, and homelab users manage many connections and artefacts without juggling separate tools. Save profiles, organise them with groups and tags, connect in tabs or split panes, move files over SFTP, inspect raw TCP traffic, open VNC desktops, browse GitHub repositories, triage binaries with Leviathan, and keep credentials protected with keychain and encrypted-vault storage.
### Product page feature bullets
- All-in-one connection and analysis workspace for SSH, Telnet, RAW TCP, VNC, GitHub, local shells, and Leviathan tools.
- Fast multi-session interface with tabs, pinned sessions, split panes, reconnect, and broadcast input.
- Rich SSH support including agent auth, TOTP, jump hosts, proxies, port forwarding, startup commands, login scripts, and host key policies.
- Built-in SFTP browser with upload, download, rename, delete, folders, bookmarks, drag-and-drop upload, and permission-aware directory picking.
- RAW TCP tooling with text, hex, split views, payload presets, captures, timestamps, and byte-level sending.
- VNC viewer with scale-to-fit, view-only mode, quality controls, shared sessions, and optional SSH tunnelling.
- GitHub repository browser with organisation grouping, filtering, activity sorting, metadata, ZIP downloads, and GitHub Enterprise support.
- Leviathan binary workbench with hex editing, hashes, entropy, strings, IOCs, carving, BinSnoop, structures, PDF/SquashFS inspection, transforms, rules, patching, case export, and spectrogram image/audio tools.
- Secure credential model using OS keychain first, encrypted vault fallback, per-profile secret modes, known hosts, encrypted exports, and audit logging.
- Productivity tools including command palette, snippets, trigger rules, session recording, terminal search, scrollback export, and local terminal.
## Who it is for
Nexus Terminal suits operators, developers, and support teams who need to connect to many endpoints quickly, keep sessions organised, and avoid re-entering credentials.
- Developers who connect to many application servers, staging environments, and production hosts.
- Platform and DevOps teams who need organised, secure access across infrastructure.
- Support teams who troubleshoot customer systems, network appliances, and legacy Telnet devices.
- Network and hardware engineers who need RAW TCP and Telnet tools beside normal SSH workflows.
- Homelab users who want SSH, VNC, file transfer, and repo access in one desktop app.
- Reverse engineers, firmware analysts, and security researchers who want binary triage beside their live operational sessions.
## Learn more
+10 -1
View File
@@ -1,6 +1,6 @@
# Nexus Terminal Quick Start
Nexus Terminal is a desktop client for SSH, Telnet, RAW TCP, and GitHub sessions. This page is a shareable quick start for everyday use.
Nexus Terminal is a desktop client for SSH, Telnet, RAW TCP, VNC, GitHub, local terminal sessions, and Leviathan binary analysis. This page is a shareable quick start for everyday use.
## Get connected in minutes
@@ -14,11 +14,20 @@ One-off session? Use **Cmd+Shift+Q** / **Ctrl+Shift+Q** for quick connect.
## Daily workflow
- **Tabs and splits**: Drag tabs to reorder, and split the workspace to view multiple sessions at once.
- **Scrollable tabs**: Use the tab bar scrollbar or mouse wheel/trackpad over the tab strip when many tabs are open. Hover a tab to see its full name and connection start time.
- **Broadcast input**: Toggle with **Cmd+Shift+B** (macOS) or **Ctrl+Shift+B** (Windows/Linux) to send the same command to all connected sessions.
- **Snippets**: Save common commands and run them with one click.
- **Search**: Press **Cmd+F** (macOS) or **Ctrl+F** (Windows/Linux) to search within the active terminal.
- **Reconnect quickly**: If a session drops, click **Reconnect** on the disconnected overlay.
## VNC remote desktops
Create a **VNC** profile for remote desktop sessions. Use SSH tunnelling when possible, especially outside trusted networks. Direct VNC is available for isolated or trusted networks, but classic VNC traffic may not be encrypted.
## Leviathan binary tools
Click **Leviathan** beside **Nexus Terminal** in the sidebar header to switch from connection profiles to binary-analysis tools. Use **Load** to choose a file, then open tools such as Hex Editor, Overview, Hashes, Entropy, Strings, Carve, BinSnoop, Structures, PDF, SquashFS, Transforms, and Spectrogram tools. Tools open in normal tabs and can be closed without a disconnect confirmation.
## File transfers on SSH
Click the **folder** icon on an SSH tab to open the SFTP browser. You can browse, upload, download, rename, and delete files.
+95 -7
View File
@@ -12,9 +12,10 @@
8. SFTP file browser
9. Drag-and-drop uploads and paste
10. Session recordings
11. Security and credentials
12. Settings
13. Troubleshooting
11. Leviathan binary workbench
12. Security and credentials
13. Settings
14. Troubleshooting
---
@@ -50,7 +51,7 @@ Tip: You can drag the divider between split panes to rebalance space.
### Create a new profile
1. Click **New** in the sidebar.
2. Choose the protocol (SSH, Telnet, RAW, GitHub).
2. Choose the protocol (SSH, Telnet, RAW, VNC, or GitHub).
3. Fill in the required fields and optional settings.
4. Save and connect.
@@ -86,6 +87,15 @@ Tip: Use the search box to filter profiles by name, host, or tag.
- Text, hex, and split views for binary-safe data.
- Payload presets and configurable encoding and line endings.
### VNC
- Remote desktop viewer embedded in the Nexus workspace.
- Password prompt and saved-password modes.
- View-only mode, scale-to-fit, shared sessions, and quality controls.
- Optional SSH tunnel for safer VNC transport.
Use SSH tunnelling for VNC whenever possible. Direct VNC is useful on trusted or isolated networks, but many classic VNC deployments do not provide strong transport encryption.
### GitHub
- Browse repositories by organisation.
@@ -97,6 +107,9 @@ Tip: Use the search box to filter profiles by name, host, or tag.
- **Reorder** tabs by dragging them.
- **Pin** important sessions to keep them at the front.
- **Scroll** the tab bar with the scrollbar or mouse wheel/trackpad when many tabs are open.
- **Hover** a tab to see the full name and, for connected sessions, the connection start time.
- **Close** tabs with the close button on the left side of each tab. Leviathan tool tabs close without a disconnect confirmation.
- **Split** the workspace vertically or horizontally to view multiple sessions at once.
- **Broadcast** input to multiple connected sessions when needed.
@@ -190,7 +203,69 @@ Screenshot: Recordings list with playback controls.
---
## 11. Security and credentials
## 11. Leviathan binary workbench
Leviathan is the integrated binary-analysis and editing workspace inside Nexus Terminal. It is useful for firmware images, unknown binaries, archives, captures, embedded files, suspicious documents, and audio spectrogram experiments.
### Switching to Leviathan
Click **Leviathan** in the top-left sidebar header. The normal connection list changes into a tool list. Click **Nexus Terminal** to return to saved servers and groups.
Use **Load** to select a local file. The selected file becomes the current Leviathan working document. Use **Save** to write the patched working copy to a chosen destination.
Leviathan tools open as regular tabs. You can open multiple tool tabs at once, switch between them, and close them without a connection-disconnect confirmation.
### Hex Editor
The Hex Editor is the primary byte-level view.
- Choose 16 or 32 bytes per row.
- Edit byte cells directly; typing two hex digits advances to the next byte.
- The ASCII preview updates as bytes are edited.
- Paste hex runs such as `de ad be ef` or `deadbeef` into consecutive cells.
- Use Search to find strings or hex values, then move through matches with previous/next.
- Undo, redo, or revert edits from the Leviathan panel header.
### Triage tools
- **Overview**: first-pass summary across hashes, entropy, strings, indicators, and carve candidates.
- **Hashes**: CRC32, MD5, SHA-1, SHA-256, and SHA-512.
- **Entropy**: graph entropy across the whole file; select a region to open that range in a new Hex Editor tab.
- **Strings**: ASCII and UTF-16 string extraction.
- **IOCs**: URLs, domains, paths, hashes, and other indicators.
- **Search** and **Multi-search**: text, hex, and regular-expression matching.
### Structure and recovery tools
- **Structures** parses common formats such as PE, ELF, Mach-O, ZIP, images, RIFF, ASN.1, and certificates.
- **Disasm** provides heuristic disassembly for common CPU families and raw byte fallback where decoding is not implemented.
- **PDF** inspects objects, streams, xrefs, and suspicious surfaces.
- **SquashFS** inspects filesystem superblocks and metadata; non-SquashFS input shows a human-readable bad-magic error.
- **Carve** and **BinSnoop** scan for embedded files and signatures and can export discovered files or ranges.
- **Keys**, **Timestamps**, **Stego**, and **Metadata** help recover common artefacts from binaries and containers.
### Editing, transforms, and cases
- **Patch Plan** validates patch scripts without modifying the working copy.
- **Patch Apply** applies validated patch scripts.
- **Transforms** runs pipelines such as hex, Base64, URL, XOR, inflate, and cipher transforms over selected data.
- **Rules** runs YARA-like scans over the current document.
- **Case Export** packages investigation artefacts from the working document.
### Spectrogram tools
- **Spectrogram Encode** converts an image into WAV audio so the image appears in a spectrogram.
- It can also insert an encoded image into an existing WAV or MP3 carrier.
- **Spectrogram View** renders WAV or MP3 audio as a spectrogram image.
- Controls include FFT size, height, maximum width, aspect ratio, frequency range, brightness, and contrast.
### Large file behaviour
Leviathan keeps loaded files file-backed where possible and writes patched working copies in chunks. Some tools still need bounded in-memory buffers because the underlying parser or decoder works on complete byte slices; those tools reject oversized inputs with a clear message rather than trying to allocate unbounded memory.
---
## 12. Security and credentials
Nexus Terminal stores credentials securely:
@@ -214,7 +289,7 @@ Screenshot: TOTP configuration in the profile editor.
---
## 12. Settings
## 13. Settings
Open settings with **Cmd+,** (macOS) or **Ctrl+,** (Windows/Linux) and configure:
@@ -227,7 +302,7 @@ Screenshot: Settings panel with appearance and security options.
---
## 13. Troubleshooting
## 14. Troubleshooting
### Session shows as disconnected
@@ -239,6 +314,19 @@ Screenshot: Settings panel with appearance and security options.
- Check the folder is writable; **READ ONLY** indicates no write permission.
- Confirm the SSH session is connected.
### VNC will not connect
- Confirm the VNC host and port are correct. The common VNC port is `5900` plus the display number.
- If using a password, check whether the profile is set to saved, session-only, or ask-every-time mode.
- If using an SSH tunnel, verify the tunnel host, username, authentication method, and SSH host key policy.
- If direct VNC is enabled, test from a trusted network first and prefer an SSH tunnel for untrusted networks.
### Leviathan cannot load or save a file
- Check that the file still exists and that your user account can read it.
- When saving, choose a writable destination and avoid overwriting a file that another process has locked.
- Some tools enforce explicit size limits for safety. If a tool rejects an oversized input, try selecting a smaller range or using a tool that works on the file-backed document.
### Cannot read a folder
- **NO READ** indicates insufficient permissions.
+23 -1
View File
@@ -23,6 +23,8 @@
<link rel="stylesheet" href="/src/css/modals.css" />
<link rel="stylesheet" href="/src/css/github.css" />
<link rel="stylesheet" href="/src/css/vnc.css" />
<link rel="stylesheet" href="/src/css/irc.css" />
<link rel="stylesheet" href="/src/css/leviathan.css" />
</head>
<body>
<div id="app" class="app">
@@ -47,7 +49,22 @@
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
<span class="sidebar-title">Nexus Terminal</span>
<span class="sidebar-product-switch" aria-label="Application mode">
<button
class="sidebar-title sidebar-mode-toggle is-active"
id="mode-nexus"
type="button"
>
Nexus Terminal
</button>
<button
class="sidebar-title sidebar-mode-toggle"
id="mode-leviathan"
type="button"
>
Leviathan
</button>
</span>
</div>
</div>
@@ -302,6 +319,9 @@
<script src="/src/js/lib/xterm-addon-unicode11.min.js"></script>
<script src="/src/js/lib/xterm-addon-web-links.min.js"></script>
<!-- Wails service bridge; loaded before application code. -->
<script src="/src/js/wails-service.js"></script>
<!-- Application scripts (loaded in dependency order) -->
<script src="/src/js/utils.js"></script>
<script src="/src/js/state.js"></script>
@@ -315,6 +335,7 @@
<script src="/src/js/github.js"></script>
<script src="/src/js/lib/novnc.min.js"></script>
<script src="/src/js/vnc.js"></script>
<script src="/src/js/irc.js"></script>
<script src="/src/js/settings.js"></script>
<script src="/src/js/commands.js"></script>
<script src="/src/js/secrets.js"></script>
@@ -324,6 +345,7 @@
<script src="/src/js/snippets.js"></script>
<script src="/src/js/sshkeys.js"></script>
<script src="/src/js/triggers.js"></script>
<script src="/src/js/leviathan.js"></script>
<script src="/src/js/app.js"></script>
</body>
</html>
+160 -156
View File
@@ -10,20 +10,20 @@
"hasInstallScript": true,
"dependencies": {
"@novnc/novnc": "^1.5.0",
"xterm": "5.3.0",
"xterm-addon-fit": "0.8.0",
"xterm-addon-search": "0.13.0",
"xterm-addon-unicode11": "0.6.0",
"xterm-addon-web-links": "0.9.0"
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-search": "0.16.0",
"@xterm/addon-unicode11": "0.9.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/xterm": "6.0.0"
},
"devDependencies": {
"esbuild": "^0.24.0"
"esbuild": "^0.28.1"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@@ -38,9 +38,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@@ -55,9 +55,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@@ -72,9 +72,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@@ -89,9 +89,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@@ -106,9 +106,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@@ -123,9 +123,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@@ -140,9 +140,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@@ -157,9 +157,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@@ -174,9 +174,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@@ -191,9 +191,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@@ -208,9 +208,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@@ -225,9 +225,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@@ -242,9 +242,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@@ -259,9 +259,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@@ -276,9 +276,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@@ -293,9 +293,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@@ -310,9 +310,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@@ -327,9 +327,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@@ -344,9 +344,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@@ -361,9 +361,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@@ -377,10 +377,27 @@
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@@ -395,9 +412,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@@ -412,9 +429,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@@ -429,9 +446,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@@ -451,10 +468,43 @@
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
"license": "MPL-2.0"
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-search": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==",
"license": "MIT"
},
"node_modules/@xterm/addon-unicode11": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
"license": "MIT"
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -465,78 +515,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
"license": "MIT"
},
"node_modules/xterm-addon-fit": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-search": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/xterm-addon-search/-/xterm-addon-search-0.13.0.tgz",
"integrity": "sha512-sDUwG4CnqxUjSEFh676DlS3gsh3XYCzAvBPSvJ5OPgF3MRL3iHLPfsb06doRicLC2xXNpeG2cWk8x1qpESWJMA==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-search instead.",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-unicode11": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/xterm-addon-unicode11/-/xterm-addon-unicode11-0.6.0.tgz",
"integrity": "sha512-5pkb8YoS/deRtNqQRw8t640mu+Ga8B2MG3RXGQu0bwgcfr8XiXIRI880TWM49ICAHhTmnOLPzIIBIjEnCq7k2A==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-unicode11 instead.",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-web-links": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz",
"integrity": "sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-web-links instead.",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
}
}
+7 -6
View File
@@ -8,17 +8,18 @@
"bundle:novnc": "node scripts/bundle-novnc.js",
"build": "node scripts/build.js",
"dev": "node scripts/build.js --watch",
"test": "node scripts/test-regressions.js",
"postinstall": "npm run install:assets && npm run bundle:novnc"
},
"dependencies": {
"@novnc/novnc": "^1.5.0",
"xterm": "5.3.0",
"xterm-addon-fit": "0.8.0",
"xterm-addon-search": "0.13.0",
"xterm-addon-unicode11": "0.6.0",
"xterm-addon-web-links": "0.9.0"
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-search": "0.16.0",
"@xterm/addon-unicode11": "0.9.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/xterm": "6.0.0"
},
"devDependencies": {
"esbuild": "^0.24.0"
"esbuild": "^0.28.1"
}
}
+1 -1
View File
@@ -1 +1 @@
99c6bbb0236daee684251a5f62bbd974
2f3e27ceb2ed8494c242e0b06d9f4da3
+69
View File
@@ -28,6 +28,7 @@ const requiredFiles = [
'dist/src/js/lib/xterm-addon-fit.min.js',
'dist/src/js/lib/xterm-addon-search.min.js',
'dist/src/js/lib/xterm-addon-web-links.min.js',
'dist/src/js/wails-service.js',
];
/**
@@ -46,6 +47,14 @@ function copyDirSync(src, dest) {
}
}
/**
* Copies one file and creates the parent directory if needed.
*/
function copyFileSync(src, dest) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
}
/**
* Removes a directory tree if it exists.
*/
@@ -55,6 +64,53 @@ function cleanDir(dir) {
}
}
function copyWailsRuntimeBindings(srcRoot, destRoot) {
const runtimeFiles = [
'go/api/Service.js',
'runtime/runtime.js',
];
for (const relPath of runtimeFiles) {
const src = path.join(srcRoot, relPath);
if (fs.existsSync(src)) {
copyFileSync(src, path.join(destRoot, relPath));
}
}
}
function walkFiles(dir, predicate, files = []) {
if (!fs.existsSync(dir)) return files;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkFiles(full, predicate, files);
} else if (entry.isFile() && predicate(full)) {
files.push(full);
}
}
return files;
}
function cleanupDistAssets() {
console.log('[build] Cleaning production frontend assets...');
const sourceMapFiles = walkFiles(distDir, (file) => file.endsWith('.js') || file.endsWith('.css'));
let mapCommentCount = 0;
for (const file of sourceMapFiles) {
const input = fs.readFileSync(file, 'utf8');
const output = input
.replace(/(?:\r?\n)?\/\/# sourceMappingURL=.*?(?=\r?\n|$)/g, '')
.replace(/(?:\r?\n)?\/\*# sourceMappingURL=.*?\*\/(?=\r?\n|$)/g, '');
if (output !== input) {
fs.writeFileSync(file, output);
mapCommentCount += 1;
}
}
if (mapCommentCount > 0) {
console.log(`[build] Removed ${mapCommentCount} source-map reference(s).`);
}
}
/**
* Runs the asset copy script to pull xterm files into src/js/lib/ and
* src/css/lib/.
@@ -65,6 +121,10 @@ function copyAssets() {
cwd: frontendDir,
stdio: 'inherit',
});
execSync('node scripts/bundle-novnc.js', {
cwd: frontendDir,
stdio: 'inherit',
});
}
/**
@@ -72,6 +132,7 @@ function copyAssets() {
* dist/index.html
* dist/src/css/ (application + lib stylesheets)
* dist/src/js/ (application + lib scripts)
* dist/wailsjs/ (generated backend bindings, if present)
* dist/src/components/ (if present)
* dist/src/assets/ (if present)
*/
@@ -94,6 +155,13 @@ function buildDist() {
copyDirSync(srcDir, path.join(distDir, 'src'));
}
// Copy only runtime Wails bindings so embedded production assets do not
// carry TypeScript declarations and model files.
const wailsjsDir = path.join(frontendDir, 'wailsjs');
if (fs.existsSync(wailsjsDir)) {
copyWailsRuntimeBindings(wailsjsDir, path.join(distDir, 'wailsjs'));
}
console.log('[build] dist/ assembled.');
}
@@ -151,6 +219,7 @@ try {
}
buildDist();
cleanupDistAssets();
if (!validate()) {
console.error('[build] Validation failed — some required files are missing.');
+6 -6
View File
@@ -14,27 +14,27 @@ const frontendDir = path.resolve(__dirname, "..");
// Mapping: source (relative to node_modules) → destination (relative to frontend/)
const assets = [
{
src: "xterm/css/xterm.css",
src: "@xterm/xterm/css/xterm.css",
dest: "src/css/lib/xterm.css",
},
{
src: "xterm/lib/xterm.js",
src: "@xterm/xterm/lib/xterm.js",
dest: "src/js/lib/xterm.min.js",
},
{
src: "xterm-addon-fit/lib/xterm-addon-fit.js",
src: "@xterm/addon-fit/lib/addon-fit.js",
dest: "src/js/lib/xterm-addon-fit.min.js",
},
{
src: "xterm-addon-search/lib/xterm-addon-search.js",
src: "@xterm/addon-search/lib/addon-search.js",
dest: "src/js/lib/xterm-addon-search.min.js",
},
{
src: "xterm-addon-unicode11/lib/xterm-addon-unicode11.js",
src: "@xterm/addon-unicode11/lib/addon-unicode11.js",
dest: "src/js/lib/xterm-addon-unicode11.min.js",
},
{
src: "xterm-addon-web-links/lib/xterm-addon-web-links.js",
src: "@xterm/addon-web-links/lib/addon-web-links.js",
dest: "src/js/lib/xterm-addon-web-links.min.js",
},
];
+3
View File
@@ -1,4 +1,7 @@
// Entry point for esbuild bundling of noVNC.
// Assigns RFB to window.noVNC so it's available as a global in the frontend.
import RFB from "@novnc/novnc";
import { initLogging } from "../node_modules/@novnc/novnc/core/util/logging.js";
initLogging("error");
window.noVNC = { RFB: RFB };
+458
View File
@@ -0,0 +1,458 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const repo = path.resolve(root, "..");
function read(rel) {
return fs.readFileSync(path.join(repo, rel), "utf8");
}
function assertContains(file, needle, message) {
const text = read(file);
if (!text.includes(needle)) {
throw new Error(`${message}\nMissing ${JSON.stringify(needle)} in ${file}`);
}
}
function assertNotContains(file, needle, message) {
const text = read(file);
if (text.includes(needle)) {
throw new Error(`${message}\nUnexpected ${JSON.stringify(needle)} in ${file}`);
}
}
function assertTreeNotContains(relDir, needle, message) {
const dir = path.join(repo, relDir);
const offenders = [];
const walk = (current) => {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
walk(full);
continue;
}
if (!entry.isFile() || !entry.name.endsWith(".js")) continue;
const rel = path.relative(repo, full);
if (read(rel).includes(needle)) offenders.push(rel);
}
};
walk(dir);
if (offenders.length) {
throw new Error(`${message}\nUnexpected ${JSON.stringify(needle)} in ${offenders.join(", ")}`);
}
}
assertContains(
"frontend/src/js/terminal.js",
"escapeHtml(title)",
"file-drop prompt titles must escape filenames containing HTML characters",
);
assertContains(
"frontend/src/js/terminal.js",
"SFTPUploadStart",
"drag-drop upload must use chunked SFTP streaming",
);
assertContains(
"frontend/src/js/terminal.js",
"SFTPUploadChunk",
"drag-drop upload must send bounded chunks",
);
assertContains(
"frontend/src/js/terminal.js",
"scheduleFit",
"terminal resize handling must be throttled through scheduled fits",
);
assertContains(
"frontend/src/js/terminal.js",
"dimensionsChanged",
"terminal resize handling must skip unchanged container dimensions",
);
assertContains(
"frontend/src/js/terminal.js",
"cancelScheduledFit",
"terminal teardown must cancel pending fit work",
);
assertNotContains(
"frontend/src/js/terminal.js",
"new ResizeObserver(() => {\n try {\n fitAddon.fit();",
"ResizeObserver must not call fitAddon.fit() directly",
);
assertNotContains(
"frontend/src/js/terminal.js",
"Use SFTP Upload for files over 25 MB",
"drag-drop upload must not reject files only because they exceed the old inline limit",
);
assertContains(
"frontend/src/js/app.js",
"confirmDirectVNCProfile",
"direct VNC connections must show an explicit warning",
);
assertContains(
"frontend/src/js/profiles.js",
"skipDirectVNCWarning",
"save-and-connect should not show duplicate direct VNC prompts",
);
assertContains(
"frontend/src/js/recordings.js",
"import-status",
"profile import failures must be visible inside the import modal",
);
assertContains(
"frontend/src/js/recordings.js",
"_formatImportError",
"profile import failures must be formatted without using HTML injection",
);
assertContains(
"internal/api/api_test.go",
"TestCreateProfileRejectsUnsafeGitHubBaseURL",
"GitHub HTTP/HTTPS URL validation must stay covered",
);
assertContains(
"frontend/index.html",
"mode-leviathan",
"Leviathan mode toggle must be present beside Nexus Terminal",
);
assertContains(
"frontend/src/css/sidebar.css",
"user-select: none",
"sidebar title and mode switcher text must not be selectable",
);
assertContains(
"frontend/src/css/sidebar.css",
"#mode-leviathan",
"Leviathan mode label must have its own right-alignment rule",
);
assertContains(
"frontend/src/css/sidebar.css",
"margin-left: auto;",
"Leviathan mode label must sit on the right side of the sidebar header",
);
assertContains(
"frontend/src/css/tokens.css",
"--tab-bar-height: 44px;",
"tab bar row must match the sidebar header height",
);
assertContains(
"frontend/src/css/sidebar.css",
"height: var(--tab-bar-height);",
"sidebar header must use the shared tab bar height",
);
assertContains(
"frontend/src/css/layout.css",
"height: var(--tab-bar-height);",
"tab bar must use the shared sidebar header height",
);
assertContains(
"frontend/src/js/tabs.js",
"this._setupScrolling();",
"tab bar must enable horizontal scroll handling for overflowed tabs",
);
assertContains(
"frontend/src/js/tabs.js",
"this._setupTooltip();",
"tab bar must show rich hover hints for truncated tabs",
);
assertContains(
"frontend/src/js/tabs.js",
"removeTab(sessionId) {\n this._hideTabTooltip();",
"removing a tab must clear any visible tab tooltip",
);
assertContains(
"frontend/src/js/tabs.js",
"async closeTab(sessionId) {\n this._hideTabTooltip();",
"closing a tab must clear any visible or pending tab tooltip",
);
assertContains(
"frontend/src/js/tabs.js",
"!tab?.isConnected",
"tab tooltip timers must not show hints for removed tab elements",
);
assertContains(
"frontend/src/js/tabs.js",
"_clearTabTooltipTimer",
"tab tooltip cleanup must cancel pending hover timers",
);
assertContains(
"frontend/src/js/tabs.js",
"session.protocol !== \"leviathan\"",
"Leviathan tabs must close without connected-session confirmation",
);
assertContains(
"frontend/src/js/tabs.js",
"session.protocol !== \"github\"",
"GitHub tabs must close without connected-session confirmation",
);
assertContains(
"frontend/src/js/tabs.js",
"Connected since:",
"connection tab hover hints must include the connected-since timestamp",
);
assertContains(
"frontend/src/js/tabs.js",
"tab.appendChild(closeBtn);\n\n // Protocol badge",
"tab close button must render on the left side of each tab",
);
assertContains(
"frontend/src/css/layout.css",
"scrollbar-gutter: stable;",
"overflowing tab lists must expose a stable horizontal scrollbar",
);
assertContains(
"frontend/src/css/tabs.css",
".tab-tooltip",
"tab hover hints must have dedicated tooltip styling",
);
assertContains(
"frontend/src/js/leviathan.js",
"Hex Editor",
"Leviathan mode must put Hex Editor in the tool list",
);
assertContains(
"frontend/src/js/settings.js",
"Leviathan Binary Tools",
"in-app help must document the integrated Leviathan workbench",
);
assertContains(
"frontend/src/js/settings.js",
"VNC Connections",
"in-app help must document VNC connection profiles",
);
assertContains(
"docs/user-manual.md",
"## 11. Leviathan binary workbench",
"user manual must include a full Leviathan section",
);
assertContains(
"docs/user-manual.md",
"### VNC",
"user manual must include VNC connection guidance",
);
assertContains(
"docs/overview.md",
"### Leviathan binary workbench",
"product overview must include Leviathan as a first-class feature group",
);
assertContains(
"frontend/src/js/leviathan.js",
"LeviathanSetByte",
"Leviathan hex view must support byte editing",
);
assertContains(
"frontend/src/js/leviathan.js",
"_handleHexByteInput",
"Leviathan hex byte edits must update while typing",
);
assertContains(
"frontend/src/js/leviathan.js",
"_updateHexAsciiCell",
"Leviathan hex byte edits must update the ASCII preview immediately",
);
assertContains(
"frontend/src/js/leviathan.js",
"_focusHexByte(cell, 1)",
"Leviathan hex byte edits must advance to the next byte after entry",
);
assertContains(
"frontend/src/js/leviathan.js",
"spectrogram-encode",
"Leviathan must expose an image-to-audio spectrogram encoder",
);
assertContains(
"frontend/src/js/leviathan.js",
"spectrogram-view",
"Leviathan must expose an audio spectrogram visualiser",
);
assertContains(
"frontend/src/js/leviathan.js",
"preserveAspectRatio",
"Leviathan spectrogram encoding must expose aspect-ratio preservation",
);
assertContains(
"frontend/src/js/leviathan.js",
"aspectRatio",
"Leviathan spectrogram visualiser must expose display aspect-ratio control",
);
assertContains(
"frontend/src/js/leviathan.js",
"spectrogram-insert",
"Leviathan spectrogram encoder must support inserting images into carrier audio",
);
assertContains(
"frontend/src/js/app.js",
"setMode(\"leviathan\")",
"App mode switch must route to Leviathan",
);
assertContains(
"internal/api/leviathan.go",
"LeviathanRunTool",
"Nexus API must expose the imported Leviathan engine",
);
assertNotContains(
"internal/api/leviathan.go",
"Pattern: \"*\"",
"Leviathan open dialogs must not use wildcard filters that grey out files on macOS",
);
assertContains(
"internal/leviathan/spectrogram/spectrogram.go",
"github.com/hajimehoshi/go-mp3",
"Leviathan spectrogram visualiser must support MP3 decoding",
);
assertNotContains(
"wails.json",
"\"obfuscated\": true",
"Wails obfuscation must stay disabled because this app embeds frontend/src and uses normal Wails globals",
);
assertNotContains(
"wails.json",
"\"garbleargs\"",
"garble arguments must not be configured when Wails obfuscation is disabled",
);
assertContains(
"frontend/index.html",
"wails-service.js",
"frontend must load the Wails service bridge before app code",
);
assertNotContains(
"frontend/index.html",
'type="module" src="/src/js/wails-service.js"',
"Wails bridge must load as a synchronous classic script before app code",
);
assertContains(
"frontend/src/js/wails-service.js",
'prop === "then"',
"Wails service proxy must not masquerade as a Promise thenable",
);
assertContains(
"frontend/src/js/wails-service.js",
"window.go?.api?.Service",
"Wails service bridge must use the normal named Wails API",
);
assertContains(
"frontend/src/js/sshkeys.js",
"textContent: \"Failed to load keys: \" + String(err)",
"SSH key manager errors must be rendered as text rather than injected HTML",
);
assertContains(
"frontend/src/js/sshkeys.js",
"textContent: message",
"SSH key delete confirmation messages must be rendered as text",
);
assertNotContains(
"frontend/src/js/sshkeys.js",
"Failed to load keys: ${err}",
"SSH key manager must not interpolate backend errors into innerHTML",
);
assertContains(
"frontend/package.json",
'"@xterm/xterm":',
"frontend dependencies must use the maintained scoped xterm package",
);
assertContains(
"frontend/package.json",
'"@xterm/addon-fit":',
"frontend dependencies must use the maintained scoped xterm addons",
);
assertContains(
"frontend/scripts/copy-assets.js",
"@xterm/xterm/lib/xterm.js",
"xterm browser assets must be copied from the maintained scoped package",
);
assertContains(
"frontend/scripts/copy-assets.js",
"@xterm/addon-fit/lib/addon-fit.js",
"xterm addon browser assets must be copied from the maintained scoped packages",
);
assertNotContains(
"frontend/package.json",
'"xterm":',
"deprecated unscoped xterm dependency must not be restored",
);
assertNotContains(
"frontend/package.json",
'"xterm-addon-',
"deprecated unscoped xterm addon dependencies must not be restored",
);
assertNotContains(
"frontend/package-lock.json",
'"node_modules/xterm"',
"package lock must not contain the deprecated unscoped xterm package",
);
assertNotContains(
"frontend/package-lock.json",
'"node_modules/xterm-addon-',
"package lock must not contain deprecated unscoped xterm addon packages",
);
assertContains(
"frontend/scripts/build.js",
"sourceMappingURL",
"frontend production build must remove source-map references",
);
assertContains(
"frontend/scripts/build.js",
"bundle-novnc.js",
"frontend production build must regenerate the noVNC bundle",
);
assertContains(
"frontend/scripts/novnc-entry.js",
"initLogging(\"error\")",
"bundled noVNC logging must default to errors only",
);
assertNotContains(
"frontend/src/js/app.js",
"console.debug(\"[session:debug]\"",
"release UI must not print telnet negotiation debug events",
);
assertNotContains(
"frontend/src/js/app.js",
"console.log(\"Backend is ready\")",
"release UI must not print backend-ready lifecycle events",
);
assertContains(
"build.sh",
"NEXUS_COMPRESS",
"local builds must expose an explicit compression switch",
);
assertContains(
"scripts/build-common.sh",
"nexus_use_upx_for_target",
"build scripts must share one UPX target-selection helper",
);
assertContains(
"build.sh",
"Skipping UPX compression on macOS",
"local compressed builds must avoid unsupported macOS UPX by default",
);
assertContains(
"build.sh",
"-trimpath",
"local production builds should trim file paths from binaries",
);
assertContains(
"scripts/build-common.sh",
"-buildid=",
"production builds must strip the Go build ID",
);
assertContains(
"scripts/build-common.sh",
"-O2 -g0",
"production builds must remove cgo debug output by default",
);
assertContains(
"Makefile",
"build-compressed",
"Makefile must expose an explicit compressed build target",
);
assertContains(
"Makefile",
"NEXUS_FORCE_MACOS_UPX",
"Makefile compressed builds must make macOS UPX opt-in",
);
assertTreeNotContains(
"frontend/src/js",
"window.go.api.Service",
"app code must not call named Wails backend methods directly",
);
console.log("frontend regression checks passed");
+13
View File
@@ -116,6 +116,19 @@
border-color: #e53935;
}
/* Warning */
.btn-warning {
background: var(--warning);
color: #1a1a1e;
border-color: var(--warning);
}
.btn-warning:hover {
filter: brightness(1.05);
}
.btn-warning:active {
filter: brightness(0.95);
}
/* Size variants */
.btn-sm {
height: 26px;
+289
View File
@@ -0,0 +1,289 @@
/* ==========================================================================
Nexus Terminal - IRC viewer
========================================================================== */
.irc-viewer-panel {
background: var(--bg-base);
}
.irc-viewer {
display: grid;
grid-template-columns: minmax(0, 1fr) 260px;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.irc-main {
display: grid;
grid-template-rows: 34px minmax(0, 1fr) 42px;
min-width: 0;
min-height: 0;
}
.irc-room-tabs {
display: flex;
align-items: center;
gap: var(--space-1);
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
padding: var(--space-1) var(--space-2);
background: var(--bg-surface);
border-bottom: 1px solid var(--border-subtle);
}
.irc-room-tab {
height: 24px;
max-width: 180px;
min-width: 44px;
padding: 0 var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 22px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.irc-room-tab:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.irc-room-tab.active {
background: var(--bg-active);
border-color: var(--border-default);
color: var(--text-primary);
}
.irc-room-tab.has-unread::after {
content: "";
display: inline-block;
width: 5px;
height: 5px;
margin-left: var(--space-1);
border-radius: 50%;
background: var(--protocol-irc);
vertical-align: middle;
}
.irc-terminal {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: var(--space-2);
}
.irc-terminal .xterm {
height: 100%;
}
.irc-composer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-2);
padding: var(--space-2);
background: var(--bg-surface);
border-top: 1px solid var(--border-subtle);
}
.irc-input {
min-width: 0;
height: 26px;
padding: 0 var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--text-sm);
}
.irc-input:focus,
.irc-join-input:focus,
.irc-filter-input:focus {
outline: none;
border-color: var(--border-focus);
}
.irc-send {
height: 26px;
}
.irc-sidebar {
display: grid;
grid-template-rows: minmax(88px, auto) minmax(120px, 1fr) minmax(120px, 1fr);
min-width: 0;
min-height: 0;
background: var(--bg-surface);
border-left: 1px solid var(--border-subtle);
}
.irc-sidebar-section {
min-height: 0;
padding: var(--space-2);
border-bottom: 1px solid var(--border-subtle);
overflow: hidden;
}
.irc-users-section {
border-bottom: 0;
}
.irc-sidebar-title,
.irc-sidebar-title-row {
min-height: 24px;
color: var(--text-tertiary);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.irc-sidebar-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.irc-join-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.irc-join-input,
.irc-filter-input {
min-width: 0;
height: 24px;
padding: 0 var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
font-size: var(--text-xs);
}
.irc-filter-input {
width: 100%;
margin-bottom: var(--space-2);
}
.irc-sidebar-list,
.irc-room-list,
.irc-user-list {
min-height: 0;
max-height: 100%;
overflow-y: auto;
}
.irc-room-row,
.irc-user-row,
.irc-listed-room-main {
width: 100%;
min-width: 0;
border: 0;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
text-align: left;
}
.irc-room-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
height: 26px;
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
}
.irc-room-row:hover,
.irc-user-row:hover,
.irc-listed-room:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.irc-room-row.active {
background: var(--bg-active);
color: var(--text-primary);
}
.irc-room-name,
.irc-listed-room-name,
.irc-listed-room-topic {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.irc-room-count,
.irc-listed-room-count {
color: var(--text-tertiary);
font-size: 10px;
font-variant-numeric: tabular-nums;
}
.irc-listed-room {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
min-height: 34px;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
}
.irc-listed-room-main {
display: grid;
gap: 1px;
}
.irc-listed-room-name {
color: var(--text-primary);
font-size: var(--text-xs);
}
.irc-listed-room-topic {
color: var(--text-tertiary);
font-size: 10px;
}
.irc-user-row {
display: block;
height: 24px;
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.irc-empty {
padding: var(--space-2);
color: var(--text-tertiary);
font-size: var(--text-xs);
}
@media (max-width: 900px) {
.irc-viewer {
grid-template-columns: minmax(0, 1fr);
}
.irc-sidebar {
display: none;
}
}
+42 -2
View File
@@ -40,10 +40,12 @@
.tab-bar {
grid-column: 2;
grid-row: 1;
position: relative;
background: var(--bg-surface);
border-bottom: 1px solid var(--border-subtle);
display: flex;
align-items: center;
height: var(--tab-bar-height);
min-height: var(--tab-bar-height);
overflow: hidden;
}
@@ -52,13 +54,51 @@
display: flex;
align-items: center;
flex: 1;
height: 100%;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none; /* Firefox */
scrollbar-width: thin;
scrollbar-gutter: stable;
scroll-behavior: smooth;
}
.tab-list::-webkit-scrollbar {
display: none;
height: 6px;
}
.tab-list::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: var(--radius-full);
}
.tab-list::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
.tab-bar::before,
.tab-bar::after {
content: "";
position: absolute;
top: 0;
bottom: 1px;
z-index: 2;
width: 18px;
pointer-events: none;
opacity: 0;
transition: opacity var(--transition-fast);
}
.tab-bar::before {
left: 0;
background: linear-gradient(90deg, var(--bg-surface), transparent);
}
.tab-bar::after {
right: calc(var(--tab-bar-height) * 2);
background: linear-gradient(-90deg, var(--bg-surface), transparent);
}
.tab-bar.has-overflow-left::before,
.tab-bar.has-overflow-right::after {
opacity: 1;
}
/* -----------------------------------------------------------------------
+752
View File
@@ -0,0 +1,752 @@
/* Leviathan mode inside the Nexus shell */
.leviathan-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-surface);
}
.leviathan-group {
margin: var(--space-3) var(--space-3) var(--space-1);
padding: 0;
color: var(--sidebar-group-title);
cursor: default;
font-size: 10px;
font-weight: var(--weight-semibold);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.leviathan-tool {
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
align-items: center;
gap: var(--space-2);
min-height: 30px;
margin: 1px var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-md);
}
.leviathan-tool:hover {
border-color: var(--border-subtle);
background: var(--bg-hover);
}
.leviathan-tool.sidebar-item-active {
border-color: rgba(78, 205, 196, 0.35);
background: var(--accent-muted);
}
.leviathan-tool-ico {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 20px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--warning);
background: rgba(255, 167, 38, 0.08);
font-family: var(--font-mono);
font-size: 10px;
font-weight: var(--weight-semibold);
}
.leviathan-tool.sidebar-item-active .leviathan-tool-ico {
color: var(--accent);
border-color: rgba(78, 205, 196, 0.35);
background: rgba(78, 205, 196, 0.12);
}
.badge-leviathan {
color: var(--warning);
border-color: rgba(255, 167, 38, 0.35);
}
.leviathan-panel {
flex-direction: column;
min-width: 0;
overflow: hidden;
background: #181818;
}
.leviathan-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
min-height: 48px;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-surface);
}
.leviathan-title-wrap {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
}
.leviathan-tool-mark {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 34px;
height: 30px;
border: 1px solid rgba(255, 167, 38, 0.28);
border-radius: var(--radius-md);
color: var(--warning);
background: rgba(255, 167, 38, 0.08);
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
}
.leviathan-heading {
display: grid;
gap: 1px;
min-width: 0;
}
.leviathan-title-wrap h2 {
margin: 0;
overflow: hidden;
color: var(--text-primary);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
line-height: var(--leading-tight);
text-overflow: ellipsis;
white-space: nowrap;
}
.leviathan-eyebrow,
.leviathan-status {
overflow: hidden;
color: var(--text-tertiary);
font-size: var(--text-xs);
line-height: var(--leading-tight);
text-overflow: ellipsis;
white-space: nowrap;
}
.leviathan-header-actions,
.leviathan-tool-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.leviathan-header-actions .btn,
.leviathan-tool-toolbar .btn {
height: 28px;
}
.leviathan-panel-body {
min-height: 0;
overflow: hidden;
padding: 0;
}
.leviathan-workbench {
display: grid;
grid-template-columns: minmax(260px, 330px) minmax(0, 1fr);
gap: var(--space-3);
height: 100%;
padding: var(--space-3);
}
.leviathan-top-workbench {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
gap: var(--space-2);
}
.leviathan-hex-workbench {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
}
.leviathan-control-plane,
.leviathan-output-plane {
min-width: 0;
min-height: 0;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--bg-surface);
}
.leviathan-control-plane {
display: flex;
flex-direction: column;
gap: var(--space-3);
overflow: auto;
padding: var(--space-3);
}
.leviathan-top-workbench .leviathan-control-plane {
align-items: flex-end;
flex-direction: row;
flex-wrap: wrap;
gap: var(--space-2);
min-height: 42px;
overflow: visible;
padding: var(--space-2) var(--space-3);
}
.leviathan-output-plane {
display: flex;
flex-direction: column;
overflow: hidden;
background: #151515;
}
.leviathan-params {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-2);
}
.leviathan-top-workbench .leviathan-params {
display: flex;
align-items: flex-end;
flex: 1 1 auto;
flex-wrap: wrap;
gap: var(--space-2);
min-width: 180px;
}
.leviathan-param,
.leviathan-param-inline {
display: grid;
gap: var(--space-1);
min-width: 0;
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.leviathan-param > span,
.leviathan-param-inline > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leviathan-top-workbench .leviathan-param,
.leviathan-top-workbench .leviathan-param-inline {
min-width: 96px;
}
.leviathan-top-workbench .leviathan-param {
flex: 0 1 150px;
}
.leviathan-top-workbench .leviathan-param-inline {
flex: 0 0 112px;
}
.leviathan-hex-workbench .leviathan-param-inline {
flex-basis: 104px;
}
.leviathan-param .form-input,
.leviathan-param-inline .form-input {
height: 30px;
border-color: var(--border-subtle);
background: var(--bg-input);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.leviathan-param textarea.form-input {
min-height: 118px;
padding: var(--space-2);
line-height: var(--leading-normal);
resize: vertical;
}
.leviathan-top-workbench .leviathan-tool-toolbar {
flex: 0 0 auto;
}
.leviathan-checkbox {
width: 18px;
height: 18px;
min-height: 18px;
padding: 0;
accent-color: var(--accent);
}
.leviathan-file-picker {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: var(--space-2);
min-width: 0;
padding-top: var(--space-2);
border-top: 1px solid var(--border-subtle);
}
.leviathan-file-path {
min-width: 0;
overflow: hidden;
color: var(--text-tertiary);
font-family: var(--font-mono);
font-size: var(--text-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.leviathan-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 1px;
flex: 0 0 auto;
min-height: 0;
border-bottom: 1px solid var(--border-subtle);
background: var(--border-subtle);
}
.leviathan-summary:empty {
display: none;
}
.leviathan-pill {
min-width: 0;
padding: var(--space-2) var(--space-3);
background: var(--bg-surface);
}
.leviathan-pill span {
display: block;
overflow: hidden;
color: var(--text-tertiary);
font-size: 10px;
line-height: var(--leading-tight);
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.leviathan-pill strong {
display: block;
overflow: hidden;
margin-top: 2px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.leviathan-results {
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
}
.leviathan-dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1px;
border-bottom: 1px solid var(--border-subtle);
background: var(--border-subtle);
}
.leviathan-metric {
min-width: 0;
padding: var(--space-3);
background: #151515;
}
.leviathan-metric span,
.leviathan-kv-row span {
display: block;
overflow: hidden;
color: var(--text-tertiary);
font-size: 10px;
line-height: var(--leading-tight);
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.leviathan-metric strong {
display: block;
overflow: hidden;
margin-top: var(--space-1);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
text-overflow: ellipsis;
white-space: nowrap;
}
.leviathan-sections {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--space-3);
padding: var(--space-3);
}
.leviathan-section {
min-width: 0;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--bg-surface);
}
.leviathan-section h3 {
margin: 0;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-subtle);
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
text-transform: uppercase;
}
.leviathan-kv-list {
display: grid;
}
.leviathan-kv-row {
display: grid;
grid-template-columns: minmax(80px, 120px) minmax(0, 1fr);
gap: var(--space-2);
min-width: 0;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-subtle);
}
.leviathan-kv-row:last-child {
border-bottom: 0;
}
.leviathan-kv-row strong {
overflow: hidden;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.leviathan-entropy-frame {
min-height: 100%;
padding: var(--space-3);
background: #101010;
}
.leviathan-entropy-canvas {
width: 100%;
height: min(280px, 42vh);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: #101010;
cursor: crosshair;
}
.leviathan-spectrogram-frame {
min-height: 100%;
overflow: auto;
padding: var(--space-3);
background: #05070c;
}
.leviathan-spectrogram-image {
display: block;
max-width: 100%;
height: auto;
image-rendering: pixelated;
}
.leviathan-empty,
.leviathan-json {
min-height: 100%;
color: var(--text-secondary);
background: #151515;
}
.leviathan-empty {
display: grid;
place-items: center;
padding: var(--space-6);
color: var(--text-tertiary);
font-size: var(--text-sm);
text-align: center;
}
.leviathan-error-state {
display: grid;
place-items: center;
min-height: 100%;
gap: var(--space-2);
padding: var(--space-6);
color: var(--text-secondary);
text-align: center;
}
.leviathan-error-icon {
position: relative;
display: grid;
place-items: center;
width: 58px;
height: 52px;
color: #161616;
font-size: 24px;
font-weight: var(--weight-bold);
}
.leviathan-error-icon::before {
position: absolute;
inset: 0;
background: var(--warning);
clip-path: polygon(50% 0, 100% 100%, 0 100%);
content: "";
}
.leviathan-error-icon span {
position: relative;
top: 7px;
}
.leviathan-error-state strong {
color: var(--text-primary);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
}
.leviathan-error-state p {
max-width: 520px;
margin: 0;
color: var(--text-tertiary);
font-size: var(--text-sm);
line-height: var(--leading-normal);
}
.leviathan-json {
margin: 0;
padding: var(--space-3);
overflow: auto;
white-space: pre-wrap;
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: var(--leading-normal);
user-select: text;
}
.leviathan-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: var(--text-xs);
}
.leviathan-table th,
.leviathan-table td {
max-width: 320px;
border-bottom: 1px solid var(--border-subtle);
padding: var(--space-2) var(--space-3);
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
vertical-align: top;
white-space: nowrap;
}
.leviathan-offset-link {
color: var(--warning);
font-family: var(--font-mono);
font-size: var(--text-xs);
text-align: left;
}
.leviathan-offset-link:hover {
color: var(--accent);
}
.leviathan-export-found {
min-width: 58px;
}
.leviathan-muted {
color: var(--text-tertiary);
}
.leviathan-table th {
position: sticky;
top: 0;
z-index: 1;
color: var(--text-tertiary);
background: var(--bg-surface);
font-weight: var(--weight-semibold);
text-transform: uppercase;
}
.leviathan-table tr:hover td {
background: rgba(255, 255, 255, 0.03);
}
.leviathan-table-note {
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--border-subtle);
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.leviathan-tree {
padding: var(--space-2) var(--space-3);
}
.leviathan-tree-node {
border-left: 1px solid var(--border-subtle);
padding: var(--space-1) 0 var(--space-1) var(--space-2);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.leviathan-tree-node strong {
color: var(--text-primary);
font-weight: var(--weight-medium);
}
.leviathan-tree-node span {
color: var(--text-tertiary);
}
.leviathan-hex-grid {
--leviathan-hex-width: 16;
display: grid;
align-content: start;
gap: 0;
min-height: 100%;
padding: var(--space-1);
overflow: auto;
background: #101010;
font-family: var(--font-mono);
font-size: 10px;
}
.leviathan-hex-row {
display: grid;
grid-template-columns: 82px max-content max-content;
align-items: center;
gap: var(--space-2);
min-height: 23px;
padding: 0 var(--space-1);
border-radius: var(--radius-sm);
}
.leviathan-hex-row:hover {
background: rgba(255, 255, 255, 0.035);
}
.leviathan-offset {
color: var(--warning);
font-variant-numeric: tabular-nums;
}
.leviathan-byte-cells {
display: grid;
grid-template-columns: repeat(var(--leviathan-hex-width), 22px);
gap: 2px;
}
.leviathan-byte {
width: 22px;
min-height: 20px;
padding: 0;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-primary);
background: #1d1d1d;
text-align: center;
font-family: var(--font-mono);
font-size: 10px;
line-height: 18px;
transition:
border-color var(--transition-fast),
background var(--transition-fast);
}
.leviathan-byte:hover,
.leviathan-byte:focus {
border-color: var(--accent);
background: var(--bg-input);
}
.leviathan-byte.is-editing {
border-color: var(--warning);
}
.leviathan-byte.is-saving {
color: var(--accent);
}
.leviathan-byte.is-search-hit {
border-color: var(--warning);
color: var(--text-inverse);
background: var(--warning);
}
.leviathan-ascii {
overflow: hidden;
color: var(--text-tertiary);
font-size: 10px;
line-height: 20px;
text-overflow: clip;
white-space: pre;
}
.leviathan-modal-fields {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: var(--space-3);
}
@media (max-width: 1120px) {
.leviathan-workbench {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
}
.leviathan-control-plane {
max-height: 260px;
}
.leviathan-params {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
}
@media (max-width: 720px) {
.leviathan-panel-header {
align-items: stretch;
flex-direction: column;
}
.leviathan-header-actions {
justify-content: flex-start;
}
.leviathan-hex-row {
grid-template-columns: 1fr;
gap: var(--space-1);
padding: var(--space-2);
}
.leviathan-byte-cells {
grid-template-columns: repeat(var(--leviathan-hex-width), 22px);
}
}
+81 -5
View File
@@ -112,10 +112,6 @@
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
@@ -140,7 +136,7 @@
cursor: crosshair;
}
.xterm .xterm-accessibility,
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
@@ -152,6 +148,21 @@
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
font-family: monospace;
user-select: text;
white-space: pre;
}
.xterm .xterm-accessibility-tree > div {
transform-origin: left;
width: fit-content;
}
.xterm .live-region {
position: absolute;
left: -9999px;
@@ -207,3 +218,68 @@
z-index: 2;
position: relative;
}
/* Derived from vs/base/browser/ui/scrollbar/media/scrollbar.css */
/* xterm.js customization: Override xterm's cursor style */
.xterm .xterm-scrollable-element > .scrollbar {
cursor: default;
}
/* Arrows */
.xterm .xterm-scrollable-element > .scrollbar > .scra {
cursor: pointer;
font-size: 11px !important;
}
.xterm .xterm-scrollable-element > .visible {
opacity: 1;
/* Background rule added for IE9 - to allow clicks on dom node */
background:rgba(0,0,0,0);
transition: opacity 100ms linear;
/* In front of peek view */
z-index: 11;
}
.xterm .xterm-scrollable-element > .invisible {
opacity: 0;
pointer-events: none;
}
.xterm .xterm-scrollable-element > .invisible.fade {
transition: opacity 800ms linear;
}
/* Scrollable Content Inset Shadow */
.xterm .xterm-scrollable-element > .shadow {
position: absolute;
display: none;
}
.xterm .xterm-scrollable-element > .shadow.top {
display: block;
top: 0;
left: 3px;
height: 3px;
width: 100%;
box-shadow: var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset;
}
.xterm .xterm-scrollable-element > .shadow.left {
display: block;
top: 3px;
left: 0;
height: 100%;
width: 3px;
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
}
.xterm .xterm-scrollable-element > .shadow.top-left-corner {
display: block;
top: 0;
left: 0;
height: 3px;
width: 3px;
}
.xterm .xterm-scrollable-element > .shadow.top.left {
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
}
+34
View File
@@ -18,6 +18,34 @@
}
/* Recordings list in browser modal */
.recording-storage {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 0 12px;
margin-bottom: 12px;
border-bottom: 1px solid var(--border, #3f3f46);
}
.recording-storage-summary {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: var(--text-secondary, #a1a1aa);
}
.recording-cleanup-controls {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.recording-cleanup-status {
min-height: 16px;
}
.recordings-list {
display: flex;
flex-direction: column;
@@ -66,6 +94,12 @@
margin-left: 12px;
}
@media (max-width: 640px) {
.recording-cleanup-controls {
grid-template-columns: 1fr;
}
}
/* Playback modal — larger to fit a terminal */
.modal-playback {
width: 90vw;
+102 -1
View File
@@ -10,6 +10,7 @@
display: flex;
align-items: center;
padding: var(--space-3) var(--space-4);
height: var(--tab-bar-height);
min-height: var(--tab-bar-height);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
@@ -19,6 +20,8 @@
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
min-width: 0;
}
.sidebar-logo svg {
@@ -35,6 +38,51 @@
letter-spacing: 0.02em;
}
.sidebar-product-switch {
display: inline-flex;
align-items: center;
gap: var(--space-2);
flex: 1;
min-width: 0;
-webkit-user-select: none;
user-select: none;
}
.sidebar-mode-toggle {
appearance: none;
border: 0;
background: transparent;
padding: 0;
height: auto;
color: var(--text-tertiary);
cursor: pointer;
white-space: nowrap;
-webkit-user-select: none;
user-select: none;
}
#mode-leviathan {
margin-left: auto;
text-align: right;
}
.sidebar-mode-toggle.is-active {
color: var(--text-primary);
}
.sidebar-mode-toggle:hover {
color: var(--text-secondary);
}
.sidebar-mode-toggle.is-active:hover {
color: var(--text-primary);
}
.mode-leviathan .sidebar-search,
.mode-leviathan .sidebar-footer {
display: none;
}
/* -----------------------------------------------------------------------
Sidebar search
----------------------------------------------------------------------- */
@@ -179,7 +227,7 @@
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-2);
margin-bottom: 1px;
margin-bottom: 0.5px;
border-radius: var(--radius-md);
cursor: pointer;
transition:
@@ -256,6 +304,9 @@
.sidebar-item .protocol-indicator.proto-vnc::before {
content: "⊞";
}
.sidebar-item .protocol-indicator.proto-irc::before {
content: "#";
}
.sidebar-item .protocol-indicator.proto-local::before {
content: "›_";
}
@@ -280,6 +331,10 @@
background: rgba(232, 93, 74, 0.18);
color: var(--protocol-vnc);
}
.sidebar-item .protocol-indicator.proto-irc {
background: rgba(216, 178, 76, 0.18);
color: var(--protocol-irc);
}
.sidebar-item .protocol-indicator.proto-local {
background: rgba(139, 195, 74, 0.18);
color: var(--protocol-local);
@@ -401,6 +456,52 @@
/* -----------------------------------------------------------------------
Sidebar empty list placeholder
----------------------------------------------------------------------- */
.sidebar-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
margin: var(--space-6) var(--space-3) 0;
padding: var(--space-5) var(--space-3);
color: var(--text-tertiary);
text-align: center;
}
.sidebar-empty-icon {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
background: var(--bg-elevated);
color: var(--accent);
font-size: var(--text-xl);
font-weight: var(--weight-semibold);
line-height: 1;
}
.sidebar-empty-title {
color: var(--text-secondary);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
line-height: var(--leading-tight);
}
.sidebar-empty-text {
max-width: 190px;
margin: 0;
color: var(--text-tertiary);
font-size: var(--text-xs);
line-height: var(--leading-normal);
}
.sidebar-empty-actions {
display: flex;
justify-content: center;
margin-top: var(--space-1);
}
.sidebar-list-empty {
padding: var(--space-3) var(--space-2);
font-size: var(--text-xs);
+37 -7
View File
@@ -34,13 +34,13 @@
}
/* Active tab */
.tab.active {
background: var(--bg-base);
.tab.tab-active {
background: var(--bg-active);
color: var(--text-primary);
}
/* Active indicator — accent bar at the bottom */
.tab.active::after {
.tab.tab-active::after {
content: "";
position: absolute;
bottom: 0;
@@ -65,7 +65,7 @@
color: var(--text-tertiary);
}
.tab.active .tab-icon {
.tab.tab-active .tab-icon {
color: var(--text-secondary);
}
@@ -118,6 +118,10 @@
background: rgba(232, 93, 74, 0.15);
color: var(--protocol-vnc);
}
.tab-badge.badge-irc {
background: rgba(216, 178, 76, 0.15);
color: var(--protocol-irc);
}
.tab-badge.badge-local {
background: rgba(139, 195, 74, 0.15);
color: var(--protocol-local);
@@ -240,7 +244,7 @@
}
.tab:hover .tab-sftp,
.tab.active .tab-sftp {
.tab.tab-active .tab-sftp {
opacity: 1;
}
@@ -268,11 +272,15 @@
background var(--transition-fast),
color var(--transition-fast);
cursor: pointer;
margin-left: 2px;
margin: 0 2px 0 0;
padding: 0;
border: 0;
background: none;
line-height: 1;
}
.tab:hover .tab-close,
.tab.active .tab-close {
.tab.tab-active .tab-close {
opacity: 1;
}
@@ -456,6 +464,28 @@
/* Transparent hit area for right-click context menu */
}
/* -----------------------------------------------------------------------
Tab hover tooltip
----------------------------------------------------------------------- */
.tab-tooltip {
max-width: 360px;
white-space: normal;
}
.tab-tooltip-title {
max-width: 320px;
overflow: hidden;
color: var(--text-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-tooltip-meta {
margin-top: 2px;
color: var(--text-tertiary);
font-weight: var(--weight-normal);
}
/* -----------------------------------------------------------------------
Modified indicator (unsaved changes)
----------------------------------------------------------------------- */
+2 -1
View File
@@ -58,6 +58,7 @@
--protocol-raw: #ab47bc;
--protocol-github: #7c8aff;
--protocol-vnc: #e85d4a;
--protocol-irc: #d8b24c;
--protocol-local: #8bc34a;
/* -----------------------------------------------------------------------
@@ -139,7 +140,7 @@
----------------------------------------------------------------------- */
--sidebar-width: 260px;
--sidebar-width-collapsed: 48px;
--tab-bar-height: 36px;
--tab-bar-height: 44px;
--toolbar-height: 40px;
--status-bar-height: 24px;
}
+192 -30
View File
@@ -22,6 +22,7 @@
SnippetLibrary,
SSHKeyManager,
TriggerEngine,
LeviathanWorkbench,
stateLabel,
} = window.NexusTerminal;
@@ -34,6 +35,7 @@
this.terminals = new TerminalManager(this);
this.profileEditor = new ProfileEditor(this);
this.rawViewer = new RawViewer(this);
window.NexusTerminal.IRCViewer?.setApp(this);
this.settings = new Settings(this);
this.commandPalette = new CommandPalette(this);
this.secretPrompt = new SecretPrompt(this);
@@ -44,7 +46,9 @@
this.snippets = new SnippetLibrary(this);
this.sshKeys = new SSHKeyManager(this);
this.triggers = new TriggerEngine(this);
this.leviathan = new LeviathanWorkbench(this);
this.updateInfo = null;
this.mode = "nexus";
}
/**
@@ -70,10 +74,9 @@
}
/**
* Polls for `window.go` and `window.runtime` to become available.
* Wails injects these into the webview; on fast machines they are
* present before DOMContentLoaded, but on slower starts there may
* be a brief delay.
* Polls for the Wails runtime and generated binding bridge.
* Application code uses wailsService as a small compatibility layer over
* the normal Wails backend global.
* @returns {Promise<void>}
* @private
*/
@@ -82,7 +85,11 @@
const maxWait = 5000;
const start = Date.now();
const check = () => {
if (window.go && window.runtime) {
const hasBridge =
typeof window.NexusTerminal?.hasWailsService === "function"
? window.NexusTerminal.hasWailsService()
: Boolean(window.NexusTerminal?.wailsService && window.runtime);
if (window.NexusTerminal?.wailsService && hasBridge) {
resolve();
} else if (Date.now() - start < maxWait) {
setTimeout(check, 50);
@@ -95,7 +102,9 @@
resolve();
}
};
check();
Promise.resolve(window.NexusTerminal?.wailsServiceReady)
.catch(() => null)
.finally(check);
});
}
@@ -106,7 +115,7 @@
*/
async loadProfiles() {
try {
const profiles = await window.go.api.Service.ListProfiles();
const profiles = await window.NexusTerminal.wailsService.ListProfiles();
this.state.setProfiles(profiles || []);
this.sidebar.render();
} catch (err) {
@@ -119,7 +128,7 @@
*/
async loadPreferences() {
try {
const prefs = await window.go.api.Service.GetPreferences();
const prefs = await window.NexusTerminal.wailsService.GetPreferences();
this.state.setPreferences(prefs || {});
this._applyPreferences();
} catch (err) {
@@ -134,10 +143,10 @@
async checkSecretStore() {
try {
const initialised =
await window.go.api.Service.IsSecretStoreInitialised();
await window.NexusTerminal.wailsService.IsSecretStoreInitialised();
this.state.setSecretStoreInitialised(!!initialised);
if (initialised) {
const unlocked = await window.go.api.Service.IsSecretStoreUnlocked();
const unlocked = await window.NexusTerminal.wailsService.IsSecretStoreUnlocked();
this.state.setSecretStoreState(!!unlocked);
} else {
this.state.setSecretStoreState(false);
@@ -215,7 +224,7 @@
if (entry) {
const { cols, rows } = entry.term;
try {
window.go.api.Service.ResizeSession(
window.NexusTerminal.wailsService.ResizeSession(
payload.sessionID,
cols,
rows,
@@ -231,6 +240,7 @@
setTimeout(fit, 50);
setTimeout(fit, 300);
setTimeout(fit, 800);
setTimeout(() => window.NexusTerminal.IRCViewer?.fit(payload.sessionID), 50);
}
});
@@ -280,6 +290,12 @@
);
});
// IRC events
window.runtime.EventsOn("session:irc:event", (payload) => {
window.NexusTerminal.IRCViewer.handleEvent(payload);
this.tabs.markActivity(payload.sessionID);
});
// App-wide commands from the native menu
window.runtime.EventsOn("app:open-about", () => {
this.settings.openTab("about");
@@ -313,14 +329,12 @@
this.recordings._updateRecordingIndicator(payload.sessionID, false);
});
// Telnet negotiation debug messages
window.runtime.EventsOn("session:debug", (payload) => {
console.debug("[session:debug]", payload);
});
// Telnet negotiation debug messages are intentionally ignored in release UI.
window.runtime.EventsOn("session:debug", () => {});
// Backend ready event (emitted from DomReady)
window.runtime.EventsOn("backend:ready", () => {
console.log("Backend is ready");
// Lifecycle signal only.
});
// Host key verification requests
@@ -332,11 +346,13 @@
payload.fingerprint,
);
window.runtime.EventsEmit("session:hostkey:response", {
requestID: payload.requestID,
sessionID: payload.sessionID,
accepted,
});
} catch (_) {
window.runtime.EventsEmit("session:hostkey:response", {
requestID: payload.requestID,
sessionID: payload.sessionID,
accepted: false,
});
@@ -375,7 +391,7 @@
*/
async closeSessionImmediate(sessionId) {
try {
await window.go.api.Service.DisconnectSession(sessionId);
await window.NexusTerminal.wailsService.DisconnectSession(sessionId);
} catch (_) {
// Ignore failures; we still want to clean up locally.
}
@@ -384,8 +400,8 @@
this.rawViewer.destroyViewer(sessionId);
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
window.NexusTerminal.VNCViewer.destroyViewer(sessionId);
this.state.removeSession(sessionId);
this.tabs.removeTab(sessionId);
this.state.removeSession(sessionId);
this.updateStatusBar();
}
@@ -540,6 +556,8 @@
this._bindClick("btn-recordings", () => this.recordings.openBrowser());
this._bindClick("btn-new-tab", () => this.profileEditor.openNew());
this._bindClick("btn-broadcast", () => this.toggleBroadcast());
this._bindClick("mode-nexus", () => this.setMode("nexus"));
this._bindClick("mode-leviathan", () => this.setMode("leviathan"));
// Wire the "Local Machine" sidebar button
this.sidebar.onConnectLocal = () => this.connectLocal();
@@ -587,6 +605,33 @@
});
}
// ── App mode switching ─────────────────────────────────────────
/**
* Switches the sidebar between Nexus profiles and Leviathan tools.
* @param {"nexus"|"leviathan"} mode
*/
setMode(mode) {
this.mode = mode === "leviathan" ? "leviathan" : "nexus";
const root = document.getElementById("app");
if (root) {
root.classList.toggle("mode-leviathan", this.mode === "leviathan");
}
document
.getElementById("mode-nexus")
?.classList.toggle("is-active", this.mode === "nexus");
document
.getElementById("mode-leviathan")
?.classList.toggle("is-active", this.mode === "leviathan");
if (this.mode === "leviathan") {
this.leviathan.renderSidebar();
this.leviathan.openTool("hex", { reuseExisting: true });
} else {
this.sidebar.render();
}
}
// ── Split pane ─────────────────────────────────────────────────
/**
@@ -742,7 +787,7 @@
* Opens a new session for the given profile ID.
* @param {string} profileId
*/
async connectProfile(profileId) {
async connectProfile(profileId, options = {}) {
const profile = this.state.getProfile(profileId);
if (!profile) {
this.notifications.error("Profile not found");
@@ -764,7 +809,12 @@
const decision = await this._confirmReuseSession(profile, preferred);
if (decision === "existing") {
this.tabs.setActive(preferred.id);
if (preferred.protocol !== "raw" && preferred.protocol !== "github") {
if (
preferred.protocol !== "raw" &&
preferred.protocol !== "github" &&
preferred.protocol !== "vnc" &&
preferred.protocol !== "irc"
) {
this.terminals.focusTerminal(preferred.id);
}
return;
@@ -772,9 +822,17 @@
if (decision !== "new") return;
}
if (
!options.skipDirectVNCWarning &&
this._requiresDirectVNCWarning(profile)
) {
const ok = await this.confirmDirectVNCProfile(profile, "connect");
if (!ok) return;
}
try {
const sessionInfo =
await window.go.api.Service.ConnectSession(profileId);
await window.NexusTerminal.wailsService.ConnectSession(profileId);
this.state.addSession(sessionInfo);
this.tabs.addTab(sessionInfo);
@@ -800,6 +858,15 @@
profile,
);
}
} else if (profile.protocol === "irc") {
const container = document.getElementById(panelId);
if (container) {
window.NexusTerminal.IRCViewer.createViewer(
sessionInfo.id,
container,
profile,
);
}
} else {
this.terminals.createTerminal(sessionInfo.id, panelId, profile);
}
@@ -817,7 +884,7 @@
*/
async connectLocal() {
try {
const sessionInfo = await window.go.api.Service.ConnectLocal();
const sessionInfo = await window.NexusTerminal.wailsService.ConnectLocal();
this.state.addSession(sessionInfo);
this.tabs.addTab(sessionInfo);
@@ -856,6 +923,7 @@
<option value="ssh">SSH</option>
<option value="telnet">Telnet</option>
<option value="raw">RAW TCP</option>
<option value="irc">IRC</option>
</select>
</div>
<div class="form-group">
@@ -870,6 +938,10 @@
<label class="form-label">Username</label>
<input class="form-input qc-username" type="text" placeholder="root" />
</div>
<div class="form-group qc-nick-group" style="display:none">
<label class="form-label">Nick</label>
<input class="form-input qc-nick" type="text" value="nexus" />
</div>
</div>
<div class="modal-footer">
<button class="btn btn-sm qc-cancel">Cancel</button>
@@ -886,14 +958,17 @@
const protocolSelect = modal.querySelector(".qc-protocol");
const portInput = modal.querySelector(".qc-port");
const usernameGroup = modal.querySelector(".qc-username-group");
const nickGroup = modal.querySelector(".qc-nick-group");
const hostInput = modal.querySelector(".qc-host");
// Update port default and username visibility when protocol changes
protocolSelect.addEventListener("change", () => {
const proto = protocolSelect.value;
const defaultPorts = { ssh: 22, telnet: 23, raw: 23 };
const defaultPorts = { ssh: 22, telnet: 23, raw: 23, irc: 6667 };
portInput.value = defaultPorts[proto] || 22;
usernameGroup.style.display = proto === "ssh" ? "" : "none";
usernameGroup.style.display =
proto === "ssh" || proto === "irc" ? "" : "none";
nickGroup.style.display = proto === "irc" ? "" : "none";
});
const close = (value) => {
@@ -920,6 +995,7 @@
host,
port: parseInt(portInput.value, 10) || 22,
username: modal.querySelector(".qc-username").value.trim(),
nick: modal.querySelector(".qc-nick").value.trim(),
});
});
backdrop.addEventListener("click", (e) => {
@@ -944,7 +1020,14 @@
displayName: result.host,
tags: [],
};
const sessionInfo = await window.go.api.Service.ConnectQuick(profile);
if (result.protocol === "irc") {
profile.irc = {
nick: result.nick || "nexus",
realName: "Nexus Terminal",
autoJoinRooms: [],
};
}
const sessionInfo = await window.NexusTerminal.wailsService.ConnectQuick(profile);
this.state.addSession(sessionInfo);
this.tabs.addTab(sessionInfo);
@@ -954,6 +1037,15 @@
protocol: "raw",
raw: {},
});
} else if (result.protocol === "irc") {
const container = document.getElementById(panelId);
if (container) {
window.NexusTerminal.IRCViewer.createViewer(
sessionInfo.id,
container,
profile,
);
}
} else {
this.terminals.createTerminal(sessionInfo.id, panelId, {
protocol: result.protocol,
@@ -975,7 +1067,9 @@
*/
_confirmReuseSession(profile, session) {
const createElement = window.NexusTerminal.createElement;
const label = (profile.displayName || profile.host || "Untitled").trim();
const label = window.NexusTerminal.escapeHtml(
(profile.displayName || profile.host || "Untitled").trim(),
);
const stateLabel =
session.state === "connected" ? "connected" : "connecting";
@@ -1031,6 +1125,74 @@
});
}
/**
* Reports whether a profile connects directly to VNC without SSH tunnelling.
* @param {Object} profile
* @returns {boolean}
* @private
*/
_requiresDirectVNCWarning(profile) {
return !!(
profile &&
profile.protocol === "vnc" &&
profile.vnc &&
!profile.vnc.sshTunnel
);
}
/**
* Warns before saving or connecting direct VNC profiles.
* @param {Object} profile
* @param {"save"|"connect"} action
* @returns {Promise<boolean>}
*/
confirmDirectVNCProfile(profile, action) {
const { createElement, createModalShell } = window.NexusTerminal;
const label = (
profile.displayName ||
profile.vnc?.vncHost ||
profile.host ||
"VNC"
).trim();
const actionLabel = action === "connect" ? "Connect" : "Save";
return new Promise((resolve) => {
const shell = createModalShell("Direct VNC Connection", "modal-sm");
const bodyText = createElement("p", "", {
textContent:
label +
" is configured without an SSH tunnel. VNC traffic may be visible to the network unless the server is otherwise protected.",
});
bodyText.style.fontSize = "var(--text-sm)";
bodyText.style.color = "var(--text-secondary)";
shell.body.appendChild(bodyText);
const cancelBtn = createElement("button", "btn btn-sm", {
textContent: "Cancel",
});
const okBtn = createElement(
"button",
"btn btn-sm btn-warning direct-vnc-confirm",
{ textContent: actionLabel },
);
shell.footer.appendChild(cancelBtn);
shell.footer.appendChild(okBtn);
const close = (value) => {
shell.remove();
resolve(value);
};
shell.closeButton.addEventListener("click", () => close(false));
cancelBtn.addEventListener("click", () => close(false));
okBtn.addEventListener("click", () => close(true));
shell.backdrop.addEventListener("click", (e) => {
if (e.target === shell.backdrop) close(false);
});
shell.show();
});
}
// ── Import / Export ──────────────────────────────────────────────
/**
@@ -1117,9 +1279,9 @@
this._latencySessionID = sessionID;
const doPing = async () => {
if (!window.go || !window.go.api || !window.go.api.Service) return;
if (!window.NexusTerminal?.wailsService) return;
try {
const ms = await window.go.api.Service.PingSession(sessionID);
const ms = await window.NexusTerminal.wailsService.PingSession(sessionID);
const el = document.getElementById("status-latency");
if (el && this._latencySessionID === sessionID) {
el.textContent = ms + " ms";
@@ -1154,12 +1316,12 @@
}
async checkForUpdates(silent) {
if (!window.go || !window.go.api || !window.go.api.Service) {
if (!window.NexusTerminal?.wailsService) {
return;
}
try {
const info = await window.go.api.Service.CheckForUpdates();
const info = await window.NexusTerminal.wailsService.CheckForUpdates();
this.setUpdateInfo(info);
if (!silent && info && info.available) {
this.notifications.info(
+2 -2
View File
@@ -172,7 +172,7 @@
section: "Security",
action: async () => {
try {
await window.go.api.Service.LockSecretStore();
await window.NexusTerminal.wailsService.LockSecretStore();
this.app.state.setSecretStoreState(false);
this.app.notifications.info("Secret store locked");
} catch (err) {
@@ -189,7 +189,7 @@
try {
const pw =
await this.app.secretPrompt.promptMasterPassword(false);
await window.go.api.Service.UnlockSecretStore(pw);
await window.NexusTerminal.wailsService.UnlockSecretStore(pw);
this.app.state.setSecretStoreState(true);
this.app.notifications.success("Secret store unlocked");
} catch (err) {
+2 -2
View File
@@ -425,7 +425,7 @@
this._statusEl.textContent = `Downloading ${repo.fullName}...`;
try {
await window.go.api.Service.DownloadGitHubRepoArchive(
await window.NexusTerminal.wailsService.DownloadGitHubRepoArchive(
this.sessionID,
repo.fullName,
ref,
@@ -441,7 +441,7 @@
if (!this._statusEl) return;
this._statusEl.textContent = "Refreshing…";
try {
await window.go.api.Service.RefreshGitHubRepos(this.sessionID);
await window.NexusTerminal.wailsService.RefreshGitHubRepos(this.sessionID);
} catch (err) {
this.showError(err.message || "Refresh failed");
}
+617
View File
@@ -0,0 +1,617 @@
(function () {
/**
* Nexus Terminal - IRC viewer.
* Uses xterm.js as the chat stream, with room subtabs, a composer, and
* room/user sidebars.
*/
/* global Terminal, FitAddon, SearchAddon, WebLinksAddon */
const { createElement } = window.NexusTerminal;
const SERVER_TARGET = "server";
const MAX_LINES_PER_TARGET = 2000;
const MAX_LISTED_ROOMS = 200;
class IRCViewer {
constructor() {
this._viewers = new Map();
this.app = null;
}
setApp(app) {
this.app = app;
}
createViewer(sessionID, container, profile) {
if (this._viewers.has(sessionID) || !container) return;
const viewer = new IRCViewerInstance(sessionID, container, profile, this.app);
this._viewers.set(sessionID, viewer);
viewer.render();
}
destroyViewer(sessionID) {
const viewer = this._viewers.get(sessionID);
if (!viewer) return;
viewer.destroy();
this._viewers.delete(sessionID);
}
handleEvent(payload) {
const sessionID = payload?.sessionID;
const event = payload?.event || payload;
const viewer = this._viewers.get(sessionID);
if (viewer) viewer.handleEvent(event);
}
fit(sessionID) {
const viewer = this._viewers.get(sessionID);
if (viewer) viewer.fit();
}
}
class IRCViewerInstance {
constructor(sessionID, container, profile, app) {
this.sessionID = sessionID;
this.container = container;
this.profile = profile || {};
this.app = app || null;
this.term = null;
this.fitAddon = null;
this.searchAddon = null;
this.observer = null;
this.activeTarget = SERVER_TARGET;
this.targets = new Map();
this.users = new Map();
this.joinedRooms = [];
this.roomList = [];
this.roomFilter = "";
this.unreadTargets = new Set();
this.tabsEl = null;
this.termHost = null;
this.inputEl = null;
this.roomsEl = null;
this.usersEl = null;
this.roomListEl = null;
this.roomFilterEl = null;
this._ensureTarget(SERVER_TARGET, "Server");
}
render() {
this.container.innerHTML = "";
this.container.classList.add("irc-viewer-panel");
const wrapper = createElement("div", "irc-viewer");
const main = createElement("div", "irc-main");
this.tabsEl = createElement("div", "irc-room-tabs");
main.appendChild(this.tabsEl);
this.termHost = createElement("div", "irc-terminal");
main.appendChild(this.termHost);
const composer = createElement("form", "irc-composer");
this.inputEl = createElement("input", "irc-input", {
type: "text",
placeholder: "Message or /command",
autocomplete: "off",
});
const sendBtn = createElement("button", "btn btn-sm btn-primary irc-send", {
type: "submit",
textContent: "Send",
});
composer.appendChild(this.inputEl);
composer.appendChild(sendBtn);
composer.addEventListener("submit", (event) => {
event.preventDefault();
this._sendComposer();
});
this.inputEl.addEventListener("keydown", (event) => {
if (event.key === "Tab" && this._completeMention()) {
event.preventDefault();
}
});
main.appendChild(composer);
const sidebar = this._buildSidebar();
wrapper.appendChild(main);
wrapper.appendChild(sidebar);
this.container.appendChild(wrapper);
this._openTerminal();
this._renderTabs();
this._renderRooms();
this._renderUsers();
this._renderRoomList();
this._appendLine(SERVER_TARGET, {
type: "system",
text: "Connecting...",
timestamp: Date.now(),
});
}
handleEvent(event) {
if (!event || !event.type) return;
if (event.type === "rooms") {
this.joinedRooms = Array.isArray(event.rooms) ? event.rooms : [];
for (const room of this.joinedRooms) {
this._ensureTarget(room.name, room.name);
}
this._renderTabs();
this._renderRooms();
return;
}
if (event.type === "users") {
const target = event.target;
if (target) {
this.users.set(target, Array.isArray(event.users) ? event.users : []);
if (target === this.activeTarget) this._renderUsers();
}
return;
}
if (event.type === "room-list") {
this.roomList = Array.isArray(event.rooms) ? event.rooms : [];
this._renderRoomList();
return;
}
const target = this._eventTarget(event);
this._ensureTarget(target, this._targetLabel(target));
this._appendLine(target, event);
if (target !== this.activeTarget) {
this.unreadTargets.add(target);
this._renderTabs();
}
if (["join", "part", "topic"].includes(event.type)) {
this._renderRooms();
}
}
fit() {
if (!this.fitAddon) return;
requestAnimationFrame(() => {
try {
this.fitAddon.fit();
} catch (_) {
/* noop */
}
});
}
destroy() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
if (this.term) {
this.term.dispose();
this.term = null;
}
this.container.classList.remove("irc-viewer-panel");
this.container.innerHTML = "";
}
_buildSidebar() {
const sidebar = createElement("aside", "irc-sidebar");
const joinSection = createElement("div", "irc-sidebar-section");
joinSection.appendChild(
createElement("div", "irc-sidebar-title", { textContent: "Rooms" }),
);
const joinRow = createElement("form", "irc-join-row");
const joinInput = createElement("input", "irc-join-input", {
type: "text",
placeholder: "#room",
autocomplete: "off",
});
const joinBtn = createElement("button", "btn btn-xs btn-secondary", {
type: "submit",
textContent: "Join",
});
joinRow.appendChild(joinInput);
joinRow.appendChild(joinBtn);
joinRow.addEventListener("submit", async (event) => {
event.preventDefault();
const room = joinInput.value.trim();
if (!room) return;
try {
await window.NexusTerminal.wailsService.IRCJoin(this.sessionID, room);
joinInput.value = "";
} catch (err) {
this._notifyError("Join failed: " + err);
}
});
joinSection.appendChild(joinRow);
this.roomsEl = createElement("div", "irc-sidebar-list");
joinSection.appendChild(this.roomsEl);
sidebar.appendChild(joinSection);
const listSection = createElement("div", "irc-sidebar-section");
const listTitleRow = createElement("div", "irc-sidebar-title-row");
listTitleRow.appendChild(
createElement("div", "irc-sidebar-title", { textContent: "Room List" }),
);
const listBtn = createElement("button", "btn btn-xs btn-outline", {
type: "button",
textContent: "List",
});
listBtn.addEventListener("click", async () => {
this.roomList = [];
this._renderRoomList("Loading rooms...");
try {
await window.NexusTerminal.wailsService.IRCListRooms(this.sessionID);
} catch (err) {
this._renderRoomList("Room list failed");
this._notifyError("Room list failed: " + err);
}
});
listTitleRow.appendChild(listBtn);
listSection.appendChild(listTitleRow);
this.roomFilterEl = createElement("input", "irc-filter-input", {
type: "text",
placeholder: "Filter rooms",
autocomplete: "off",
});
this.roomFilterEl.addEventListener("input", () => {
this.roomFilter = this.roomFilterEl.value.toLowerCase();
this._renderRoomList();
});
listSection.appendChild(this.roomFilterEl);
this.roomListEl = createElement("div", "irc-room-list");
listSection.appendChild(this.roomListEl);
sidebar.appendChild(listSection);
const usersSection = createElement("div", "irc-sidebar-section irc-users-section");
usersSection.appendChild(
createElement("div", "irc-sidebar-title", { textContent: "Users" }),
);
this.usersEl = createElement("div", "irc-user-list");
usersSection.appendChild(this.usersEl);
sidebar.appendChild(usersSection);
return sidebar;
}
_openTerminal() {
const themeName =
(this.app?.state?.preferences || {}).terminalTheme || "charcoal";
this.term = new Terminal({
theme: window.NexusTerminal.getTerminalTheme(themeName),
fontFamily:
this.profile.terminalFontFamily ||
'JetBrains Mono, Menlo, Monaco, Consolas, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace',
fontSize: this.profile.terminalFontSize || 14,
scrollback: this.profile.terminalScrollback || 10000,
cursorBlink: false,
convertEol: true,
disableStdin: true,
});
this.fitAddon = new FitAddon.FitAddon();
this.searchAddon = new SearchAddon.SearchAddon();
this.term.loadAddon(this.fitAddon);
this.term.loadAddon(this.searchAddon);
try {
this.term.loadAddon(new WebLinksAddon.WebLinksAddon((_event, uri) => {
window.runtime.BrowserOpenURL(uri);
}));
} catch (_) {
/* optional */
}
this.term.open(this.termHost);
this.fit();
this.observer = new ResizeObserver(() => this.fit());
this.observer.observe(this.termHost);
}
_renderTabs() {
if (!this.tabsEl) return;
this.tabsEl.innerHTML = "";
for (const [target, state] of this.targets) {
const btn = createElement(
"button",
`irc-room-tab ${target === this.activeTarget ? "active" : ""} ${
this.unreadTargets.has(target) ? "has-unread" : ""
}`,
{
type: "button",
textContent: state.label,
title: state.label,
},
);
btn.addEventListener("click", () => this._setActiveTarget(target));
this.tabsEl.appendChild(btn);
}
}
_renderRooms() {
if (!this.roomsEl) return;
this.roomsEl.innerHTML = "";
if (this.joinedRooms.length === 0) {
this.roomsEl.appendChild(
createElement("div", "irc-empty", { textContent: "No joined rooms" }),
);
return;
}
for (const room of this.joinedRooms) {
const row = createElement("button", "irc-room-row", {
type: "button",
title: room.topic || room.name,
});
row.classList.toggle("active", room.name === this.activeTarget);
row.appendChild(createElement("span", "irc-room-name", { textContent: room.name }));
row.appendChild(
createElement("span", "irc-room-count", {
textContent: String(room.users || 0),
}),
);
row.addEventListener("click", () => this._setActiveTarget(room.name));
this.roomsEl.appendChild(row);
}
}
_renderUsers() {
if (!this.usersEl) return;
this.usersEl.innerHTML = "";
const users = this.users.get(this.activeTarget) || [];
if (!this._isRoom(this.activeTarget)) {
this.usersEl.appendChild(
createElement("div", "irc-empty", { textContent: "No room selected" }),
);
return;
}
if (users.length === 0) {
this.usersEl.appendChild(
createElement("div", "irc-empty", { textContent: "No users yet" }),
);
return;
}
for (const user of users) {
const row = createElement("button", "irc-user-row", {
type: "button",
textContent: this._displayNick(user),
title: user,
});
row.addEventListener("click", () => {
this._insertMention(this._displayNick(user));
});
this.usersEl.appendChild(row);
}
}
_renderRoomList(message = "") {
if (!this.roomListEl) return;
this.roomListEl.innerHTML = "";
if (message) {
this.roomListEl.appendChild(
createElement("div", "irc-empty", { textContent: message }),
);
return;
}
let rooms = this.roomList;
if (this.roomFilter) {
rooms = rooms.filter((room) => {
const haystack = `${room.name || ""} ${room.topic || ""}`.toLowerCase();
return haystack.includes(this.roomFilter);
});
}
rooms = rooms.slice(0, MAX_LISTED_ROOMS);
if (rooms.length === 0) {
this.roomListEl.appendChild(
createElement("div", "irc-empty", { textContent: "No rooms loaded" }),
);
return;
}
for (const room of rooms) {
const row = createElement("div", "irc-listed-room");
const meta = createElement("button", "irc-listed-room-main", {
type: "button",
title: room.topic || room.name,
});
meta.appendChild(
createElement("span", "irc-listed-room-name", {
textContent: room.name || "",
}),
);
meta.appendChild(
createElement("span", "irc-listed-room-topic", {
textContent: room.topic || "",
}),
);
meta.addEventListener("click", () => this._joinRoom(room.name));
const count = createElement("span", "irc-listed-room-count", {
textContent: String(room.users || 0),
});
row.appendChild(meta);
row.appendChild(count);
this.roomListEl.appendChild(row);
}
}
_setActiveTarget(target) {
this._ensureTarget(target, this._targetLabel(target));
this.activeTarget = target;
this.unreadTargets.delete(target);
this._renderTabs();
this._renderRooms();
this._renderUsers();
this._replayActiveTarget();
if (this.inputEl) this.inputEl.focus();
}
_appendLine(target, event) {
const state = this._ensureTarget(target, this._targetLabel(target));
const line = this._formatEvent(event);
if (!line) return;
state.lines.push(line);
if (state.lines.length > MAX_LINES_PER_TARGET) {
state.lines.splice(0, state.lines.length - MAX_LINES_PER_TARGET);
}
if (target === this.activeTarget && this.term) {
this.term.writeln(line);
this.term.scrollToBottom();
}
}
_replayActiveTarget() {
if (!this.term) return;
this.term.clear();
const state = this.targets.get(this.activeTarget);
for (const line of state?.lines || []) {
this.term.writeln(line);
}
this.term.scrollToBottom();
this.fit();
}
async _sendComposer() {
const text = this.inputEl.value;
if (!text.trim()) return;
this.inputEl.value = "";
try {
await window.NexusTerminal.wailsService.IRCHandleInput(
this.sessionID,
this.activeTarget,
text,
);
} catch (err) {
this._notifyError(String(err));
this._appendLine(this.activeTarget, {
type: "error",
text: String(err),
timestamp: Date.now(),
});
}
}
async _joinRoom(room) {
if (!room) return;
try {
await window.NexusTerminal.wailsService.IRCJoin(this.sessionID, room);
} catch (err) {
this._notifyError("Join failed: " + err);
}
}
_completeMention() {
if (!this.inputEl || !this._isRoom(this.activeTarget)) return false;
const start = this.inputEl.selectionStart || 0;
const before = this.inputEl.value.slice(0, start);
const match = before.match(/(^|\s)@([^\s@]*)$/);
if (!match) return false;
const prefix = match[2].toLowerCase();
const users = this.users.get(this.activeTarget) || [];
const nick = users
.map((user) => this._displayNick(user))
.find((user) => user.toLowerCase().startsWith(prefix));
if (!nick) return false;
const replacement = `${match[1]}@${nick} `;
const nextValue =
this.inputEl.value.slice(0, start - match[0].length) +
replacement +
this.inputEl.value.slice(start);
const caret = start - match[0].length + replacement.length;
this.inputEl.value = nextValue;
this.inputEl.setSelectionRange(caret, caret);
return true;
}
_insertMention(nick) {
if (!this.inputEl) return;
const current = this.inputEl.value;
const spacer = current && !/\s$/.test(current) ? " " : "";
this.inputEl.value = `${current}${spacer}@${nick} `;
this.inputEl.focus();
}
_ensureTarget(target, label) {
const safeTarget = target || SERVER_TARGET;
if (!this.targets.has(safeTarget)) {
this.targets.set(safeTarget, {
label: label || this._targetLabel(safeTarget),
lines: [],
});
}
return this.targets.get(safeTarget);
}
_eventTarget(event) {
if (!event) return SERVER_TARGET;
if (event.target) return event.target;
return SERVER_TARGET;
}
_targetLabel(target) {
return target === SERVER_TARGET ? "Server" : target;
}
_formatEvent(event) {
const time = this._time(event.timestamp);
const from = this._sanitize(event.from || "");
const text = this._sanitize(event.text || "");
switch (event.type) {
case "message":
return `[${time}] <${from}> ${text}`;
case "action":
return `[${time}] * ${from} ${text}`;
case "notice":
return `[${time}] -${from || "notice"}- ${text}`;
case "join":
return `[${time}] --> ${from || "user"} joined`;
case "part":
return `[${time}] <-- ${from || "user"} left${text ? ` (${text})` : ""}`;
case "quit":
return `[${time}] <-- ${from || "user"} quit${text ? ` (${text})` : ""}`;
case "nick":
return `[${time}] ${from || "user"} is now known as ${text}`;
case "topic":
return `[${time}] topic: ${text}`;
case "error":
return `[${time}] error: ${text}`;
case "system":
default:
return `[${time}] ${text}`;
}
}
_time(value) {
const date = value ? new Date(value) : new Date();
const hours = String(date.getHours()).padStart(2, "0");
const mins = String(date.getMinutes()).padStart(2, "0");
const secs = String(date.getSeconds()).padStart(2, "0");
return `${hours}:${mins}:${secs}`;
}
_sanitize(value) {
return String(value || "")
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
.replace(/\x03(\d{1,2}(,\d{1,2})?)?/g, "")
.replace(/[\x02\x0f\x16\x1d\x1f]/g, "");
}
_displayNick(user) {
return String(user || "").replace(/^[~&@%+]+/, "");
}
_isRoom(target) {
return /^[#&+!]/.test(target || "");
}
_notifyError(message) {
if (this.app?.notifications) {
this.app.notifications.error(message);
}
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.IRCViewer = new IRCViewer();
})();
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,2 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=xterm-addon-fit.js.map
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,2 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(self,(()=>(()=>{"use strict";var e={6:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,i={}){this._terminal=e,this._regex=t,this._handler=n,this._options=i}provideLinks(e,t){const i=n.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(i))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:i}=e;this._options.hover(t,n,i)}},e)))}};class n{static computeLink(e,t,i,r){const o=new RegExp(t.source,(t.flags||"")+"g"),[s,a]=n._getWindowedLineStrings(e-1,i),c=s.join("");let d;const l=[];for(;d=o.exec(c);){const e=d[0];try{const t=new URL(e),n=decodeURI(t.toString());if(e!==n&&e+"/"!==n)continue}catch(e){continue}const[t,o]=n._mapStrIdx(i,a,0,d.index),[s,c]=n._mapStrIdx(i,t,o,e.length);if(-1===t||-1===o||-1===s||-1===c)continue;const p={start:{x:o+1,y:t+1},end:{x:c,y:s+1}};l.push({range:p,text:e,activate:r})}return l}static _getWindowedLineStrings(e,t){let n,i=e,r=e,o=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(o=0;(n=t.buffer.active.getLine(--i))&&o<2048&&(s=n.translateToString(!0),o+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),o=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&o<2048&&(s=n.translateToString(!0),o+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,i]}static _mapStrIdx(e,t,n,i){const r=e.buffer.active,o=r.getNullCell();let s=n;for(;i;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,o);const s=o.getChars();if(o.getWidth()&&(i-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,o),2===o.getWidth()&&(i+=1))}if(i<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=n}},t={};function n(i){var r=t[i];if(void 0!==r)return r.exports;var o=t[i]={exports:{}};return e[i](o,o.exports,n),o.exports}var i={};return(()=>{var e=i;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(6),r=/https?:[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function o(e,t){const n=window.open();if(n){try{n.opener=null}catch(e){}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=o,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,i=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,i,this._handler,n))}dispose(){var e;null===(e=this._linkProvider)||void 0===e||e.dispose()}}})(),i})()));
//# sourceMappingURL=xterm-addon-web-links.js.map
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={490:(e,t)=>{function n(e){try{const t=new URL(e),n=t.password&&t.username?`${t.protocol}//${t.username}:${t.password}@${t.host}`:t.username?`${t.protocol}//${t.username}@${t.host}`:`${t.protocol}//${t.host}`;return e.toLocaleLowerCase().startsWith(n.toLocaleLowerCase())}catch(e){return!1}}Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,o={}){this._terminal=e,this._regex=t,this._handler=n,this._options=o}provideLinks(e,t){const n=o.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(n))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:o}=e;this._options.hover(t,n,o)}},e)))}};class o{static computeLink(e,t,r,i){const s=new RegExp(t.source,(t.flags||"")+"g"),[a,c]=o._getWindowedLineStrings(e-1,r),l=a.join("");let d;const p=[];for(;d=s.exec(l);){const e=d[0];if(!n(e))continue;const[t,s]=o._mapStrIdx(r,c,0,d.index),[a,l]=o._mapStrIdx(r,t,s,e.length);if(-1===t||-1===s||-1===a||-1===l)continue;const h={start:{x:s+1,y:t+1},end:{x:l,y:a+1}};p.push({range:h,text:e,activate:i})}return p}static _getWindowedLineStrings(e,t){let n,o=e,r=e,i=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(i=0;(n=t.buffer.active.getLine(--o))&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),i=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,o]}static _mapStrIdx(e,t,n,o){const r=e.buffer.active,i=r.getNullCell();let s=n;for(;o;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,i);const s=i.getChars();if(i.getWidth()&&(o-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,i),2===i.getWidth()&&(o+=1))}if(o<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=o}},t={};function n(o){var r=t[o];if(void 0!==r)return r.exports;var i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}var o={};return(()=>{var e=o;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(490),r=/(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function i(e,t){const n=window.open();if(n){try{n.opener=null}catch{}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=i,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,o=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,o,this._handler,n))}dispose(){this._linkProvider?.dispose()}}})(),o})()));
//# sourceMappingURL=addon-web-links.js.map
+1 -1
View File
File diff suppressed because one or more lines are too long
+300 -21
View File
@@ -86,6 +86,31 @@
sshAgentAuth: false,
},
},
irc: {
port: 6667,
protocol: "irc",
irc: {
nick: "nexus",
realName: "Nexus Terminal",
useTLS: false,
serverPasswordRequired: false,
serverPasswordMode: "ask-every-time",
nickServIdentify: false,
nickServAccount: "",
nickServMode: "saved",
autoJoinRooms: [],
sshTunnel: false,
sshHost: "",
sshPort: 22,
sshUsername: "",
sshAuthMethod: "publickey",
sshSecretMode: "saved",
sshPrivateKey: "",
sshAgentAuth: false,
autoReconnect: true,
maxReconnectTries: 5,
},
},
};
class ProfileEditor {
@@ -162,10 +187,11 @@
drawer.appendChild(header);
// ── Protocol selector tabs ──────────────────────────────
const protocols = ["ssh", "telnet", "raw", "github", "vnc"];
const protocols = ["ssh", "telnet", "raw", "github", "vnc", "irc"];
const tabBar = createElement("div", "drawer-protocol-tabs");
for (const proto of protocols) {
const label = proto === "github" ? "GitHub" : proto.toUpperCase();
const label =
proto === "github" ? "GitHub" : proto === "irc" ? "IRC" : proto.toUpperCase();
const btn = createElement(
"button",
`drawer-proto-tab ${profile.protocol === proto ? "active" : ""}`,
@@ -208,6 +234,10 @@
case "vnc":
this._renderVNCSections(profile).forEach((s) => body.appendChild(s));
break;
case "irc":
this._renderIRCSections(profile).forEach((s) => body.appendChild(s));
body.appendChild(sshKeyDatalist);
break;
}
// Terminal section (SSH and Telnet only)
@@ -437,10 +467,10 @@
_loadSSHKeyOptions() {
if (this._sshKeyOptionsLoading || this._sshKeyOptionsLoaded) return;
if (!window.go || !window.go.api || !window.go.api.Service) return;
if (!window.NexusTerminal?.wailsService) return;
this._sshKeyOptionsLoading = true;
window.go.api.Service.ListSSHKeys()
window.NexusTerminal.wailsService.ListSSHKeys()
.then((keys) => {
this._sshKeyOptions = Array.isArray(keys) ? keys : [];
this._sshKeyOptionsLoaded = true;
@@ -1075,6 +1105,185 @@
return sections;
}
/** @private */
_renderIRCSections(profile) {
const sections = [];
const irc = profile.irc || DEFAULTS.irc.irc;
sections.push(
this._renderSection("IRC", [
this._field("text", "irc.nick", "Nick", irc.nick, {
placeholder: "yourNick",
}),
this._field("text", "irc.realName", "Real Name", irc.realName, {
placeholder: "Shown in WHOIS",
}),
this._field("toggle", "irc.useTLS", "Use TLS", irc.useTLS, {
help: "Use port 6697 for most TLS IRC networks.",
}),
this._field(
"toggle",
"irc.serverPasswordRequired",
"Server Password",
irc.serverPasswordRequired,
{
help: "Prompt for an IRC PASS secret before registration.",
},
),
this._field(
"select",
"irc.serverPasswordMode",
"Server Password Storage",
irc.serverPasswordMode,
{
options: [
{ value: "saved", label: "Save to keychain" },
{ value: "session-only", label: "Remember for session" },
{ value: "ask-every-time", label: "Ask every time" },
],
},
),
]),
);
sections.push(
this._renderSection("NickServ", [
this._field(
"toggle",
"irc.nickServIdentify",
"Identify on Connect",
irc.nickServIdentify,
),
this._field(
"text",
"irc.nickServAccount",
"Account",
irc.nickServAccount,
{
placeholder: "Optional if it matches your nick",
},
),
this._field(
"select",
"irc.nickServMode",
"Password Storage",
irc.nickServMode,
{
options: [
{ value: "saved", label: "Save to keychain" },
{ value: "session-only", label: "Remember for session" },
{ value: "ask-every-time", label: "Ask every time" },
],
},
),
]),
);
sections.push(
this._renderSection("Rooms", [
this._field(
"textarea",
"irc.autoJoinRoomsText",
"Auto-Join Rooms",
this._roomsToText(irc.autoJoinRooms),
{
placeholder: "#general\n#ops",
rows: 4,
help: "One room per line or comma-separated.",
},
),
]),
);
sections.push(
this._renderSection("SSH Tunnel", [
this._field(
"toggle",
"irc.sshTunnel",
"Enable SSH Tunnel",
irc.sshTunnel,
),
this._field("text", "irc.sshHost", "SSH Host", irc.sshHost, {
placeholder: "jump.example.com",
}),
this._field("number", "irc.sshPort", "SSH Port", irc.sshPort, {
min: 1,
max: 65535,
}),
this._field(
"text",
"irc.sshUsername",
"SSH Username",
irc.sshUsername,
{
placeholder: "admin",
},
),
this._field(
"select",
"irc.sshAuthMethod",
"SSH Auth Method",
irc.sshAuthMethod,
{
options: [
{ value: "publickey", label: "Public Key" },
{ value: "password", label: "Password" },
],
},
),
this._field(
"text",
"irc.sshPrivateKey",
"SSH Private Key Path",
irc.sshPrivateKey,
{
placeholder: "~/.ssh/id_ed25519",
datalistId: "ssh-key-path-options",
},
),
this._field(
"toggle",
"irc.sshAgentAuth",
"Use SSH Agent",
irc.sshAgentAuth,
),
this._field(
"select",
"irc.sshSecretMode",
"SSH Secret Storage",
irc.sshSecretMode,
{
options: [
{ value: "saved", label: "Save to keychain" },
{ value: "session-only", label: "Remember for session" },
{ value: "ask-every-time", label: "Ask every time" },
],
},
),
]),
);
sections.push(
this._renderSection("Reconnection", [
this._field(
"toggle",
"irc.autoReconnect",
"Auto-Reconnect",
irc.autoReconnect,
),
this._field(
"number",
"irc.maxReconnectTries",
"Max Reconnect Attempts",
irc.maxReconnectTries || 5,
{ min: 1, max: 20 },
),
]),
);
return sections;
}
/** @private */
_renderTerminalSection(profile) {
return this._renderSection("Terminal", [
@@ -1215,7 +1424,7 @@
const profileId = profile.id;
if (profileId) {
try {
const code = await window.go.api.Service.GetTOTPCode(profileId);
const code = await window.NexusTerminal.wailsService.GetTOTPCode(profileId);
testBtn.textContent = code;
setTimeout(() => {
testBtn.textContent = "Test";
@@ -1254,7 +1463,7 @@
if (!secret) {
// Delete existing secret
try {
await window.go.api.Service.DeleteTOTPSecret(profileId);
await window.NexusTerminal.wailsService.DeleteTOTPSecret(profileId);
NexusTerminal.Notifications.show("TOTP secret removed.", "info");
} catch (err) {
NexusTerminal.Notifications.show(
@@ -1265,7 +1474,7 @@
return;
}
try {
await window.go.api.Service.SetTOTPSecret(profileId, secret);
await window.NexusTerminal.wailsService.SetTOTPSecret(profileId, secret);
NexusTerminal.Notifications.show(
"TOTP secret saved securely.",
"success",
@@ -1295,7 +1504,7 @@
const profileId = profile.id;
if (!profileId) return;
try {
await window.go.api.Service.DeleteTOTPSecret(profileId);
await window.NexusTerminal.wailsService.DeleteTOTPSecret(profileId);
NexusTerminal.Notifications.show("TOTP secret removed.", "info");
secretInput.placeholder =
"Base32-encoded secret (e.g. from authenticator app setup)";
@@ -1319,7 +1528,7 @@
if (profile.id) {
(async () => {
try {
const has = await window.go.api.Service.HasTOTPSecret(profile.id);
const has = await window.NexusTerminal.wailsService.HasTOTPSecret(profile.id);
if (has) {
secretInput.placeholder = "••••••••• (secret saved)";
}
@@ -1763,8 +1972,8 @@
});
browseBtn.addEventListener("mousedown", (e) => e.preventDefault());
browseBtn.addEventListener("click", () => {
if (!window.go || !window.go.api || !window.go.api.Service) return;
window.go.api.Service.SelectFile("Select SSH Private Key", "*")
if (!window.NexusTerminal?.wailsService) return;
window.NexusTerminal.wailsService.SelectFile("Select SSH Private Key", "*")
.then((path) => {
if (path) input.value = path;
})
@@ -1831,8 +2040,8 @@
browseBtn.addEventListener("mousedown", (e) => e.preventDefault());
browseBtn.addEventListener("click", () => {
if (!window.go || !window.go.api || !window.go.api.Service) return;
window.go.api.Service.SelectFile("Select SSH Private Key", "*")
if (!window.NexusTerminal?.wailsService) return;
window.NexusTerminal.wailsService.SelectFile("Select SSH Private Key", "*")
.then((path) => {
if (path) keyInput.value = path;
this._validateKeyPath(keyInput, error);
@@ -1908,10 +2117,10 @@
}
return;
}
if (!window.go || !window.go.api || !window.go.api.Service) {
if (!window.NexusTerminal?.wailsService) {
return;
}
window.go.api.Service.FileExists(value)
window.NexusTerminal.wailsService.FileExists(value)
.then((exists) => {
if (!exists) {
errorEl.textContent = "Key file not found.";
@@ -1922,7 +2131,7 @@
}
return;
}
return window.go.api.Service.FileIsPrivateKey(value).then((isKey) => {
return window.NexusTerminal.wailsService.FileIsPrivateKey(value).then((isKey) => {
if (!isKey) {
errorEl.textContent = "File is not a recognised private key.";
errorEl.style.display = "block";
@@ -2275,6 +2484,34 @@
p.vnc.sshAgentAuth = val("vnc.sshAgentAuth") || false;
p.vnc.sshSecretMode = val("vnc.sshSecretMode") || "saved";
}
// IRC-specific
if (p.protocol === "irc") {
if (!p.irc) p.irc = {};
p.irc.nick = val("irc.nick") || "nexus";
p.irc.realName = val("irc.realName") || "Nexus Terminal";
p.irc.useTLS = val("irc.useTLS") || false;
p.irc.serverPasswordRequired =
val("irc.serverPasswordRequired") || false;
p.irc.serverPasswordMode =
val("irc.serverPasswordMode") || "ask-every-time";
p.irc.nickServIdentify = val("irc.nickServIdentify") || false;
p.irc.nickServAccount = val("irc.nickServAccount") || "";
p.irc.nickServMode = val("irc.nickServMode") || "saved";
p.irc.autoJoinRooms = this._parseRoomList(
val("irc.autoJoinRoomsText") || "",
);
p.irc.sshTunnel = val("irc.sshTunnel") || false;
p.irc.sshHost = val("irc.sshHost") || "";
p.irc.sshPort = val("irc.sshPort") || 22;
p.irc.sshUsername = val("irc.sshUsername") || "";
p.irc.sshAuthMethod = val("irc.sshAuthMethod") || "publickey";
p.irc.sshPrivateKey = val("irc.sshPrivateKey") || "";
p.irc.sshAgentAuth = val("irc.sshAgentAuth") || false;
p.irc.sshSecretMode = val("irc.sshSecretMode") || "saved";
p.irc.autoReconnect = val("irc.autoReconnect") !== false;
p.irc.maxReconnectTries = val("irc.maxReconnectTries") || 5;
}
}
// ── Validation ───────────────────────────────────────────────────
@@ -2300,6 +2537,25 @@
if (!profile.port || profile.port < 1 || profile.port > 65535) {
errors.push("Port must be between 1 and 65535.");
}
if (profile.protocol === "irc") {
const nick = profile.irc?.nick || "";
if (!nick.trim()) {
errors.push("IRC nick is required.");
}
for (const room of profile.irc?.autoJoinRooms || []) {
if (!/^[#&+!][^\s,]+$/.test(room)) {
errors.push(`Invalid IRC room "${room}".`);
}
}
if (profile.irc?.sshTunnel) {
if (!profile.irc.sshHost?.trim()) {
errors.push("SSH Host is required when tunnelling IRC.");
}
if (!profile.irc.sshUsername?.trim()) {
errors.push("SSH Username is required when tunnelling IRC.");
}
}
}
return errors;
}
@@ -2314,14 +2570,25 @@
return;
}
if (
this.app._requiresDirectVNCWarning &&
this.app._requiresDirectVNCWarning(this._profile)
) {
const ok = await this.app.confirmDirectVNCProfile(
this._profile,
andConnect ? "connect" : "save",
);
if (!ok) return;
}
try {
let saved;
if (this._isNew) {
saved = await window.go.api.Service.CreateProfile(this._profile);
saved = await window.NexusTerminal.wailsService.CreateProfile(this._profile);
this.app.state.addProfile(saved);
this.app.notifications.success("Profile created");
} else {
saved = await window.go.api.Service.UpdateProfile(this._profile);
saved = await window.NexusTerminal.wailsService.UpdateProfile(this._profile);
this.app.state.updateProfile(saved);
this.app.notifications.success("Profile updated");
}
@@ -2329,7 +2596,7 @@
this.close();
if (andConnect && saved && saved.id) {
this.app.connectProfile(saved.id);
this.app.connectProfile(saved.id, { skipDirectVNCWarning: true });
}
} catch (err) {
this.app.notifications.error("Save failed: " + err);
@@ -2342,7 +2609,7 @@
if (!ok) return;
try {
await window.go.api.Service.DeleteProfile(this._profile.id);
await window.NexusTerminal.wailsService.DeleteProfile(this._profile.id);
this.app.state.removeProfile(this._profile.id);
this.app.sidebar.render();
this.app.notifications.success("Profile deleted");
@@ -2366,7 +2633,7 @@
this.app.notifications.info(`Testing connection to ${host}:${port}…`);
try {
const result = await window.go.api.Service.TestConnection(host, port);
const result = await window.NexusTerminal.wailsService.TestConnection(host, port);
this.app.notifications.success(
`Reachable — ${result.latencyMs}ms latency`,
);
@@ -2465,6 +2732,7 @@
raw: null,
github: null,
vnc: null,
irc: null,
};
const defaults = DEFAULTS[protocol] || {};
@@ -2483,6 +2751,17 @@
.map(([k, v]) => `${k}=${v}`)
.join("\n");
}
_roomsToText(rooms) {
return Array.isArray(rooms) ? rooms.join("\n") : "";
}
_parseRoomList(value) {
return String(value || "")
.split(/[\n,]/)
.map((room) => room.trim())
.filter(Boolean);
}
}
window.NexusTerminal = window.NexusTerminal || {};
+402 -347
View File
@@ -1,30 +1,31 @@
(function() {
/**
* Nexus Terminal — RAW TCP data viewer.
* Provides text, hex, and split display modes for raw socket data,
* along with a send bar, pause/resume, and capture saving.
*/
(function () {
/**
* Nexus Terminal — RAW TCP data viewer.
* Provides text, hex, and split display modes for raw socket data,
* along with a send bar, pause/resume, and capture saving.
*/
const { createElement, escapeHtml, formatTimestamp, formatBytes } = window.NexusTerminal;
const { createElement, escapeHtml, formatTimestamp, formatBytes } =
window.NexusTerminal;
/**
* Maximum number of data entries retained in the scrollback buffer.
* @type {number}
*/
const MAX_BUFFER_ENTRIES = 5000;
/**
* Maximum number of data entries retained in the scrollback buffer.
* @type {number}
*/
const MAX_BUFFER_ENTRIES = 5000;
class RawViewer {
class RawViewer {
/**
* @param {App} app
*/
constructor(app) {
this.app = app;
this.app = app;
/**
* Map of sessionId → viewer state.
* @type {Map<string, Object>}
*/
this.viewers = new Map();
/**
* Map of sessionId → viewer state.
* @type {Map<string, Object>}
*/
this.viewers = new Map();
}
// ── Public API ───────────────────────────────────────────────────
@@ -36,71 +37,85 @@ class RawViewer {
* @param {Object} profile
*/
createViewer(sessionId, containerId, profile) {
const container = document.getElementById(containerId);
if (!container) return;
const container = document.getElementById(containerId);
if (!container) return;
const state = {
mode: 'text', // text | hex | split
paused: false,
buffer: [],
totalBytes: 0,
sendFormat: 'text', // text | hex | escaped
};
const state = {
mode: "text", // text | hex | split
paused: false,
buffer: [],
totalBytes: 0,
sendFormat: "text", // text | hex | escaped
};
// ── Toolbar ──────────────────────────────────────────────
const toolbar = createElement('div', 'raw-toolbar');
// ── Toolbar ──────────────────────────────────────────────
const toolbar = createElement("div", "raw-toolbar");
// Mode toggle
const modeGroup = createElement('div', 'raw-mode-group');
for (const mode of ['text', 'hex', 'split']) {
const btn = createElement('button', `raw-mode-btn ${mode === state.mode ? 'active' : ''}`, {
textContent: mode.charAt(0).toUpperCase() + mode.slice(1),
dataset: { mode },
});
btn.addEventListener('click', () => this._setMode(sessionId, mode));
modeGroup.appendChild(btn);
}
toolbar.appendChild(modeGroup);
// Mode toggle
const modeGroup = createElement("div", "raw-mode-group");
for (const mode of ["text", "hex", "split"]) {
const btn = createElement(
"button",
`raw-mode-btn ${mode === state.mode ? "active" : ""}`,
{
textContent: mode.charAt(0).toUpperCase() + mode.slice(1),
dataset: { mode },
},
);
btn.addEventListener("click", () => this._setMode(sessionId, mode));
modeGroup.appendChild(btn);
}
toolbar.appendChild(modeGroup);
// Controls
const controls = createElement('div', 'raw-controls');
// Controls
const controls = createElement("div", "raw-controls");
const pauseBtn = createElement('button', 'btn btn-sm btn-outline raw-pause', { textContent: '⏸ Pause' });
pauseBtn.addEventListener('click', () => this.togglePause(sessionId));
controls.appendChild(pauseBtn);
const pauseBtn = createElement(
"button",
"btn btn-sm btn-outline raw-pause",
{ textContent: "⏸ Pause" },
);
pauseBtn.addEventListener("click", () => this.togglePause(sessionId));
controls.appendChild(pauseBtn);
const clearBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '✕ Clear' });
clearBtn.addEventListener('click', () => this.clearBuffer(sessionId));
controls.appendChild(clearBtn);
const clearBtn = createElement("button", "btn btn-sm btn-outline", {
textContent: "✕ Clear",
});
clearBtn.addEventListener("click", () => this.clearBuffer(sessionId));
controls.appendChild(clearBtn);
const saveBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '💾 Save' });
saveBtn.addEventListener('click', () => this.saveCapture(sessionId));
controls.appendChild(saveBtn);
const saveBtn = createElement("button", "btn btn-sm btn-outline", {
textContent: "💾 Save",
});
saveBtn.addEventListener("click", () => this.saveCapture(sessionId));
controls.appendChild(saveBtn);
// Byte counter
const counter = createElement('span', 'raw-counter', { textContent: '0 B' });
controls.appendChild(counter);
// Byte counter
const counter = createElement("span", "raw-counter", {
textContent: "0 B",
});
controls.appendChild(counter);
toolbar.appendChild(controls);
container.appendChild(toolbar);
toolbar.appendChild(controls);
container.appendChild(toolbar);
// ── Data display ─────────────────────────────────────────
const display = createElement('div', 'raw-display');
container.appendChild(display);
// ── Data display ─────────────────────────────────────────
const display = createElement("div", "raw-display");
container.appendChild(display);
// ── Send bar ─────────────────────────────────────────────
const sendBar = this._renderSendBar(sessionId, profile);
container.appendChild(sendBar);
// ── Send bar ─────────────────────────────────────────────
const sendBar = this._renderSendBar(sessionId, profile);
container.appendChild(sendBar);
this.viewers.set(sessionId, {
...state,
container,
display,
toolbar,
pauseBtn,
counter,
profile,
});
this.viewers.set(sessionId, {
...state,
container,
display,
toolbar,
pauseBtn,
counter,
profile,
});
}
/**
@@ -108,45 +123,45 @@ class RawViewer {
* @param {Object} payload - { sessionID, data, direction, timestamp }
*/
handleData(payload) {
const viewer = this.viewers.get(payload.sessionID);
if (!viewer) return;
const viewer = this.viewers.get(payload.sessionID);
if (!viewer) return;
// Decode base64 payload
let rawStr;
try {
rawStr = atob(payload.data);
} catch (_) {
rawStr = payload.data || '';
}
// Decode base64 payload
let rawStr;
try {
rawStr = atob(payload.data);
} catch (_) {
rawStr = payload.data || "";
}
const bytes = new Uint8Array(rawStr.length);
for (let i = 0; i < rawStr.length; i++) {
bytes[i] = rawStr.charCodeAt(i);
}
const bytes = new Uint8Array(rawStr.length);
for (let i = 0; i < rawStr.length; i++) {
bytes[i] = rawStr.charCodeAt(i);
}
const entry = {
direction: payload.direction || 'recv', // 'send' | 'recv'
timestamp: payload.timestamp ? new Date(payload.timestamp) : new Date(),
data: rawStr,
bytes,
};
const entry = {
direction: payload.direction || "recv", // 'send' | 'recv'
timestamp: payload.timestamp ? new Date(payload.timestamp) : new Date(),
data: rawStr,
bytes,
};
viewer.buffer.push(entry);
viewer.totalBytes += bytes.length;
viewer.buffer.push(entry);
viewer.totalBytes += bytes.length;
// Trim buffer if it exceeds the maximum
if (viewer.buffer.length > MAX_BUFFER_ENTRIES) {
viewer.buffer.splice(0, viewer.buffer.length - MAX_BUFFER_ENTRIES);
}
// Trim buffer if it exceeds the maximum
if (viewer.buffer.length > MAX_BUFFER_ENTRIES) {
viewer.buffer.splice(0, viewer.buffer.length - MAX_BUFFER_ENTRIES);
}
// Update counter
viewer.counter.textContent = formatBytes(viewer.totalBytes);
// Update counter
viewer.counter.textContent = formatBytes(viewer.totalBytes);
// Render unless paused
if (!viewer.paused) {
this._renderEntry(viewer, entry);
this._scrollToBottom(viewer);
}
// Render unless paused
if (!viewer.paused) {
this._renderEntry(viewer, entry);
this._scrollToBottom(viewer);
}
}
/**
@@ -154,17 +169,17 @@ class RawViewer {
* @param {string} sessionId
*/
togglePause(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
viewer.paused = !viewer.paused;
viewer.pauseBtn.textContent = viewer.paused ? '▶ Resume' : '⏸ Pause';
viewer.pauseBtn.classList.toggle('active', viewer.paused);
viewer.paused = !viewer.paused;
viewer.pauseBtn.textContent = viewer.paused ? "▶ Resume" : "⏸ Pause";
viewer.pauseBtn.classList.toggle("active", viewer.paused);
// On resume, flush any buffered entries
if (!viewer.paused) {
this._renderAllEntries(viewer);
}
// On resume, flush any buffered entries
if (!viewer.paused) {
this._renderAllEntries(viewer);
}
}
/**
@@ -172,13 +187,13 @@ class RawViewer {
* @param {string} sessionId
*/
clearBuffer(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
viewer.buffer = [];
viewer.totalBytes = 0;
viewer.display.innerHTML = '';
viewer.counter.textContent = '0 B';
viewer.buffer = [];
viewer.totalBytes = 0;
viewer.display.innerHTML = "";
viewer.counter.textContent = "0 B";
}
/**
@@ -186,24 +201,24 @@ class RawViewer {
* @param {string} sessionId
*/
async saveCapture(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer || viewer.buffer.length === 0) {
this.app.notifications.warning('Nothing to save');
return;
}
const viewer = this.viewers.get(sessionId);
if (!viewer || viewer.buffer.length === 0) {
this.app.notifications.warning("Nothing to save");
return;
}
try {
const lines = viewer.buffer.map((e) => {
const ts = formatTimestamp(e.timestamp);
const dir = e.direction === 'send' ? 'TX' : 'RX';
return `[${ts}] [${dir}] ${e.data}`;
});
const content = lines.join('\n');
await window.go.api.Service.SaveSessionCapture(sessionId, content);
this.app.notifications.success('Capture saved');
} catch (err) {
this.app.notifications.error('Save failed: ' + err);
}
try {
const lines = viewer.buffer.map((e) => {
const ts = formatTimestamp(e.timestamp);
const dir = e.direction === "send" ? "TX" : "RX";
return `[${ts}] [${dir}] ${e.data}`;
});
const content = lines.join("\n");
await window.NexusTerminal.wailsService.SaveSessionCapture(sessionId, content);
this.app.notifications.success("Capture saved");
} catch (err) {
this.app.notifications.error("Save failed: " + err);
}
}
/**
@@ -211,45 +226,50 @@ class RawViewer {
* @param {string} sessionId
*/
async send(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
const input = viewer.container.querySelector('.raw-send-input');
const formatSelect = viewer.container.querySelector('.raw-send-format');
if (!input) return;
const input = viewer.container.querySelector(".raw-send-input");
const formatSelect = viewer.container.querySelector(".raw-send-format");
if (!input) return;
const rawValue = input.value;
if (!rawValue) return;
const rawValue = input.value;
if (!rawValue) return;
const format = formatSelect?.value || 'text';
let dataToSend;
const format = formatSelect?.value || "text";
let dataToSend;
switch (format) {
case 'hex':
dataToSend = this._hexToString(rawValue);
break;
case 'escaped':
dataToSend = this._unescapeString(rawValue);
break;
default:
dataToSend = rawValue;
}
switch (format) {
case "hex":
dataToSend = this._hexToString(rawValue);
break;
case "escaped":
dataToSend = this._unescapeString(rawValue);
break;
default:
dataToSend = rawValue;
}
try {
const b64 = btoa(dataToSend);
await window.go.api.Service.WriteSession(sessionId, b64);
input.value = '';
try {
// For hex/escaped, data is already raw bytes (0-255) so btoa is correct.
// For text mode, UTF-8 encode so non-ASCII characters are transmitted properly.
const b64 =
format === "hex" || format === "escaped"
? btoa(dataToSend)
: window.NexusTerminal.utf8ToBase64(dataToSend);
await window.NexusTerminal.wailsService.WriteSession(sessionId, b64);
input.value = "";
// Also show in viewer as sent data
this.handleData({
sessionID: sessionId,
data: b64,
direction: 'send',
timestamp: new Date().toISOString(),
});
} catch (err) {
this.app.notifications.error('Send failed: ' + err);
}
// Also show in viewer as sent data
this.handleData({
sessionID: sessionId,
data: b64,
direction: "send",
timestamp: new Date().toISOString(),
});
} catch (err) {
this.app.notifications.error("Send failed: " + err);
}
}
/**
@@ -257,7 +277,7 @@ class RawViewer {
* @param {string} sessionId
*/
destroyViewer(sessionId) {
this.viewers.delete(sessionId);
this.viewers.delete(sessionId);
}
// ── Rendering ────────────────────────────────────────────────────
@@ -270,76 +290,97 @@ class RawViewer {
* @private
*/
_renderSendBar(sessionId, profile) {
const bar = createElement('div', 'raw-send-bar');
const bar = createElement("div", "raw-send-bar");
// Format selector
const formatSelect = createElement('select', 'form-select form-select-sm raw-send-format');
for (const [value, label] of [['text', 'Text'], ['hex', 'Hex'], ['escaped', 'Escaped']]) {
const opt = createElement('option', '', { value, textContent: label });
formatSelect.appendChild(opt);
}
bar.appendChild(formatSelect);
// Format selector
const formatSelect = createElement(
"select",
"form-select form-select-sm raw-send-format",
);
for (const [value, label] of [
["text", "Text"],
["hex", "Hex"],
["escaped", "Escaped"],
]) {
const opt = createElement("option", "", { value, textContent: label });
formatSelect.appendChild(opt);
}
bar.appendChild(formatSelect);
// Input
const input = createElement('input', 'form-input raw-send-input', {
type: 'text',
placeholder: 'Type data to send…',
// Input
const input = createElement("input", "form-input raw-send-input", {
type: "text",
placeholder: "Type data to send…",
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") this.send(sessionId);
});
bar.appendChild(input);
// Quick-insert buttons for common control bytes
const quickBtns = createElement("div", "raw-quick-btns");
const controlBytes = [
{ label: "NUL", value: "\\x00" },
{ label: "CR", value: "\\r" },
{ label: "LF", value: "\\n" },
{ label: "ETX", value: "\\x03" },
{ label: "EOT", value: "\\x04" },
];
for (const cb of controlBytes) {
const btn = createElement("button", "btn btn-xs btn-outline", {
textContent: cb.label,
title: cb.value,
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') this.send(sessionId);
btn.addEventListener("click", () => {
input.value += cb.value;
input.focus();
});
bar.appendChild(input);
quickBtns.appendChild(btn);
}
bar.appendChild(quickBtns);
// Quick-insert buttons for common control bytes
const quickBtns = createElement('div', 'raw-quick-btns');
const controlBytes = [
{ label: 'NUL', value: '\\x00' },
{ label: 'CR', value: '\\r' },
{ label: 'LF', value: '\\n' },
{ label: 'ETX', value: '\\x03' },
{ label: 'EOT', value: '\\x04' },
];
for (const cb of controlBytes) {
const btn = createElement('button', 'btn btn-xs btn-outline', { textContent: cb.label, title: cb.value });
btn.addEventListener('click', () => {
input.value += cb.value;
input.focus();
});
quickBtns.appendChild(btn);
// Payload preset dropdown
const presets = profile?.raw?.payloadPresets || [];
if (presets.length > 0) {
const presetSelect = createElement(
"select",
"form-select form-select-sm raw-preset-select",
);
const defaultOpt = createElement("option", "", {
value: "",
textContent: "Presets…",
});
presetSelect.appendChild(defaultOpt);
for (const ps of presets) {
const opt = createElement("option", "", {
value: ps.payload,
textContent: ps.name,
});
opt.dataset.format = ps.format;
presetSelect.appendChild(opt);
}
bar.appendChild(quickBtns);
// Payload preset dropdown
const presets = profile?.raw?.payloadPresets || [];
if (presets.length > 0) {
const presetSelect = createElement('select', 'form-select form-select-sm raw-preset-select');
const defaultOpt = createElement('option', '', { value: '', textContent: 'Presets…' });
presetSelect.appendChild(defaultOpt);
for (const ps of presets) {
const opt = createElement('option', '', { value: ps.payload, textContent: ps.name });
opt.dataset.format = ps.format;
presetSelect.appendChild(opt);
presetSelect.addEventListener("change", () => {
if (presetSelect.value) {
input.value = presetSelect.value;
const selOpt = presetSelect.selectedOptions[0];
if (selOpt?.dataset.format) {
formatSelect.value = selOpt.dataset.format;
}
presetSelect.addEventListener('change', () => {
if (presetSelect.value) {
input.value = presetSelect.value;
const selOpt = presetSelect.selectedOptions[0];
if (selOpt?.dataset.format) {
formatSelect.value = selOpt.dataset.format;
}
presetSelect.value = '';
input.focus();
}
});
bar.appendChild(presetSelect);
}
presetSelect.value = "";
input.focus();
}
});
bar.appendChild(presetSelect);
}
// Send button
const sendBtn = createElement('button', 'btn btn-sm btn-primary', { textContent: 'Send' });
sendBtn.addEventListener('click', () => this.send(sessionId));
bar.appendChild(sendBtn);
// Send button
const sendBtn = createElement("button", "btn btn-sm btn-primary", {
textContent: "Send",
});
sendBtn.addEventListener("click", () => this.send(sessionId));
bar.appendChild(sendBtn);
return bar;
return bar;
}
/**
@@ -349,18 +390,18 @@ class RawViewer {
* @private
*/
_renderEntry(viewer, entry) {
let el;
switch (viewer.mode) {
case 'hex':
el = this._renderHexView(entry);
break;
case 'split':
el = this._renderSplitView(entry, viewer.profile);
break;
default:
el = this._renderTextView(entry, viewer.profile);
}
viewer.display.appendChild(el);
let el;
switch (viewer.mode) {
case "hex":
el = this._renderHexView(entry);
break;
case "split":
el = this._renderSplitView(entry, viewer.profile);
break;
default:
el = this._renderTextView(entry, viewer.profile);
}
viewer.display.appendChild(el);
}
/**
@@ -369,11 +410,11 @@ class RawViewer {
* @private
*/
_renderAllEntries(viewer) {
viewer.display.innerHTML = '';
for (const entry of viewer.buffer) {
this._renderEntry(viewer, entry);
}
this._scrollToBottom(viewer);
viewer.display.innerHTML = "";
for (const entry of viewer.buffer) {
this._renderEntry(viewer, entry);
}
this._scrollToBottom(viewer);
}
/**
@@ -384,21 +425,24 @@ class RawViewer {
* @private
*/
_renderTextView(entry, profile) {
const row = createElement('div', `raw-entry raw-entry-${entry.direction}`);
const raw = profile?.raw || {};
const row = createElement(
"div",
`raw-entry raw-entry-${entry.direction}`,
);
const raw = profile?.raw || {};
let prefix = '';
if (raw.showTimestamps) {
prefix += `[${formatTimestamp(entry.timestamp)}] `;
}
if (raw.showDirection) {
prefix += entry.direction === 'send' ? '[TX] ' : '[RX] ';
}
let prefix = "";
if (raw.showTimestamps) {
prefix += `[${formatTimestamp(entry.timestamp)}] `;
}
if (raw.showDirection) {
prefix += entry.direction === "send" ? "[TX] " : "[RX] ";
}
const pre = createElement('pre', 'raw-text');
pre.textContent = prefix + entry.data;
row.appendChild(pre);
return row;
const pre = createElement("pre", "raw-text");
pre.textContent = prefix + entry.data;
row.appendChild(pre);
return row;
}
/**
@@ -408,11 +452,14 @@ class RawViewer {
* @private
*/
_renderHexView(entry) {
const row = createElement('div', `raw-entry raw-entry-${entry.direction}`);
const pre = createElement('pre', 'raw-hex');
pre.textContent = this.formatHexDump(entry.bytes);
row.appendChild(pre);
return row;
const row = createElement(
"div",
`raw-entry raw-entry-${entry.direction}`,
);
const pre = createElement("pre", "raw-hex");
pre.textContent = this.formatHexDump(entry.bytes);
row.appendChild(pre);
return row;
}
/**
@@ -423,30 +470,34 @@ class RawViewer {
* @private
*/
_renderSplitView(entry, profile) {
const row = createElement('div', `raw-entry raw-entry-split raw-entry-${entry.direction}`);
const raw = profile?.raw || {};
const row = createElement(
"div",
`raw-entry raw-entry-split raw-entry-${entry.direction}`,
);
const raw = profile?.raw || {};
// Direction/timestamp header
const header = createElement('div', 'raw-split-header');
let headerText = '';
if (raw.showTimestamps) headerText += formatTimestamp(entry.timestamp) + ' ';
headerText += entry.direction === 'send' ? '▲ TX' : '▼ RX';
header.textContent = headerText;
row.appendChild(header);
// Direction/timestamp header
const header = createElement("div", "raw-split-header");
let headerText = "";
if (raw.showTimestamps)
headerText += formatTimestamp(entry.timestamp) + " ";
headerText += entry.direction === "send" ? "▲ TX" : "▼ RX";
header.textContent = headerText;
row.appendChild(header);
// Split panes
const panes = createElement('div', 'raw-split-panes');
// Split panes
const panes = createElement("div", "raw-split-panes");
const textPane = createElement('pre', 'raw-text raw-split-text');
textPane.textContent = entry.data;
panes.appendChild(textPane);
const textPane = createElement("pre", "raw-text raw-split-text");
textPane.textContent = entry.data;
panes.appendChild(textPane);
const hexPane = createElement('pre', 'raw-hex raw-split-hex');
hexPane.textContent = this.formatHexDump(entry.bytes);
panes.appendChild(hexPane);
const hexPane = createElement("pre", "raw-hex raw-split-hex");
hexPane.textContent = this.formatHexDump(entry.bytes);
panes.appendChild(hexPane);
row.appendChild(panes);
return row;
row.appendChild(panes);
return row;
}
/**
@@ -456,39 +507,39 @@ class RawViewer {
* @returns {string}
*/
formatHexDump(bytes) {
const lines = [];
const bytesPerLine = 16;
const lines = [];
const bytesPerLine = 16;
for (let offset = 0; offset < bytes.length; offset += bytesPerLine) {
const chunk = bytes.slice(offset, offset + bytesPerLine);
for (let offset = 0; offset < bytes.length; offset += bytesPerLine) {
const chunk = bytes.slice(offset, offset + bytesPerLine);
// Offset column
const offsetStr = offset.toString(16).padStart(8, '0');
// Offset column
const offsetStr = offset.toString(16).padStart(8, "0");
// Hex column
const hexParts = [];
for (let i = 0; i < bytesPerLine; i++) {
if (i < chunk.length) {
hexParts.push(chunk[i].toString(16).padStart(2, '0'));
} else {
hexParts.push(' ');
}
// Extra space after 8th byte for readability
if (i === 7) hexParts.push('');
}
const hexStr = hexParts.join(' ');
// Hex column
const hexParts = [];
for (let i = 0; i < bytesPerLine; i++) {
if (i < chunk.length) {
hexParts.push(chunk[i].toString(16).padStart(2, "0"));
} else {
hexParts.push(" ");
}
// Extra space after 8th byte for readability
if (i === 7) hexParts.push("");
}
const hexStr = hexParts.join(" ");
// ASCII column
let ascii = '';
for (let i = 0; i < chunk.length; i++) {
const b = chunk[i];
ascii += (b >= 0x20 && b <= 0x7e) ? String.fromCharCode(b) : '.';
}
lines.push(`${offsetStr} ${hexStr} |${ascii}|`);
// ASCII column
let ascii = "";
for (let i = 0; i < chunk.length; i++) {
const b = chunk[i];
ascii += b >= 0x20 && b <= 0x7e ? String.fromCharCode(b) : ".";
}
return lines.join('\n');
lines.push(`${offsetStr} ${hexStr} |${ascii}|`);
}
return lines.join("\n");
}
// ── Internal helpers ─────────────────────────────────────────────
@@ -500,17 +551,17 @@ class RawViewer {
* @private
*/
_setMode(sessionId, mode) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
viewer.mode = mode;
viewer.mode = mode;
// Update active button
viewer.toolbar.querySelectorAll('.raw-mode-btn').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.mode === mode);
});
// Update active button
viewer.toolbar.querySelectorAll(".raw-mode-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.mode === mode);
});
this._renderAllEntries(viewer);
this._renderAllEntries(viewer);
}
/**
@@ -519,9 +570,9 @@ class RawViewer {
* @private
*/
_scrollToBottom(viewer) {
requestAnimationFrame(() => {
viewer.display.scrollTop = viewer.display.scrollHeight;
});
requestAnimationFrame(() => {
viewer.display.scrollTop = viewer.display.scrollHeight;
});
}
/**
@@ -531,12 +582,12 @@ class RawViewer {
* @private
*/
_hexToString(hex) {
const cleaned = hex.replace(/\s+/g, '');
let str = '';
for (let i = 0; i < cleaned.length; i += 2) {
str += String.fromCharCode(parseInt(cleaned.substring(i, i + 2), 16));
}
return str;
const cleaned = hex.replace(/\s+/g, "");
let str = "";
for (let i = 0; i < cleaned.length; i += 2) {
str += String.fromCharCode(parseInt(cleaned.substring(i, i + 2), 16));
}
return str;
}
/**
@@ -546,25 +597,29 @@ class RawViewer {
* @private
*/
_unescapeString(str) {
return str.replace(/\\(n|r|t|0|x[0-9a-fA-F]{2}|\\)/g, (match, seq) => {
switch (seq) {
case 'n': return '\n';
case 'r': return '\r';
case 't': return '\t';
case '0': return '\0';
case '\\': return '\\';
default:
// \\xHH
if (seq.startsWith('x')) {
return String.fromCharCode(parseInt(seq.slice(1), 16));
}
return match;
return str.replace(/\\(n|r|t|0|x[0-9a-fA-F]{2}|\\)/g, (match, seq) => {
switch (seq) {
case "n":
return "\n";
case "r":
return "\r";
case "t":
return "\t";
case "0":
return "\0";
case "\\":
return "\\";
default:
// \\xHH
if (seq.startsWith("x")) {
return String.fromCharCode(parseInt(seq.slice(1), 16));
}
});
return match;
}
});
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.RawViewer = RawViewer;
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.RawViewer = RawViewer;
})();
+74 -11
View File
@@ -41,7 +41,7 @@ class RecordingManager {
const cols = entry ? entry.term.cols : 80;
const rows = entry ? entry.term.rows : 24;
await window.go.api.Service.StartRecording(sessionId, cols, rows);
await window.NexusTerminal.wailsService.StartRecording(sessionId, cols, rows);
this.activeSessions.add(sessionId);
this._updateRecordingIndicator(sessionId, true);
this.app.notifications.success('Recording started');
@@ -56,7 +56,7 @@ class RecordingManager {
*/
async stop(sessionId) {
try {
await window.go.api.Service.StopRecording(sessionId);
await window.NexusTerminal.wailsService.StopRecording(sessionId);
this.activeSessions.delete(sessionId);
this._updateRecordingIndicator(sessionId, false);
this.app.notifications.success('Recording saved');
@@ -70,8 +70,11 @@ class RecordingManager {
*/
async openBrowser() {
try {
const recordings = await window.go.api.Service.ListRecordings();
this._showBrowserModal(recordings || []);
const [recordings, storageInfo] = await Promise.all([
window.NexusTerminal.wailsService.ListRecordings(),
window.NexusTerminal.wailsService.GetRecordingStorageInfo(),
]);
this._showBrowserModal(recordings || [], storageInfo || {});
} catch (err) {
this.app.notifications.error('Failed to list recordings: ' + err);
}
@@ -83,7 +86,7 @@ class RecordingManager {
*/
async playback(id) {
try {
const data = await window.go.api.Service.GetRecording(id);
const data = await window.NexusTerminal.wailsService.GetRecording(id);
this._showPlaybackModal(data);
} catch (err) {
this.app.notifications.error('Failed to load recording: ' + err);
@@ -96,7 +99,7 @@ class RecordingManager {
*/
async deleteRecording(id) {
try {
await window.go.api.Service.DeleteRecording(id);
await window.NexusTerminal.wailsService.DeleteRecording(id);
this.app.notifications.success('Recording deleted');
} catch (err) {
this.app.notifications.error('Failed to delete recording: ' + err);
@@ -133,9 +136,10 @@ class RecordingManager {
/**
* Renders a modal listing all saved recordings.
* @param {Array} recordings
* @param {Object} storageInfo
* @private
*/
_showBrowserModal(recordings) {
_showBrowserModal(recordings, storageInfo = {}) {
const backdrop = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-lg');
@@ -164,12 +168,36 @@ class RecordingManager {
listHTML += '</div>';
}
const totalSize = _formatBytes(storageInfo.totalBytes || 0);
const count = storageInfo.count ?? recordings.length;
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Session Recordings</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body" style="max-height:60vh;overflow-y:auto;">
<div class="recording-storage">
<div class="recording-storage-summary">
<span>${count} recording${count === 1 ? '' : 's'}</span>
<span>${totalSize}</span>
</div>
<div class="recording-cleanup-controls">
<select class="form-input recording-cleanup-age">
<option value="30">Older than 30 days</option>
<option value="7">Older than 7 days</option>
<option value="90">Older than 90 days</option>
<option value="0">Ignore age</option>
</select>
<select class="form-input recording-cleanup-size">
<option value="1073741824">Keep under 1 GB</option>
<option value="5368709120">Keep under 5 GB</option>
<option value="0">Ignore size</option>
</select>
<button class="btn btn-sm recording-cleanup">Clean up</button>
</div>
<div class="form-help recording-cleanup-status"></div>
</div>
${listHTML}
</div>
<div class="modal-footer">
@@ -189,6 +217,26 @@ class RecordingManager {
modal.querySelector('.modal-close').addEventListener('click', close);
modal.querySelector('.modal-close-btn').addEventListener('click', close);
modal.querySelector('.recording-cleanup').addEventListener('click', async () => {
const ageDays = parseInt(modal.querySelector('.recording-cleanup-age').value, 10) || 0;
const maxBytes = parseInt(modal.querySelector('.recording-cleanup-size').value, 10) || 0;
const status = modal.querySelector('.recording-cleanup-status');
const btn = modal.querySelector('.recording-cleanup');
status.textContent = '';
btn.disabled = true;
try {
const result = await window.NexusTerminal.wailsService.CleanupRecordings(ageDays, maxBytes);
status.textContent = `Deleted ${result.deleted || 0}; freed ${_formatBytes(result.freedBytes || 0)}.`;
const fresh = await window.NexusTerminal.wailsService.ListRecordings();
close();
this._showBrowserModal(fresh || [], await window.NexusTerminal.wailsService.GetRecordingStorageInfo());
} catch (err) {
status.textContent = 'Cleanup failed: ' + err;
} finally {
btn.disabled = false;
}
});
// Play buttons
modal.querySelectorAll('.rec-play').forEach(btn => {
btn.addEventListener('click', () => {
@@ -508,7 +556,7 @@ class ExportImportUI {
}
try {
const path = await window.go.api.Service.ExportProfilesEncrypted(selectedIds, password);
const path = await window.NexusTerminal.wailsService.ExportProfilesEncrypted(selectedIds, password);
this.app.notifications.success(`Exported ${selectedIds.length} profile(s) to ${path}`);
close();
} catch (err) {
@@ -545,6 +593,7 @@ class ExportImportUI {
<option value="duplicate">Create duplicates</option>
</select>
</div>
<div class="form-error import-status" aria-live="polite"></div>
</div>
<div class="modal-footer">
<button class="btn btn-sm import-cancel">Cancel</button>
@@ -567,14 +616,19 @@ class ExportImportUI {
modal.querySelector('.import-confirm').addEventListener('click', async () => {
const password = modal.querySelector('.import-password').value;
const strategy = modal.querySelector('.import-strategy').value;
const status = modal.querySelector('.import-status');
const confirmBtn = modal.querySelector('.import-confirm');
status.textContent = '';
if (!password) {
this.app.notifications.error('Please enter the decryption password.');
status.textContent = 'Please enter the decryption password.';
return;
}
try {
const count = await window.go.api.Service.ImportProfilesEncrypted(password, strategy);
confirmBtn.disabled = true;
confirmBtn.textContent = 'Importing...';
const count = await window.NexusTerminal.wailsService.ImportProfilesEncrypted(password, strategy);
this.app.notifications.success(`Imported ${count} profile(s)`);
await this.app.loadProfiles();
close();
@@ -582,10 +636,19 @@ class ExportImportUI {
if (String(err).includes('cancelled')) {
return;
}
this.app.notifications.error('Import failed: ' + err);
status.textContent = this._formatImportError(err);
this.app.notifications.error('Import failed');
} finally {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Import';
}
});
}
_formatImportError(err) {
const msg = String(err || 'Unknown import error');
return msg.replace(/\s+/g, ' ').trim();
}
}
// ---------------------------------------------------------------------------
+5 -1
View File
@@ -280,7 +280,11 @@ class SecretPrompt {
document.body.appendChild(overlay);
this._overlay = overlay;
acceptBtn.focus();
requestAnimationFrame(() => {
overlay.classList.add('is-visible');
modal.classList.add('is-visible');
acceptBtn.focus();
});
acceptBtn.addEventListener('click', () => {
this._close();
+101 -20
View File
@@ -48,13 +48,13 @@
this._activeTab = tabKey || this._activeTab || "general";
try {
this._knownHosts = (await window.go.api.Service.ListKnownHosts()) || [];
this._knownHosts = (await window.NexusTerminal.wailsService.ListKnownHosts()) || [];
} catch (_) {
this._knownHosts = [];
}
try {
this._defaultShell = await window.go.api.Service.GetDefaultShell();
this._defaultShell = await window.NexusTerminal.wailsService.GetDefaultShell();
} catch (_) {
this._defaultShell = "";
}
@@ -88,7 +88,7 @@
this._collectValues();
try {
await window.go.api.Service.SavePreferences(this._prefs);
await window.NexusTerminal.wailsService.SavePreferences(this._prefs);
this.app.state.setPreferences(this._prefs);
// Apply terminal colour theme change immediately.
@@ -312,7 +312,7 @@
});
browseBtn.addEventListener("click", async () => {
try {
const path = await window.go.api.Service.BrowseForShell();
const path = await window.NexusTerminal.wailsService.BrowseForShell();
if (path) {
shellInput.value = path;
}
@@ -429,7 +429,7 @@
initBtn.addEventListener("click", async () => {
try {
const pw = await this.app.secretPrompt.promptMasterPassword(true);
await window.go.api.Service.InitialiseSecretStore(pw);
await window.NexusTerminal.wailsService.InitialiseSecretStore(pw);
this.app.state.setSecretStoreInitialised(true);
this.app.state.setSecretStoreState(true);
this.app.notifications.success("Secret store created");
@@ -448,7 +448,7 @@
unlockBtn.addEventListener("click", async () => {
try {
const pw = await this.app.secretPrompt.promptMasterPassword(false);
await window.go.api.Service.UnlockSecretStore(pw);
await window.NexusTerminal.wailsService.UnlockSecretStore(pw);
this.app.state.setSecretStoreState(true);
this.app.notifications.success("Secret store unlocked");
this._renderTabContent(body);
@@ -465,7 +465,7 @@
});
lockBtn.addEventListener("click", async () => {
try {
await window.go.api.Service.LockSecretStore();
await window.NexusTerminal.wailsService.LockSecretStore();
this.app.state.setSecretStoreState(false);
this.app.notifications.info("Secret store locked");
this._renderTabContent(body);
@@ -522,7 +522,7 @@
});
deleteBtn.addEventListener("click", async () => {
try {
await window.go.api.Service.DeleteKnownHost(kh.id);
await window.NexusTerminal.wailsService.DeleteKnownHost(kh.id);
this._knownHosts = this._knownHosts.filter((h) => h.id !== kh.id);
this._renderTabContent(body);
this.app.notifications.success("Known host removed");
@@ -549,15 +549,17 @@
id: "getting-started",
title: "Getting Started",
content: `
<p>Welcome to <strong>Nexus Terminal</strong> — a desktop application for connecting to remote servers via SSH, Telnet, and RAW TCP, as well as browsing your GitHub repositories.</p>
<p>Welcome to <strong>Nexus Terminal</strong> — a desktop application for SSH, Telnet, RAW TCP, VNC, GitHub, local terminal, and Leviathan binary-analysis workflows.</p>
<h4>Creating Your First Connection</h4>
<ol>
<li>Click the <strong>+</strong> button at the top of the sidebar, or press <kbd>⌘N</kbd> (Mac) / <kbd>Ctrl+N</kbd> (Windows/Linux).</li>
<li>Choose a protocol (SSH, Telnet, RAW, or GitHub).</li>
<li>Choose a protocol (SSH, Telnet, RAW, VNC, or GitHub).</li>
<li>Fill in the hostname, port, and any credentials required.</li>
<li>Optionally assign the connection to a <strong>group</strong> to keep your sidebar organised.</li>
<li>Click <strong>Save</strong>, then double-click the entry in the sidebar to connect.</li>
</ol>
<h4>Leviathan Tools</h4>
<p>Click <strong>Leviathan</strong> beside <strong>Nexus Terminal</strong> in the sidebar header to switch from connection profiles to binary-analysis tools. Use <strong>Load</strong> to choose a local file, then open tools such as Hex Editor, Overview, Entropy, Carve, BinSnoop, Structures, and Spectrogram View.</p>
<h4>Quick Connect</h4>
<p>Need to connect quickly without saving a profile? Press <kbd>⌘⇧Q</kbd> / <kbd>Ctrl+Shift+Q</kbd> to open the quick connect dialog. Choose SSH, Telnet, or RAW, enter the host and port, and you're connected immediately.</p>
<h4>Managing Connections</h4>
@@ -631,6 +633,22 @@
</ul>
`,
},
{
id: "vnc",
title: "VNC Connections",
content: `
<p>VNC profiles open a remote desktop viewer inside the Nexus workspace.</p>
<ul>
<li><strong>Password handling</strong> — ask each time, use the session only, or save the password in the encrypted secret store.</li>
<li><strong>View-only mode</strong> — observe a desktop without sending mouse or keyboard input.</li>
<li><strong>Scale-to-fit</strong> — resize the remote desktop to fit the available workspace.</li>
<li><strong>Shared session</strong> — request a shared VNC session where the server supports it.</li>
<li><strong>Quality</strong> — tune image quality for slower links.</li>
<li><strong>SSH tunnel</strong> — carry VNC traffic through an SSH connection using password, public key, or SSH agent authentication.</li>
</ul>
<p>Prefer <strong>SSH tunnel</strong> for VNC outside trusted networks. Direct VNC is supported for isolated or trusted networks, but many classic VNC deployments do not provide strong transport encryption.</p>
`,
},
{
id: "github",
title: "GitHub Connections",
@@ -646,6 +664,35 @@
<p>Your repositories are listed grouped by organisation or owner, sorted by last commit date (newest first). The list refreshes automatically every few minutes.</p>
`,
},
{
id: "leviathan",
title: "Leviathan Binary Tools",
content: `
<p>Leviathan is the built-in binary-analysis and editing workbench. It is useful for firmware images, unknown binaries, archives, captures, embedded files, documents, and audio spectrogram experiments.</p>
<h4>Opening and Saving Files</h4>
<p>Switch to <strong>Leviathan</strong> in the sidebar header, click <strong>Load</strong>, and select a local file. The file becomes the active working document. Use <strong>Save</strong> to write the patched working copy to a destination you choose.</p>
<p>Leviathan tools open in normal tabs beside terminal and VNC sessions. They close immediately without a disconnect confirmation.</p>
<h4>Hex Editor</h4>
<ul>
<li>Edit byte cells directly; two hex digits commit the byte and move to the next cell.</li>
<li>The ASCII preview updates while editing.</li>
<li>Switch between 16-byte and 32-byte rows.</li>
<li>Search for strings or hex values and move through matches with previous/next.</li>
<li>Paste runs such as <code>de ad be ef</code> or <code>deadbeef</code> into consecutive cells.</li>
<li>Use Undo, Redo, and Revert from the Leviathan panel header.</li>
</ul>
<h4>Triage Tools</h4>
<p><strong>Overview</strong>, <strong>Hashes</strong>, <strong>Entropy</strong>, <strong>Strings</strong>, <strong>IOCs</strong>, <strong>Search</strong>, and <strong>Multi-search</strong> provide a first pass over the current document. The Entropy tool renders the whole-file graph and lets you open selected ranges in the Hex Editor.</p>
<h4>Structure and Recovery Tools</h4>
<p><strong>Structures</strong>, <strong>Disasm</strong>, <strong>PDF</strong>, <strong>SquashFS</strong>, <strong>Carve</strong>, <strong>Keys</strong>, <strong>Timestamps</strong>, <strong>Stego</strong>, <strong>Metadata</strong>, <strong>BinSnoop</strong>, and <strong>Rules</strong> help identify embedded formats, files, signatures, strings, keys, and suspicious structures. Carve and BinSnoop can export discovered files or ranges.</p>
<h4>Edit, Transform, and Case Tools</h4>
<p><strong>Patch Plan</strong> validates patch scripts, <strong>Patch Apply</strong> applies them, <strong>Transforms</strong> runs hex/Base64/URL/XOR/inflate/cipher pipelines, and <strong>Case Export</strong> packages investigation artefacts.</p>
<h4>Spectrogram Tools</h4>
<p><strong>Spectrogram Encode</strong> turns an image into WAV audio or inserts an encoded image into an existing WAV/MP3 carrier. <strong>Spectrogram View</strong> visualises WAV or MP3 audio as a spectrogram image with controls for FFT size, height, aspect ratio, frequency range, brightness, and contrast.</p>
<h4>Large Files</h4>
<p>Leviathan keeps loaded files file-backed where possible and saves patched working copies in chunks. Tools that require complete image, audio, PDF, or other parser buffers enforce explicit size limits and show a clear message if an input is too large.</p>
`,
},
{
id: "sessions",
title: "Sessions & Tabs",
@@ -655,7 +702,9 @@
<li><strong>Rename</strong> — right-click a tab and choose "Rename".</li>
<li><strong>Reorder</strong> — drag tabs left or right to rearrange.</li>
<li><strong>Pin</strong> — right-click and pin a tab so it stays at the left of the bar.</li>
<li><strong>Close</strong> — click the <strong>×</strong> on a tab, or press <kbd>⌘W</kbd> / <kbd>Ctrl+W</kbd>.</li>
<li><strong>Scroll</strong> — use the tab bar scrollbar or mouse wheel/trackpad over the tab strip when many tabs are open.</li>
<li><strong>Hover hints</strong> — hover a tab to see its full name and connected-since time where available.</li>
<li><strong>Close</strong> — click the <strong>×</strong> on the left side of a tab, or press <kbd>⌘W</kbd> / <kbd>Ctrl+W</kbd>. Leviathan tabs close without a disconnect confirmation.</li>
</ul>
<h4>Split Panes</h4>
<p>You can split the terminal into multiple panes:</p>
@@ -797,12 +846,26 @@
<li>If the server is genuinely unreachable, attempts will exhaust after the configured maximum (default 5).</li>
<li>Check the reconnection settings in the profile's Advanced section.</li>
</ul>
<h4>VNC will not connect</h4>
<ul>
<li>Check the VNC host and port. The common port is <code>5900</code> plus the display number.</li>
<li>If a password is required, check whether the profile is set to saved, session-only, or ask-every-time mode.</li>
<li>If using an SSH tunnel, verify the tunnel host, username, authentication method, private key, and host-key policy.</li>
<li>If using direct VNC, test from a trusted network and prefer SSH tunnelling on untrusted networks.</li>
</ul>
<h4>The terminal looks garbled or the wrong size</h4>
<ul>
<li>Try resizing the window — the terminal should re-fit automatically.</li>
<li>If you're using split panes, drag the divider to give the terminal more space.</li>
<li>Some older servers may not support modern terminal features. Try a different terminal theme in <strong>Settings → Terminal</strong>.</li>
</ul>
<h4>Leviathan cannot load, save, or run a tool</h4>
<ul>
<li>Check that the selected file still exists and that your account can read it.</li>
<li>When saving, choose a writable destination and avoid files that are locked by another process.</li>
<li>Some tools have explicit size limits because their parsers or decoders need a complete buffer. Use a smaller file or selected range when available.</li>
<li>If SquashFS reports bad magic, the file is probably not a SquashFS image; inspect the header in Hex Editor or try Overview/BinSnoop first.</li>
</ul>
<h4>"Host key verification failed"</h4>
<p>This means the server's fingerprint has changed since your last connection. This can happen if the server was reinstalled, but it could also indicate a security threat. If you're certain it's safe, go to <strong>Settings → Known Hosts</strong>, find the entry, and remove it. You'll be prompted to accept the new key on your next connection.</p>
<h4>I forgot my vault master password</h4>
@@ -911,8 +974,8 @@
const versionEl = body.querySelector("[data-role='app-version']");
if (versionEl) {
if (window.go && window.go.api && window.go.api.Service) {
window.go.api.Service.GetAppInfo()
if (window.NexusTerminal?.wailsService) {
window.NexusTerminal.wailsService.GetAppInfo()
.then((info) => {
const version = (info && info.version) || "dev";
versionEl.textContent = `Version ${version}`;
@@ -931,17 +994,35 @@
updateBtn.disabled = true;
updateBtn.textContent = "Checking…";
try {
const info = await window.go.api.Service.CheckForUpdates();
const info = await window.NexusTerminal.wailsService.CheckForUpdates();
const resultEl = body.querySelector("#settings-update-result");
if (this.app && typeof this.app.setUpdateInfo === "function") {
this.app.setUpdateInfo(info);
}
if (info.available) {
resultEl.innerHTML =
`<span style="color:var(--accent)">Update available: <strong>${info.latestVersion}</strong></span>` +
(info.releaseURL
? ` — <a href="#" onclick="window.runtime.BrowserOpenURL('${info.releaseURL}');return false;" style="color:var(--accent)">View release</a>`
: "");
resultEl.textContent = "";
const updateText = createElement("span");
updateText.style.color = "var(--accent)";
updateText.appendChild(document.createTextNode("Update available: "));
const versionText = createElement("strong");
versionText.textContent = String(info.latestVersion || "");
updateText.appendChild(versionText);
resultEl.appendChild(updateText);
if (info.releaseURL) {
resultEl.appendChild(document.createTextNode(" - "));
const releaseLink = createElement("a", "", {
href: "#",
textContent: "View release",
});
releaseLink.style.color = "var(--accent)";
releaseLink.addEventListener("click", (e) => {
e.preventDefault();
if (window.runtime && window.runtime.BrowserOpenURL) {
window.runtime.BrowserOpenURL(String(info.releaseURL));
}
});
resultEl.appendChild(releaseLink);
}
} else {
resultEl.textContent = "You are running the latest version.";
}
@@ -961,7 +1042,7 @@
importBtn.disabled = true;
importBtn.textContent = "Importing…";
try {
const count = await window.go.api.Service.ImportSSHConfig(
const count = await window.NexusTerminal.wailsService.ImportSSHConfig(
"",
"SSH Config",
);
+9 -9
View File
@@ -281,9 +281,9 @@
try {
if (path === null) {
path = await window.go.api.Service.SFTPPwd(sessionId);
path = await window.NexusTerminal.wailsService.SFTPPwd(sessionId);
}
var files = await window.go.api.Service.SFTPList(sessionId, path);
var files = await window.NexusTerminal.wailsService.SFTPList(sessionId, path);
state.cwd = path;
state.files = this._sortFiles(files || []);
state.loading = false;
@@ -603,7 +603,7 @@
async _download(sessionId, filePath) {
try {
await window.go.api.Service.SFTPDownload(sessionId, filePath);
await window.NexusTerminal.wailsService.SFTPDownload(sessionId, filePath);
this.app.notifications.success("Download started");
} catch (err) {
this.app.notifications.error(
@@ -616,7 +616,7 @@
var state = this.panels.get(sessionId);
if (!state) return;
try {
await window.go.api.Service.SFTPUpload(sessionId, state.cwd);
await window.NexusTerminal.wailsService.SFTPUpload(sessionId, state.cwd);
this.app.notifications.success("Upload complete");
await this._refresh(sessionId);
} catch (err) {
@@ -633,7 +633,7 @@
if (!state) return;
var fullPath = pathJoin(state.cwd, name);
try {
await window.go.api.Service.SFTPMkdir(sessionId, fullPath);
await window.NexusTerminal.wailsService.SFTPMkdir(sessionId, fullPath);
this.app.notifications.success("Directory created");
await this._refresh(sessionId);
} catch (err) {
@@ -646,7 +646,7 @@
if (!state) return;
var newPath = pathJoin(parentPath(oldPath), newName);
try {
await window.go.api.Service.SFTPRename(sessionId, oldPath, newPath);
await window.NexusTerminal.wailsService.SFTPRename(sessionId, oldPath, newPath);
this.app.notifications.success("Renamed");
await this._refresh(sessionId);
} catch (err) {
@@ -656,7 +656,7 @@
async _delete(sessionId, entry) {
try {
await window.go.api.Service.SFTPRemove(sessionId, entry.path);
await window.NexusTerminal.wailsService.SFTPRemove(sessionId, entry.path);
this.app.notifications.success(
(entry.isDir ? "Directory" : "File") + " deleted",
);
@@ -671,7 +671,7 @@
var failed = 0;
for (var i = 0; i < files.length; i++) {
try {
await window.go.api.Service.SFTPDownload(sessionId, files[i].path);
await window.NexusTerminal.wailsService.SFTPDownload(sessionId, files[i].path);
} catch (err) {
failed++;
this.app.notifications.error(
@@ -705,7 +705,7 @@
var failed = 0;
for (var i = 0; i < entries.length; i++) {
try {
await window.go.api.Service.SFTPRemove(sessionId, entries[i].path);
await window.NexusTerminal.wailsService.SFTPRemove(sessionId, entries[i].path);
} catch (err) {
failed++;
this.app.notifications.error(
+59 -10
View File
@@ -59,6 +59,10 @@
*/
render() {
if (!this.el) return;
if (this.app.mode === "leviathan") {
this.app.leviathan.renderSidebar();
return;
}
this.el.innerHTML = "";
const profiles = this._filteredProfiles();
@@ -117,11 +121,7 @@
// Empty state
if (profiles.length === 0) {
const empty = createElement("div", "sidebar-empty");
empty.innerHTML = `
<p class="sidebar-empty-text">No profiles found</p>
`;
this.el.appendChild(empty);
this.el.appendChild(this._renderEmptyState());
}
}
@@ -162,6 +162,53 @@
// ── Rendering helpers ────────────────────────────────────────────
/**
* Renders the sidebar empty state for first-run and no-search-results cases.
* @returns {HTMLElement}
* @private
*/
_renderEmptyState() {
const isSearching = this.searchQuery.length > 0;
const empty = createElement("div", "sidebar-empty");
const icon = createElement("div", "sidebar-empty-icon", {
textContent: isSearching ? "⌕" : "+",
"aria-hidden": "true",
});
empty.appendChild(icon);
empty.appendChild(
createElement("div", "sidebar-empty-title", {
textContent: isSearching ? "No matching profiles" : "No saved profiles",
}),
);
empty.appendChild(
createElement("p", "sidebar-empty-text", {
textContent: isSearching
? "Try a different name, host, group, or protocol."
: "Create a profile for the servers and tools you use often.",
}),
);
if (!isSearching) {
const actions = createElement("div", "sidebar-empty-actions");
const addButton = createElement("button", "btn btn-primary btn-sm", {
type: "button",
textContent: "New Profile",
});
addButton.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
this.app.profileEditor.openNew();
});
actions.appendChild(addButton);
empty.appendChild(actions);
}
return empty;
}
/**
* Renders a collapsible section of profiles.
* @param {string} title
@@ -394,7 +441,7 @@
async _toggleFavourite(profile) {
try {
const newVal = !profile.favourite;
await window.go.api.Service.SetFavourite(profile.id, newVal);
await window.NexusTerminal.wailsService.SetFavourite(profile.id, newVal);
profile.favourite = newVal;
this.app.state.updateProfile(profile);
this.render();
@@ -410,7 +457,7 @@
*/
async _duplicateProfile(profile) {
try {
const dup = await window.go.api.Service.DuplicateProfile(profile.id);
const dup = await window.NexusTerminal.wailsService.DuplicateProfile(profile.id);
this.app.state.addProfile(dup);
this.render();
this.app.notifications.success(`Duplicated "${profile.displayName}"`);
@@ -431,7 +478,7 @@
if (!ok) return;
try {
await window.go.api.Service.DeleteProfile(profile.id);
await window.NexusTerminal.wailsService.DeleteProfile(profile.id);
this.app.state.removeProfile(profile.id);
this.render();
this.app.notifications.success("Profile deleted");
@@ -640,7 +687,7 @@
}
try {
const saved = await window.go.api.Service.UpdateProfile(updated);
const saved = await window.NexusTerminal.wailsService.UpdateProfile(updated);
this.app.state.updateProfile(saved);
} catch (err) {
this.app.notifications.error("Failed to update tags: " + err);
@@ -661,7 +708,7 @@
const updated = JSON.parse(JSON.stringify(p));
updated.folder = folder;
try {
const saved = await window.go.api.Service.UpdateProfile(updated);
const saved = await window.NexusTerminal.wailsService.UpdateProfile(updated);
this.app.state.updateProfile(saved);
} catch (err) {
this.app.notifications.error("Failed to move group: " + err);
@@ -879,6 +926,8 @@
return "TELNET";
case "raw":
return "RAW";
case "irc":
return "IRC";
default:
return (protocol || "").toUpperCase() || "UNKNOWN";
}
+6 -6
View File
@@ -91,8 +91,8 @@
/** @private */
_sendCommand(sessionId, command) {
try {
const b64 = btoa(command + "\n");
window.go.api.Service.WriteSession(sessionId, b64);
const b64 = window.NexusTerminal.utf8ToBase64(command + "\n");
window.NexusTerminal.wailsService.WriteSession(sessionId, b64);
this.app.notifications.info("Snippet sent");
} catch (err) {
this.app.notifications.error("Failed to send snippet: " + err);
@@ -171,7 +171,7 @@
/** @private */
async _loadSnippets() {
try {
this.snippets = (await window.go.api.Service.ListSnippets()) || [];
this.snippets = (await window.NexusTerminal.wailsService.ListSnippets()) || [];
} catch (err) {
this.snippets = [];
console.error("Failed to load snippets:", err);
@@ -334,7 +334,7 @@
});
deleteBtn.addEventListener("click", async () => {
try {
await window.go.api.Service.DeleteSnippet(snippet.id);
await window.NexusTerminal.wailsService.DeleteSnippet(snippet.id);
this.snippets = this.snippets.filter((s) => s.id !== snippet.id);
this._renderList(modal, closeFn);
detailPanel.innerHTML =
@@ -418,10 +418,10 @@
let saved;
if (isNew) {
saved = await window.go.api.Service.CreateSnippet(data);
saved = await window.NexusTerminal.wailsService.CreateSnippet(data);
this.snippets.push(saved);
} else {
saved = await window.go.api.Service.UpdateSnippet(data);
saved = await window.NexusTerminal.wailsService.UpdateSnippet(data);
const idx = this.snippets.findIndex((s) => s.id === saved.id);
if (idx !== -1) this.snippets[idx] = saved;
}
+1 -1
View File
@@ -434,7 +434,7 @@ class SplitPaneManager {
if (placeholder) placeholder.remove();
// Connect via backend
window.go.api.Service.ConnectSession(profile.id).then(function(sessionInfo) {
window.NexusTerminal.wailsService.ConnectSession(profile.id).then(function(sessionInfo) {
self.app.state.addSession(sessionInfo);
// Create a terminal container inside this pane
+45 -18
View File
@@ -81,7 +81,7 @@
list.innerHTML = '<div class="sshkey-loading">Loading keys…</div>';
try {
const keys = await window.go.api.Service.ListSSHKeyPairs();
const keys = await window.NexusTerminal.wailsService.ListSSHKeyPairs();
if (keys.length === 0) {
list.innerHTML =
'<div class="sshkey-empty">No SSH keys found in ~/.ssh/</div>';
@@ -92,7 +92,12 @@
list.appendChild(this._renderKeyRow(key));
}
} catch (err) {
list.innerHTML = `<div class="sshkey-error">Failed to load keys: ${err}</div>`;
list.innerHTML = "";
list.appendChild(
createElement("div", "sshkey-error", {
textContent: "Failed to load keys: " + String(err),
}),
);
}
}
@@ -133,7 +138,7 @@
async _copyPublicKey(key) {
try {
const pubKey = await window.go.api.Service.GetSSHPublicKey(key.name);
const pubKey = await window.NexusTerminal.wailsService.GetSSHPublicKey(key.name);
if (window.runtime && window.runtime.ClipboardSetText) {
window.runtime.ClipboardSetText(pubKey);
} else {
@@ -158,7 +163,7 @@
async _deleteKey(key) {
try {
await window.go.api.Service.DeleteSSHKey(key.name);
await window.NexusTerminal.wailsService.DeleteSSHKey(key.name);
this.app.notifications.success(`Key "${key.name}" deleted`);
this._loadKeys();
} catch (err) {
@@ -237,7 +242,7 @@
}
try {
await window.go.api.Service.GenerateSSHKey(
await window.NexusTerminal.wailsService.GenerateSSHKey(
name,
keyType,
bits,
@@ -263,19 +268,41 @@
return new Promise((resolve) => {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-sm");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">${title}</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<p style="font-size:var(--text-sm);color:var(--text-secondary)">${message}</p>
</div>
<div class="modal-footer">
<button class="btn btn-sm confirm-cancel">Cancel</button>
<button class="btn btn-sm btn-danger confirm-ok">${confirmLabel}</button>
</div>
`;
const header = createElement("div", "modal-header");
header.appendChild(
createElement("h3", "modal-title", { textContent: title }),
);
header.appendChild(
createElement("button", "btn btn-icon btn-xs modal-close", {
textContent: "x",
type: "button",
"aria-label": "Close",
}),
);
const body = createElement("div", "modal-body");
const messageEl = createElement("p", "", { textContent: message });
messageEl.style.fontSize = "var(--text-sm)";
messageEl.style.color = "var(--text-secondary)";
body.appendChild(messageEl);
const footer = createElement("div", "modal-footer");
footer.appendChild(
createElement("button", "btn btn-sm confirm-cancel", {
textContent: "Cancel",
type: "button",
}),
);
footer.appendChild(
createElement("button", "btn btn-sm btn-danger confirm-ok", {
textContent: confirmLabel,
type: "button",
}),
);
modal.appendChild(header);
modal.appendChild(body);
modal.appendChild(footer);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
+239 -16
View File
@@ -32,7 +32,13 @@
/** @type {string[]} Ordered list of session IDs. */
this.order = [];
this.tooltip = null;
this.tooltipTimer = null;
this.tooltipSessionId = null;
this._setupDragAndDrop();
this._setupScrolling();
this._setupTooltip();
}
// ── Public API ───────────────────────────────────────────────────
@@ -56,6 +62,7 @@
// Hide the empty-state BEFORE activating the panel so the
// panel-container gets the full workspace height immediately.
this._updateEmptyState();
this._updateScrollState();
this.setActive(sessionInfo.id);
}
@@ -64,7 +71,9 @@
* @param {string} sessionId
*/
removeTab(sessionId) {
this._hideTabTooltip();
const idx = this.order.indexOf(sessionId);
const wasActive = this.app.state.activeSessionId === sessionId;
if (idx !== -1) this.order.splice(idx, 1);
// Remove tab element
@@ -77,8 +86,8 @@
const panel = document.getElementById(`panel-${sessionId}`);
if (panel) panel.remove();
// If this was the active tab, activate the nearest neighbour
if (this.app.state.activeSessionId === sessionId) {
// Activate the nearest neighbour
if (wasActive) {
const next =
this.order.length > 0
? this.order[Math.min(idx, this.order.length - 1)]
@@ -91,6 +100,7 @@
}
this._updateEmptyState();
this._updateScrollState();
}
/**
@@ -118,6 +128,8 @@
if (session) {
this.app.sidebar.highlightActive(session.profileId);
}
this.app.leviathan?.handleTabFocus?.(sessionId);
this._scrollTabIntoView(sessionId);
// Fit and refresh the terminal for the newly active session.
// Double-RAF ensures the panel has layout dimensions before fitting.
@@ -155,12 +167,15 @@
* @param {string} sessionId
*/
async closeTab(sessionId) {
this._hideTabTooltip();
const session = this.app.state.getSession(sessionId);
// Ask for confirmation if connected and the preference is set
if (
session &&
session.state === "connected" &&
session.protocol !== "leviathan" &&
session.protocol !== "github" &&
this.app.state.preferences.confirmOnDisconnect
) {
const ok = await this._confirmClose(sessionId);
@@ -168,7 +183,7 @@
}
try {
await window.go.api.Service.DisconnectSession(sessionId);
await window.NexusTerminal.wailsService.DisconnectSession(sessionId);
} catch (_) {
// Session may already be closed — carry on
}
@@ -176,8 +191,10 @@
this.app.terminals.destroyTerminal(sessionId);
this.app.rawViewer.destroyViewer(sessionId);
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
this.app.state.removeSession(sessionId);
window.NexusTerminal.IRCViewer.destroyViewer(sessionId);
this.app.leviathan?.destroyPanel(sessionId);
this.removeTab(sessionId);
this.app.state.removeSession(sessionId);
}
/**
@@ -188,7 +205,7 @@
*/
_confirmClose(sessionId) {
const createElement = window.NexusTerminal.createElement;
const label = this._tabLabel(sessionId);
const label = escapeHtml(this._tabLabel(sessionId));
return new Promise((resolve) => {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-sm");
@@ -476,6 +493,18 @@
draggable: "true",
});
// Close button
const closeBtn = createElement("button", "tab-close", {
textContent: "×",
"aria-label": "Close tab",
});
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this._hideTabTooltip();
this.closeTab(sessionInfo.id);
});
tab.appendChild(closeBtn);
// Protocol badge
const protoLabel =
sessionInfo.protocol === "github"
@@ -531,17 +560,6 @@
tab.appendChild(sftpBtn);
}
// Close button
const closeBtn = createElement("button", "tab-close", {
textContent: "×",
"aria-label": "Close tab",
});
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.closeTab(sessionInfo.id);
});
tab.appendChild(closeBtn);
// Click → activate
tab.addEventListener("click", () => this.setActive(sessionInfo.id));
@@ -576,6 +594,7 @@
const el = this.tabList.querySelector(`[data-session-id="${id}"]`);
if (el) this.tabList.appendChild(el);
}
this._updateScrollState();
}
/**
@@ -593,6 +612,210 @@
}
}
/**
* Makes the tab strip usable when the tab list overflows horizontally.
* @private
*/
_setupScrolling() {
if (!this.tabList) return;
this.tabList.addEventListener(
"wheel",
(event) => {
if (this.tabList.scrollWidth <= this.tabList.clientWidth) return;
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY)
? event.deltaX
: event.deltaY;
if (!delta) return;
event.preventDefault();
this.tabList.scrollLeft += delta;
this._updateScrollState();
},
{ passive: false },
);
this.tabList.addEventListener("scroll", () => this._updateScrollState(), {
passive: true,
});
window.addEventListener("resize", () => this._updateScrollState());
}
/**
* Keeps the active tab visible inside the scrollable tab list.
* @param {string} sessionId
* @private
*/
_scrollTabIntoView(sessionId) {
const tab = this.tabList?.querySelector(`[data-session-id="${sessionId}"]`);
if (!tab) return;
requestAnimationFrame(() => {
tab.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest",
});
this._updateScrollState();
});
}
/**
* Reflects whether tab overflow exists at either edge.
* @private
*/
_updateScrollState() {
if (!this.tabList || !this.tabBar) return;
const max = this.tabList.scrollWidth - this.tabList.clientWidth;
this.tabBar.classList.toggle("has-tab-overflow", max > 1);
this.tabBar.classList.toggle("has-overflow-left", this.tabList.scrollLeft > 1);
this.tabBar.classList.toggle(
"has-overflow-right",
max > 1 && this.tabList.scrollLeft < max - 1,
);
}
/**
* Shows a custom hover hint for tabs with full names and connection start.
* @private
*/
_setupTooltip() {
if (!this.tabList) return;
this.tooltip = createElement("div", "tooltip tab-tooltip", {
role: "tooltip",
});
document.body.appendChild(this.tooltip);
this.tabList.addEventListener("pointerover", (event) => {
const tab = event.target.closest(".tab");
if (!tab || !this.tabList.contains(tab)) return;
if (tab.contains(event.relatedTarget)) return;
this._clearTabTooltipTimer();
this.tooltipTimer = setTimeout(() => {
this.tooltipTimer = null;
this._showTabTooltip(tab.dataset.sessionId, tab);
}, 250);
});
this.tabList.addEventListener("pointerdown", () => this._hideTabTooltip());
this.tabList.addEventListener("pointermove", (event) => {
const tab = event.target.closest(".tab");
if (!tab || !this.tooltip?.classList.contains("is-visible")) return;
this._positionTabTooltip(tab);
});
this.tabList.addEventListener("pointerout", (event) => {
const tab = event.target.closest(".tab");
if (!tab || tab.contains(event.relatedTarget)) return;
this._hideTabTooltip();
});
this.tabList.addEventListener("scroll", () => this._hideTabTooltip(), {
passive: true,
});
window.addEventListener("blur", () => this._hideTabTooltip());
document.addEventListener("visibilitychange", () => {
if (document.hidden) this._hideTabTooltip();
});
}
/**
* Renders and positions the tab hover tooltip.
* @param {string} sessionId
* @param {HTMLElement} tab
* @private
*/
_showTabTooltip(sessionId, tab) {
const session = this.app.state.getSession(sessionId);
if (!session || !this.tooltip || !tab?.isConnected || !this.tabList.contains(tab)) {
this._hideTabTooltip();
return;
}
this.tooltip.replaceChildren();
this.tooltip.appendChild(
createElement("div", "tab-tooltip-title", {
textContent: this._tabLabel(sessionId),
}),
);
if (
session.protocol !== "leviathan" &&
session.state === "connected" &&
session.connectedAt
) {
const connected = this._formatConnectedSince(session.connectedAt);
if (connected) {
this.tooltip.appendChild(
createElement("div", "tab-tooltip-meta", {
textContent: `Connected since: ${connected}`,
}),
);
}
}
this._positionTabTooltip(tab);
this.tooltipSessionId = sessionId;
this.tooltip.classList.add("is-visible");
}
/**
* Positions the tab tooltip under the hovered tab.
* @param {HTMLElement} tab
* @private
*/
_positionTabTooltip(tab) {
if (!this.tooltip) return;
if (!tab?.isConnected || !this.tabList.contains(tab)) {
this._hideTabTooltip();
return;
}
const rect = tab.getBoundingClientRect();
const tooltipRect = this.tooltip.getBoundingClientRect();
const left = Math.min(
Math.max(8, rect.left + rect.width / 2 - tooltipRect.width / 2),
window.innerWidth - tooltipRect.width - 8,
);
const top = Math.min(rect.bottom + 8, window.innerHeight - tooltipRect.height - 8);
this.tooltip.style.left = `${left}px`;
this.tooltip.style.top = `${top}px`;
}
/**
* Hides the tab hover tooltip.
* @private
*/
_hideTabTooltip() {
this._clearTabTooltipTimer();
this.tooltipSessionId = null;
if (!this.tooltip) return;
this.tooltip.classList.remove("is-visible");
this.tooltip.replaceChildren();
}
/**
* Cancels any pending tab tooltip show timer.
* @private
*/
_clearTabTooltipTimer() {
if (!this.tooltipTimer) return;
clearTimeout(this.tooltipTimer);
this.tooltipTimer = null;
}
/**
* Formats a session connection timestamp for the tab tooltip.
* @param {string} value
* @returns {string}
* @private
*/
_formatConnectedSince(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime()) || date.getFullYear() <= 1) return "";
try {
return date.toLocaleString([], {
dateStyle: "medium",
timeStyle: "short",
});
} catch (_) {
return date.toLocaleString();
}
}
/**
* Shows/hides the empty-state panel based on whether tabs exist.
* @private
+170 -46
View File
@@ -7,6 +7,22 @@
/* global Terminal, FitAddon, SearchAddon, WebLinksAddon, Unicode11Addon */
const UPLOAD_CHUNK_BYTES = 768 * 1024;
/**
* Encode a string as UTF-8 bytes then base64-encode them.
* Unlike bare btoa(), this handles characters above U+007F correctly
* (e.g. degree symbol °, emoji) and won't throw InvalidCharacterError.
*/
function utf8ToBase64(str) {
const bytes = new TextEncoder().encode(str);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
class TerminalManager {
/**
* @param {App} app
@@ -112,6 +128,42 @@
});
}
// Alternate-screen scroll passthrough for screen/tmux/byobu.
// When the terminal is in the alternate buffer (used by multiplexers),
// convert mouse-wheel events into arrow-key sequences so the remote
// application handles scrollback instead of xterm.js (which has no
// scrollback content in alternate mode).
const viewportEl = container.querySelector(".xterm-viewport");
if (viewportEl) {
viewportEl.addEventListener(
"wheel",
(e) => {
if (term.buffer.active !== term.buffer.alternate) return;
// Only intercept when mouse tracking is NOT active (if mouse mode
// is on, xterm.js already forwards events correctly).
if (
term.modes &&
term.modes.mouseTrackingMode &&
term.modes.mouseTrackingMode !== "none"
)
return;
e.preventDefault();
e.stopPropagation();
const lines = Math.max(1, Math.round(Math.abs(e.deltaY) / 40));
const seq = e.deltaY < 0 ? "\x1b[A" : "\x1b[B";
const data = seq.repeat(lines);
const b64 = utf8ToBase64(data);
try {
window.NexusTerminal.wailsService.WriteSession(sessionId, b64);
} catch (_) {
/* non-critical */
}
},
{ passive: false },
);
}
// Drag-and-drop file handling
container.addEventListener("dragover", (e) => {
if (
@@ -130,27 +182,76 @@
this._handleFileDrop(sessionId, term, files);
});
let fitRaf = 0;
let forceNextFit = false;
let lastFitWidth = 0;
let lastFitHeight = 0;
const cancelScheduledFit = () => {
if (!fitRaf) return;
cancelAnimationFrame(fitRaf);
fitRaf = 0;
};
const scheduleFit = (force = false) => {
forceNextFit = forceNextFit || force;
if (fitRaf) return;
fitRaf = requestAnimationFrame(() => {
fitRaf = 0;
if (!container.isConnected) {
forceNextFit = false;
return;
}
const rect = container.getBoundingClientRect();
const width = Math.round(rect.width);
const height = Math.round(rect.height);
if (width <= 0 || height <= 0) {
return;
}
const dimensionsChanged =
width !== lastFitWidth || height !== lastFitHeight;
if (!forceNextFit && !dimensionsChanged) {
return;
}
lastFitWidth = width;
lastFitHeight = height;
forceNextFit = false;
try {
fitAddon.fit();
} catch (_) {
/* noop */
}
});
};
// Force a synchronous layout reflow so the container reports its
// true dimensions, then fit the terminal to fill the space.
// eslint-disable-next-line no-unused-expressions
container.offsetHeight;
const doFit = () => {
scheduleFit(true);
// Belt-and-braces: retry once after a paint in case the initial
// reflow dimensions were still stale.
requestAnimationFrame(() => scheduleFit(true));
const refreshTerminal = () => {
try {
fitAddon.fit();
term.refresh(0, term.rows - 1);
} catch (_) {
/* noop */
}
};
doFit();
// Belt-and-braces: retry once after a paint in case the initial
// reflow dimensions were still stale.
requestAnimationFrame(doFit);
// Forward user keystrokes to the backend
term.onData((data) => {
try {
const b64 = btoa(data);
window.go.api.Service.WriteSession(sessionId, b64);
const b64 = utf8ToBase64(data);
window.NexusTerminal.wailsService.WriteSession(sessionId, b64);
// Broadcast to other connected sessions if enabled
if (this.app.state.broadcastMode) {
@@ -162,7 +263,7 @@
const otherSession = this.app.state.getSession(otherId);
if (otherSession && otherSession.state === "connected") {
try {
window.go.api.Service.WriteSession(otherId, b64);
window.NexusTerminal.wailsService.WriteSession(otherId, b64);
} catch (_) {
/* non-critical */
}
@@ -230,19 +331,17 @@
// Notify backend when terminal dimensions change
term.onResize(({ cols, rows }) => {
try {
window.go.api.Service.ResizeSession(sessionId, cols, rows);
window.NexusTerminal.wailsService.ResizeSession(sessionId, cols, rows);
} catch (err) {
console.error("ResizeSession error:", err);
}
});
// Re-fit when the container itself resizes
// Re-fit when the container itself resizes. ResizeObserver can fire
// repeatedly while xterm mutates its DOM, so schedule at most one fit
// per frame and skip work when the container dimensions are unchanged.
const observer = new ResizeObserver(() => {
try {
fitAddon.fit();
} catch (_) {
// Ignore transient errors during layout shifts
}
scheduleFit();
});
observer.observe(container);
@@ -252,6 +351,9 @@
searchAddon,
webLinksAddon,
observer,
scheduleFit,
cancelScheduledFit,
refreshTerminal,
decoder:
typeof TextDecoder !== "undefined" ? new TextDecoder("utf-8") : null,
});
@@ -294,6 +396,7 @@
if (!entry) return;
entry.observer.disconnect();
entry.cancelScheduledFit();
entry.term.dispose();
this.terminals.delete(sessionId);
}
@@ -313,13 +416,7 @@
// Re-fit the newly visible terminal
const entry = this.terminals.get(sessionId);
if (entry) {
requestAnimationFrame(() => {
try {
entry.fitAddon.fit();
} catch (_) {
/* noop */
}
});
entry.scheduleFit(true);
}
}
@@ -338,13 +435,7 @@
fitSession(sessionId) {
const entry = this.terminals.get(sessionId);
if (entry) {
requestAnimationFrame(() => {
try {
entry.fitAddon.fit();
} catch (_) {
/* noop */
}
});
entry.scheduleFit(true);
}
}
@@ -666,7 +757,7 @@
}
_promptFileDropAction(files, canUpload) {
const { createElement, formatBytes } = window.NexusTerminal;
const { createElement, escapeHtml, formatBytes } = window.NexusTerminal;
return new Promise((resolve) => {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-sm");
@@ -686,7 +777,7 @@
</div>
<div class="modal-body">
<p style="font-size:var(--text-sm);color:var(--text-secondary)">
<strong>${title}</strong>${count === 1 && sizeLabel ? " (" + sizeLabel + ")" : ""}
<strong>${escapeHtml(title)}</strong>${count === 1 && sizeLabel ? " (" + sizeLabel + ")" : ""}
</p>
<p style="font-size:var(--text-sm);color:var(--text-tertiary)">
Choose what to do with the dropped ${count === 1 ? "file" : "files"}.
@@ -777,14 +868,7 @@
for (let i = 0; i < files.length; i += 1) {
const file = files[i];
try {
const buffer = await this._readFileBuffer(file);
const base64Data = this._arrayBufferToBase64(buffer);
await window.go.api.Service.SFTPUploadData(
sessionId,
remoteDir,
file.name,
base64Data,
);
await this._uploadDroppedFileStream(sessionId, remoteDir, file);
} catch (err) {
const msg = err && err.message ? err.message : String(err);
this.app.notifications.error(
@@ -807,6 +891,46 @@
});
}
async _uploadDroppedFileStream(sessionId, remoteDir, file) {
const totalBytes = typeof file.size === "number" ? file.size : 0;
let uploadId = "";
try {
uploadId = await window.NexusTerminal.wailsService.SFTPUploadStart(
sessionId,
remoteDir,
file.name,
totalBytes,
);
for (let offset = 0; offset < totalBytes; offset += UPLOAD_CHUNK_BYTES) {
const end = Math.min(offset + UPLOAD_CHUNK_BYTES, totalBytes);
const buffer = await this._readFileSlice(file, offset, end);
const base64Data = this._arrayBufferToBase64(buffer);
await window.NexusTerminal.wailsService.SFTPUploadChunk(uploadId, base64Data);
}
await window.NexusTerminal.wailsService.SFTPUploadFinish(uploadId);
} catch (err) {
if (uploadId) {
try {
await window.NexusTerminal.wailsService.SFTPUploadCancel(uploadId);
} catch (_) {
/* noop */
}
}
throw err;
}
}
_readFileSlice(file, start, end) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error("read failed"));
reader.readAsArrayBuffer(file.slice(start, end));
});
}
_readFileBuffer(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -1043,12 +1167,12 @@
renderLoading();
try {
const targetPath =
path || (await window.go.api.Service.SFTPPwd(sessionId));
const files = await window.go.api.Service.SFTPList(
path || (await window.NexusTerminal.wailsService.SFTPPwd(sessionId));
const files = await window.NexusTerminal.wailsService.SFTPList(
sessionId,
targetPath,
);
const stat = await window.go.api.Service.SFTPStat(
const stat = await window.NexusTerminal.wailsService.SFTPStat(
sessionId,
targetPath,
);
@@ -1322,7 +1446,7 @@
const renderForwards = async () => {
try {
const forwards =
(await window.go.api.Service.ListPortForwards(sessionId)) || [];
(await window.NexusTerminal.wailsService.ListPortForwards(sessionId)) || [];
listEl.innerHTML = "";
if (forwards.length === 0) {
listEl.innerHTML =
@@ -1348,7 +1472,7 @@
});
stopBtn.addEventListener("click", async () => {
try {
await window.go.api.Service.StopPortForward(sessionId, fwd.id);
await window.NexusTerminal.wailsService.StopPortForward(sessionId, fwd.id);
self.app.notifications.info("Forward stopped");
renderForwards();
} catch (err) {
@@ -1393,7 +1517,7 @@
}
try {
await window.go.api.Service.StartPortForward(sessionId, {
await window.NexusTerminal.wailsService.StartPortForward(sessionId, {
id: "",
direction: direction,
bindHost: bindHost,
+65
View File
@@ -52,6 +52,52 @@
return el;
}
/**
* Creates a standard modal skeleton without HTML string interpolation.
* @param {string} title
* @param {string} [sizeClass]
* @returns {{backdrop: HTMLElement, modal: HTMLElement, body: HTMLElement, footer: HTMLElement, closeButton: HTMLElement, show: Function, remove: Function}}
*/
function createModalShell(title, sizeClass = "modal-sm") {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", `modal ${sizeClass}`);
const header = createElement("div", "modal-header");
const titleEl = createElement("h3", "modal-title", { textContent: title });
const closeButton = createElement("button", "btn btn-icon btn-xs modal-close", {
textContent: "x",
"aria-label": "Close",
});
const body = createElement("div", "modal-body");
const footer = createElement("div", "modal-footer");
header.appendChild(titleEl);
header.appendChild(closeButton);
modal.appendChild(header);
modal.appendChild(body);
modal.appendChild(footer);
backdrop.appendChild(modal);
return {
backdrop,
modal,
body,
footer,
closeButton,
show() {
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
},
remove() {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => backdrop.remove(), 200);
},
};
}
/**
* Escapes HTML special characters to prevent XSS.
* @param {string} str - Raw string.
@@ -163,6 +209,21 @@
return btoa(str);
}
/**
* UTF-8-encodes a string and then base64-encodes the bytes.
* Unlike bare btoa(), this handles characters above U+007F correctly.
* @param {string} str
* @returns {string}
*/
function utf8ToBase64(str) {
const bytes = new TextEncoder().encode(str);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Base64-decodes a string.
* @param {string} str
@@ -229,6 +290,8 @@
return "proto-github";
case "vnc":
return "proto-vnc";
case "irc":
return "proto-irc";
case "local":
return "proto-local";
default:
@@ -290,11 +353,13 @@
$,
$$,
createElement,
createModalShell,
escapeHtml,
formatTimestamp,
debounce,
fuzzyMatch,
base64Encode,
utf8ToBase64,
base64Decode,
generateId,
formatBytes,
+28
View File
@@ -0,0 +1,28 @@
(function () {
const root = (window.NexusTerminal = window.NexusTerminal || {});
const directService = () => window.go?.api?.Service || null;
const unavailable = (name) =>
Promise.reject(new Error(`Wails service method is not available: ${String(name)}`));
root.wailsService = new Proxy(
{},
{
get(_target, prop) {
if (prop === "then") return undefined;
const service = directService();
const value = service?.[prop];
if (typeof value === "function") return value.bind(service);
if (value !== undefined) return value;
return () => unavailable(prop);
},
has(_target, prop) {
return prop in (directService() || {});
},
},
);
root.wailsServiceReady = Promise.resolve(root.wailsService);
root.hasWailsService = () =>
Boolean(root.wailsService && window.runtime && directService());
})();
+48
View File
@@ -10,6 +10,8 @@ export function CancelReconnect(arg1:string):Promise<void>;
export function CheckForUpdates():Promise<Record<string, any>>;
export function CleanupRecordings(arg1:number,arg2:number):Promise<Record<string, any>>;
export function ClearSessionHistory(arg1:string):Promise<void>;
export function CloseAllSessions():Promise<void>;
@@ -64,6 +66,8 @@ export function GetProfile(arg1:string):Promise<models.Profile>;
export function GetRecording(arg1:string):Promise<Record<string, any>>;
export function GetRecordingStorageInfo():Promise<Record<string, any>>;
export function GetSSHPublicKey(arg1:string):Promise<string>;
export function GetSessionCwd(arg1:string):Promise<string>;
@@ -76,6 +80,18 @@ export function GetVNCWebSocketURL(arg1:string):Promise<string>;
export function HasTOTPSecret(arg1:string):Promise<boolean>;
export function IRCHandleInput(arg1:string,arg2:string,arg3:string):Promise<void>;
export function IRCJoin(arg1:string,arg2:string):Promise<void>;
export function IRCListRooms(arg1:string):Promise<void>;
export function IRCPart(arg1:string,arg2:string):Promise<void>;
export function IRCRooms(arg1:string):Promise<Array<Record<string, any>>>;
export function IRCUsers(arg1:string,arg2:string):Promise<Array<string>>;
export function ImportProfiles(arg1:string,arg2:string):Promise<number>;
export function ImportProfilesEncrypted(arg1:string,arg2:string):Promise<number>;
@@ -90,6 +106,30 @@ export function IsSecretStoreInitialised():Promise<boolean>;
export function IsSecretStoreUnlocked():Promise<boolean>;
export function LeviathanExportJSON(arg1:string,arg2:Record<string, any>):Promise<string>;
export function LeviathanExportRange(arg1:string,arg2:number,arg3:number):Promise<string>;
export function LeviathanLoadFile(arg1:string,arg2:boolean):Promise<Record<string, any>>;
export function LeviathanOpenCompare():Promise<Record<string, any>>;
export function LeviathanOpenFile():Promise<Record<string, any>>;
export function LeviathanRedo():Promise<api.leviathanDocState>;
export function LeviathanRevert():Promise<api.leviathanDocState>;
export function LeviathanRunTool(arg1:string,arg2:Record<string, any>):Promise<Record<string, any>>;
export function LeviathanSaveWorking():Promise<string>;
export function LeviathanSetByte(arg1:number,arg2:string):Promise<Record<string, any>>;
export function LeviathanState():Promise<api.leviathanDocState>;
export function LeviathanUndo():Promise<api.leviathanDocState>;
export function ListAuditEntries(arg1:number):Promise<Array<models.AuditEntry>>;
export function ListFavourites():Promise<Array<models.Profile>>;
@@ -150,8 +190,16 @@ export function SFTPStat(arg1:string,arg2:string):Promise<Record<string, any>>;
export function SFTPUpload(arg1:string,arg2:string):Promise<void>;
export function SFTPUploadCancel(arg1:string):Promise<void>;
export function SFTPUploadChunk(arg1:string,arg2:string):Promise<void>;
export function SFTPUploadData(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
export function SFTPUploadFinish(arg1:string):Promise<void>;
export function SFTPUploadStart(arg1:string,arg2:string,arg3:string,arg4:number):Promise<string>;
export function SaveFile(arg1:string,arg2:string):Promise<string>;
export function SavePreferences(arg1:models.AppPreferences):Promise<void>;
+96
View File
@@ -14,6 +14,10 @@ export function CheckForUpdates() {
return window['go']['api']['Service']['CheckForUpdates']();
}
export function CleanupRecordings(arg1, arg2) {
return window['go']['api']['Service']['CleanupRecordings'](arg1, arg2);
}
export function ClearSessionHistory(arg1) {
return window['go']['api']['Service']['ClearSessionHistory'](arg1);
}
@@ -122,6 +126,10 @@ export function GetRecording(arg1) {
return window['go']['api']['Service']['GetRecording'](arg1);
}
export function GetRecordingStorageInfo() {
return window['go']['api']['Service']['GetRecordingStorageInfo']();
}
export function GetSSHPublicKey(arg1) {
return window['go']['api']['Service']['GetSSHPublicKey'](arg1);
}
@@ -146,6 +154,30 @@ export function HasTOTPSecret(arg1) {
return window['go']['api']['Service']['HasTOTPSecret'](arg1);
}
export function IRCHandleInput(arg1, arg2, arg3) {
return window['go']['api']['Service']['IRCHandleInput'](arg1, arg2, arg3);
}
export function IRCJoin(arg1, arg2) {
return window['go']['api']['Service']['IRCJoin'](arg1, arg2);
}
export function IRCListRooms(arg1) {
return window['go']['api']['Service']['IRCListRooms'](arg1);
}
export function IRCPart(arg1, arg2) {
return window['go']['api']['Service']['IRCPart'](arg1, arg2);
}
export function IRCRooms(arg1) {
return window['go']['api']['Service']['IRCRooms'](arg1);
}
export function IRCUsers(arg1, arg2) {
return window['go']['api']['Service']['IRCUsers'](arg1, arg2);
}
export function ImportProfiles(arg1, arg2) {
return window['go']['api']['Service']['ImportProfiles'](arg1, arg2);
}
@@ -174,6 +206,54 @@ export function IsSecretStoreUnlocked() {
return window['go']['api']['Service']['IsSecretStoreUnlocked']();
}
export function LeviathanExportJSON(arg1, arg2) {
return window['go']['api']['Service']['LeviathanExportJSON'](arg1, arg2);
}
export function LeviathanExportRange(arg1, arg2, arg3) {
return window['go']['api']['Service']['LeviathanExportRange'](arg1, arg2, arg3);
}
export function LeviathanLoadFile(arg1, arg2) {
return window['go']['api']['Service']['LeviathanLoadFile'](arg1, arg2);
}
export function LeviathanOpenCompare() {
return window['go']['api']['Service']['LeviathanOpenCompare']();
}
export function LeviathanOpenFile() {
return window['go']['api']['Service']['LeviathanOpenFile']();
}
export function LeviathanRedo() {
return window['go']['api']['Service']['LeviathanRedo']();
}
export function LeviathanRevert() {
return window['go']['api']['Service']['LeviathanRevert']();
}
export function LeviathanRunTool(arg1, arg2) {
return window['go']['api']['Service']['LeviathanRunTool'](arg1, arg2);
}
export function LeviathanSaveWorking() {
return window['go']['api']['Service']['LeviathanSaveWorking']();
}
export function LeviathanSetByte(arg1, arg2) {
return window['go']['api']['Service']['LeviathanSetByte'](arg1, arg2);
}
export function LeviathanState() {
return window['go']['api']['Service']['LeviathanState']();
}
export function LeviathanUndo() {
return window['go']['api']['Service']['LeviathanUndo']();
}
export function ListAuditEntries(arg1) {
return window['go']['api']['Service']['ListAuditEntries'](arg1);
}
@@ -294,10 +374,26 @@ export function SFTPUpload(arg1, arg2) {
return window['go']['api']['Service']['SFTPUpload'](arg1, arg2);
}
export function SFTPUploadCancel(arg1) {
return window['go']['api']['Service']['SFTPUploadCancel'](arg1);
}
export function SFTPUploadChunk(arg1, arg2) {
return window['go']['api']['Service']['SFTPUploadChunk'](arg1, arg2);
}
export function SFTPUploadData(arg1, arg2, arg3, arg4) {
return window['go']['api']['Service']['SFTPUploadData'](arg1, arg2, arg3, arg4);
}
export function SFTPUploadFinish(arg1) {
return window['go']['api']['Service']['SFTPUploadFinish'](arg1);
}
export function SFTPUploadStart(arg1, arg2, arg3, arg4) {
return window['go']['api']['Service']['SFTPUploadStart'](arg1, arg2, arg3, arg4);
}
export function SaveFile(arg1, arg2) {
return window['go']['api']['Service']['SaveFile'](arg1, arg2);
}
+92
View File
@@ -20,6 +20,48 @@ export namespace api {
this.privateKeyPath = source["privateKeyPath"];
}
}
export class leviathanDocState {
name: string;
size: number;
dirty: boolean;
undoLen: number;
redoLen: number;
compare?: leviathanDocState;
previewHex: any[];
static createFrom(source: any = {}) {
return new leviathanDocState(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.size = source["size"];
this.dirty = source["dirty"];
this.undoLen = source["undoLen"];
this.redoLen = source["redoLen"];
this.compare = this.convertValues(source["compare"], leviathanDocState);
this.previewHex = source["previewHex"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
@@ -139,6 +181,54 @@ export namespace models {
this.updatedAt = source["updatedAt"];
}
}
export class IRCConfig {
nick: string;
realName: string;
useTLS: boolean;
serverPasswordRequired: boolean;
serverPasswordMode: string;
nickServIdentify: boolean;
nickServAccount: string;
nickServMode: string;
autoJoinRooms: string[];
sshTunnel: boolean;
sshHost: string;
sshPort: number;
sshUsername: string;
sshAuthMethod: string;
sshSecretMode: string;
sshPrivateKeyPath: string;
sshAgentAuth: boolean;
autoReconnect: boolean;
maxReconnectTries: number;
static createFrom(source: any = {}) {
return new IRCConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.nick = source["nick"];
this.realName = source["realName"];
this.useTLS = source["useTLS"];
this.serverPasswordRequired = source["serverPasswordRequired"];
this.serverPasswordMode = source["serverPasswordMode"];
this.nickServIdentify = source["nickServIdentify"];
this.nickServAccount = source["nickServAccount"];
this.nickServMode = source["nickServMode"];
this.autoJoinRooms = source["autoJoinRooms"];
this.sshTunnel = source["sshTunnel"];
this.sshHost = source["sshHost"];
this.sshPort = source["sshPort"];
this.sshUsername = source["sshUsername"];
this.sshAuthMethod = source["sshAuthMethod"];
this.sshSecretMode = source["sshSecretMode"];
this.sshPrivateKeyPath = source["sshPrivateKeyPath"];
this.sshAgentAuth = source["sshAgentAuth"];
this.autoReconnect = source["autoReconnect"];
this.maxReconnectTries = source["maxReconnectTries"];
}
}
export class JumpHost {
host: string;
port: number;
@@ -478,6 +568,7 @@ export namespace models {
raw?: RAWConfig;
github?: GitHubConfig;
vnc?: VNCConfig;
irc?: IRCConfig;
static createFrom(source: any = {}) {
return new Profile(source);
@@ -511,6 +602,7 @@ export namespace models {
this.raw = this.convertValues(source["raw"], RAWConfig);
this.github = this.convertValues(source["github"], GitHubConfig);
this.vnc = this.convertValues(source["vnc"], VNCConfig);
this.irc = this.convertValues(source["irc"], IRCConfig);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
+7 -3
View File
@@ -6,13 +6,17 @@ require (
github.com/creack/pty v1.1.24
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hajimehoshi/go-mp3 v0.3.4
github.com/klauspost/compress v1.17.11
github.com/lrstanley/girc v1.1.1
github.com/mattn/go-sqlite3 v1.14.44
github.com/pkg/sftp v1.13.10
github.com/pquerna/otp v1.5.0
github.com/ulikunitz/xz v0.5.15
github.com/wailsapp/wails/v2 v2.12.0
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.51.0
golang.org/x/net v0.54.0
golang.org/x/crypto v0.52.0
golang.org/x/net v0.55.0
golang.org/x/text v0.37.0
)
@@ -42,5 +46,5 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/sys v0.45.0 // indirect
)
+16 -6
View File
@@ -20,8 +20,13 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
@@ -38,6 +43,8 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk=
github.com/lrstanley/girc v1.1.1/go.mod h1:lgrnhcF8bg/Bd5HA5DOb4Z+uGqUqGnp4skr+J2GwVgI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
@@ -70,6 +77,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
@@ -82,17 +91,18 @@ github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozH
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
+417 -42
View File
@@ -8,6 +8,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"os"
"os/exec"
@@ -32,6 +33,7 @@ import (
"github.com/andydixon/nexus/internal/sshconfig"
"github.com/andydixon/nexus/internal/storage"
"github.com/andydixon/nexus/internal/updater"
"github.com/andydixon/nexus/internal/validation"
"github.com/google/uuid"
)
@@ -41,6 +43,15 @@ var appVersion = "dev"
// appUpdateURL is set at build time via ldflags.
var appUpdateURL = ""
const (
maxProfileImportJSONBytes = 5 * 1024 * 1024
maxInlineSFTPUploadBytes = 25 * 1024 * 1024
maxSFTPUploadChunkBytes = 1024 * 1024
maxStreamingSFTPUploadSize = int64(2 * 1024 * 1024 * 1024)
maxSSHPublicKeyBytes = 64 * 1024
sshKeygenTimeout = 30 * time.Second
)
// Service is the main API surface exposed to the frontend via Wails bindings.
type Service struct {
store *storage.Store
@@ -54,6 +65,19 @@ type Service struct {
// secretChans maps request IDs to channels awaiting user secret input.
secretMu sync.Mutex
secretChans map[string]chan secretResponse
uploadMu sync.Mutex
uploads map[string]*sftpUpload
leviathan *leviathanState
}
type sftpUpload struct {
mu sync.Mutex
writer io.WriteCloser
sessionID string
remotePath string
written int64
}
// NewService creates a Service wired to the given backing components.
@@ -72,6 +96,8 @@ func NewService(
sessions: sess,
auditLog: auditLog,
prefs: prefs,
uploads: make(map[string]*sftpUpload),
leviathan: &leviathanState{},
}
}
@@ -90,6 +116,7 @@ func (s *Service) CreateProfile(profile models.Profile) (models.Profile, error)
if profile.ID == "" {
profile.ID = uuid.New().String()
}
normaliseProfileForSave(&profile)
now := time.Now()
profile.CreatedAt = now
profile.UpdatedAt = now
@@ -98,6 +125,9 @@ func (s *Service) CreateProfile(profile models.Profile) (models.Profile, error)
profile.Tags = []string{}
}
if err := validation.ValidateProfile(&profile); err != nil {
return models.Profile{}, err
}
if err := s.store.CreateProfile(&profile); err != nil {
return models.Profile{}, fmt.Errorf("failed to create profile: %w", err)
}
@@ -116,7 +146,11 @@ func (s *Service) GetProfile(id string) (models.Profile, error) {
// UpdateProfile persists changes to an existing profile.
func (s *Service) UpdateProfile(profile models.Profile) (models.Profile, error) {
normaliseProfileForSave(&profile)
profile.UpdatedAt = time.Now()
if err := validation.ValidateProfile(&profile); err != nil {
return models.Profile{}, err
}
if err := s.store.UpdateProfile(&profile); err != nil {
return models.Profile{}, fmt.Errorf("failed to update profile: %w", err)
}
@@ -359,6 +393,7 @@ func (s *Service) ConnectQuick(profile models.Profile) (models.SessionInfo, erro
if profile.ID == "" {
profile.ID = uuid.New().String()
}
normaliseProfileForSave(&profile)
sessionID, err := s.sessions.Connect(&profile, s.promptSecret)
if err != nil {
return models.SessionInfo{}, err
@@ -493,6 +528,52 @@ func (s *Service) SendBreak(sessionID string) error {
return s.sessions.SendBreak(sessionID)
}
// ---------------------------------------------------------------------------
// IRC-specific
// ---------------------------------------------------------------------------
// IRCHandleInput sends an IRC composer line, including slash commands.
func (s *Service) IRCHandleInput(sessionID string, target string, input string) error {
return s.sessions.IRCHandleInput(sessionID, target, input)
}
// IRCJoin joins an IRC room.
func (s *Service) IRCJoin(sessionID string, room string) error {
return s.sessions.IRCJoin(sessionID, room)
}
// IRCPart leaves an IRC room.
func (s *Service) IRCPart(sessionID string, room string) error {
return s.sessions.IRCPart(sessionID, room)
}
// IRCListRooms requests the server room list. Results are emitted as events.
func (s *Service) IRCListRooms(sessionID string) error {
return s.sessions.IRCListRooms(sessionID)
}
// IRCRooms returns currently joined IRC rooms.
func (s *Service) IRCRooms(sessionID string) ([]map[string]interface{}, error) {
rooms, err := s.sessions.IRCRooms(sessionID)
if err != nil {
return nil, err
}
result := make([]map[string]interface{}, len(rooms))
for i, room := range rooms {
result[i] = map[string]interface{}{
"name": room.Name,
"topic": room.Topic,
"users": room.Users,
}
}
return result, nil
}
// IRCUsers returns the known users in a joined IRC room.
func (s *Service) IRCUsers(sessionID string, room string) ([]string, error) {
return s.sessions.IRCUsers(sessionID, room)
}
// ---------------------------------------------------------------------------
// Secret store
// ---------------------------------------------------------------------------
@@ -680,6 +761,9 @@ func (s *Service) ExportProfiles(includeSecrets bool) (string, error) {
if err != nil {
return "", fmt.Errorf("failed to list profiles for export: %w", err)
}
if !includeSecrets {
profiles = redactProfileSecrets(profiles)
}
data, err := json.MarshalIndent(profiles, "", " ")
if err != nil {
@@ -696,11 +780,21 @@ func (s *Service) ExportProfiles(includeSecrets bool) (string, error) {
// - "overwrite" replaces existing profiles
// - "duplicate" always creates a new profile with a fresh ID
func (s *Service) ImportProfiles(jsonData string, strategy string) (int, error) {
if len(jsonData) > maxProfileImportJSONBytes {
return 0, fmt.Errorf("import data is too large (max %d bytes)", maxProfileImportJSONBytes)
}
var profiles []*models.Profile
if err := json.Unmarshal([]byte(jsonData), &profiles); err != nil {
return 0, fmt.Errorf("invalid JSON: %w", err)
}
if err := validateImportStrategy(strategy); err != nil {
return 0, err
}
if err := validateImportProfiles(profiles); err != nil {
return 0, err
}
imported := 0
for _, p := range profiles {
switch strategy {
@@ -1061,6 +1155,39 @@ func (s *Service) DeleteRecording(id string) error {
return s.sessions.DeleteRecording(id)
}
// GetRecordingStorageInfo returns aggregate recording storage usage and limits.
func (s *Service) GetRecordingStorageInfo() (map[string]interface{}, error) {
info, err := s.sessions.RecordingStorageInfo()
if err != nil {
return nil, err
}
result := map[string]interface{}{
"count": info.Count,
"totalBytes": info.TotalBytes,
"maxRecordingBytes": info.MaxRecordingBytes,
"maxPlaybackBytes": info.MaxPlaybackBytes,
}
if info.OldestCreatedAt != nil {
result["oldestCreatedAt"] = info.OldestCreatedAt.Format(time.RFC3339)
}
if info.NewestCreatedAt != nil {
result["newestCreatedAt"] = info.NewestCreatedAt.Format(time.RFC3339)
}
return result, nil
}
// CleanupRecordings removes recordings by age and total storage limit.
func (s *Service) CleanupRecordings(maxAgeDays int, maxTotalBytes int64) (map[string]interface{}, error) {
result, err := s.sessions.CleanupRecordings(maxAgeDays, maxTotalBytes)
if err != nil {
return nil, err
}
return map[string]interface{}{
"deleted": result.Deleted,
"freedBytes": result.FreedBytes,
}, nil
}
// ---------------------------------------------------------------------------
// Encrypted export / import
// ---------------------------------------------------------------------------
@@ -1151,6 +1278,17 @@ func (s *Service) ImportProfilesEncrypted(password string, strategy string) (int
return 0, err
}
if err := validateImportStrategy(strategy); err != nil {
return 0, err
}
profilePtrs := make([]*models.Profile, len(profiles))
for i := range profiles {
profilePtrs[i] = &profiles[i]
}
if err := validateImportProfiles(profilePtrs); err != nil {
return 0, err
}
// Import using the existing strategy logic.
imported := 0
for _, p := range profiles {
@@ -1299,21 +1437,132 @@ func (s *Service) SFTPUploadData(sessionID string, remoteDir string, fileName st
if base64Data == "" {
return fmt.Errorf("empty upload payload")
}
if base64.StdEncoding.DecodedLen(len(base64Data)) > maxInlineSFTPUploadBytes {
return fmt.Errorf("upload payload is too large (max %d bytes)", maxInlineSFTPUploadBytes)
}
data, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return fmt.Errorf("failed to decode upload data: %w", err)
}
if len(data) > maxInlineSFTPUploadBytes {
return fmt.Errorf("upload payload is too large (max %d bytes)", maxInlineSFTPUploadBytes)
}
safeName := filepath.Base(fileName)
if safeName == "" || safeName == "." || safeName == "/" {
return fmt.Errorf("invalid file name")
safeName, err := safeSFTPUploadName(fileName)
if err != nil {
return err
}
destPath := path.Join(remoteDir, safeName)
return s.sessions.SFTPUploadBytes(sessionID, destPath, data)
}
// SFTPUploadStart opens a remote SFTP file for chunked browser uploads.
func (s *Service) SFTPUploadStart(sessionID string, remoteDir string, fileName string, totalBytes int64) (string, error) {
if s.sessions == nil {
return "", fmt.Errorf("sessions service is not configured")
}
if totalBytes < 0 {
return "", fmt.Errorf("upload size must not be negative")
}
if totalBytes > maxStreamingSFTPUploadSize {
return "", fmt.Errorf("upload is too large (max %d bytes)", maxStreamingSFTPUploadSize)
}
safeName, err := safeSFTPUploadName(fileName)
if err != nil {
return "", err
}
remotePath := path.Join(remoteDir, safeName)
writer, err := s.sessions.SFTPOpenUpload(sessionID, remotePath)
if err != nil {
return "", err
}
uploadID := uuid.New().String()
s.uploadMu.Lock()
s.uploads[uploadID] = &sftpUpload{
writer: writer,
sessionID: sessionID,
remotePath: remotePath,
}
s.uploadMu.Unlock()
return uploadID, nil
}
// SFTPUploadChunk writes a base64-encoded chunk to an active browser upload.
func (s *Service) SFTPUploadChunk(uploadID string, base64Chunk string) error {
if base64Chunk == "" {
return fmt.Errorf("empty upload chunk")
}
if base64.StdEncoding.DecodedLen(len(base64Chunk)) > maxSFTPUploadChunkBytes {
return fmt.Errorf("upload chunk is too large (max %d bytes)", maxSFTPUploadChunkBytes)
}
data, err := base64.StdEncoding.DecodeString(base64Chunk)
if err != nil {
return fmt.Errorf("failed to decode upload chunk: %w", err)
}
if len(data) > maxSFTPUploadChunkBytes {
return fmt.Errorf("upload chunk is too large (max %d bytes)", maxSFTPUploadChunkBytes)
}
upload, err := s.lookupSFTPUpload(uploadID)
if err != nil {
return err
}
upload.mu.Lock()
defer upload.mu.Unlock()
if upload.written+int64(len(data)) > maxStreamingSFTPUploadSize {
return fmt.Errorf("upload is too large (max %d bytes)", maxStreamingSFTPUploadSize)
}
n, err := upload.writer.Write(data)
upload.written += int64(n)
if err != nil {
s.removeSFTPUpload(uploadID)
_ = upload.writer.Close()
if s.sessions != nil {
_ = s.sessions.SFTPRemove(upload.sessionID, upload.remotePath)
}
return fmt.Errorf("failed to write upload chunk: %w", err)
}
if n != len(data) {
s.removeSFTPUpload(uploadID)
_ = upload.writer.Close()
if s.sessions != nil {
_ = s.sessions.SFTPRemove(upload.sessionID, upload.remotePath)
}
return io.ErrShortWrite
}
return nil
}
// SFTPUploadFinish closes an active browser upload.
func (s *Service) SFTPUploadFinish(uploadID string) error {
upload, err := s.takeSFTPUpload(uploadID)
if err != nil {
return err
}
upload.mu.Lock()
defer upload.mu.Unlock()
return upload.writer.Close()
}
// SFTPUploadCancel closes and removes a partially uploaded remote file.
func (s *Service) SFTPUploadCancel(uploadID string) error {
upload, err := s.takeSFTPUpload(uploadID)
if err != nil {
return err
}
upload.mu.Lock()
closeErr := upload.writer.Close()
upload.mu.Unlock()
if s.sessions != nil {
_ = s.sessions.SFTPRemove(upload.sessionID, upload.remotePath)
}
return closeErr
}
// SFTPPwd returns the current working directory on the remote host.
func (s *Service) SFTPPwd(sessionID string) (string, error) {
return s.sessions.SFTPPwd(sessionID)
@@ -1566,7 +1815,7 @@ func (s *Service) ListSSHKeyPairs() ([]SSHKeyInfo, error) {
seen[baseName] = true
// Read public key content
pubBytes, err := os.ReadFile(filepath.Join(sshDir, name))
pubBytes, err := readSSHPublicKeyFile(filepath.Join(sshDir, name))
if err != nil {
continue
}
@@ -1604,37 +1853,26 @@ func (s *Service) ListSSHKeyPairs() ([]SSHKeyInfo, error) {
// GenerateSSHKey generates a new SSH key pair.
func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphrase string) error {
if name == "" {
return fmt.Errorf("key name is required")
}
// Sanitise name — only allow alphanumeric, hyphens, underscores
for _, ch := range name {
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') {
return fmt.Errorf("key name contains invalid characters (use alphanumeric, hyphens, underscores)")
}
}
home, err := os.UserHomeDir()
keyPath, _, err := sshKeyPairPaths(name)
if err != nil {
return fmt.Errorf("unable to determine home directory: %w", err)
return err
}
sshDir := filepath.Join(home, ".ssh")
keyType, bits, err = validateSSHKeyGenerationOptions(keyType, bits)
if err != nil {
return err
}
sshDir := filepath.Dir(keyPath)
// Ensure .ssh directory exists
if err := os.MkdirAll(sshDir, 0700); err != nil {
return fmt.Errorf("unable to create .ssh directory: %w", err)
}
keyPath := filepath.Join(sshDir, name)
// Check if key already exists
if _, err := os.Stat(keyPath); err == nil {
return fmt.Errorf("key %q already exists", name)
}
if keyType == "" {
keyType = "ed25519"
}
// Build ssh-keygen arguments
args := []string{"-t", keyType, "-f", keyPath, "-C", name + "@nexus-terminal"}
if passphrase != "" {
@@ -1647,10 +1885,15 @@ func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphra
}
// #nosec G204 — arguments are sanitised above
cmd := exec.Command("ssh-keygen", args...)
ctx, cancel := context.WithTimeout(context.Background(), sshKeygenTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "ssh-keygen", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("ssh-keygen timed out after %s", sshKeygenTimeout)
}
return fmt.Errorf("ssh-keygen failed: %s", stderr.String())
}
@@ -1659,12 +1902,11 @@ func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphra
// GetSSHPublicKey returns the content of a public key file.
func (s *Service) GetSSHPublicKey(name string) (string, error) {
home, err := os.UserHomeDir()
_, pubPath, err := sshKeyPairPaths(name)
if err != nil {
return "", fmt.Errorf("unable to determine home directory: %w", err)
return "", err
}
pubPath := filepath.Join(home, ".ssh", name+".pub")
data, err := os.ReadFile(pubPath)
data, err := readSSHPublicKeyFile(pubPath)
if err != nil {
return "", fmt.Errorf("unable to read public key: %w", err)
}
@@ -1673,22 +1915,9 @@ func (s *Service) GetSSHPublicKey(name string) (string, error) {
// DeleteSSHKey removes an SSH key pair.
func (s *Service) DeleteSSHKey(name string) error {
if name == "" {
return fmt.Errorf("key name is required")
}
home, err := os.UserHomeDir()
privPath, pubPath, err := sshKeyPairPaths(name)
if err != nil {
return fmt.Errorf("unable to determine home directory: %w", err)
}
sshDir := filepath.Join(home, ".ssh")
privPath := filepath.Join(sshDir, name)
pubPath := privPath + ".pub"
// Verify paths are within .ssh directory (prevent directory traversal)
absPriv, _ := filepath.Abs(privPath)
absSsh, _ := filepath.Abs(sshDir)
if !strings.HasPrefix(absPriv, absSsh+string(os.PathSeparator)) {
return fmt.Errorf("invalid key name")
return err
}
var errs []string
@@ -1703,3 +1932,149 @@ func (s *Service) DeleteSSHKey(name string) error {
}
return nil
}
func sshKeyPairPaths(name string) (string, string, error) {
keyName, err := validateSSHKeyName(name)
if err != nil {
return "", "", err
}
home, err := os.UserHomeDir()
if err != nil {
return "", "", fmt.Errorf("unable to determine home directory: %w", err)
}
sshDir := filepath.Join(home, ".ssh")
privPath := filepath.Join(sshDir, keyName)
pubPath := privPath + ".pub"
return privPath, pubPath, nil
}
func readSSHPublicKeyFile(path string) ([]byte, error) {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
if info.IsDir() {
return nil, fmt.Errorf("public key path is a directory")
}
if info.Size() > maxSSHPublicKeyBytes {
return nil, fmt.Errorf("public key file is too large: %d bytes exceeds %d bytes", info.Size(), maxSSHPublicKeyBytes)
}
return os.ReadFile(path)
}
func validateSSHKeyName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("key name is required")
}
for _, ch := range name {
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') {
return "", fmt.Errorf("key name contains invalid characters (use alphanumeric, hyphens, underscores)")
}
}
return name, nil
}
func safeSFTPUploadName(fileName string) (string, error) {
name := strings.TrimSpace(fileName)
if name == "" {
return "", fmt.Errorf("invalid file name")
}
name = strings.ReplaceAll(name, "\\", "/")
safeName := path.Base(name)
if safeName == "" || safeName == "." || safeName == ".." || safeName == "/" || strings.ContainsRune(safeName, 0) {
return "", fmt.Errorf("invalid file name")
}
return safeName, nil
}
func validateSSHKeyGenerationOptions(keyType string, bits int) (string, int, error) {
keyType = strings.ToLower(strings.TrimSpace(keyType))
if keyType == "" {
keyType = "ed25519"
}
switch keyType {
case "ed25519", "ecdsa":
return keyType, bits, nil
case "rsa":
if bits == 0 {
bits = 4096
}
switch bits {
case 2048, 3072, 4096:
return keyType, bits, nil
default:
return "", 0, fmt.Errorf("unsupported RSA key size %d (use 2048, 3072, or 4096)", bits)
}
default:
return "", 0, fmt.Errorf("unsupported SSH key type %q", keyType)
}
}
func (s *Service) lookupSFTPUpload(uploadID string) (*sftpUpload, error) {
s.uploadMu.Lock()
defer s.uploadMu.Unlock()
upload := s.uploads[uploadID]
if upload == nil {
return nil, fmt.Errorf("upload session not found")
}
return upload, nil
}
func (s *Service) takeSFTPUpload(uploadID string) (*sftpUpload, error) {
s.uploadMu.Lock()
defer s.uploadMu.Unlock()
upload := s.uploads[uploadID]
if upload == nil {
return nil, fmt.Errorf("upload session not found")
}
delete(s.uploads, uploadID)
return upload, nil
}
func (s *Service) removeSFTPUpload(uploadID string) {
s.uploadMu.Lock()
defer s.uploadMu.Unlock()
delete(s.uploads, uploadID)
}
func validateImportStrategy(strategy string) error {
switch strategy {
case "skip", "overwrite", "duplicate":
return nil
default:
return fmt.Errorf("unknown import strategy %q (expected skip, overwrite, or duplicate)", strategy)
}
}
func validateImportProfiles(profiles []*models.Profile) error {
for i, p := range profiles {
if p == nil {
return fmt.Errorf("profile import entry %d is null", i)
}
normaliseProfileForSave(p)
if err := validation.ValidateProfile(p); err != nil {
return fmt.Errorf("profile import entry %d is invalid: %w", i, err)
}
}
return nil
}
func normaliseProfileForSave(profile *models.Profile) {
models.NormalizeProfileDefaults(profile)
}
func redactProfileSecrets(profiles []*models.Profile) []*models.Profile {
redacted := make([]*models.Profile, len(profiles))
for i, profile := range profiles {
cp := *profile
if cp.SSH != nil {
sshCfg := *cp.SSH
sshCfg.ProxyPassword = ""
sshCfg.TOTPSecret = ""
cp.SSH = &sshCfg
}
redacted[i] = &cp
}
return redacted
}
+315
View File
@@ -0,0 +1,315 @@
package api
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
"github.com/andydixon/nexus/internal/audit"
"github.com/andydixon/nexus/internal/leviathan/document"
"github.com/andydixon/nexus/internal/models"
"github.com/andydixon/nexus/internal/storage"
)
func TestSSHKeyPairPathsAcceptsSafeName(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
privPath, pubPath, err := sshKeyPairPaths("deploy_key-01")
if err != nil {
t.Fatalf("sshKeyPairPaths() error = %v", err)
}
wantPriv := filepath.Join(home, ".ssh", "deploy_key-01")
if privPath != wantPriv {
t.Fatalf("private path = %q, want %q", privPath, wantPriv)
}
if pubPath != wantPriv+".pub" {
t.Fatalf("public path = %q, want %q", pubPath, wantPriv+".pub")
}
}
func TestSSHKeyPairPathsRejectsTraversal(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
_, _, err := sshKeyPairPaths("../outside")
if err == nil {
t.Fatal("expected traversal key name to be rejected")
}
if !strings.Contains(err.Error(), "invalid characters") {
t.Fatalf("error = %q, want invalid characters", err.Error())
}
}
func TestSSHKeyPairPathsRejectsNestedPath(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
_, _, err := sshKeyPairPaths("nested/key")
if err == nil {
t.Fatal("expected nested key path to be rejected")
}
}
func TestRedactProfileSecretsClearsInlineSSHSecrets(t *testing.T) {
profile := models.NewProfile(models.ProtocolSSH)
profile.SSH.ProxyPassword = "proxy-secret"
profile.SSH.TOTPSecret = "totp-secret"
got := redactProfileSecrets([]*models.Profile{&profile})
if got[0].SSH.ProxyPassword != "" {
t.Fatalf("ProxyPassword = %q, want empty", got[0].SSH.ProxyPassword)
}
if got[0].SSH.TOTPSecret != "" {
t.Fatalf("TOTPSecret = %q, want empty", got[0].SSH.TOTPSecret)
}
if profile.SSH.ProxyPassword == "" || profile.SSH.TOTPSecret == "" {
t.Fatal("redaction mutated the original profile")
}
}
func TestCreateProfileRejectsUnsafeGitHubBaseURL(t *testing.T) {
svc := newTestService(t)
profile := models.NewProfile(models.ProtocolGitHub)
profile.DisplayName = "Unsafe GitHub"
profile.GitHub.BaseURL = "http://evil.example/api/v3"
if _, err := svc.CreateProfile(profile); err == nil {
t.Fatal("expected unsafe GitHub base URL to be rejected")
}
}
func TestImportProfilesRejectsNullEntry(t *testing.T) {
svc := newTestService(t)
_, err := svc.ImportProfiles(`[null]`, "skip")
if err == nil {
t.Fatal("expected null import entry to be rejected")
}
if !strings.Contains(err.Error(), "entry 0 is null") {
t.Fatalf("error = %q, want null entry detail", err.Error())
}
}
func TestImportProfilesRejectsOversizedJSON(t *testing.T) {
svc := newTestService(t)
oversized := strings.Repeat(" ", maxProfileImportJSONBytes+1)
_, err := svc.ImportProfiles(oversized, "skip")
if err == nil {
t.Fatal("expected oversized import JSON to be rejected")
}
}
func TestSFTPUploadDataRejectsOversizedPayloadBeforeSessionLookup(t *testing.T) {
svc := newTestService(t)
payload := base64.StdEncoding.EncodeToString(make([]byte, maxInlineSFTPUploadBytes+1))
err := svc.SFTPUploadData("missing-session", "/", "large.bin", payload)
if err == nil {
t.Fatal("expected oversized upload payload to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("error = %q, want too large", err.Error())
}
}
func TestSFTPUploadChunkRejectsOversizedChunkBeforeUploadLookup(t *testing.T) {
svc := newTestService(t)
payload := base64.StdEncoding.EncodeToString(make([]byte, maxSFTPUploadChunkBytes+1))
err := svc.SFTPUploadChunk("missing-upload", payload)
if err == nil {
t.Fatal("expected oversized upload chunk to be rejected")
}
if !strings.Contains(err.Error(), "chunk is too large") {
t.Fatalf("error = %q, want chunk is too large", err.Error())
}
}
func TestSafeSFTPUploadNamePreservesHTMLCharacters(t *testing.T) {
got, err := safeSFTPUploadName(`../report <final>.txt`)
if err != nil {
t.Fatalf("safeSFTPUploadName() error = %v", err)
}
if got != "report <final>.txt" {
t.Fatalf("safe name = %q, want HTML characters preserved in base name", got)
}
}
func TestSafeSFTPUploadNameRejectsParentDirectory(t *testing.T) {
for _, name := range []string{"..", "folder/..", `folder\..`} {
t.Run(name, func(t *testing.T) {
if _, err := safeSFTPUploadName(name); err == nil {
t.Fatal("expected parent directory upload name to be rejected")
}
})
}
}
func TestValidateSSHKeyGenerationOptions(t *testing.T) {
keyType, bits, err := validateSSHKeyGenerationOptions("", 0)
if err != nil {
t.Fatalf("default options error = %v", err)
}
if keyType != "ed25519" {
t.Fatalf("default key type = %q, want ed25519", keyType)
}
if bits != 0 {
t.Fatalf("default bits = %d, want 0", bits)
}
keyType, bits, err = validateSSHKeyGenerationOptions("rsa", 0)
if err != nil {
t.Fatalf("rsa default options error = %v", err)
}
if keyType != "rsa" || bits != 4096 {
t.Fatalf("rsa defaults = %q/%d, want rsa/4096", keyType, bits)
}
if _, _, err := validateSSHKeyGenerationOptions("rsa", 999999); err == nil {
t.Fatal("expected oversized RSA key size to be rejected")
}
if _, _, err := validateSSHKeyGenerationOptions("dsa", 0); err == nil {
t.Fatal("expected unsupported key type to be rejected")
}
}
func TestImportProfilesNormalizesSparseRAWProfile(t *testing.T) {
svc := newTestService(t)
data := `[
{
"displayName": "Raw Device",
"protocol": "raw",
"host": "device.local",
"raw": {}
}
]`
count, err := svc.ImportProfiles(data, "skip")
if err != nil {
t.Fatalf("ImportProfiles() error = %v", err)
}
if count != 1 {
t.Fatalf("imported = %d, want 1", count)
}
profiles, err := svc.ListProfiles()
if err != nil {
t.Fatalf("ListProfiles() error = %v", err)
}
if len(profiles) != 1 {
t.Fatalf("profiles = %d, want 1", len(profiles))
}
if profiles[0].Port != models.DefaultRAWPort {
t.Fatalf("port = %d, want %d", profiles[0].Port, models.DefaultRAWPort)
}
if profiles[0].RAW.Encoding != "utf-8" {
t.Fatalf("raw encoding = %q, want utf-8", profiles[0].RAW.Encoding)
}
}
func TestLeviathanDocumentBytesRejectsOversizedDocument(t *testing.T) {
doc := document.New("large.bin", []byte("abcd"))
_, err := leviathanDocumentBytes(doc, 3, "test")
if err == nil {
t.Fatal("expected oversized document to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestWriteLeviathanDocumentFileWritesPatchedBytes(t *testing.T) {
doc := document.New("patched.bin", []byte("abcd"))
if _, err := doc.Set(1, 'Z'); err != nil {
t.Fatalf("Set() failed: %v", err)
}
path := filepath.Join(t.TempDir(), "out.bin")
if err := writeLeviathanDocumentFile(path, doc); err != nil {
t.Fatalf("writeLeviathanDocumentFile() failed: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() failed: %v", err)
}
if string(got) != "aZcd" {
t.Fatalf("written bytes = %q, want %q", string(got), "aZcd")
}
}
func TestWriteLeviathanDocumentRangeFileWritesSelectedPatchedBytes(t *testing.T) {
doc := document.New("patched.bin", []byte("abcdef"))
if _, err := doc.Set(3, 'Z'); err != nil {
t.Fatalf("Set() failed: %v", err)
}
path := filepath.Join(t.TempDir(), "range.bin")
if err := writeLeviathanDocumentRangeFile(path, doc, 2, 3); err != nil {
t.Fatalf("writeLeviathanDocumentRangeFile() failed: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() failed: %v", err)
}
if string(got) != "cZe" {
t.Fatalf("written bytes = %q, want %q", string(got), "cZe")
}
}
func TestReadLeviathanSmallFileRejectsOversizedFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "big.img")
if err := os.WriteFile(path, []byte("abcd"), 0600); err != nil {
t.Fatalf("WriteFile() failed: %v", err)
}
_, err := readLeviathanSmallFile(path, 3, "image")
if err == nil {
t.Fatal("expected oversized file to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestReadSSHPublicKeyFileRejectsOversizedFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "big.pub")
if err := os.WriteFile(path, []byte(strings.Repeat("x", maxSSHPublicKeyBytes+1)), 0600); err != nil {
t.Fatalf("WriteFile() failed: %v", err)
}
_, err := readSSHPublicKeyFile(path)
if err == nil {
t.Fatal("expected oversized public key to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateProfileNormalizesLegacyVNCSecretMode(t *testing.T) {
svc := newTestService(t)
profile := models.NewProfile(models.ProtocolVNC)
profile.DisplayName = "VNC"
profile.Host = "vnc.local"
profile.VNC.SecretMode = "prompt"
profile.VNC.SSHSecretMode = "prompt"
got, err := svc.CreateProfile(profile)
if err != nil {
t.Fatalf("CreateProfile() error = %v", err)
}
if got.VNC.SecretMode != models.SecretModeAskEveryTime {
t.Fatalf("secret mode = %q, want %q", got.VNC.SecretMode, models.SecretModeAskEveryTime)
}
if got.VNC.SSHSecretMode != models.SecretModeAskEveryTime {
t.Fatalf("ssh secret mode = %q, want %q", got.VNC.SSHSecretMode, models.SecretModeAskEveryTime)
}
}
func newTestService(t *testing.T) *Service {
t.Helper()
store, err := storage.NewStore(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("NewStore() error = %v", err)
}
t.Cleanup(func() { store.Close() })
return NewService(store, nil, nil, nil, audit.NewLogger(store), nil)
}
File diff suppressed because it is too large Load Diff
-5
View File
@@ -110,8 +110,6 @@ func (a *App) Startup(ctx context.Context) {
// Wire the API service with all backing stores now that they exist.
*a.Service = *api.NewService(store, a.SecretStore, a.KeychainStore, a.SessionManager, a.AuditLog, a.Prefs)
a.Service.SetContext(ctx)
log.Printf("Nexus Terminal initialised – data directory: %s", cfg.DataDir)
}
// Shutdown is called by Wails when the application is closing. It ensures all
@@ -134,8 +132,6 @@ func (a *App) Shutdown(ctx context.Context) {
if a.SecretStore != nil {
a.SecretStore.Lock()
}
log.Println("Nexus Terminal shut down cleanly")
}
// DomReady is called by Wails once the frontend DOM is fully loaded. It emits
@@ -144,7 +140,6 @@ func (a *App) DomReady(ctx context.Context) {
runtime.EventsEmit(ctx, "backend:ready", map[string]interface{}{
"message": "Backend is ready",
})
log.Println("DOM ready – backend:ready event emitted")
}
// EmitOpenAbout tells the frontend to open the About tab.
+10
View File
@@ -33,6 +33,8 @@ const (
saltLength = 32
nonceLength = 12 // AES-GCM standard nonce size
maxEncryptedImportBytes = 10 * 1024 * 1024
)
// ExportBundle is the cleartext payload that gets encrypted.
@@ -131,6 +133,14 @@ func DecryptAndImport(filePath, password string) ([]models.Profile, error) {
return nil, errors.New("exportimport: password cannot be empty")
}
info, err := os.Stat(filePath)
if err != nil {
return nil, fmt.Errorf("exportimport: failed to stat file: %w", err)
}
if info.Size() > maxEncryptedImportBytes {
return nil, fmt.Errorf("exportimport: file is too large (max %d bytes)", maxEncryptedImportBytes)
}
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("exportimport: failed to read file: %w", err)
@@ -129,3 +129,24 @@ func TestDecryptCorruptFile(t *testing.T) {
t.Fatal("expected error for corrupt file, got nil")
}
}
func TestDecryptRejectsOversizedFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "huge.nexus")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := f.Truncate(maxEncryptedImportBytes + 1); err != nil {
f.Close()
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
_, err = DecryptAndImport(path, "password")
if err == nil {
t.Fatal("expected error for oversized file, got nil")
}
}
+130 -39
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strings"
@@ -19,6 +20,12 @@ import (
"github.com/andydixon/nexus/internal/models"
)
const (
maxGitHubJSONBodyBytes = 4 << 20
maxGitHubErrorBodyBytes = 64 << 10
maxGitHubArchiveBytes = int64(2 << 30)
)
// Client wraps the GitHub REST API for repository listing.
type Client struct {
baseURL string
@@ -41,19 +48,19 @@ func New(baseURL, token string) *Client {
// apiRepo is the subset of the GitHub API response we care about.
type apiRepo struct {
FullName string `json:"full_name"`
Name string `json:"name"`
Description string `json:"description"`
HTMLURL string `json:"html_url"`
DefaultBranch string `json:"default_branch"`
Private bool `json:"private"`
Fork bool `json:"fork"`
Archived bool `json:"archived"`
Language string `json:"language"`
Stars int `json:"stargazers_count"`
PushedAt time.Time `json:"pushed_at"`
UpdatedAt time.Time `json:"updated_at"`
Owner struct {
FullName string `json:"full_name"`
Name string `json:"name"`
Description string `json:"description"`
HTMLURL string `json:"html_url"`
DefaultBranch string `json:"default_branch"`
Private bool `json:"private"`
Fork bool `json:"fork"`
Archived bool `json:"archived"`
Language string `json:"language"`
Stars int `json:"stargazers_count"`
PushedAt time.Time `json:"pushed_at"`
UpdatedAt time.Time `json:"updated_at"`
Owner struct {
Login string `json:"login"`
} `json:"owner"`
}
@@ -90,7 +97,7 @@ func (c *Client) fetchRepos(ctx context.Context, progress func(done int, total i
return nil, fmt.Errorf("fetching repos page %d: %w", page, err)
}
body, readErr := io.ReadAll(resp.Body)
body, readErr := readLimitedGitHubBody(resp.Body, maxGitHubJSONBodyBytes)
resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("reading response body: %w", readErr)
@@ -128,19 +135,19 @@ func (c *Client) fetchRepos(ctx context.Context, progress func(done int, total i
result := make([]models.GitHubRepo, len(all))
for i, r := range all {
result[i] = models.GitHubRepo{
FullName: r.FullName,
Name: r.Name,
Owner: r.Owner.Login,
Description: r.Description,
HTMLURL: r.HTMLURL,
FullName: r.FullName,
Name: r.Name,
Owner: r.Owner.Login,
Description: r.Description,
HTMLURL: r.HTMLURL,
DefaultBranch: r.DefaultBranch,
Private: r.Private,
Fork: r.Fork,
Archived: r.Archived,
Language: r.Language,
Stars: r.Stars,
PushedAt: r.PushedAt.Format(time.RFC3339),
UpdatedAt: r.UpdatedAt.Format(time.RFC3339),
Private: r.Private,
Fork: r.Fork,
Archived: r.Archived,
Language: r.Language,
Stars: r.Stars,
PushedAt: r.PushedAt.Format(time.RFC3339),
UpdatedAt: r.UpdatedAt.Format(time.RFC3339),
}
}
@@ -185,9 +192,16 @@ func (c *Client) fetchRepos(ctx context.Context, progress func(done int, total i
// FetchLatestCommit returns the committer name and commit time for a repo/ref.
func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref string) (string, string, error) {
url := fmt.Sprintf("%s/repos/%s/commits/%s", c.baseURL, fullName, ref)
if ref == "" {
url = fmt.Sprintf("%s/repos/%s/commits/HEAD", c.baseURL, fullName)
ref = "HEAD"
}
owner, repo, err := splitRepoFullName(fullName)
if err != nil {
return "", "", err
}
url, err := c.endpointURL("repos", owner, repo, "commits", ref)
if err != nil {
return "", "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@@ -208,8 +222,7 @@ func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref str
return "", "", fmt.Errorf("authentication failed (HTTP %d)", resp.StatusCode)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", "", fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, string(body))
return "", "", fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, readGitHubErrorBody(resp.Body))
}
var payload struct {
@@ -225,7 +238,11 @@ func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref str
} `json:"commit"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
body, err := readLimitedGitHubBody(resp.Body, maxGitHubJSONBodyBytes)
if err != nil {
return "", "", fmt.Errorf("reading commit response: %w", err)
}
if err := json.Unmarshal(body, &payload); err != nil {
return "", "", fmt.Errorf("decoding commit: %w", err)
}
@@ -244,12 +261,17 @@ func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref str
// DownloadRepoArchive downloads a repository zip archive to the given path.
func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref string, destPath string) error {
if fullName == "" {
return fmt.Errorf("repository name cannot be empty")
owner, repo, err := splitRepoFullName(fullName)
if err != nil {
return err
}
url := fmt.Sprintf("%s/repos/%s/zipball", c.baseURL, fullName)
segments := []string{"repos", owner, repo, "zipball"}
if ref != "" {
url = url + "/" + ref
segments = append(segments, ref)
}
url, err := c.endpointURL(segments...)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@@ -270,8 +292,10 @@ func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref s
return fmt.Errorf("authentication failed (HTTP %d) — check your access token", resp.StatusCode)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, string(body))
return fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, readGitHubErrorBody(resp.Body))
}
if resp.ContentLength > maxGitHubArchiveBytes {
return fmt.Errorf("archive is too large: %d bytes exceeds %d bytes", resp.ContentLength, maxGitHubArchiveBytes)
}
file, err := os.Create(destPath)
@@ -280,7 +304,7 @@ func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref s
}
defer file.Close()
if _, err := io.Copy(file, resp.Body); err != nil {
if _, err := copyLimitedArchive(file, resp.Body, maxGitHubArchiveBytes); err != nil {
return fmt.Errorf("writing archive: %w", err)
}
return nil
@@ -310,8 +334,75 @@ func (c *Client) ValidateToken(ctx context.Context) (string, error) {
var user struct {
Login string `json:"login"`
}
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
body, err := readLimitedGitHubBody(resp.Body, maxGitHubJSONBodyBytes)
if err != nil {
return "", fmt.Errorf("reading user response: %w", err)
}
if err := json.Unmarshal(body, &user); err != nil {
return "", fmt.Errorf("decoding user: %w", err)
}
return user.Login, nil
}
func readLimitedGitHubBody(r io.Reader, maxBytes int64) ([]byte, error) {
body, err := io.ReadAll(io.LimitReader(r, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(body)) > maxBytes {
return nil, fmt.Errorf("response body exceeds %d bytes", maxBytes)
}
return body, nil
}
func readGitHubErrorBody(r io.Reader) string {
body, err := readLimitedGitHubBody(r, maxGitHubErrorBodyBytes)
if err != nil {
return "<response body unavailable: " + err.Error() + ">"
}
return strings.TrimSpace(string(body))
}
func splitRepoFullName(fullName string) (string, string, error) {
parts := strings.Split(strings.TrimSpace(fullName), "/")
if len(parts) != 2 {
return "", "", fmt.Errorf("repository name must be in owner/repo format")
}
owner := strings.TrimSpace(parts[0])
repo := strings.TrimSpace(parts[1])
if owner == "" || repo == "" {
return "", "", fmt.Errorf("repository name must be in owner/repo format")
}
return owner, repo, nil
}
func (c *Client) endpointURL(segments ...string) (string, error) {
u, err := url.Parse(c.baseURL)
if err != nil {
return "", fmt.Errorf("invalid GitHub base URL: %w", err)
}
plainPath := strings.TrimRight(u.Path, "/")
escapedPath := strings.TrimRight(u.EscapedPath(), "/")
for _, segment := range segments {
if segment == "" {
return "", fmt.Errorf("empty GitHub API path segment")
}
plainPath += "/" + segment
escapedPath += "/" + url.PathEscape(segment)
}
u.Path = plainPath
u.RawPath = escapedPath
return u.String(), nil
}
func copyLimitedArchive(dst io.Writer, src io.Reader, maxBytes int64) (int64, error) {
limited := &io.LimitedReader{R: src, N: maxBytes + 1}
n, err := io.Copy(dst, limited)
if err != nil {
return n, err
}
if n > maxBytes {
return n, fmt.Errorf("archive exceeds %d bytes", maxBytes)
}
return n, nil
}
@@ -0,0 +1,75 @@
package githubclient
import (
"bytes"
"io"
"strings"
"testing"
)
func TestReadLimitedGitHubBodyRejectsOversizedResponse(t *testing.T) {
_, err := readLimitedGitHubBody(strings.NewReader("abcd"), 3)
if err == nil {
t.Fatal("expected oversized response to be rejected")
}
if !strings.Contains(err.Error(), "response body exceeds") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestReadLimitedGitHubBodyAllowsResponseAtLimit(t *testing.T) {
got, err := readLimitedGitHubBody(strings.NewReader("abc"), 3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(got) != "abc" {
t.Fatalf("body = %q, want abc", string(got))
}
}
func TestEndpointURLEscapesRepoPathSegments(t *testing.T) {
client := New("https://ghe.example.com/api/v3", "")
got, err := client.endpointURL("repos", "owner", "repo", "commits", "feature/a b")
if err != nil {
t.Fatalf("endpointURL() error = %v", err)
}
want := "https://ghe.example.com/api/v3/repos/owner/repo/commits/feature%2Fa%20b"
if got != want {
t.Fatalf("endpointURL() = %q, want %q", got, want)
}
}
func TestSplitRepoFullNameRejectsExtraSegments(t *testing.T) {
if _, _, err := splitRepoFullName("owner/repo/../../user"); err == nil {
t.Fatal("expected malformed repository name to be rejected")
}
}
func TestCopyLimitedArchiveRejectsOversizedStream(t *testing.T) {
var dst bytes.Buffer
n, err := copyLimitedArchive(&dst, strings.NewReader("abcd"), 3)
if err == nil {
t.Fatal("expected oversized archive stream to be rejected")
}
if n != 4 {
t.Fatalf("copied = %d, want 4", n)
}
if !strings.Contains(err.Error(), "archive exceeds") {
t.Fatalf("error = %q, want archive exceeds", err.Error())
}
}
func TestCopyLimitedArchiveAllowsStreamAtLimit(t *testing.T) {
var dst bytes.Buffer
n, err := copyLimitedArchive(&dst, strings.NewReader("abc"), 3)
if err != nil {
t.Fatalf("copyLimitedArchive() error = %v", err)
}
if n != 3 {
t.Fatalf("copied = %d, want 3", n)
}
if got, err := io.ReadAll(&dst); err != nil || string(got) != "abc" {
t.Fatalf("dst = %q, err = %v; want abc", string(got), err)
}
}
+767
View File
@@ -0,0 +1,767 @@
// Package ircclient wraps a full IRC client with Nexus-specific event and
// command handling, including optional SSH tunnelling.
package ircclient
import (
"crypto/tls"
"fmt"
"net"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/lrstanley/girc"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
)
const ServerTarget = "server"
const maxRoomListEntries = 2000
// Config describes an IRC connection.
type Config struct {
Server string
Port int
Nick string
User string
RealName string
UseTLS bool
ServerPass string
AutoJoinRooms []string
NickServIdentify bool
NickServAccount string
NickServPassword string
SSHTunnel bool
SSHHost string
SSHPort int
SSHUsername string
SSHAuthMethod string
SSHPassword string
SSHPrivateKey []byte
SSHPassphrase string
SSHAgentAuth bool
SSHAgentConn net.Conn
SSHHostKeyCallback ssh.HostKeyCallback
ConnectTimeout time.Duration
OnEvent func(Event)
OnError func(error)
OnClose func()
}
// Event is a typed IRC event suitable for JSON delivery to the frontend.
type Event struct {
Type string `json:"type"`
Target string `json:"target,omitempty"`
From string `json:"from,omitempty"`
Text string `json:"text,omitempty"`
Command string `json:"command,omitempty"`
Timestamp int64 `json:"timestamp"`
Rooms []RoomInfo `json:"rooms,omitempty"`
Users []string `json:"users,omitempty"`
Topic string `json:"topic,omitempty"`
}
// RoomInfo describes a joined or listed IRC room.
type RoomInfo struct {
Name string `json:"name"`
Topic string `json:"topic,omitempty"`
Users int `json:"users,omitempty"`
}
// Client owns a girc client and optional SSH tunnel.
type Client struct {
cfg Config
client *girc.Client
sshClient *ssh.Client
agentConn net.Conn
mu sync.Mutex
connected bool
closing bool
listRooms []RoomInfo
}
// New creates an IRC client wrapper.
func New(cfg Config) *Client {
if cfg.ConnectTimeout <= 0 {
cfg.ConnectTimeout = 30 * time.Second
}
if cfg.User == "" {
cfg.User = cfg.Nick
}
if cfg.RealName == "" {
cfg.RealName = cfg.Nick
}
c := &Client{cfg: cfg}
c.client = girc.New(girc.Config{
Server: cfg.Server,
ServerPass: cfg.ServerPass,
Port: cfg.Port,
Nick: cfg.Nick,
User: cfg.User,
Name: cfg.RealName,
SSL: cfg.UseTLS && !cfg.SSHTunnel,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: cfg.Server,
},
AllowFlood: false,
})
c.registerHandlers()
return c
}
// Connect starts the IRC connection and returns once registration succeeds.
func (c *Client) Connect() error {
connectedCh := make(chan struct{}, 1)
errCh := make(chan error, 1)
var connectedOnce sync.Once
c.client.Handlers.Add(girc.CONNECTED, func(gc *girc.Client, e girc.Event) {
c.mu.Lock()
c.connected = true
c.mu.Unlock()
c.emit(Event{
Type: "system",
Target: ServerTarget,
Text: fmt.Sprintf("Connected to %s", gc.Server()),
Timestamp: eventTime(e),
})
c.identifyWithNickServ(gc)
if len(c.cfg.AutoJoinRooms) > 0 {
gc.Cmd.Join(c.cfg.AutoJoinRooms...)
}
c.emitRooms()
connectedOnce.Do(func() {
select {
case connectedCh <- struct{}{}:
default:
}
})
})
go func() {
var err error
if c.cfg.SSHTunnel {
dialer, dialerErr := c.newSSHDialer()
if dialerErr != nil {
select {
case errCh <- dialerErr:
default:
}
return
}
err = c.client.DialerConnect(dialer)
} else {
err = c.client.Connect()
}
c.mu.Lock()
wasClosing := c.closing
wasConnected := c.connected
c.connected = false
c.mu.Unlock()
if err != nil && !wasClosing {
c.emit(Event{
Type: "error",
Target: ServerTarget,
Text: err.Error(),
Timestamp: time.Now().UnixMilli(),
})
if c.cfg.OnError != nil {
c.cfg.OnError(err)
}
select {
case errCh <- err:
default:
}
}
if wasConnected && !wasClosing && c.cfg.OnClose != nil {
c.cfg.OnClose()
}
}()
select {
case <-connectedCh:
return nil
case err := <-errCh:
return err
case <-time.After(c.cfg.ConnectTimeout):
c.Close()
return fmt.Errorf("IRC connection timed out after %s", c.cfg.ConnectTimeout)
}
}
// Close disconnects from IRC and closes any SSH tunnel resources.
func (c *Client) Close() {
c.mu.Lock()
if c.closing {
c.mu.Unlock()
return
}
c.closing = true
c.mu.Unlock()
if c.client != nil {
c.client.Quit("Nexus Terminal disconnected")
c.client.Close()
}
if c.sshClient != nil {
_ = c.sshClient.Close()
c.sshClient = nil
}
if c.agentConn != nil {
_ = c.agentConn.Close()
c.agentConn = nil
}
}
// HandleInput parses a user-entered IRC composer line.
func (c *Client) HandleInput(target string, input string) error {
text := strings.TrimSpace(input)
if text == "" {
return nil
}
if target == "" {
target = ServerTarget
}
if !strings.HasPrefix(text, "/") || text == "/" {
if target == ServerTarget {
return fmt.Errorf("select a room or use /msg <nick> <message>")
}
c.client.Cmd.Message(target, text)
c.emitLocalMessage("message", target, text)
return nil
}
commandLine := strings.TrimSpace(strings.TrimPrefix(text, "/"))
if commandLine == "" {
return nil
}
fields := strings.Fields(commandLine)
cmd := strings.ToLower(fields[0])
args := strings.TrimSpace(commandLine[len(fields[0]):])
switch cmd {
case "me", "action":
if target == ServerTarget {
return fmt.Errorf("/me requires an active room or private tab")
}
if args == "" {
return fmt.Errorf("/me requires an action")
}
c.client.Cmd.Action(target, args)
c.emitLocalMessage("action", target, args)
case "msg", "query":
recipient, body, ok := splitFirstArg(args)
if !ok || body == "" {
return fmt.Errorf("/msg requires a target and message")
}
c.client.Cmd.Message(recipient, body)
c.emitLocalMessage("message", recipient, body)
case "notice":
recipient, body, ok := splitFirstArg(args)
if !ok || body == "" {
return fmt.Errorf("/notice requires a target and message")
}
c.client.Cmd.Notice(recipient, body)
c.emitLocalMessage("notice", recipient, body)
case "join", "j":
room, key, ok := splitFirstArg(args)
if !ok {
return fmt.Errorf("/join requires a room")
}
if key != "" {
c.client.Cmd.JoinKey(room, key)
} else {
c.client.Cmd.Join(room)
}
case "part", "leave":
room, msg, _ := splitFirstArg(args)
if room == "" {
room = target
}
if room == "" || room == ServerTarget {
return fmt.Errorf("/part requires a room")
}
if msg != "" {
c.client.Cmd.PartMessage(room, msg)
} else {
c.client.Cmd.Part(room)
}
case "quit":
if args == "" {
args = "Nexus Terminal disconnected"
}
c.client.Quit(args)
c.client.Close()
case "nick":
if args == "" {
return fmt.Errorf("/nick requires a nickname")
}
c.client.Cmd.Nick(strings.Fields(args)[0])
case "topic":
room, topic, _ := splitFirstArg(args)
if room == "" {
room = target
}
if room == "" || room == ServerTarget {
return fmt.Errorf("/topic requires a room")
}
c.client.Cmd.Topic(room, topic)
case "list":
if args == "" {
c.client.Cmd.List()
} else {
c.client.Cmd.List(strings.Fields(args)...)
}
case "names":
room := args
if room == "" && target != ServerTarget {
room = target
}
if room == "" {
return c.client.Cmd.SendRaw("NAMES")
}
return c.client.Cmd.SendRaw("NAMES " + room)
case "whois":
if args == "" {
return fmt.Errorf("/whois requires a nickname")
}
c.client.Cmd.Whois(strings.Fields(args)...)
case "who":
if args == "" {
if target == ServerTarget {
return fmt.Errorf("/who requires a target")
}
args = target
}
c.client.Cmd.Who(strings.Fields(args)...)
case "raw", "quote":
if args == "" {
return fmt.Errorf("/raw requires an IRC command")
}
return c.client.Cmd.SendRawNoSplit(args)
case "help":
c.emit(Event{
Type: "system",
Target: target,
Text: "Commands: /join, /part, /msg, /notice, /me, /nick, /topic, /list, /names, /whois, /who, /raw, /quit",
Timestamp: time.Now().UnixMilli(),
})
default:
raw := strings.ToUpper(fields[0])
if args != "" {
raw += " " + args
}
return c.client.Cmd.SendRawNoSplit(raw)
}
return nil
}
// Join requests a room join.
func (c *Client) Join(room string) error {
room = strings.TrimSpace(room)
if room == "" {
return fmt.Errorf("room is required")
}
c.client.Cmd.Join(room)
return nil
}
// Part leaves a room.
func (c *Client) Part(room string) error {
room = strings.TrimSpace(room)
if room == "" {
return fmt.Errorf("room is required")
}
c.client.Cmd.Part(room)
return nil
}
// ListRooms requests a server-side room list. Results arrive as events.
func (c *Client) ListRooms() error {
c.mu.Lock()
c.listRooms = nil
c.mu.Unlock()
c.client.Cmd.List()
return nil
}
// Rooms returns currently joined rooms.
func (c *Client) Rooms() []RoomInfo {
if c.client == nil {
return nil
}
channels := c.client.Channels()
rooms := make([]RoomInfo, 0, len(channels))
for _, ch := range channels {
if ch == nil {
continue
}
rooms = append(rooms, RoomInfo{
Name: ch.Name,
Topic: ch.Topic,
Users: len(ch.UserList),
})
}
sort.Slice(rooms, func(i, j int) bool {
return strings.ToLower(rooms[i].Name) < strings.ToLower(rooms[j].Name)
})
return rooms
}
// Users returns known users in a room.
func (c *Client) Users(room string) []string {
if c.client == nil {
return nil
}
ch := c.client.LookupChannel(room)
if ch == nil {
return []string{}
}
users := append([]string{}, ch.UserList...)
sort.Slice(users, func(i, j int) bool {
return strings.TrimLeft(users[i], "~&@%+") < strings.TrimLeft(users[j], "~&@%+")
})
return users
}
func (c *Client) registerHandlers() {
c.client.Handlers.Add(girc.PRIVMSG, c.handleMessage)
c.client.Handlers.Add(girc.NOTICE, c.handleNotice)
c.client.Handlers.Add(girc.JOIN, c.handleJoin)
c.client.Handlers.Add(girc.PART, c.handlePart)
c.client.Handlers.Add(girc.QUIT, c.handleQuit)
c.client.Handlers.Add(girc.NICK, c.handleNick)
c.client.Handlers.Add(girc.TOPIC, c.handleTopic)
c.client.Handlers.Add(girc.RPL_TOPIC, c.handleTopic)
c.client.Handlers.Add(girc.RPL_NAMREPLY, c.handleNames)
c.client.Handlers.Add(girc.RPL_ENDOFNAMES, c.handleEndOfNames)
c.client.Handlers.Add(girc.RPL_LIST, c.handleList)
c.client.Handlers.Add(girc.RPL_LISTEND, c.handleListEnd)
c.client.Handlers.Add(girc.ERROR, func(_ *girc.Client, e girc.Event) {
c.emit(Event{Type: "error", Target: ServerTarget, Text: e.Last(), Timestamp: eventTime(e)})
})
}
func (c *Client) handleMessage(gc *girc.Client, e girc.Event) {
if len(e.Params) < 2 || e.Source == nil {
return
}
target := e.Params[0]
if strings.EqualFold(target, gc.GetNick()) {
target = e.Source.Name
}
eventType := "message"
text := e.Last()
if e.IsAction() {
eventType = "action"
text = e.StripAction()
}
c.emit(Event{Type: eventType, Target: target, From: e.Source.Name, Text: text, Timestamp: eventTime(e)})
}
func (c *Client) handleNotice(gc *girc.Client, e girc.Event) {
target := ServerTarget
if len(e.Params) > 0 {
target = e.Params[0]
if target == "*" {
target = ServerTarget
}
if strings.EqualFold(target, gc.GetNick()) && e.Source != nil {
target = e.Source.Name
}
}
from := ""
if e.Source != nil {
from = e.Source.Name
}
c.emit(Event{Type: "notice", Target: target, From: from, Text: e.Last(), Timestamp: eventTime(e)})
}
func (c *Client) handleJoin(gc *girc.Client, e girc.Event) {
room := eventRoom(e)
if room == "" {
return
}
from := sourceName(e)
c.emit(Event{Type: "join", Target: room, From: from, Text: fmt.Sprintf("%s joined %s", from, room), Timestamp: eventTime(e)})
c.emitRooms()
c.emitUsers(room)
}
func (c *Client) handlePart(gc *girc.Client, e girc.Event) {
room := eventRoom(e)
if room == "" {
return
}
from := sourceName(e)
message := ""
if len(e.Params) > 1 {
message = strings.TrimSpace(e.Last())
}
c.emit(Event{Type: "part", Target: room, From: from, Text: message, Timestamp: eventTime(e)})
c.emitRooms()
c.emitUsers(room)
}
func (c *Client) handleQuit(gc *girc.Client, e girc.Event) {
from := sourceName(e)
c.emit(Event{Type: "quit", Target: ServerTarget, From: from, Text: strings.TrimSpace(e.Last()), Timestamp: eventTime(e)})
c.emitUsersForAll()
}
func (c *Client) handleNick(gc *girc.Client, e girc.Event) {
from := sourceName(e)
newNick := e.Last()
c.emit(Event{Type: "nick", Target: ServerTarget, From: from, Text: newNick, Timestamp: eventTime(e)})
c.emitUsersForAll()
}
func (c *Client) handleTopic(gc *girc.Client, e girc.Event) {
room := ""
topic := e.Last()
if e.Command == girc.RPL_TOPIC && len(e.Params) >= 3 {
room = e.Params[1]
topic = e.Params[2]
} else if len(e.Params) >= 2 {
room = e.Params[0]
topic = e.Params[1]
}
if room == "" {
return
}
c.emit(Event{Type: "topic", Target: room, From: sourceName(e), Text: topic, Topic: topic, Timestamp: eventTime(e)})
c.emitRooms()
}
func (c *Client) handleNames(gc *girc.Client, e girc.Event) {
if len(e.Params) >= 4 {
c.emit(Event{Type: "users", Target: e.Params[2], Users: splitNames(e.Params[3]), Timestamp: eventTime(e)})
}
}
func (c *Client) handleEndOfNames(gc *girc.Client, e girc.Event) {
if len(e.Params) >= 2 {
c.emitUsers(e.Params[1])
}
}
func (c *Client) handleList(gc *girc.Client, e girc.Event) {
if len(e.Params) < 4 {
return
}
users, _ := strconv.Atoi(e.Params[2])
room := RoomInfo{Name: e.Params[1], Users: users, Topic: e.Params[3]}
c.mu.Lock()
if len(c.listRooms) < maxRoomListEntries {
c.listRooms = append(c.listRooms, room)
}
c.mu.Unlock()
}
func (c *Client) handleListEnd(gc *girc.Client, e girc.Event) {
c.mu.Lock()
rooms := append([]RoomInfo{}, c.listRooms...)
c.listRooms = nil
c.mu.Unlock()
sort.Slice(rooms, func(i, j int) bool {
if rooms[i].Users == rooms[j].Users {
return strings.ToLower(rooms[i].Name) < strings.ToLower(rooms[j].Name)
}
return rooms[i].Users > rooms[j].Users
})
c.emit(Event{Type: "room-list", Target: ServerTarget, Rooms: rooms, Timestamp: eventTime(e)})
}
func (c *Client) identifyWithNickServ(gc *girc.Client) {
if !c.cfg.NickServIdentify || c.cfg.NickServPassword == "" {
return
}
if c.cfg.NickServAccount != "" {
gc.Cmd.Message("NickServ", fmt.Sprintf("IDENTIFY %s %s", c.cfg.NickServAccount, c.cfg.NickServPassword))
} else {
gc.Cmd.Message("NickServ", "IDENTIFY "+c.cfg.NickServPassword)
}
c.emit(Event{Type: "system", Target: ServerTarget, Text: "Sent NickServ identify request", Timestamp: time.Now().UnixMilli()})
}
func (c *Client) newSSHDialer() (*sshDialer, error) {
if c.cfg.SSHHostKeyCallback == nil {
return nil, fmt.Errorf("SSH host key callback is required")
}
authMethods, err := c.sshAuthMethods()
if err != nil {
return nil, err
}
sshAddr := fmt.Sprintf("%s:%d", c.cfg.SSHHost, c.cfg.SSHPort)
sshConfig := &ssh.ClientConfig{
User: c.cfg.SSHUsername,
Auth: authMethods,
HostKeyCallback: c.cfg.SSHHostKeyCallback,
Timeout: c.cfg.ConnectTimeout,
}
sshClient, err := ssh.Dial("tcp", sshAddr, sshConfig)
if err != nil {
return nil, fmt.Errorf("SSH connection failed: %w", err)
}
c.sshClient = sshClient
return &sshDialer{
client: sshClient,
useTLS: c.cfg.UseTLS,
serverName: c.cfg.Server,
timeout: c.cfg.ConnectTimeout,
}, nil
}
func (c *Client) sshAuthMethods() ([]ssh.AuthMethod, error) {
var authMethods []ssh.AuthMethod
if c.cfg.SSHAgentAuth && c.cfg.SSHAgentConn != nil {
c.agentConn = c.cfg.SSHAgentConn
ag := agent.NewClient(c.cfg.SSHAgentConn)
signers, err := ag.Signers()
if err == nil && len(signers) > 0 {
authMethods = append(authMethods, ssh.PublicKeys(signers...))
}
}
if len(c.cfg.SSHPrivateKey) > 0 {
var signer ssh.Signer
var err error
if c.cfg.SSHPassphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase(c.cfg.SSHPrivateKey, []byte(c.cfg.SSHPassphrase))
} else {
signer, err = ssh.ParsePrivateKey(c.cfg.SSHPrivateKey)
}
if err != nil {
return nil, fmt.Errorf("parsing SSH private key: %w", err)
}
authMethods = append(authMethods, ssh.PublicKeys(signer))
}
if c.cfg.SSHPassword != "" {
authMethods = append(authMethods, ssh.Password(c.cfg.SSHPassword))
}
if len(authMethods) == 0 {
return nil, fmt.Errorf("no SSH authentication method available")
}
return authMethods, nil
}
func (c *Client) emitRooms() {
c.emit(Event{Type: "rooms", Target: ServerTarget, Rooms: c.Rooms(), Timestamp: time.Now().UnixMilli()})
}
func (c *Client) emitUsers(room string) {
if room == "" {
return
}
c.emit(Event{Type: "users", Target: room, Users: c.Users(room), Timestamp: time.Now().UnixMilli()})
}
func (c *Client) emitUsersForAll() {
for _, room := range c.Rooms() {
c.emitUsers(room.Name)
}
}
func (c *Client) emitLocalMessage(eventType string, target string, text string) {
c.emit(Event{Type: eventType, Target: target, From: c.client.GetNick(), Text: text, Timestamp: time.Now().UnixMilli()})
}
func (c *Client) emit(event Event) {
if event.Timestamp == 0 {
event.Timestamp = time.Now().UnixMilli()
}
if event.Target == "" {
event.Target = ServerTarget
}
if c.cfg.OnEvent != nil {
c.cfg.OnEvent(event)
}
}
type sshDialer struct {
client *ssh.Client
useTLS bool
serverName string
timeout time.Duration
}
func (d *sshDialer) Dial(network, address string) (net.Conn, error) {
conn, err := d.client.Dial(network, address)
if err != nil {
return nil, err
}
if !d.useTLS {
return conn, nil
}
tlsConn := tls.Client(conn, &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: d.serverName,
})
if d.timeout > 0 {
_ = tlsConn.SetDeadline(time.Now().Add(d.timeout))
}
if err := tlsConn.Handshake(); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("IRC TLS handshake failed: %w", err)
}
_ = tlsConn.SetDeadline(time.Time{})
return tlsConn, nil
}
func eventTime(e girc.Event) int64 {
if e.Timestamp.IsZero() {
return time.Now().UnixMilli()
}
return e.Timestamp.UnixMilli()
}
func eventRoom(e girc.Event) string {
if len(e.Params) == 0 {
return ""
}
return e.Params[0]
}
func sourceName(e girc.Event) string {
if e.Source == nil {
return ""
}
return e.Source.Name
}
func splitFirstArg(input string) (first string, rest string, ok bool) {
fields := strings.Fields(input)
if len(fields) == 0 {
return "", "", false
}
first = fields[0]
rest = strings.TrimSpace(input[len(first):])
return first, rest, true
}
func splitNames(input string) []string {
fields := strings.Fields(input)
names := make([]string, 0, len(fields))
for _, name := range fields {
name = strings.TrimSpace(name)
if name != "" {
names = append(names, name)
}
}
return names
}
+876
View File
@@ -0,0 +1,876 @@
package analysis
import (
"bytes"
"compress/gzip"
"context"
"encoding/binary"
"io"
"github.com/andydixon/nexus/internal/leviathan/document"
)
// Signature describes a carveable object. `validate` is run against
// the candidate start and returns the object length if it is a real
// object, or 0 to reject. `trailer`, if present, must appear before
// the object ends (for simple trailer-based terminations).
type Signature struct {
Kind string
Magic []byte
Extension string
// Anchor is how many bytes into the object the magic appears. It is 0 for
// almost every format (magic at offset 0), but a few — notably ISO-BMFF
// (MP4/MOV), whose `ftyp` magic follows a 4-byte box size — carry it.
Anchor int64
Validate func(d *document.Document, start, max int64) (length int64, confidence float64)
}
// carveSignatures is the phase-1 signature set. We stay conservative
// and only declare a carve when we can determine length reliably.
var carveSignatures = []Signature{
{Kind: "png", Magic: []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}, Extension: "png", Validate: validatePNG},
{Kind: "jpeg", Magic: []byte{0xff, 0xd8, 0xff}, Extension: "jpg", Validate: validateJPEG},
{Kind: "gif87a", Magic: []byte("GIF87a"), Extension: "gif", Validate: validateGIF},
{Kind: "gif89a", Magic: []byte("GIF89a"), Extension: "gif", Validate: validateGIF},
{Kind: "zip", Magic: []byte{'P', 'K', 0x03, 0x04}, Extension: "zip", Validate: validateZIP},
{Kind: "pdf", Magic: []byte("%PDF-"), Extension: "pdf", Validate: validatePDF},
{Kind: "elf", Magic: []byte{0x7f, 'E', 'L', 'F'}, Extension: "elf", Validate: validateELF},
{Kind: "mz", Magic: []byte{'M', 'Z'}, Extension: "exe", Validate: validatePE},
{Kind: "gzip", Magic: []byte{0x1f, 0x8b, 0x08}, Extension: "gz", Validate: validateGZIP},
{Kind: "bzip2", Magic: []byte{'B', 'Z', 'h'}, Extension: "bz2", Validate: validateBzip2},
{Kind: "sevenz", Magic: []byte{'7', 'z', 0xbc, 0xaf, 0x27, 0x1c}, Extension: "7z", Validate: validate7z},
{Kind: "rar4", Magic: []byte{'R', 'a', 'r', '!', 0x1a, 0x07, 0x00}, Extension: "rar", Validate: validateRAR},
{Kind: "rar5", Magic: []byte{'R', 'a', 'r', '!', 0x1a, 0x07, 0x01, 0x00}, Extension: "rar", Validate: validateRAR},
{Kind: "wav", Magic: []byte("RIFF"), Extension: "wav", Validate: validateRIFF},
{Kind: "mach-o-le32", Magic: []byte{0xce, 0xfa, 0xed, 0xfe}, Extension: "macho", Validate: validateMachO},
{Kind: "mach-o-le64", Magic: []byte{0xcf, 0xfa, 0xed, 0xfe}, Extension: "macho", Validate: validateMachO},
{Kind: "mach-o-be32", Magic: []byte{0xfe, 0xed, 0xfa, 0xce}, Extension: "macho", Validate: validateMachO},
{Kind: "mach-o-be64", Magic: []byte{0xfe, 0xed, 0xfa, 0xcf}, Extension: "macho", Validate: validateMachO},
{Kind: "sqlite", Magic: []byte("SQLite format 3\x00"), Extension: "sqlite", Validate: validateSQLite},
{Kind: "mp3", Magic: []byte("ID3"), Extension: "mp3", Validate: validateID3},
{Kind: "ogg", Magic: []byte("OggS"), Extension: "ogg", Validate: validateOgg},
{Kind: "matroska", Magic: []byte{0x1a, 0x45, 0xdf, 0xa3}, Extension: "mkv", Validate: validateMatroska},
{Kind: "flac", Magic: []byte("fLaC"), Extension: "flac", Validate: validateFLAC},
{Kind: "bmp", Magic: []byte{'B', 'M'}, Extension: "bmp", Validate: validateBMP},
{Kind: "mp4", Magic: []byte("ftyp"), Anchor: 4, Extension: "mp4", Validate: validateMP4},
}
// Carve scans the document for known signatures and returns a list of
// discovered objects with offset, length, kind, and confidence.
func Carve(d *document.Document, start, length int64, maxHits int) ([]map[string]any, error) {
return CarveContext(context.Background(), d, start, length, maxHits)
}
func CarveContext(ctx context.Context, d *document.Document, start, length int64, maxHits int) ([]map[string]any, error) {
if length < 0 {
length = d.Size() - start
}
if maxHits <= 0 {
maxHits = 10_000
}
results := make([]map[string]any, 0, 64)
// Build a flat list of (magicBytes, sigIndex) for a simple first-pass scan.
// Phase 1 uses per-signature naive scan chunks.
for idx, sig := range carveSignatures {
if err := ctx.Err(); err != nil {
return nil, err
}
hits, err := SearchBytesContext(ctx, d, start, length, sig.Magic, maxHits)
if err != nil {
return nil, err
}
for _, h := range hits {
if err := ctx.Err(); err != nil {
return nil, err
}
// The object begins Anchor bytes before the magic (0 for most formats).
objStart := h.Offset - sig.Anchor
if objStart < start {
continue
}
ln, conf := int64(0), 0.4
if sig.Validate != nil {
ln, conf = sig.Validate(d, objStart, start+length)
}
if ln <= 0 {
// record low-confidence magic hit only for kinds where length is not determinable
if sig.Kind == "mz" || sig.Kind == "elf" || sig.Kind == "mach-o-le32" ||
sig.Kind == "mach-o-le64" || sig.Kind == "mach-o-be32" || sig.Kind == "mach-o-be64" ||
sig.Kind == "sqlite" || sig.Kind == "mp3" || sig.Kind == "matroska" ||
sig.Kind == "flac" || sig.Kind == "bzip2" || sig.Kind == "rar4" || sig.Kind == "rar5" {
results = append(results, map[string]any{
"kind": sig.Kind,
"extension": sig.Extension,
"offset": objStart,
"length": int64(0),
"confidence": 0.3,
"status": "magic-only",
"sigIndex": idx,
})
}
continue
}
results = append(results, map[string]any{
"kind": sig.Kind,
"extension": sig.Extension,
"offset": objStart,
"length": ln,
"confidence": conf,
"status": "valid",
"sigIndex": idx,
})
if len(results) >= maxHits {
return results, nil
}
}
}
return results, nil
}
// --- validators ---
func validatePNG(d *document.Document, start, max int64) (int64, float64) {
// Walk chunks until IEND
off := start + 8
for off < max {
hdr, err := d.Range(off, 8)
if err != nil || len(hdr) < 8 {
return 0, 0
}
size := int64(binary.BigEndian.Uint32(hdr[0:4]))
typ := string(hdr[4:8])
// Skip payload + CRC (4 bytes)
off += 8 + size + 4
if typ == "IEND" {
return off - start, 0.95
}
if off > max {
return 0, 0
}
}
return 0, 0
}
func validateJPEG(d *document.Document, start, max int64) (int64, float64) {
// Walk segments until EOI (0xFFD9)
off := start + 2
for off < max-1 {
buf, err := d.Range(off, 2)
if err != nil || len(buf) < 2 {
return 0, 0
}
if buf[0] != 0xff {
return 0, 0
}
marker := buf[1]
off += 2
// No-length markers
if marker == 0xd9 { // EOI
return off - start, 0.95
}
if marker == 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker == 0x00 {
continue
}
// Read length
lb, err := d.Range(off, 2)
if err != nil || len(lb) < 2 {
return 0, 0
}
ln := int64(binary.BigEndian.Uint16(lb))
off += ln
if marker == 0xda { // SOS, scan until next 0xff!=0
for off < max {
b, err := d.Range(off, 2)
if err != nil || len(b) < 2 {
return 0, 0
}
if b[0] == 0xff && b[1] != 0x00 && !(b[1] >= 0xd0 && b[1] <= 0xd7) {
break
}
off++
}
}
}
return 0, 0
}
func validateGIF(d *document.Document, start, max int64) (int64, float64) {
// Minimal: locate trailer 0x3B
buf, err := d.Range(start, min64(max-start, 1<<20))
if err != nil {
return 0, 0
}
idx := bytes.IndexByte(buf[13:], 0x3b)
if idx < 0 {
return 0, 0
}
return int64(idx + 13 + 1), 0.7
}
func validateZIP(d *document.Document, start, max int64) (int64, float64) {
// Search forward for End of Central Directory record 0x06054b50.
const sig = "\x50\x4b\x05\x06"
limit := min64(max-start, 64<<20)
buf, err := d.Range(start, limit)
if err != nil {
return 0, 0
}
idx := bytes.LastIndex(buf, []byte(sig))
if idx < 0 {
return 0, 0
}
// EOCD record is at least 22 bytes; read comment length.
if idx+22 > len(buf) {
return 0, 0
}
commentLen := int(binary.LittleEndian.Uint16(buf[idx+20 : idx+22]))
end := int64(idx + 22 + commentLen)
return end, 0.9
}
func validatePDF(d *document.Document, start, max int64) (int64, float64) {
limit := min64(max-start, 64<<20)
buf, err := d.Range(start, limit)
if err != nil {
return 0, 0
}
idx := bytes.LastIndex(buf, []byte("%%EOF"))
if idx < 0 {
return 0, 0
}
return int64(idx + 5), 0.85
}
func validateELF(d *document.Document, start, max int64) (int64, float64) {
hdr, err := d.Range(start, 64)
if err != nil || len(hdr) < 52 {
return 0, 0
}
if hdr[4] != 1 && hdr[4] != 2 {
return 0, 0
}
is64 := hdr[4] == 2
little := hdr[5] == 1
var end int64
if is64 {
if len(hdr) < 64 {
return 0, 0
}
// e_shoff at 40, e_shentsize at 58, e_shnum at 60
off := binary.LittleEndian.Uint64(hdr[40:48])
entSz := uint64(binary.LittleEndian.Uint16(hdr[58:60]))
num := uint64(binary.LittleEndian.Uint16(hdr[60:62]))
if !little {
off = binary.BigEndian.Uint64(hdr[40:48])
entSz = uint64(binary.BigEndian.Uint16(hdr[58:60]))
num = uint64(binary.BigEndian.Uint16(hdr[60:62]))
}
end = int64(off + entSz*num)
} else {
off := binary.LittleEndian.Uint32(hdr[32:36])
entSz := uint32(binary.LittleEndian.Uint16(hdr[46:48]))
num := uint32(binary.LittleEndian.Uint16(hdr[48:50]))
if !little {
off = binary.BigEndian.Uint32(hdr[32:36])
entSz = uint32(binary.BigEndian.Uint16(hdr[46:48]))
num = uint32(binary.BigEndian.Uint16(hdr[48:50]))
}
end = int64(off) + int64(entSz)*int64(num)
}
if end <= 0 || start+end > max {
return 0, 0.5
}
return end, 0.9
}
func validatePE(d *document.Document, start, max int64) (int64, float64) {
// e_lfanew at offset 0x3C points to PE header
hdr, err := d.Range(start, 64)
if err != nil || len(hdr) < 64 {
return 0, 0
}
peOff := int64(binary.LittleEndian.Uint32(hdr[0x3c:0x40]))
if peOff < 0x40 || start+peOff+4 > max {
return 0, 0
}
sig, err := d.Range(start+peOff, 4)
if err != nil || string(sig) != "PE\x00\x00" {
return 0, 0.3
}
coff, err := d.Range(start+peOff+4, 20)
if err != nil || len(coff) < 20 {
return 0, 0.4
}
numSections := int(binary.LittleEndian.Uint16(coff[2:4]))
sizeOpt := int64(binary.LittleEndian.Uint16(coff[16:18]))
if numSections <= 0 || numSections > 96 || sizeOpt < 0 || sizeOpt > 4096 {
return 0, 0.5
}
sectOff := start + peOff + 24 + sizeOpt
end := peOff + 24 + sizeOpt + int64(numSections*40)
for i := 0; i < numSections; i++ {
sh, err := d.Range(sectOff+int64(i*40), 40)
if err != nil || len(sh) < 40 {
return 0, 0.5
}
rawSize := int64(binary.LittleEndian.Uint32(sh[16:20]))
rawPtr := int64(binary.LittleEndian.Uint32(sh[20:24]))
if rawSize < 0 || rawPtr < 0 {
return 0, 0.5
}
if rawSize > 0 && rawPtr+rawSize > end {
end = rawPtr + rawSize
}
}
if end <= peOff || start+end > max {
return 0, 0.5
}
return end, 0.85
}
func validateGZIP(d *document.Document, start, max int64) (int64, float64) {
limit := min64(max-start, 64<<20)
buf, err := d.Range(start, limit)
if err != nil || len(buf) < 18 {
return 0, 0
}
br := bytes.NewReader(buf)
zr, err := gzip.NewReader(br)
if err != nil {
return 0, 0
}
zr.Multistream(false)
if _, err := io.Copy(io.Discard, io.LimitReader(zr, 512<<20)); err != nil {
_ = zr.Close()
return 0, 0
}
if err := zr.Close(); err != nil {
return 0, 0
}
consumed := int64(len(buf)) - int64(br.Len())
if consumed <= 10 || start+consumed > max {
return 0, 0
}
return consumed, 0.9
}
// validateBzip2 locates the bit-aligned end-of-stream marker (0x177245385090,
// the "sqrt(pi)" magic) and returns the stream length including the trailing
// 32-bit combined CRC. bzip2 carries no length in its header, so this is the
// only reliable way to bound the object.
func validateBzip2(d *document.Document, start, max int64) (int64, float64) {
if start+4 > max {
return 0, 0.4
}
hdr, err := d.Range(start, 4)
if err != nil || len(hdr) < 4 || hdr[0] != 'B' || hdr[1] != 'Z' || hdr[2] != 'h' || hdr[3] < '1' || hdr[3] > '9' {
return 0, 0
}
limit := min64(max-start, 64<<20)
buf, err := d.Range(start, limit)
if err != nil || len(buf) < 14 {
return 0, 0.4
}
const eos uint64 = 0x177245385090
const mask48 uint64 = (1 << 48) - 1
var w uint64
for i := 0; i < len(buf); i++ {
w = (w << 8) | uint64(buf[i])
// Check all eight bit alignments ending at or before this byte.
for s := 0; s < 8; s++ {
if (w>>uint(s))&mask48 == eos {
endBit := (int64(i)+1)*8 - int64(s) + 32 // include stream CRC
endByte := (endBit + 7) / 8
if endByte > 14 && start+endByte <= max {
return endByte, 0.85
}
}
}
}
return 0, 0.4
}
func validate7z(d *document.Document, start, max int64) (int64, float64) {
hdr, err := d.Range(start, 32)
if err != nil || len(hdr) < 32 {
return 0, 0
}
nextHdrOff := binary.LittleEndian.Uint64(hdr[12:20])
nextHdrSz := binary.LittleEndian.Uint64(hdr[20:28])
end := int64(32 + nextHdrOff + nextHdrSz)
if end <= 0 || start+end > max {
return 0, 0.5
}
return end, 0.85
}
// validateRAR walks the block headers of a RAR4 or RAR5 archive to the
// end-of-archive marker. The two formats differ structurally, so the version
// is detected from the signature byte at offset 6.
func validateRAR(d *document.Document, start, max int64) (int64, float64) {
sig, err := d.Range(start, 8)
if err != nil || len(sig) < 8 {
return 0, 0
}
if sig[6] == 0x01 {
return validateRAR5(d, start, max)
}
return validateRAR4(d, start, max)
}
func validateRAR4(d *document.Document, start, max int64) (int64, float64) {
pos := start + 7 // 7-byte signature
for n := 0; n < 100000; n++ {
h, err := d.Range(pos, 11)
if err != nil || len(h) < 7 {
return 0, 0.4
}
flags := binary.LittleEndian.Uint16(h[3:5])
headSize := int64(binary.LittleEndian.Uint16(h[5:7]))
if headSize < 7 {
return 0, 0.4
}
blockSize := headSize
if flags&0x8000 != 0 { // ADD_SIZE present
if len(h) < 11 {
return 0, 0.4
}
blockSize += int64(binary.LittleEndian.Uint32(h[7:11]))
}
pos += blockSize
if h[2] == 0x7b { // terminator block
if pos > start && pos <= max {
return pos - start, 0.85
}
return 0, 0.4
}
if pos > max {
return 0, 0.4
}
}
return 0, 0.4
}
func validateRAR5(d *document.Document, start, max int64) (int64, float64) {
pos := start + 8 // 8-byte signature
for n := 0; n < 100000; n++ {
// Each block: CRC32(4) + vint(header_size) + header_size bytes [+ data].
h, err := d.Range(pos, 4+11)
if err != nil || len(h) < 5 {
return 0, 0.4
}
headerSize, vlen := rar5Vint(h, 4)
if vlen == 0 || headerSize == 0 {
return 0, 0.4
}
hdrStart := pos + 4 + int64(vlen)
body, err := d.Range(hdrStart, int64(headerSize))
if err != nil || int64(len(body)) < int64(headerSize) {
return 0, 0.4
}
htype, t1 := rar5Vint(body, 0)
hflags, t2 := rar5Vint(body, t1)
p := t1 + t2
var dataSize uint64
if hflags&0x0001 != 0 { // extra area present
_, te := rar5Vint(body, p)
p += te
}
if hflags&0x0002 != 0 { // data area present
dataSize, _ = rar5Vint(body, p)
}
pos = hdrStart + int64(headerSize) + int64(dataSize)
if htype == 5 { // end-of-archive header
if pos > start && pos <= max {
return pos - start, 0.85
}
return 0, 0.4
}
if pos > max {
return 0, 0.4
}
}
return 0, 0.4
}
// rar5Vint decodes a RAR5 little-endian base-128 variable integer.
func rar5Vint(buf []byte, pos int) (uint64, int) {
var v uint64
shift := uint(0)
for n := 0; pos+n < len(buf) && n < 10; n++ {
b := buf[pos+n]
v |= uint64(b&0x7f) << shift
if b&0x80 == 0 {
return v, n + 1
}
shift += 7
}
return 0, 0
}
func validateRIFF(d *document.Document, start, max int64) (int64, float64) {
hdr, err := d.Range(start, 12)
if err != nil || len(hdr) < 12 {
return 0, 0
}
size := int64(binary.LittleEndian.Uint32(hdr[4:8]))
return size + 8, 0.9
}
func validateMachO(d *document.Document, start, max int64) (int64, float64) {
hdr, err := d.Range(start, 32)
if err != nil || len(hdr) < 28 {
return 0, 0
}
var (
bo binary.ByteOrder
is64 bool
hdrSize int64 = 28
)
switch binary.BigEndian.Uint32(hdr[0:4]) {
case 0xfeedface:
bo = binary.BigEndian
case 0xfeedfacf:
bo = binary.BigEndian
is64 = true
hdrSize = 32
case 0xcefaedfe:
bo = binary.LittleEndian
case 0xcffaedfe:
bo = binary.LittleEndian
is64 = true
hdrSize = 32
default:
return 0, 0
}
if is64 && len(hdr) < 32 {
return 0, 0
}
ncmds := bo.Uint32(hdr[16:20])
sizeofcmds := bo.Uint32(hdr[20:24])
if ncmds == 0 || ncmds > 10000 || sizeofcmds > 128<<20 {
return 0, 0
}
cmdBytes, err := d.Range(start+hdrSize, int64(sizeofcmds))
if err != nil || len(cmdBytes) < int(sizeofcmds) {
return 0, 0
}
end := hdrSize + int64(sizeofcmds)
off := 0
for i := uint32(0); i < ncmds; i++ {
if off+8 > len(cmdBytes) {
return 0, 0
}
cmd := bo.Uint32(cmdBytes[off : off+4])
cmdSize := int(bo.Uint32(cmdBytes[off+4 : off+8]))
if cmdSize < 8 || off+cmdSize > len(cmdBytes) {
return 0, 0
}
switch cmd {
case 0x1: // LC_SEGMENT
if cmdSize >= 56 {
fileOff := int64(bo.Uint32(cmdBytes[off+32 : off+36]))
fileSize := int64(bo.Uint32(cmdBytes[off+36 : off+40]))
if fileOff >= 0 && fileSize >= 0 && fileOff+fileSize > end {
end = fileOff + fileSize
}
}
case 0x19: // LC_SEGMENT_64
if cmdSize >= 72 {
fileOff := int64(bo.Uint64(cmdBytes[off+40 : off+48]))
fileSize := int64(bo.Uint64(cmdBytes[off+48 : off+56]))
if fileOff >= 0 && fileSize >= 0 && fileOff+fileSize > end {
end = fileOff + fileSize
}
}
}
off += cmdSize
}
if end <= hdrSize || start+end > max {
return 0, 0
}
return end, 0.85
}
func validateID3(d *document.Document, start, max int64) (int64, float64) {
// ID3v2 header: "ID3" + ver(2) + flags(1) + size(4, synchsafe).
hdr, err := d.Range(start, 10)
if err != nil || len(hdr) < 10 {
return 0, 0.3
}
if hdr[3] == 0xff || hdr[4] == 0xff {
return 0, 0.3
}
// Synchsafe 28-bit size (7 bits per byte).
sz := (int64(hdr[6]&0x7f) << 21) | (int64(hdr[7]&0x7f) << 14) |
(int64(hdr[8]&0x7f) << 7) | int64(hdr[9]&0x7f)
if sz < 0 || sz > 32*1024*1024 {
return 0, 0.4
}
// Skip the ID3v2 tag (header + synchsafe body, plus a 10-byte footer if the
// footer-present flag is set), then walk MPEG audio frames to the end.
audioStart := start + 10 + sz
if hdr[5]&0x10 != 0 {
audioStart += 10
}
if end := walkMP3Frames(d, audioStart, max); end > audioStart {
return end - start, 0.85
}
return 0, 0.7
}
// walkMP3Frames follows MPEG-1/2/2.5 Layer I/II/III frame headers from start
// until a byte that is not a valid frame sync, returning the offset just past
// the last frame. APE/Lyrics3 trailers are not stripped — the audio extent is
// what matters for carving.
func walkMP3Frames(d *document.Document, start, max int64) int64 {
pos := start
frames := 0
for pos+4 <= max && frames < 5_000_000 {
h, err := d.Range(pos, 4)
if err != nil || len(h) < 4 {
break
}
ln := mp3FrameLen(h)
if ln <= 0 {
break
}
pos += ln
frames++
}
if frames == 0 {
return 0
}
if pos > max {
return max
}
return pos
}
var mp3BitrateV1 = [16]int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
var mp3BitrateV2 = [16]int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}
var mp3SampleRate = map[int][3]int{ // [version]{rate index 0,1,2}
3: {44100, 48000, 32000}, // MPEG1
2: {22050, 24000, 16000}, // MPEG2
0: {11025, 12000, 8000}, // MPEG2.5
}
// mp3FrameLen returns the byte length of the MPEG audio frame whose 4-byte
// header is h, or 0 if h is not a valid frame header.
func mp3FrameLen(h []byte) int64 {
if h[0] != 0xff || h[1]&0xe0 != 0xe0 {
return 0
}
version := int(h[1] >> 3 & 0x03) // 3=MPEG1, 2=MPEG2, 0=MPEG2.5
layer := int(h[1] >> 1 & 0x03) // 3=I, 2=II, 1=III
if version == 1 || layer == 0 {
return 0 // reserved
}
brIndex := int(h[2] >> 4 & 0x0f)
srIndex := int(h[2] >> 2 & 0x03)
padding := int(h[2] >> 1 & 0x01)
if brIndex == 0 || brIndex == 15 || srIndex == 3 {
return 0
}
var bitrate int
if version == 3 {
bitrate = mp3BitrateV1[brIndex]
} else {
bitrate = mp3BitrateV2[brIndex]
}
rates, ok := mp3SampleRate[version]
if !ok || bitrate == 0 {
return 0
}
sampleRate := rates[srIndex]
if sampleRate == 0 {
return 0
}
bps := bitrate * 1000
if layer == 3 { // Layer I
return int64(((12 * bps / sampleRate) + padding) * 4)
}
// Layer II & III. MPEG2/2.5 Layer III uses 72 samples-per-byte factor.
coef := 144
if version != 3 && layer == 1 {
coef = 72
}
return int64(coef*bps/sampleRate + padding)
}
func validateOgg(d *document.Document, start, max int64) (int64, float64) {
off := start
first := true
for off+27 <= max {
hdr, err := d.Range(off, 27)
if err != nil || len(hdr) < 27 || string(hdr[:4]) != "OggS" || hdr[4] != 0 {
return 0, 0
}
headerType := hdr[5]
if first && headerType&0x02 == 0 {
return 0, 0.4
}
segCount := int(hdr[26])
lacing, err := d.Range(off+27, int64(segCount))
if err != nil || len(lacing) < segCount {
return 0, 0
}
payload := int64(0)
for _, v := range lacing {
payload += int64(v)
}
pageLen := int64(27 + segCount)
if payload < 0 || off+pageLen+payload > max {
return 0, 0
}
off += pageLen + payload
if headerType&0x04 != 0 {
return off - start, 0.9
}
first = false
}
return 0, 0.6
}
// validateMatroska reads the EBML header element and the following Segment
// element's size to bound a Matroska/WebM stream. A Segment with "unknown size"
// (all size bits set, as live-streamed files use) cannot be bounded this way.
func validateMatroska(d *document.Document, start, max int64) (int64, float64) {
buf, err := d.Range(start, min64(max-start, 4096))
if err != nil || len(buf) < 8 {
return 0, 0
}
// EBML header: ID 0x1A45DFA3, then a vint size.
if !(buf[0] == 0x1a && buf[1] == 0x45 && buf[2] == 0xdf && buf[3] == 0xa3) {
return 0, 0
}
hsz, n, unknown := ebmlSize(buf, 4)
if n == 0 || unknown {
return 0, 0.6
}
pos := 4 + n + int(hsz) // start of the Segment element
if pos+4 > len(buf) {
return 0, 0.6
}
// Segment: ID 0x18538067, then a vint size.
if !(buf[pos] == 0x18 && buf[pos+1] == 0x53 && buf[pos+2] == 0x80 && buf[pos+3] == 0x67) {
return 0, 0.6
}
segSz, sn, segUnknown := ebmlSize(buf, pos+4)
if sn == 0 || segUnknown {
return 0, 0.6 // live/streamed segment without a known size
}
end := int64(pos+4+sn) + segSz
if end <= 0 || start+end > max {
return 0, 0.6
}
return end, 0.9
}
// ebmlSize decodes an EBML variable-length integer (data size form). The length
// is the number of leading zero bits in the first byte plus one; the value is
// the remaining bits. unknown is true when every value bit is set.
func ebmlSize(buf []byte, pos int) (val int64, n int, unknown bool) {
if pos >= len(buf) || buf[pos] == 0 {
return 0, 0, false
}
first := buf[pos]
mask := byte(0x80)
length := 1
for first&mask == 0 {
mask >>= 1
length++
}
if length > 8 || pos+length > len(buf) {
return 0, 0, false
}
low := mask - 1
v := int64(first & low)
allOnes := (first & low) == low
for i := 1; i < length; i++ {
b := buf[pos+i]
v = (v << 8) | int64(b)
if b != 0xff {
allOnes = false
}
}
return v, length, allOnes
}
func validateFLAC(d *document.Document, start, max int64) (int64, float64) {
return 0, 0.8
}
// validateBMP reads the BITMAPFILEHEADER size field (bfSize), sanity-checking
// the embedded pixel-data offset against the declared length.
func validateBMP(d *document.Document, start, max int64) (int64, float64) {
hdr, err := d.Range(start, 14)
if err != nil || len(hdr) < 14 {
return 0, 0
}
size := int64(binary.LittleEndian.Uint32(hdr[2:6]))
dataOff := int64(binary.LittleEndian.Uint32(hdr[10:14]))
if size < 26 || dataOff < 26 || dataOff > size || start+size > max {
return 0, 0
}
return size, 0.8
}
// validateMP4 walks the top-level boxes of an ISO base-media file (MP4/MOV/M4A)
// from the file start (the ftyp box). 64-bit box sizes are honoured.
func validateMP4(d *document.Document, start, max int64) (int64, float64) {
pos := start
boxes := 0
for pos+8 <= max && boxes < 100000 {
h, err := d.Range(pos, 16)
if err != nil || len(h) < 8 {
break
}
size := int64(binary.BigEndian.Uint32(h[0:4]))
switch {
case size == 1: // 64-bit largesize follows the type
if len(h) < 16 {
return 0, 0.4
}
size = int64(binary.BigEndian.Uint64(h[8:16]))
case size == 0: // box extends to end of file
return max - start, 0.8
}
if size < 8 {
break
}
pos += size
boxes++
if pos > max {
return 0, 0.4
}
}
if pos > start && pos <= max && boxes > 0 {
return pos - start, 0.85
}
return 0, 0.4
}
func validateSQLite(d *document.Document, start, max int64) (int64, float64) {
hdr, err := d.Range(start, 32)
if err != nil || len(hdr) < 32 {
return 0, 0
}
pageSize := int64(binary.BigEndian.Uint16(hdr[16:18]))
if pageSize == 1 {
pageSize = 65536
}
pages := int64(binary.BigEndian.Uint32(hdr[28:32]))
if pages == 0 || pageSize == 0 {
return 0, 0.5
}
return pageSize * pages, 0.9
}
func min64(a, b int64) int64 {
if a < b {
return a
}
return b
}
+244
View File
@@ -0,0 +1,244 @@
package analysis
import (
"context"
"github.com/andydixon/nexus/internal/leviathan/document"
)
// DiffRegion describes a contiguous run of differing bytes.
type DiffRegion struct {
Offset int64 `json:"offset"`
Length int64 `json:"length"`
// kinds: "edit" (both sides have bytes), "add" (only B), "remove" (only A)
Kind string `json:"kind"`
}
type DiffBlock struct {
Offset int64 `json:"offset"`
Length int64 `json:"length"`
ChangedBytes int64 `json:"changedBytes"`
EditBytes int64 `json:"editBytes"`
AddBytes int64 `json:"addBytes"`
RemoveBytes int64 `json:"removeBytes"`
Similarity float64 `json:"similarity"`
}
// Diff compares two documents by byte. It handles documents of
// different sizes by treating the tail of the longer as add/remove.
// Chunks at both sides. Returns a compact list of regions.
func Diff(a, b *document.Document, start, length int64) (map[string]any, error) {
return DiffContext(context.Background(), a, b, start, length)
}
func DiffContext(ctx context.Context, a, b *document.Document, start, length int64) (map[string]any, error) {
aSize := a.Size()
bSize := b.Size()
maxSize := aSize
if bSize > maxSize {
maxSize = bSize
}
if length < 0 {
length = maxSize - start
}
end := start + length
if end > maxSize {
end = maxSize
}
const chunk = 1 << 20
regions := make([]DiffRegion, 0, 64)
var inRun bool
var runStart int64
var runKind string
flush := func(off int64) {
if inRun {
regions = append(regions, DiffRegion{Offset: runStart, Length: off - runStart, Kind: runKind})
inRun = false
}
}
for off := start; off < end; {
if err := ctx.Err(); err != nil {
return nil, err
}
winEnd := off + chunk
if winEnd > end {
winEnd = end
}
// Read both sides, tolerating short reads for the shorter file.
var ab, bb []byte
if off < aSize {
n := winEnd - off
if off+n > aSize {
n = aSize - off
}
var err error
ab, err = a.Range(off, n)
if err != nil {
return nil, err
}
}
if off < bSize {
n := winEnd - off
if off+n > bSize {
n = bSize - off
}
var err error
bb, err = b.Range(off, n)
if err != nil {
return nil, err
}
}
for i := int64(0); i < winEnd-off; i++ {
if i%4096 == 0 {
if err := ctx.Err(); err != nil {
return nil, err
}
}
curOff := off + i
var av, bv byte
var av_ok, bv_ok bool
if curOff < aSize && int64(len(ab)) > i {
av = ab[i]
av_ok = true
}
if curOff < bSize && int64(len(bb)) > i {
bv = bb[i]
bv_ok = true
}
kind := ""
if av_ok && bv_ok {
if av != bv {
kind = "edit"
}
} else if av_ok && !bv_ok {
kind = "remove"
} else if !av_ok && bv_ok {
kind = "add"
}
if kind == "" {
flush(curOff)
continue
}
if !inRun {
inRun = true
runStart = curOff
runKind = kind
} else if kind != runKind {
flush(curOff)
inRun = true
runStart = curOff
runKind = kind
}
}
off = winEnd
}
flush(end)
// Convert to JS-friendly output + summary
out := make([]map[string]any, len(regions))
editBytes := int64(0)
addBytes := int64(0)
removeBytes := int64(0)
for i, r := range regions {
out[i] = map[string]any{
"offset": r.Offset,
"length": r.Length,
"kind": r.Kind,
}
switch r.Kind {
case "edit":
editBytes += r.Length
case "add":
addBytes += r.Length
case "remove":
removeBytes += r.Length
}
}
const blockSize int64 = 4096
blocks := diffBlocksFromRegions(regions, start, end, blockSize)
blockOut := make([]map[string]any, len(blocks))
for i, b := range blocks {
blockOut[i] = map[string]any{
"offset": b.Offset,
"length": b.Length,
"changedBytes": b.ChangedBytes,
"editBytes": b.EditBytes,
"addBytes": b.AddBytes,
"removeBytes": b.RemoveBytes,
"similarity": b.Similarity,
}
}
return map[string]any{
"regions": out,
"regionCount": len(regions),
"blocks": blockOut,
"blockCount": len(blocks),
"blockSize": blockSize,
"editBytes": editBytes,
"addBytes": addBytes,
"removeBytes": removeBytes,
"aSize": aSize,
"bSize": bSize,
}, nil
}
func diffBlocksFromRegions(regions []DiffRegion, start, end, blockSize int64) []DiffBlock {
if blockSize <= 0 || end <= start || len(regions) == 0 {
return nil
}
out := []DiffBlock{}
for alignedStart := start - start%blockSize; alignedStart < end; alignedStart += blockSize {
blockStart := alignedStart
blockEnd := alignedStart + blockSize
if blockEnd > end {
blockEnd = end
}
if blockEnd <= start {
continue
}
if blockStart < start {
blockStart = start
}
b := DiffBlock{Offset: blockStart, Length: blockEnd - blockStart}
for _, r := range regions {
rEnd := r.Offset + r.Length
if rEnd <= blockStart {
continue
}
if r.Offset >= blockEnd {
break
}
ovStart := r.Offset
if ovStart < blockStart {
ovStart = blockStart
}
ovEnd := rEnd
if ovEnd > blockEnd {
ovEnd = blockEnd
}
changed := ovEnd - ovStart
if changed <= 0 {
continue
}
b.ChangedBytes += changed
switch r.Kind {
case "edit":
b.EditBytes += changed
case "add":
b.AddBytes += changed
case "remove":
b.RemoveBytes += changed
}
}
if b.ChangedBytes == 0 {
continue
}
b.Similarity = 1 - float64(b.ChangedBytes)/float64(b.Length)
out = append(out, b)
}
return out
}
+125
View File
@@ -0,0 +1,125 @@
package analysis
import (
"context"
"math"
"github.com/andydixon/nexus/internal/leviathan/document"
)
// Entropy computes Shannon entropy per sliding window over the range.
//
// The returned map has:
//
// windowSize: int
// step: int
// samples: []float64 — entropy per window (0..8)
// offsets: []int64 — start offset of each window
// mean/min/max: summary stats
// highCount: number of windows with entropy >= 7.5 (candidate compressed/encrypted)
func Entropy(d *document.Document, start, length, windowSize, step int64) (map[string]any, error) {
return EntropyContext(context.Background(), d, start, length, windowSize, step)
}
func EntropyContext(ctx context.Context, d *document.Document, start, length, windowSize, step int64) (map[string]any, error) {
if windowSize <= 0 {
windowSize = 4096
}
if step <= 0 {
step = windowSize
}
if length < 0 {
length = d.Size() - start
}
if length <= 0 {
return map[string]any{
"windowSize": windowSize, "step": step,
"samples": []float64{}, "offsets": []int64{},
"mean": 0.0, "min": 0.0, "max": 0.0, "highCount": 0,
}, nil
}
// We run in a streaming fashion: advance through chunks, refill
// a rolling window, and emit a sample whenever the cursor hits
// a step boundary.
samples := make([]float64, 0, length/step+1)
offsets := make([]int64, 0, length/step+1)
// For bounded memory we read the document range chunk by chunk
// but we still need a window of size `windowSize`. Windows can be
// as small as 256 or as large as 1MB; we cap to length.
if windowSize > length {
windowSize = length
}
// Simple approach: iterate in step-sized hops, reading window
// bytes at each hop. This scales O(n * (w/step)) but window and
// step are usually the same for UI.
mean := 0.0
minE := math.Inf(1)
maxE := math.Inf(-1)
high := 0
for off := start; off < start+length; off += step {
if err := ctx.Err(); err != nil {
return nil, err
}
winLen := windowSize
if off+winLen > start+length {
winLen = start + length - off
}
buf, err := d.Range(off, winLen)
if err != nil {
return nil, err
}
e := shannon(buf)
samples = append(samples, e)
offsets = append(offsets, off)
mean += e
if e < minE {
minE = e
}
if e > maxE {
maxE = e
}
if e >= 7.5 {
high++
}
}
if len(samples) > 0 {
mean /= float64(len(samples))
} else {
minE, maxE = 0, 0
}
return map[string]any{
"windowSize": windowSize,
"step": step,
"samples": samples,
"offsets": offsets,
"mean": mean,
"min": minE,
"max": maxE,
"highCount": high,
}, nil
}
func shannon(buf []byte) float64 {
if len(buf) == 0 {
return 0
}
var counts [256]int
for _, b := range buf {
counts[b]++
}
n := float64(len(buf))
e := 0.0
for _, c := range counts {
if c == 0 {
continue
}
p := float64(c) / n
e -= p * math.Log2(p)
}
return e
}
+224
View File
@@ -0,0 +1,224 @@
package analysis
import (
"fmt"
"strconv"
"strings"
"github.com/andydixon/nexus/internal/leviathan/document"
)
// fuzzy.go implements context-triggered piecewise hashing (CTPH) compatible
// with ssdeep. CTPH produces a short signature of the form
// "blocksize:hash1:hash2" that stays similar when the input changes only a
// little, so investigators can cluster related samples or match a file
// against a known-bad ssdeep hash even after minor edits.
//
// The signature format and the rolling/sum hashes follow Andrew Tridgell's
// spamsum / Jesse Kornblum's ssdeep so the output interoperates with the
// standard tooling (e.g. an empty input hashes to "3::").
const (
spamsumLength = 64
rollingWindow = 7
minBlocksize = 3
hashInit = uint32(0x28021967)
hashPrime = uint32(0x01000193)
)
const b64alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
type rollState struct {
h1, h2, h3 uint32
window [rollingWindow]byte
n int
}
func (r *rollState) roll(c byte) uint32 {
r.h2 -= r.h1
r.h2 += rollingWindow * uint32(c)
r.h1 += uint32(c)
r.h1 -= uint32(r.window[r.n%rollingWindow])
r.window[r.n%rollingWindow] = c
r.n++
r.h3 = r.h3 << 5
r.h3 ^= uint32(c)
return r.h1 + r.h2 + r.h3
}
func sumHash(c byte, h uint32) uint32 { return (h * hashPrime) ^ uint32(c) }
// FuzzyDigest computes the ssdeep-compatible signature for data.
func FuzzyDigest(data []byte) string {
bs := uint32(minBlocksize)
for uint64(bs)*spamsumLength < uint64(len(data)) {
bs *= 2
}
for {
sig1, sig2 := ctphSigs(data, bs)
if bs > minBlocksize && len(sig1) < spamsumLength/2 {
bs /= 2
continue
}
return fmt.Sprintf("%d:%s:%s", bs, sig1, sig2)
}
}
func ctphSigs(data []byte, bs uint32) (string, string) {
var rs rollState
h1, h2 := hashInit, hashInit
var sig1, sig2 strings.Builder
var lastRoll uint32
for _, c := range data {
h1 = sumHash(c, h1)
h2 = sumHash(c, h2)
rh := rs.roll(c)
lastRoll = rh
if rh%bs == bs-1 && sig1.Len() < spamsumLength-1 {
sig1.WriteByte(b64alphabet[h1%64])
h1 = hashInit
}
if rh%(bs*2) == bs*2-1 && sig2.Len() < spamsumLength/2-1 {
sig2.WriteByte(b64alphabet[h2%64])
h2 = hashInit
}
}
if lastRoll != 0 {
sig1.WriteByte(b64alphabet[h1%64])
sig2.WriteByte(b64alphabet[h2%64])
}
return sig1.String(), sig2.String()
}
// FuzzyHash computes the CTPH signature over a document range.
func FuzzyHash(d *document.Document, start, length int64) (map[string]any, error) {
if length < 0 {
length = d.Size() - start
}
buf, err := d.Range(start, length)
if err != nil {
return nil, err
}
sig := FuzzyDigest(buf)
bs := strings.SplitN(sig, ":", 2)[0]
return map[string]any{
"algo": "ssdeep",
"signature": sig,
"blocksize": bs,
"length": length,
}, nil
}
// FuzzyCompare returns a 0-100 similarity score between two ssdeep
// signatures. 0 means unrelated (or incomparable block sizes); 100 means the
// signatures are identical.
func FuzzyCompare(a, b string) int {
bs1, a1, a2, ok1 := parseSig(a)
bs2, b1, b2, ok2 := parseSig(b)
if !ok1 || !ok2 {
return 0
}
if bs1 != bs2 && bs1 != bs2*2 && bs2 != bs1*2 {
return 0
}
switch {
case bs1 == bs2:
s1 := scoreStrings(a1, b1, bs1)
s2 := scoreStrings(a2, b2, bs1*2)
if s2 > s1 {
return s2
}
return s1
case bs1 == bs2*2:
return scoreStrings(a1, b2, bs1)
default: // bs2 == bs1*2
return scoreStrings(a2, b1, bs2)
}
}
func parseSig(s string) (uint32, string, string, bool) {
parts := strings.SplitN(s, ":", 3)
if len(parts) != 3 {
return 0, "", "", false
}
bs, err := strconv.ParseUint(parts[0], 10, 32)
if err != nil || bs == 0 {
return 0, "", "", false
}
return uint32(bs), parts[1], parts[2], true
}
func scoreStrings(s1, s2 string, blocksize uint32) int {
if !hasCommonSubstring(s1, s2, rollingWindow) {
return 0
}
if s1 == s2 {
return 100
}
d := editDistance(s1, s2)
if len(s1)+len(s2) == 0 {
return 0
}
score := (d * spamsumLength) / (len(s1) + len(s2))
score = (100 * score) / spamsumLength
score = 100 - score
// Small block sizes describe little data, so cap the score they can claim.
cap := int(blocksize) / minBlocksize * minInt(len(s1), len(s2))
if score > cap {
score = cap
}
if score > 100 {
score = 100
}
if score < 0 {
score = 0
}
return score
}
func hasCommonSubstring(s1, s2 string, n int) bool {
if len(s1) < n || len(s2) < n {
return false
}
seen := make(map[string]struct{}, len(s1))
for i := 0; i+n <= len(s1); i++ {
seen[s1[i:i+n]] = struct{}{}
}
for i := 0; i+n <= len(s2); i++ {
if _, ok := seen[s2[i:i+n]]; ok {
return true
}
}
return false
}
// editDistance is classic Levenshtein with unit insert/delete/substitute cost.
func editDistance(s1, s2 string) int {
m, n := len(s1), len(s2)
prev := make([]int, n+1)
cur := make([]int, n+1)
for j := 0; j <= n; j++ {
prev[j] = j
}
for i := 1; i <= m; i++ {
cur[0] = i
for j := 1; j <= n; j++ {
cost := 1
if s1[i-1] == s2[j-1] {
cost = 0
}
cur[j] = minInt(minInt(cur[j-1]+1, prev[j]+1), prev[j-1]+cost)
}
prev, cur = cur, prev
}
return prev[n]
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
+63
View File
@@ -0,0 +1,63 @@
package analysis
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"hash"
"hash/crc32"
"github.com/andydixon/nexus/internal/leviathan/document"
)
// HashSet contains CRC32/MD5/SHA-1/SHA-256/SHA-512 for a range.
type HashSet struct {
Length int64 `json:"length"`
CRC32 string `json:"crc32"`
MD5 string `json:"md5"`
SHA1 string `json:"sha1"`
SHA256 string `json:"sha256"`
SHA512 string `json:"sha512"`
}
// Hashes computes all supported hashes over the document range.
// The computation runs chunk-by-chunk so large files do not force a
// full-buffer duplication.
func Hashes(d *document.Document, start, length int64) (map[string]any, error) {
if length < 0 {
length = d.Size() - start
}
crc := crc32.NewIEEE()
mh := md5.New()
s1 := sha1.New()
s256 := sha256.New()
s512 := sha512.New()
writers := []hash.Hash{mh, s1, s256, s512}
total := int64(0)
err := d.ForEachChunk(start, length, func(_ int64, chunk []byte) error {
crc.Write(chunk)
for _, w := range writers {
w.Write(chunk)
}
total += int64(len(chunk))
return nil
})
if err != nil {
return nil, err
}
return map[string]any{
"length": total,
"crc32": toHex(crc.Sum(nil)),
"md5": toHex(mh.Sum(nil)),
"sha1": toHex(s1.Sum(nil)),
"sha256": toHex(s256.Sum(nil)),
"sha512": toHex(s512.Sum(nil)),
}, nil
}
func toHex(b []byte) string { return hex.EncodeToString(b) }
+162
View File
@@ -0,0 +1,162 @@
package analysis
import (
"encoding/binary"
"fmt"
"math"
"time"
"unicode/utf16"
"unicode/utf8"
)
// Inspect interprets bytes starting at `buf[0]` as many candidate
// types. It is resilient: types that don't have enough bytes simply
// return empty strings.
func Inspect(buf []byte, offset int64) map[string]any {
out := map[string]any{
"offset": offset,
"length": len(buf),
}
// 8-bit
if len(buf) >= 1 {
out["u8"] = int(buf[0])
out["i8"] = int(int8(buf[0]))
out["char"] = printable(buf[0])
out["bin8"] = fmt.Sprintf("%08b", buf[0])
}
// 16-bit
if len(buf) >= 2 {
le16 := binary.LittleEndian.Uint16(buf)
be16 := binary.BigEndian.Uint16(buf)
out["u16_le"] = int(le16)
out["u16_be"] = int(be16)
out["i16_le"] = int(int16(le16))
out["i16_be"] = int(int16(be16))
}
// 32-bit
if len(buf) >= 4 {
le32 := binary.LittleEndian.Uint32(buf)
be32 := binary.BigEndian.Uint32(buf)
out["u32_le"] = uint(le32)
out["u32_be"] = uint(be32)
out["i32_le"] = int(int32(le32))
out["i32_be"] = int(int32(be32))
out["f32_le"] = float64(math.Float32frombits(le32))
out["f32_be"] = float64(math.Float32frombits(be32))
}
// 64-bit
if len(buf) >= 8 {
le64 := binary.LittleEndian.Uint64(buf)
be64 := binary.BigEndian.Uint64(buf)
out["u64_le"] = float64(le64)
out["u64_be"] = float64(be64)
out["i64_le"] = float64(int64(le64))
out["i64_be"] = float64(int64(be64))
out["f64_le"] = math.Float64frombits(le64)
out["f64_be"] = math.Float64frombits(be64)
}
// Timestamp guesses
if len(buf) >= 4 {
le := binary.LittleEndian.Uint32(buf)
out["unix_le"] = formatUnix(int64(le))
}
if len(buf) >= 8 {
le := binary.LittleEndian.Uint64(buf)
out["filetime_le"] = formatFiletime(le)
out["webkit_le"] = formatWebkit(le)
out["oletime_le"] = formatOLEDate(math.Float64frombits(le))
}
// ASCII preview (up to 32 bytes)
n := len(buf)
if n > 32 {
n = 32
}
ascii := make([]byte, n)
for i := 0; i < n; i++ {
if buf[i] >= 0x20 && buf[i] <= 0x7e {
ascii[i] = buf[i]
} else {
ascii[i] = '.'
}
}
out["ascii"] = string(ascii)
// UTF-8 attempt
if utf8.Valid(buf[:n]) {
out["utf8"] = string(buf[:n])
}
// UTF-16 LE/BE preview
if len(buf) >= 2 {
lu := n / 2
if lu > 0 {
u := make([]uint16, lu)
for i := 0; i < lu; i++ {
u[i] = uint16(buf[i*2]) | uint16(buf[i*2+1])<<8
}
out["utf16le"] = string(utf16.Decode(u))
for i := 0; i < lu; i++ {
u[i] = uint16(buf[i*2])<<8 | uint16(buf[i*2+1])
}
out["utf16be"] = string(utf16.Decode(u))
}
}
return out
}
func printable(b byte) string {
if b >= 0x20 && b <= 0x7e {
return string([]byte{b})
}
return "."
}
func formatUnix(sec int64) string {
if sec < 0 || sec > 4102444800 { // past 2100
return ""
}
return time.Unix(sec, 0).UTC().Format(time.RFC3339)
}
// Windows FILETIME epoch 1601-01-01 in 100ns ticks.
const filetimeEpochDelta = 11644473600
func formatFiletime(v uint64) string {
if v == 0 {
return ""
}
sec := int64(v/10_000_000) - filetimeEpochDelta
if sec < 0 || sec > 4102444800 {
return ""
}
return time.Unix(sec, 0).UTC().Format(time.RFC3339)
}
// WebKit/Chrome epoch 1601-01-01 in microseconds.
func formatWebkit(v uint64) string {
if v == 0 {
return ""
}
sec := int64(v/1_000_000) - filetimeEpochDelta
if sec < 0 || sec > 4102444800 {
return ""
}
return time.Unix(sec, 0).UTC().Format(time.RFC3339)
}
// OLE Automation date: days since 1899-12-30, as float64.
func formatOLEDate(days float64) string {
if days < 2 || days > 75000 {
return ""
}
base := time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
sec := int64(days * 86400)
return base.Add(time.Duration(sec) * time.Second).Format(time.RFC3339)
}
+89
View File
@@ -0,0 +1,89 @@
package analysis
import (
"context"
"regexp"
"github.com/andydixon/nexus/internal/leviathan/document"
)
// ioc regexes. They run against printable ASCII strings extracted
// from the document, which is the standard approach for IOC hunting
// and avoids matching noise in raw binary data.
var iocRegexes = map[string]*regexp.Regexp{
"url": regexp.MustCompile(`(?i)\b(?:https?|ftp)://[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+`),
"domain": regexp.MustCompile(`(?i)\b(?:[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?\.)+(?:com|net|org|io|co|uk|de|fr|ru|cn|info|xyz|app|dev|sh|me|gov|mil|edu|biz)\b`),
"email": regexp.MustCompile(`(?i)\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b`),
"ipv4": regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`),
"ipv6": regexp.MustCompile(`\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b`),
"guid": regexp.MustCompile(`\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b`),
"md5": regexp.MustCompile(`\b[a-fA-F0-9]{32}\b`),
"sha1": regexp.MustCompile(`\b[a-fA-F0-9]{40}\b`),
"sha256": regexp.MustCompile(`\b[a-fA-F0-9]{64}\b`),
"winpath": regexp.MustCompile(`[A-Za-z]:\\(?:[^\\/:*?"<>|\r\n]+\\)*[^\\/:*?"<>|\r\n]*`),
"unc": regexp.MustCompile(`\\\\[A-Za-z0-9._\-]+(?:\\[^\\/:*?"<>|\r\n]+)+`),
"unixpath": regexp.MustCompile(`(?:/(?:etc|var|usr|opt|home|tmp|root|srv|bin|sbin|lib|lib64)(?:/[A-Za-z0-9._\-]+)+)`),
"regkey": regexp.MustCompile(`(?i)\b(?:HKLM|HKCU|HKCR|HKU|HKCC|HKEY_[A-Z_]+)\\[^\s"<>]+`),
"onion": regexp.MustCompile(`(?i)\b[a-z2-7]{16,56}\.onion\b`),
"btc": regexp.MustCompile(`\b(?:[13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[a-z0-9]{20,90})\b`),
}
// ExtractIOCs runs the IOC regexes over ASCII strings first and then
// UTF-16 LE/BE strings. Returns a map of category -> slice of hits
// with offsets into the original file.
func ExtractIOCs(d *document.Document, start, length int64, minLen int) (map[string]any, error) {
return ExtractIOCsContext(context.Background(), d, start, length, minLen)
}
func ExtractIOCsContext(ctx context.Context, d *document.Document, start, length int64, minLen int) (map[string]any, error) {
if length < 0 {
length = d.Size() - start
}
if minLen <= 0 {
minLen = 5
}
strings, err := ExtractStringsContext(ctx, d, start, length, minLen, []string{"ascii", "utf16le", "utf16be"}, 200_000)
if err != nil {
return nil, err
}
result := map[string]any{}
totals := map[string]int{}
for category, re := range iocRegexes {
if err := ctx.Err(); err != nil {
return nil, err
}
hits := make([]map[string]any, 0)
for _, s := range strings {
if err := ctx.Err(); err != nil {
return nil, err
}
str, _ := s["value"].(string)
base, _ := s["offset"].(int64)
for _, loc := range re.FindAllStringIndex(str, -1) {
hits = append(hits, map[string]any{
"category": category,
"offset": base + int64(loc[0]),
"length": loc[1] - loc[0],
"value": str[loc[0]:loc[1]],
})
}
}
result[category] = hits
totals[category] = len(hits)
}
// flat list for UI convenience
all := []map[string]any{}
for _, v := range result {
if s, ok := v.([]map[string]any); ok {
all = append(all, s...)
}
}
return map[string]any{
"byCategory": result,
"all": all,
"totals": totals,
"totalStrings": len(strings),
}, nil
}
+367
View File
@@ -0,0 +1,367 @@
package analysis
import (
"bytes"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"strings"
"github.com/andydixon/nexus/internal/leviathan/document"
)
// KeyArtefact is a single key-ish object we've found in the raw file.
// Length is the span we recognised; Note carries any decoding detail.
type KeyArtefact struct {
Kind string `json:"kind"`
Format string `json:"format"` // "pem" | "der" | "ssh" | "p12"
Offset int64 `json:"offset"`
Length int64 `json:"length"`
Summary string `json:"summary"`
Note string `json:"note,omitempty"`
}
// ScanKeys walks the whole document looking for cryptographic artefacts:
// - PEM blocks with common key-related labels
// - OpenSSH keys (authorized_keys lines and the "openssh-key-v1" private-key envelope)
// - PKCS#12 magic (1.2.840.113549.1.12.1 appears in headers)
// - Loose DER SEQUENCEs that parse as RSA/EC/PKCS#8 keys
//
// We cap the scan at 64 MiB. Users with larger blobs can select a range
// and call this against that subset via a future "keys.scanRange" bridge.
func ScanKeys(d *document.Document) ([]KeyArtefact, error) {
size := d.Size()
if size > 64*1024*1024 {
return nil, fmt.Errorf("keys.scan: file > 64 MiB; use selection")
}
buf, err := d.Range(0, size)
if err != nil {
return nil, err
}
out := []KeyArtefact{}
out = append(out, scanPEMKeys(buf)...)
out = append(out, scanOpenSSHPubKeys(buf)...)
out = append(out, scanOpenSSHPrivKeys(buf)...)
out = append(out, scanPKCS12(buf)...)
// Loose DER pass — for raw PKCS#8 / PKCS#1 blobs that aren't wrapped in PEM.
out = append(out, scanLooseDERKeys(buf)...)
return out, nil
}
// Known PEM labels that indicate key-ish content.
var pemKeyLabels = map[string]string{
"RSA PRIVATE KEY": "rsa-private",
"RSA PUBLIC KEY": "rsa-public",
"EC PRIVATE KEY": "ec-private",
"EC PUBLIC KEY": "ec-public",
"DSA PRIVATE KEY": "dsa-private",
"PRIVATE KEY": "pkcs8-private",
"ENCRYPTED PRIVATE KEY": "pkcs8-encrypted",
"PUBLIC KEY": "pkix-public",
"OPENSSH PRIVATE KEY": "openssh-private",
"SSH2 PUBLIC KEY": "ssh2-public",
"PGP PRIVATE KEY BLOCK": "pgp-private",
"PGP PUBLIC KEY BLOCK": "pgp-public",
"CERTIFICATE REQUEST": "csr",
"NEW CERTIFICATE REQUEST": "csr",
"DH PARAMETERS": "dh-params",
"PARAMETERS": "params",
}
func scanPEMKeys(buf []byte) []KeyArtefact {
out := []KeyArtefact{}
rest := buf
origLen := int64(len(buf))
for {
block, next := pem.Decode(rest)
if block == nil {
break
}
beginIdx := bytes.Index(rest, []byte("-----BEGIN"))
if beginIdx < 0 {
break
}
start := origLen - int64(len(rest)) + int64(beginIdx)
end := origLen - int64(len(next))
kind, match := pemKeyLabels[block.Type]
if !match {
rest = next
continue
}
art := KeyArtefact{
Kind: kind, Format: "pem",
Offset: start, Length: end - start,
}
art.Summary = describePEMBlock(block)
out = append(out, art)
rest = next
}
return out
}
func describePEMBlock(b *pem.Block) string {
name := b.Type
switch b.Type {
case "RSA PRIVATE KEY":
if k, err := x509.ParsePKCS1PrivateKey(b.Bytes); err == nil {
return fmt.Sprintf("%s — RSA %d-bit", name, k.N.BitLen())
}
case "RSA PUBLIC KEY":
if k, err := x509.ParsePKCS1PublicKey(b.Bytes); err == nil {
return fmt.Sprintf("%s — RSA %d-bit", name, k.N.BitLen())
}
case "EC PRIVATE KEY":
if k, err := x509.ParseECPrivateKey(b.Bytes); err == nil {
return fmt.Sprintf("%s — curve %s", name, k.Curve.Params().Name)
}
case "PRIVATE KEY":
if k, err := x509.ParsePKCS8PrivateKey(b.Bytes); err == nil {
return fmt.Sprintf("%s — %s", name, describePrivateKey(k))
}
case "PUBLIC KEY":
if k, err := x509.ParsePKIXPublicKey(b.Bytes); err == nil {
return fmt.Sprintf("%s — %s", name, describePublicKey(k))
}
case "OPENSSH PRIVATE KEY":
return fmt.Sprintf("%s (%d B payload)", name, len(b.Bytes))
case "ENCRYPTED PRIVATE KEY":
return fmt.Sprintf("%s — PKCS#8 encrypted (%d B)", name, len(b.Bytes))
}
return fmt.Sprintf("%s (%d B)", name, len(b.Bytes))
}
func describePrivateKey(k any) string {
switch key := k.(type) {
case *rsa.PrivateKey:
return fmt.Sprintf("RSA %d-bit", key.N.BitLen())
case *ecdsa.PrivateKey:
return fmt.Sprintf("ECDSA %s", key.Curve.Params().Name)
case ed25519.PrivateKey:
return "Ed25519"
}
return fmt.Sprintf("%T", k)
}
func describePublicKey(k any) string {
switch key := k.(type) {
case *rsa.PublicKey:
return fmt.Sprintf("RSA %d-bit", key.N.BitLen())
case *ecdsa.PublicKey:
return fmt.Sprintf("ECDSA %s", key.Curve.Params().Name)
case ed25519.PublicKey:
return "Ed25519"
}
return fmt.Sprintf("%T", k)
}
// OpenSSH authorized_keys / id_*.pub format is one line per key:
// "<algo> <base64> [comment]\n". We search for well-known algos and
// delimit by the next newline. False positives on text logs are possible,
// so we validate by base64-decoding.
var sshAlgos = []string{
"ssh-rsa", "ssh-dss", "ssh-ed25519",
"ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
"sk-ssh-ed25519@openssh.com", "sk-ecdsa-sha2-nistp256@openssh.com",
}
func scanOpenSSHPubKeys(buf []byte) []KeyArtefact {
out := []KeyArtefact{}
for _, algo := range sshAlgos {
needle := []byte(algo + " ")
off := 0
for {
idx := bytes.Index(buf[off:], needle)
if idx < 0 {
break
}
absStart := off + idx
// Must be at start of line or after whitespace.
if absStart > 0 {
prev := buf[absStart-1]
if prev != '\n' && prev != '\r' && prev != ' ' && prev != '\t' {
off = absStart + 1
continue
}
}
// End of line.
endOfLine := absStart + len(needle)
for endOfLine < len(buf) && buf[endOfLine] != '\n' && buf[endOfLine] != '\r' {
endOfLine++
}
line := buf[absStart:endOfLine]
// Expect at least one space-separated payload that decodes.
parts := bytes.SplitN(line, []byte(" "), 3)
if len(parts) < 2 || len(parts[1]) < 16 || !isBase64Like(parts[1]) {
off = absStart + 1
continue
}
art := KeyArtefact{
Kind: "openssh-public", Format: "ssh",
Offset: int64(absStart), Length: int64(len(line)),
Summary: fmt.Sprintf("%s (%d-byte payload b64)", algo, len(parts[1])),
}
if len(parts) == 3 && len(parts[2]) > 0 {
art.Note = "comment: " + string(parts[2])
}
out = append(out, art)
off = endOfLine
}
}
return out
}
func scanOpenSSHPrivKeys(buf []byte) []KeyArtefact {
// Already caught by PEM scan (label "OPENSSH PRIVATE KEY"). We also
// look for the magic string inside the PEM body — "openssh-key-v1\0".
out := []KeyArtefact{}
needle := []byte("openssh-key-v1\x00")
off := 0
for {
idx := bytes.Index(buf[off:], needle)
if idx < 0 {
break
}
absStart := off + idx
out = append(out, KeyArtefact{
Kind: "openssh-private-magic", Format: "ssh",
Offset: int64(absStart), Length: int64(len(needle)),
Summary: "OpenSSH v1 private-key magic",
Note: "magic only; no envelope length inferred",
})
off = absStart + len(needle)
}
return out
}
// PKCS#12 (.p12 / .pfx) files begin with an ASN.1 SEQUENCE; the second
// element is an OID for the bag type. Its encoded form is reasonably
// distinctive: "\x06\x0b\x2a\x86\x48\x86\xf7\x0d\x01\x07\x01" (data).
// The simplest reliable magic for our purposes is the OID for
// pkcs-12 (1.2.840.113549.1.12.1): "\x06\x0a\x2a\x86\x48\x86\xf7\x0d\x01\x0c\x01".
func scanPKCS12(buf []byte) []KeyArtefact {
out := []KeyArtefact{}
needle := []byte{0x06, 0x0a, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x0c, 0x01}
off := 0
for {
idx := bytes.Index(buf[off:], needle)
if idx < 0 {
break
}
absStart := off + idx
out = append(out, KeyArtefact{
Kind: "pkcs12-oid", Format: "p12",
Offset: int64(absStart), Length: int64(len(needle)),
Summary: "pkcs-12 OID (1.2.840.113549.1.12.1)",
Note: "bag-type marker; may indicate a .p12 or .pfx container",
})
off = absStart + len(needle)
}
return out
}
// scanLooseDERKeys walks DER SEQUENCE headers and tries to parse each
// candidate as a known key format. False negatives are fine; we only
// emit an artefact on a positive parse.
func scanLooseDERKeys(buf []byte) []KeyArtefact {
out := []KeyArtefact{}
// Use the same SEQUENCE 0x30 0x82 xx yy (long-form, 2 length bytes)
// pattern as the cert scanner. Minimum useful key length ~200 bytes.
//
// Each candidate runs up to six x509 parse attempts over a blob up to
// 64 KiB, so an unfiltered scan of a buffer dense with "30 82" pairs is
// O(n) expensive parses and could freeze the UI. A cheap structural
// pre-filter (a DER key body begins with a SEQUENCE 0x30 or an INTEGER
// 0x02) rejects almost all false candidates without parsing, and a hard
// cap bounds adversarial input that defeats the pre-filter.
const maxDERAttempts = 1 << 16
derAttempts := 0
for i := 0; i+4 < len(buf); i++ {
if buf[i] != 0x30 || buf[i+1] != 0x82 {
continue
}
if c := buf[i+4]; c != 0x30 && c != 0x02 {
continue
}
total := int(buf[i+2])<<8 | int(buf[i+3])
if total < 80 || i+4+total > len(buf) {
continue
}
if derAttempts++; derAttempts > maxDERAttempts {
break
}
blob := buf[i : i+4+total]
// Skip anything that looks like a certificate (already caught by certs panel).
if _, err := x509.ParseCertificate(blob); err == nil {
i += 4 + total - 1
continue
}
if _, err := x509.ParsePKCS1PrivateKey(blob); err == nil {
out = append(out, KeyArtefact{
Kind: "rsa-private", Format: "der",
Offset: int64(i), Length: int64(4 + total),
Summary: "RSA private key (PKCS#1 DER)",
})
i += 4 + total - 1
continue
}
if _, err := x509.ParsePKCS1PublicKey(blob); err == nil {
out = append(out, KeyArtefact{
Kind: "rsa-public", Format: "der",
Offset: int64(i), Length: int64(4 + total),
Summary: "RSA public key (PKCS#1 DER)",
})
i += 4 + total - 1
continue
}
if k, err := x509.ParseECPrivateKey(blob); err == nil {
out = append(out, KeyArtefact{
Kind: "ec-private", Format: "der",
Offset: int64(i), Length: int64(4 + total),
Summary: "EC private key (SEC1 DER) curve=" + k.Curve.Params().Name,
})
i += 4 + total - 1
continue
}
if k, err := x509.ParsePKCS8PrivateKey(blob); err == nil {
out = append(out, KeyArtefact{
Kind: "pkcs8-private", Format: "der",
Offset: int64(i), Length: int64(4 + total),
Summary: "PKCS#8 private key — " + describePrivateKey(k),
})
i += 4 + total - 1
continue
}
if k, err := x509.ParsePKIXPublicKey(blob); err == nil {
out = append(out, KeyArtefact{
Kind: "pkix-public", Format: "der",
Offset: int64(i), Length: int64(4 + total),
Summary: "PKIX public key — " + describePublicKey(k),
})
i += 4 + total - 1
continue
}
}
return out
}
func isBase64Like(b []byte) bool {
if len(b) == 0 {
return false
}
for _, c := range b {
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
c == '+' || c == '/' || c == '=' {
continue
}
return false
}
return true
}
// Stringer helper used by the bridge; avoids leaking internal types to JS.
func (a KeyArtefact) String() string {
return strings.TrimSpace(fmt.Sprintf("%s %s @0x%x %s", a.Kind, a.Format, a.Offset, a.Summary))
}
+113
View File
@@ -0,0 +1,113 @@
package analysis
import (
"encoding/hex"
"errors"
"fmt"
"regexp"
"strings"
"github.com/andydixon/nexus/internal/leviathan/document"
)
type MultiSearchQuery struct {
Mode string
Query string
}
func ParseMultiSearchSpec(spec string) ([]MultiSearchQuery, error) {
spec = strings.ReplaceAll(spec, ";", "\n")
out := []MultiSearchQuery{}
for _, line := range strings.Split(spec, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
return nil, errors.New("multi-search lines require: text|hex|regex <query>")
}
mode := strings.ToLower(parts[0])
query := strings.TrimSpace(strings.TrimPrefix(line, parts[0]))
switch mode {
case "text", "hex", "regex":
default:
return nil, fmt.Errorf("unknown multi-search mode %q", mode)
}
if query == "" {
return nil, errors.New("empty multi-search query")
}
out = append(out, MultiSearchQuery{Mode: mode, Query: query})
}
if len(out) == 0 {
return nil, errors.New("no multi-search queries")
}
return out, nil
}
func MultiSearch(d *document.Document, start, length int64, spec string, maxPerQuery int) (map[string]any, error) {
queries, err := ParseMultiSearchSpec(spec)
if err != nil {
return nil, err
}
if maxPerQuery <= 0 {
maxPerQuery = 1000
}
results := make([]map[string]any, 0, len(queries))
all := []map[string]any{}
for i, q := range queries {
var hits []SearchHit
switch q.Mode {
case "text":
hits, err = SearchBytes(d, start, length, []byte(q.Query), maxPerQuery)
case "hex":
var needle []byte
needle, err = parseMultiSearchHex(q.Query)
if err == nil {
hits, err = SearchBytes(d, start, length, needle, maxPerQuery)
}
case "regex":
if _, err = regexp.Compile(q.Query); err == nil {
hits, err = SearchRegex(d, start, length, q.Query, maxPerQuery)
}
}
if err != nil {
return nil, fmt.Errorf("query %d: %w", i+1, err)
}
maps := HitsToMaps(hits)
name := fmt.Sprintf("%s:%s", q.Mode, q.Query)
for _, h := range maps {
h["mode"] = q.Mode
h["query"] = q.Query
h["name"] = name
all = append(all, h)
}
results = append(results, map[string]any{
"mode": q.Mode,
"query": q.Query,
"count": len(hits),
"hits": maps,
})
}
return map[string]any{
"queries": results,
"all": all,
"total": len(all),
}, nil
}
func parseMultiSearchHex(s string) ([]byte, error) {
s = strings.Map(func(r rune) rune {
switch r {
case ' ', '\t', '\n', '\r', ':', '-':
return -1
default:
return r
}
}, strings.TrimSpace(s))
s = strings.TrimPrefix(strings.ToLower(s), "0x")
if len(s)%2 != 0 {
s = "0" + s
}
return hex.DecodeString(s)
}
+88
View File
@@ -0,0 +1,88 @@
package analysis
import (
"github.com/andydixon/nexus/internal/leviathan/document"
)
// Overview returns a compact first-pass report intended for both UI
// surfaces. It favours fast, bounded summaries over exhaustive output.
func Overview(d *document.Document) (map[string]any, error) {
size := d.Size()
hashes, err := Hashes(d, 0, -1)
if err != nil {
return nil, err
}
entropy, err := Entropy(d, 0, min64(size, 8<<20), 4096, 0)
if err != nil {
return nil, err
}
stringsOut, err := ExtractStrings(d, 0, min64(size, 16<<20), 4, []string{"ascii", "utf16le", "utf16be"}, 5000)
if err != nil {
return nil, err
}
iocs, err := ExtractIOCs(d, 0, min64(size, 16<<20), 5)
if err != nil {
return nil, err
}
carves, err := Carve(d, 0, min64(size, 64<<20), 128)
if err != nil {
return nil, err
}
return map[string]any{
"name": d.Name(),
"size": size,
"hashes": hashes,
"entropy": overviewEntropy(entropy),
"strings": overviewStrings(stringsOut),
"iocs": iocs["totals"],
"carve": overviewCarve(carves),
"limits": map[string]any{
"entropyBytes": min64(size, 8<<20),
"stringsBytes": min64(size, 16<<20),
"iocBytes": min64(size, 16<<20),
"carveBytes": min64(size, 64<<20),
},
}, nil
}
func overviewEntropy(in map[string]any) map[string]any {
return map[string]any{
"windowSize": in["windowSize"],
"step": in["step"],
"mean": in["mean"],
"min": in["min"],
"max": in["max"],
"highCount": in["highCount"],
}
}
func overviewStrings(items []map[string]any) map[string]any {
counts := map[string]int{}
for _, item := range items {
if enc, ok := item["encoding"].(string); ok {
counts[enc]++
}
}
return map[string]any{
"total": len(items),
"byEncoding": counts,
}
}
func overviewCarve(items []map[string]any) map[string]any {
counts := map[string]int{}
valid := 0
for _, item := range items {
if kind, ok := item["kind"].(string); ok {
counts[kind]++
}
if status, ok := item["status"].(string); ok && status == "valid" {
valid++
}
}
return map[string]any{
"total": len(items),
"valid": valid,
"byKind": counts,
}
}
+648
View File
@@ -0,0 +1,648 @@
package analysis
import (
"context"
"encoding/hex"
"errors"
"regexp"
"strconv"
"strings"
"github.com/andydixon/nexus/internal/leviathan/document"
)
// Rule represents a lightweight YARA-like rule. A rule matches when
// Condition evaluates to true for the document. Phase 1 supports:
// - "all of them" (every string pattern must appear)
// - "any of them" (at least one pattern must appear)
// - "<count> of them" (at least N patterns)
// - named identifiers referencing specific strings with $x or !$x
type Rule struct {
Name string
Tags []string
Strings []RuleString
Condition string
}
// RuleString is one string directive inside a rule.
type RuleString struct {
ID string
Kind string // "ascii", "hex", "regex"
Value string // original source text
// compiled:
raw []byte
regex *regexp.Regexp
nocase bool
ascii bool
wide bool
fullword bool
}
// ParseRules parses a simplified YARA-like source:
//
// rule Name : tag1 tag2 {
// strings:
// $a = "hello" nocase
// $b = { 4d 5a ?? ?? }
// $c = /foo[0-9]+/
// condition:
// any of them
// }
//
// Hex patterns support `??` wildcards by being converted to a regex.
func ParseRules(src string) ([]Rule, error) {
rules := []Rule{}
s := strings.TrimSpace(src)
if s == "" {
return rules, nil
}
// Split on "rule " keywords from start of line.
entries := splitRules(s)
for _, e := range entries {
r, err := parseRule(e)
if err != nil {
return rules, err
}
rules = append(rules, r)
}
return rules, nil
}
func splitRules(src string) []string {
out := []string{}
i := 0
for i < len(src) {
idx := strings.Index(src[i:], "rule ")
if idx < 0 {
break
}
j := i + idx
// find matching close brace (naïve but workable for phase 1)
depth := 0
start := j
k := j
for k < len(src) {
c := src[k]
if c == '{' {
depth++
} else if c == '}' {
depth--
if depth == 0 {
out = append(out, src[start:k+1])
k++
break
}
}
k++
}
i = k
}
return out
}
var ruleHeaderRe = regexp.MustCompile(`(?s)^\s*rule\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*:\s*([^{]*))?\s*\{(.*)\}\s*$`)
func parseRule(src string) (Rule, error) {
m := ruleHeaderRe.FindStringSubmatch(src)
if m == nil {
return Rule{}, errors.New("malformed rule")
}
r := Rule{Name: m[1]}
if tags := strings.TrimSpace(m[2]); tags != "" {
r.Tags = strings.Fields(tags)
}
body := m[3]
if i := strings.Index(body, "strings:"); i >= 0 {
sEnd := strings.Index(body, "condition:")
sect := body[i+len("strings:") : sEnd]
for _, line := range strings.Split(sect, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "//") {
continue
}
rs, err := parseRuleString(line)
if err != nil {
return r, err
}
r.Strings = append(r.Strings, rs)
}
}
if i := strings.Index(body, "condition:"); i >= 0 {
r.Condition = strings.TrimSpace(body[i+len("condition:"):])
} else {
return r, errors.New("rule missing condition")
}
return r, nil
}
var ruleStringRe = regexp.MustCompile(`^\s*(\$[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$`)
func parseRuleString(line string) (RuleString, error) {
m := ruleStringRe.FindStringSubmatch(line)
if m == nil {
return RuleString{}, errors.New("bad string line: " + line)
}
id := m[1]
rest := strings.TrimSpace(m[2])
value, modifiers, err := splitRuleValueModifiers(rest)
if err != nil {
return RuleString{}, err
}
rs := RuleString{ID: id, Value: value}
for _, mod := range modifiers {
switch strings.ToLower(mod) {
case "nocase":
rs.nocase = true
case "ascii":
rs.ascii = true
case "wide":
rs.wide = true
case "fullword":
rs.fullword = true
default:
return rs, errors.New("unknown string modifier: " + mod)
}
}
switch {
case strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) && len(value) >= 2:
rs.Kind = "ascii"
rs.raw = []byte(value[1 : len(value)-1])
if !rs.ascii && !rs.wide {
rs.ascii = true
}
case strings.HasPrefix(value, "{") && strings.HasSuffix(value, "}"):
rs.Kind = "hex"
pattern := regexFromHexPattern(value[1 : len(value)-1])
re, err := regexp.Compile("(?s)" + pattern)
if err != nil {
return rs, err
}
rs.regex = re
case strings.HasPrefix(value, "/") && strings.HasSuffix(value, "/"):
rs.Kind = "regex"
pat := value[1 : len(value)-1]
if rs.nocase {
pat = "(?i)" + pat
}
re, err := regexp.Compile(pat)
if err != nil {
return rs, err
}
rs.regex = re
default:
return rs, errors.New("unknown string form: " + value)
}
return rs, nil
}
func splitRuleValueModifiers(rest string) (string, []string, error) {
rest = strings.TrimSpace(rest)
if rest == "" {
return "", nil, errors.New("empty string value")
}
var end int
switch rest[0] {
case '"':
escaped := false
end = -1
for i := 1; i < len(rest); i++ {
c := rest[i]
if escaped {
escaped = false
continue
}
if c == '\\' {
escaped = true
continue
}
if c == '"' {
end = i + 1
break
}
}
if end < 0 {
return "", nil, errors.New("unterminated string literal")
}
case '{':
idx := strings.IndexByte(rest, '}')
if idx < 0 {
return "", nil, errors.New("unterminated hex string")
}
end = idx + 1
case '/':
escaped := false
end = -1
for i := 1; i < len(rest); i++ {
c := rest[i]
if escaped {
escaped = false
continue
}
if c == '\\' {
escaped = true
continue
}
if c == '/' {
end = i + 1
break
}
}
if end < 0 {
return "", nil, errors.New("unterminated regex")
}
default:
return "", nil, errors.New("unknown string form: " + rest)
}
return strings.TrimSpace(rest[:end]), strings.Fields(strings.TrimSpace(rest[end:])), nil
}
func regexFromHexPattern(hexPat string) string {
var sb strings.Builder
hexPat = strings.ReplaceAll(hexPat, " ", "")
for i := 0; i < len(hexPat); {
if i+1 >= len(hexPat) {
break
}
a, b := hexPat[i], hexPat[i+1]
if a == '?' && b == '?' {
sb.WriteString(`[\x00-\xff]`)
} else {
decoded, err := hex.DecodeString(string([]byte{a, b}))
if err != nil {
// bail to literal to avoid panic
sb.WriteString(regexp.QuoteMeta(string([]byte{a, b})))
} else {
sb.WriteString(regexp.QuoteMeta(string(decoded)))
}
}
i += 2
}
return sb.String()
}
// RuleMatch is a per-rule result.
type RuleMatch struct {
Name string
Tags []string
Matched bool
Hits map[string][]SearchHit
}
// RunRules scans rules against the document range and returns matches.
func RunRules(d *document.Document, start, length int64, rules []Rule, maxPerString int) ([]map[string]any, error) {
return RunRulesContext(context.Background(), d, start, length, rules, maxPerString)
}
func RunRulesContext(ctx context.Context, d *document.Document, start, length int64, rules []Rule, maxPerString int) ([]map[string]any, error) {
if maxPerString <= 0 {
maxPerString = 1000
}
out := make([]map[string]any, 0, len(rules))
for _, r := range rules {
if err := ctx.Err(); err != nil {
return nil, err
}
hits := map[string][]SearchHit{}
for _, s := range r.Strings {
if err := ctx.Err(); err != nil {
return nil, err
}
switch s.Kind {
case "ascii":
found, err := searchRuleTextContext(ctx, d, start, length, s, maxPerString)
if err != nil {
return nil, err
}
hits[s.ID] = found
case "hex", "regex":
found, err := SearchRegexContext(ctx, d, start, length, s.regex.String(), maxPerString)
if err != nil {
return nil, err
}
if s.fullword {
found = filterFullWordHits(d, found, false)
}
hits[s.ID] = found
}
}
matched := evalCondition(r, hits)
var hitFlat []map[string]any
for id, hs := range hits {
for _, h := range hs {
hitFlat = append(hitFlat, map[string]any{
"id": id,
"offset": h.Offset,
"length": h.Length,
"sample": h.Sample,
})
}
}
out = append(out, map[string]any{
"name": r.Name,
"tags": toAnySlice(r.Tags),
"matched": matched,
"hits": hitFlat,
})
}
return out, nil
}
func searchRuleText(d *document.Document, start, length int64, s RuleString, max int) ([]SearchHit, error) {
return searchRuleTextContext(context.Background(), d, start, length, s, max)
}
func searchRuleTextContext(ctx context.Context, d *document.Document, start, length int64, s RuleString, max int) ([]SearchHit, error) {
out := []SearchHit{}
if s.ascii {
var (
found []SearchHit
err error
)
if s.nocase {
found, err = SearchRegexContext(ctx, d, start, length, "(?i)"+regexp.QuoteMeta(string(s.raw)), max)
} else {
found, err = SearchBytesContext(ctx, d, start, length, s.raw, max)
}
if err != nil {
return nil, err
}
if s.fullword {
found = filterFullWordHits(d, found, false)
}
out = append(out, found...)
}
if s.wide {
wide := utf16LEBytes(s.raw)
var (
found []SearchHit
err error
)
if s.nocase {
pat := wideNoCasePattern(s.raw)
found, err = SearchRegexContext(ctx, d, start, length, pat, max)
} else {
found, err = SearchBytesContext(ctx, d, start, length, wide, max)
}
if err != nil {
return nil, err
}
if s.fullword {
found = filterFullWordHits(d, found, true)
}
out = append(out, found...)
}
if len(out) > max {
out = out[:max]
}
return out, nil
}
func utf16LEBytes(in []byte) []byte {
out := make([]byte, 0, len(in)*2)
for _, b := range in {
out = append(out, b, 0)
}
return out
}
func wideNoCasePattern(in []byte) string {
var sb strings.Builder
sb.WriteString("(?i)")
for _, b := range in {
sb.WriteString(regexp.QuoteMeta(string([]byte{b, 0})))
}
return sb.String()
}
func filterFullWordHits(d *document.Document, hits []SearchHit, wide bool) []SearchHit {
out := hits[:0]
for _, h := range hits {
if isFullWordHit(d, h, wide) {
out = append(out, h)
}
}
return out
}
func isFullWordHit(d *document.Document, h SearchHit, wide bool) bool {
if wide {
beforeOff := h.Offset - 2
afterOff := h.Offset + int64(h.Length)
beforeWord := false
afterWord := false
if beforeOff >= 0 {
if b, err := d.Range(beforeOff, 2); err == nil && len(b) == 2 && b[1] == 0 {
beforeWord = isWordByte(b[0])
}
}
if afterOff+1 < d.Size() {
if b, err := d.Range(afterOff, 2); err == nil && len(b) == 2 && b[1] == 0 {
afterWord = isWordByte(b[0])
}
}
return !beforeWord && !afterWord
}
beforeWord := false
afterWord := false
if h.Offset > 0 {
if b, err := d.At(h.Offset - 1); err == nil {
beforeWord = isWordByte(b)
}
}
if h.Offset+int64(h.Length) < d.Size() {
if b, err := d.At(h.Offset + int64(h.Length)); err == nil {
afterWord = isWordByte(b)
}
}
return !beforeWord && !afterWord
}
func isWordByte(b byte) bool {
return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '_'
}
func toAnySlice(in []string) []any {
out := make([]any, len(in))
for i, s := range in {
out[i] = s
}
return out
}
func evalCondition(r Rule, hits map[string][]SearchHit) bool {
cond := strings.TrimSpace(r.Condition)
condLower := strings.ToLower(cond)
present := 0
for _, s := range r.Strings {
if len(hits[s.ID]) > 0 {
present++
}
}
switch {
case condLower == "any of them":
return present > 0
case condLower == "all of them":
return present == len(r.Strings)
case strings.HasSuffix(condLower, "of them"):
// "<n> of them"
parts := strings.Fields(condLower)
if len(parts) >= 3 {
n, ok := parseRuleCount(parts[0])
return ok && present >= n
}
}
if ok, matched := evalRuleSetCondition(cond, hits); ok {
return matched
}
p := conditionParser{tokens: tokenizeCondition(cond), hits: hits}
return p.parse()
}
func evalRuleSetCondition(cond string, hits map[string][]SearchHit) (bool, bool) {
tokens := tokenizeCondition(cond)
if len(tokens) < 4 || strings.ToLower(tokens[1]) != "of" || tokens[2] != "(" || tokens[len(tokens)-1] != ")" {
return false, false
}
ids := []string{}
for _, tok := range tokens[3 : len(tokens)-1] {
if tok == "," {
continue
}
if strings.HasPrefix(tok, "$") {
ids = append(ids, tok)
}
}
if len(ids) == 0 {
return true, false
}
count := 0
for _, id := range ids {
if len(hits[id]) > 0 {
count++
}
}
switch strings.ToLower(tokens[0]) {
case "any":
return true, count > 0
case "all":
return true, count == len(ids)
default:
n, ok := parseRuleCount(strings.ToLower(tokens[0]))
return true, ok && count >= n
}
}
func parseRuleCount(s string) (int, bool) {
switch s {
case "1", "one":
return 1, true
case "2", "two":
return 2, true
case "3", "three":
return 3, true
default:
n, err := strconv.Atoi(s)
return n, err == nil
}
}
type conditionParser struct {
tokens []string
pos int
hits map[string][]SearchHit
}
func (p *conditionParser) parse() bool {
v := p.parseOr()
return v
}
func (p *conditionParser) parseOr() bool {
v := p.parseAnd()
for strings.EqualFold(p.peek(), "or") {
p.next()
v = p.parseAnd() || v
}
return v
}
func (p *conditionParser) parseAnd() bool {
v := p.parseNot()
for strings.EqualFold(p.peek(), "and") {
p.next()
v = p.parseNot() && v
}
return v
}
func (p *conditionParser) parseNot() bool {
if strings.EqualFold(p.peek(), "not") || p.peek() == "!" {
p.next()
return !p.parseNot()
}
return p.parsePrimary()
}
func (p *conditionParser) parsePrimary() bool {
tok := p.next()
switch {
case tok == "":
return false
case tok == "(":
v := p.parseOr()
if p.peek() == ")" {
p.next()
}
return v
case strings.EqualFold(tok, "true"):
return true
case strings.EqualFold(tok, "false"):
return false
case strings.HasPrefix(tok, "$"):
return len(p.hits[tok]) > 0
default:
return false
}
}
func (p *conditionParser) peek() string {
if p.pos >= len(p.tokens) {
return ""
}
return p.tokens[p.pos]
}
func (p *conditionParser) next() string {
if p.pos >= len(p.tokens) {
return ""
}
tok := p.tokens[p.pos]
p.pos++
return tok
}
func tokenizeCondition(cond string) []string {
out := []string{}
for i := 0; i < len(cond); {
c := cond[i]
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
i++
continue
}
if strings.ContainsRune("(),!", rune(c)) {
out = append(out, string(c))
i++
continue
}
start := i
for i < len(cond) {
c = cond[i]
if c == ' ' || c == '\t' || c == '\n' || c == '\r' || strings.ContainsRune("(),!", rune(c)) {
break
}
i++
}
out = append(out, cond[start:i])
}
return out
}
+189
View File
@@ -0,0 +1,189 @@
package analysis
import (
"bytes"
"context"
"errors"
"regexp"
"github.com/andydixon/nexus/internal/leviathan/document"
)
// SearchHit describes one search result.
type SearchHit struct {
Offset int64 `json:"offset"`
Length int `json:"length"`
Sample string `json:"sample"` // short hex preview
}
// SearchBytes finds `needle` in the document range and returns hit
// offsets. Scans chunk by chunk with an overlap equal to len(needle)-1
// so cross-chunk matches are not missed.
func SearchBytes(d *document.Document, start, length int64, needle []byte, max int) ([]SearchHit, error) {
return SearchBytesContext(context.Background(), d, start, length, needle, max)
}
func SearchBytesContext(ctx context.Context, d *document.Document, start, length int64, needle []byte, max int) ([]SearchHit, error) {
if len(needle) == 0 {
return nil, errors.New("empty needle")
}
if max <= 0 {
max = 10_000
}
if length < 0 {
length = d.Size() - start
}
hits := make([]SearchHit, 0, 64)
overlap := int64(len(needle) - 1)
// We chunk in windows of chunkSize + overlap.
const chunkSize = 1 << 20 // 1MB
off := start
endExclusive := start + length
for off < endExclusive && len(hits) < max {
if err := ctx.Err(); err != nil {
return nil, err
}
winEnd := off + chunkSize
if winEnd > endExclusive {
winEnd = endExclusive
}
readEnd := winEnd + overlap
if readEnd > endExclusive {
readEnd = endExclusive
}
buf, err := d.Range(off, readEnd-off)
if err != nil {
return nil, err
}
// Limit search to bytes whose start lies in [0, winEnd-off)
searchLimit := winEnd - off
scan := 0
for {
if err := ctx.Err(); err != nil {
return nil, err
}
idx := bytes.Index(buf[scan:], needle)
if idx < 0 {
break
}
abs := int64(scan+idx) + off
relStart := abs - off
if relStart >= searchLimit {
break
}
hits = append(hits, SearchHit{
Offset: abs,
Length: len(needle),
Sample: hexPreview(buf[scan+idx:], len(needle)),
})
if len(hits) >= max {
break
}
scan = scan + idx + 1
if scan >= len(buf) {
break
}
}
off = winEnd
}
return hits, nil
}
// SearchRegex runs a Go regexp across the document range, matching
// byte-wise. It decodes matches chunk by chunk with overlap, bounded
// by a generous max hit count.
func SearchRegex(d *document.Document, start, length int64, pattern string, max int) ([]SearchHit, error) {
return SearchRegexContext(context.Background(), d, start, length, pattern, max)
}
func SearchRegexContext(ctx context.Context, d *document.Document, start, length int64, pattern string, max int) ([]SearchHit, error) {
re, err := regexp.Compile(pattern)
if err != nil {
return nil, err
}
if max <= 0 {
max = 10_000
}
if length < 0 {
length = d.Size() - start
}
// For regex, a conservative overlap is 4 KiB so matches spanning
// chunk boundaries are still caught. Very long matches may still
// be missed in extreme cases.
const chunkSize = 1 << 20
const overlap = 4096
hits := make([]SearchHit, 0, 64)
off := start
endExclusive := start + length
for off < endExclusive && len(hits) < max {
if err := ctx.Err(); err != nil {
return nil, err
}
winEnd := off + chunkSize
if winEnd > endExclusive {
winEnd = endExclusive
}
readEnd := winEnd + overlap
if readEnd > endExclusive {
readEnd = endExclusive
}
buf, err := d.Range(off, readEnd-off)
if err != nil {
return nil, err
}
searchLimit := winEnd - off
locs := re.FindAllIndex(buf, max-len(hits))
for _, loc := range locs {
if err := ctx.Err(); err != nil {
return nil, err
}
relStart := int64(loc[0])
if relStart >= searchLimit {
continue
}
abs := relStart + off
hits = append(hits, SearchHit{
Offset: abs,
Length: loc[1] - loc[0],
Sample: hexPreview(buf[loc[0]:loc[1]], loc[1]-loc[0]),
})
}
off = winEnd
}
return hits, nil
}
func hexPreview(b []byte, n int) string {
if n > 32 {
n = 32
}
if n > len(b) {
n = len(b)
}
const hexchars = "0123456789abcdef"
out := make([]byte, n*2)
for i := 0; i < n; i++ {
out[i*2] = hexchars[b[i]>>4]
out[i*2+1] = hexchars[b[i]&0x0f]
}
return string(out)
}
// HitsToMaps converts hits for bridge return.
func HitsToMaps(hits []SearchHit) []map[string]any {
out := make([]map[string]any, len(hits))
for i, h := range hits {
out[i] = map[string]any{
"offset": h.Offset,
"length": h.Length,
"sample": h.Sample,
}
}
return out
}
+198
View File
@@ -0,0 +1,198 @@
package analysis
import (
"context"
"github.com/andydixon/nexus/internal/leviathan/document"
)
// StringHit represents one extracted string.
type StringHit struct {
Offset int64 `json:"offset"`
Length int `json:"length"`
Encoding string `json:"encoding"` // ascii | utf16le | utf16be
Value string `json:"value"`
}
// ExtractStrings finds printable runs in the specified range.
// Supports ASCII and UTF-16 LE/BE. minLen is in characters.
func ExtractStrings(d *document.Document, start, length int64, minLen int, encodings []string, max int) ([]map[string]any, error) {
return ExtractStringsContext(context.Background(), d, start, length, minLen, encodings, max)
}
func ExtractStringsContext(ctx context.Context, d *document.Document, start, length int64, minLen int, encodings []string, max int) ([]map[string]any, error) {
if minLen <= 0 {
minLen = 4
}
if max <= 0 {
max = 50_000
}
if length < 0 {
length = d.Size() - start
}
doASCII, doLE, doBE := false, false, false
if len(encodings) == 0 {
doASCII, doLE, doBE = true, true, true
} else {
for _, e := range encodings {
switch e {
case "ascii":
doASCII = true
case "utf16le":
doLE = true
case "utf16be":
doBE = true
}
}
}
out := make([]map[string]any, 0, 256)
// ASCII
if doASCII {
if err := appendASCII(ctx, d, start, length, minLen, &out, max); err != nil {
return nil, err
}
}
// UTF-16
if len(out) < max && doLE {
if err := appendUTF16(ctx, d, start, length, minLen, true, &out, max); err != nil {
return nil, err
}
}
if len(out) < max && doBE {
if err := appendUTF16(ctx, d, start, length, minLen, false, &out, max); err != nil {
return nil, err
}
}
return out, nil
}
func appendASCII(ctx context.Context, d *document.Document, start, length int64, minLen int, out *[]map[string]any, max int) error {
// Chunked scan with a carry for runs crossing chunk boundaries.
var runStart int64 = -1
var runBuf []byte
emit := func() {
if runStart >= 0 && len(runBuf) >= minLen {
*out = append(*out, map[string]any{
"offset": runStart,
"length": len(runBuf),
"encoding": "ascii",
"value": string(runBuf),
})
}
runStart = -1
runBuf = runBuf[:0]
}
if err := d.ForEachChunk(start, length, func(off int64, chunk []byte) error {
if err := ctx.Err(); err != nil {
return err
}
for i, b := range chunk {
if i%4096 == 0 {
if err := ctx.Err(); err != nil {
return err
}
}
if isPrintableASCII(b) {
if runStart < 0 {
runStart = off + int64(i)
runBuf = make([]byte, 0, 32)
}
runBuf = append(runBuf, b)
} else {
if len(*out) >= max {
return nil
}
emit()
}
}
return nil
}); err != nil {
return err
}
emit()
return nil
}
func appendUTF16(ctx context.Context, d *document.Document, start, length int64, minLen int, le bool, out *[]map[string]any, max int) error {
// Step by 2. Produce one string for each contiguous run of printable code units.
if length < 2 {
return nil
}
// Read chunks aligned to 2 bytes so pairs don't split.
const chunkSize = 1 << 20
end := start + length
var runStart int64 = -1
var runBuf []rune
enc := "utf16le"
if !le {
enc = "utf16be"
}
emit := func() {
if runStart >= 0 && len(runBuf) >= minLen {
*out = append(*out, map[string]any{
"offset": runStart,
"length": len(runBuf) * 2,
"encoding": enc,
"value": string(runBuf),
})
}
runStart = -1
runBuf = runBuf[:0]
}
for off := start; off+1 < end; {
if err := ctx.Err(); err != nil {
return err
}
winEnd := off + chunkSize
if winEnd > end {
winEnd = end
}
if (winEnd-off)%2 != 0 {
winEnd-- // keep pair alignment
}
buf, err := d.Range(off, winEnd-off)
if err != nil {
return err
}
for i := 0; i+1 < len(buf); i += 2 {
if i%4096 == 0 {
if err := ctx.Err(); err != nil {
return err
}
}
var cu uint16
if le {
cu = uint16(buf[i]) | uint16(buf[i+1])<<8
} else {
cu = uint16(buf[i])<<8 | uint16(buf[i+1])
}
if cu >= 0x20 && cu <= 0x7e {
if runStart < 0 {
runStart = off + int64(i)
runBuf = make([]rune, 0, 32)
}
runBuf = append(runBuf, rune(cu))
} else {
if len(*out) >= max {
emit()
return nil
}
emit()
}
}
off = winEnd
}
emit()
return nil
}
func isPrintableASCII(b byte) bool {
return b == '\t' || (b >= 0x20 && b <= 0x7e)
}
+106
View File
@@ -0,0 +1,106 @@
package analysis
import (
"encoding/binary"
"math"
"time"
"github.com/andydixon/nexus/internal/leviathan/document"
)
// ScanTimestamps walks the range looking for plausible timestamps in
// common encodings. This is a heuristic: it reports any 4/8-byte
// window whose decoded time falls in [1990-01-01, 2050-12-31].
func ScanTimestamps(d *document.Document, start, length int64, maxHits int) (map[string]any, error) {
if length < 0 {
length = d.Size() - start
}
if maxHits <= 0 {
maxHits = 20_000
}
minT := time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC).Unix()
maxT := time.Date(2050, 12, 31, 0, 0, 0, 0, time.UTC).Unix()
ok := func(sec int64) bool { return sec >= minT && sec <= maxT }
hits := make([]map[string]any, 0, 256)
counts := map[string]int{}
const chunk = 1 << 20
end := start + length
overlap := int64(7) // enough for 8-byte types
for off := start; off < end && len(hits) < maxHits; {
winEnd := off + chunk
if winEnd > end {
winEnd = end
}
readEnd := winEnd + overlap
if readEnd > end {
readEnd = end
}
buf, err := d.Range(off, readEnd-off)
if err != nil {
return nil, err
}
limit := int(winEnd - off)
for i := 0; i < limit && len(hits) < maxHits; i += 4 {
if i+4 <= len(buf) {
vLE := int64(int32(binary.LittleEndian.Uint32(buf[i:])))
if ok(vLE) {
hits = append(hits, hit("unix_le", off+int64(i), 4, time.Unix(vLE, 0).UTC()))
counts["unix_le"]++
continue
}
vBE := int64(int32(binary.BigEndian.Uint32(buf[i:])))
if ok(vBE) {
hits = append(hits, hit("unix_be", off+int64(i), 4, time.Unix(vBE, 0).UTC()))
counts["unix_be"]++
continue
}
}
if i+8 <= len(buf) {
v := binary.LittleEndian.Uint64(buf[i:])
// Windows FILETIME
sec := int64(v/10_000_000) - filetimeEpochDelta
if ok(sec) {
hits = append(hits, hit("filetime_le", off+int64(i), 8, time.Unix(sec, 0).UTC()))
counts["filetime_le"]++
continue
}
// WebKit (microseconds since 1601)
sec = int64(v/1_000_000) - filetimeEpochDelta
if ok(sec) {
hits = append(hits, hit("webkit_le", off+int64(i), 8, time.Unix(sec, 0).UTC()))
counts["webkit_le"]++
continue
}
// OLE automation date
fdays := math.Float64frombits(v)
if fdays > 2 && fdays < 75000 {
base := time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
t := base.Add(time.Duration(fdays*86400) * time.Second)
if t.Unix() >= minT && t.Unix() <= maxT {
hits = append(hits, hit("ole_le", off+int64(i), 8, t))
counts["ole_le"]++
continue
}
}
}
}
off = winEnd
}
return map[string]any{
"hits": hits,
"counts": counts,
}, nil
}
func hit(kind string, off int64, size int, t time.Time) map[string]any {
return map[string]any{
"kind": kind,
"offset": off,
"length": size,
"iso": t.Format(time.RFC3339),
"unix": t.Unix(),
}
}
+277
View File
@@ -0,0 +1,277 @@
# binsnoop
A pure-Go binary analysis library for detecting and extracting embedded files, firmware images, compressed data, filesystems, and more from binary blobs. Ported from the [binwalk](https://github.com/ReFirmLabs/binwalk) Rust codebase with like-for-like scanning logic.
**Zero external dependencies** — everything is self-contained, including the Aho-Corasick multi-pattern matcher, CRC32 validation, and binary structure parsing.
## Features
- **111 built-in signatures** covering archives, compression, filesystems, images, executables, firmware headers, cryptographic constructs, and more
- **Aho-Corasick** multi-pattern scanning with overlapping match support
- **Signature validation** — every magic byte match is verified by a dedicated parser that checks headers, checksums, and structural integrity
- **Confidence scoring** — results are ranked Low / Medium / High so callers can filter noise
- **Extraction support** — carve identified objects out of a binary, or invoke external tools (7z, unsquashfs, etc.) to fully extract them
- **Filtering** — include/exclude signatures by name, restrict short signatures to offset 0, or add custom signatures
- **JSON-friendly** — all result types carry `json` struct tags for easy serialisation
## Installation
```bash
go get binsnoop
```
Or add to your `go.mod`:
```
require binsnoop v0.0.0
```
## Quick Start
```go
package main
import (
"fmt"
"binsnoop"
)
func main() {
scanner := binsnoop.New()
// Scan a file
results, err := scanner.ScanFile("firmware.bin")
if err != nil {
panic(err)
}
for _, r := range results {
fmt.Printf("[0x%08X] %s (confidence: %d, size: %d)\n",
r.Offset, r.Description, r.Confidence, r.Size)
}
}
```
## API Reference
### Creating a Scanner
```go
// Default scanner with all 111 signatures
scanner := binsnoop.New()
// Scanner with options
scanner, err := binsnoop.NewWithOptions(binsnoop.Options{
Include: []string{"gzip", "squashfs", "uimage"}, // only these signatures
Exclude: []string{"copyright"}, // remove these signatures
FullSearch: true, // match short sigs at any offset
AdditionalSignatures: []binsnoop.Signature{...}, // add custom signatures
})
```
### Scanning
```go
// Scan raw bytes
results := scanner.ScanBytes(data)
// Scan a file on disk
results, err := scanner.ScanFile("/path/to/firmware.bin")
```
Both return `[]SignatureResult` sorted by offset.
### Analysis (Scan + Extraction)
```go
// Analyse raw bytes (with optional extraction)
analysis := scanner.AnalyzeBytes(data, "firmware.bin", true)
// Analyse a file on disk
analysis, err := scanner.AnalyzeFile("/path/to/firmware.bin", true)
```
Returns `*AnalysisResults` containing the file map and extraction outcomes.
### Carving
Extract a single identified object from raw data:
```go
err := binsnoop.CarveResult(data, results[0], "/tmp/output")
```
This writes the bytes at the result's offset/size to a file in the output directory.
### Listing Signatures
```go
names := binsnoop.ListSignatures() // []string of all registered signature names
```
### Scanner Info
```go
scanner.SignatureCount() // number of registered signatures (111)
scanner.PatternCount() // total magic byte patterns across all signatures
```
## Types
### `SignatureResult`
```go
type SignatureResult struct {
Offset int `json:"offset"` // byte offset in the input data
ID string `json:"id"` // unique identifier for this match
Size int `json:"size"` // size in bytes (0 = unknown)
Name string `json:"name"` // signature name (e.g. "gzip")
Confidence uint8 `json:"confidence"` // 0=Low, 128=Medium, 250=High
Description string `json:"description"` // human-readable description
AlwaysDisplay bool `json:"always_display"` // show even if low confidence
ExtractionDeclined bool `json:"extraction_declined"` // parser advises against extraction
PreferredExtractor *Extractor `json:"-"` // extractor to use for this result
}
```
### `Options`
```go
type Options struct {
Include []string // restrict to these signature names
Exclude []string // remove these signature names
FullSearch bool // short sigs match at any offset
AdditionalSignatures []Signature // user-defined signatures
}
```
### `AnalysisResults`
```go
type AnalysisResults struct {
FilePath string `json:"file_path"`
FileMap []SignatureResult `json:"file_map"`
Extractions map[string]*ExtractionResult `json:"extractions"`
}
```
### Confidence Levels
| Constant | Value | Meaning |
|----------|-------|---------|
| `ConfidenceLow` | 0 | Magic matched but weak validation |
| `ConfidenceMedium` | 128 | Header parsed and mostly valid |
| `ConfidenceHigh` | 250 | Full structural validation passed |
## Custom Signatures
You can add your own signatures at scanner creation:
```go
mySignature := binsnoop.Signature{
Name: "my_format",
Magic: [][]byte{[]byte("MYMAGIC")},
Parser: func(data []byte, offset int) (*binsnoop.SignatureResult, error) {
// Validate the match and return a result
if offset+16 > len(data) {
return nil, fmt.Errorf("insufficient data")
}
return &binsnoop.SignatureResult{
Offset: offset,
Size: 1024,
Description: "My custom format, size: 1024 bytes",
Confidence: binsnoop.ConfidenceHigh,
}, nil
},
Description: "My custom file format",
}
scanner, _ := binsnoop.NewWithOptions(binsnoop.Options{
AdditionalSignatures: []binsnoop.Signature{mySignature},
})
```
## Supported Signatures
### Archives (12)
gzip, zip, tarball, 7zip, rar, cab, deb, cpio, arj, iso9660, dahua\_zip
### Compression (10)
lzma, bzip2, xz, zstd, lz4, lzop, lzfse, zlib, compress'd
### Filesystems (13)
squashfs, cramfs, jffs2, yaffs, romfs, ext, fat, ntfs, apfs, btrfs, logfs, ubi, ubifs
### Images & Media (7)
png, jpeg, bmp, gif, svg, riff, pdf
### Executables (2)
elf, pe
### Firmware (32)
uimage, uboot, trx, tplink, tplink\_rtos, dlob, arcadyan, chk, seama, cfe, jboot\_arm, jboot\_stag, jboot\_sch2, packimg, binhdr, autel, android\_sparse, android\_bootimg, dtb, dlink\_tlv, dlke, shrs, dkbs, dms, encfw, encrpted\_img, wince, mh01, csman, dxbc, matter\_ota, rtk
### Cryptographic (17)
aes\_sbox, aes\_forward\_table, aes\_reverse\_table, aes\_rcon, aes\_acceleration\_table, rsa, openssl, gpg\_signed, pem\_certificate, pem\_public\_key, pem\_private\_key, pkcs\_der\_hash, luks, crc32, sha256, md5
### Miscellaneous (18)
copyright, wind\_kernel, vxworks\_symtab, ecos, linux\_kernel, linux\_boot\_image, linux\_arm\_zimage, linux\_arm64\_boot\_image, efigpt, mbr, uefi\_pi\_volume, uefi\_capsule, pchrom, pcapng, pjl, srecord, dmg, qnx\_ifs, dpapi, qcow
## Extraction
Many signatures have associated extractors that invoke external command-line tools. When you call `AnalyzeFile` or `AnalyzeBytes` with `extract: true`, binsnoop will:
1. Carve the identified data to a temporary file
2. Run the configured external tool (e.g. `7z x`, `unsquashfs`, `jefferson`)
3. Return the extraction results including output directory and success/failure status
**Common external tools used:**
| Tool | Signatures |
|------|-----------|
| `7z` | zip, cpio, arj, iso9660, xz, lzma, squashfs, dmg, efigpt |
| `gzip` | gzip |
| `bzip2` | bzip2 |
| `zstd` | zstd |
| `lz4` | lz4 |
| `unsquashfs` | squashfs |
| `jefferson` | jffs2 |
| `unyaffs` | yaffs |
| `tsk_recover` | ext, fat, ntfs |
| `uefi-firmware-parser` | uefi\_pi\_volume, uefi\_capsule, pchrom |
| `simg2img` | android\_sparse |
| `dtc` | dtb |
## How It Works
### Scanning Algorithm
1. **Short-signature pass** — signatures marked `Short` (e.g. zlib, MBR) are checked only at offset 0 (unless `FullSearch` is enabled)
2. **Aho-Corasick scan** — all magic byte patterns are compiled into an Aho-Corasick automaton for efficient multi-pattern matching. When a high-confidence sized match is found, scanning restarts from the end of that match
3. **Validation** — every magic match is passed to its signature parser which validates headers, checksums, and structure
4. **Post-processing** — results are sorted by offset, overlapping/duplicate matches are resolved (higher confidence wins), and unknown sizes are estimated based on the next significant match or EOF
### Architecture
```
binsnoop.go Public API (New, ScanBytes, ScanFile, AnalyzeFile, CarveResult, etc.)
scanner.go Core scan engine (Aho-Corasick integration, post-processing)
match.go Self-contained Aho-Corasick trie with overlapping match support
parse.go Generic binary structure parser (u8/u16/u24/u32/u64, big/little endian)
types.go Core types (SignatureResult, Signature, Extractor, Options, etc.)
common.go Utility functions (CRC32, endian helpers, string helpers)
extract.go Carving and external tool execution
magic.go Signature registry — wires all 111 signatures together
sig_archives.go Archive signature parsers
sig_compression.go Compression signature parsers
sig_filesystems.go Filesystem signature parsers
sig_images.go Image/media signature parsers
sig_executables.go Executable signature parsers
sig_firmware.go Firmware header signature parsers
sig_crypto.go Cryptographic signature parsers
sig_misc.go Miscellaneous signature parsers
```
## License
This project is a Go port of [binwalk](https://github.com/ReFirmLabs/binwalk) by ReFirmLabs. See the original project for licensing details.
+177
View File
@@ -0,0 +1,177 @@
// Package binsnoop identifies and optionally extracts files embedded inside binary data.
//
// It is a Go port of the binwalk scanning engine, implementing all magic byte signatures,
// structure parsers, and the Aho-Corasick based multi-pattern matching algorithm.
//
// # Quick Start
//
// scanner := binsnoop.New()
// data, _ := os.ReadFile("firmware.bin")
// results := scanner.Scan(data)
// for _, r := range results {
// fmt.Printf("0x%08X %s\n", r.Offset, r.Description)
// }
//
// # Extraction
//
// // Extract a specific result
// err := binsnoop.CarveResult(data, results[0], "/tmp/output")
//
// # Filtering
//
// // Only scan for specific signatures
// scanner := binsnoop.NewWithOptions(Options{Include: []string{"gzip", "squashfs"}})
package binsnoop
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Options configures the Scanner.
type Options struct {
// Include restricts scanning to only these signature names.
Include []string
// Exclude removes these signature names from scanning.
Exclude []string
// FullSearch makes short signatures match at any offset, not just offset 0.
FullSearch bool
// AdditionalSignatures adds user-defined signatures to the scanner.
AdditionalSignatures []Signature
}
// New creates a Scanner with all default signatures enabled.
func New() *Scanner {
s, _ := NewWithOptions(Options{})
return s
}
// NewWithOptions creates a Scanner with the specified options.
func NewWithOptions(opts Options) (*Scanner, error) {
s := &Scanner{
patternSigTable: make(map[int]Signature),
extractorLookup: make(map[string]*Extractor),
}
allSigs := AllSignatures()
if opts.AdditionalSignatures != nil {
allSigs = append(allSigs, opts.AdditionalSignatures...)
}
for _, sig := range allSigs {
if !includeSig(&sig, opts.Include, opts.Exclude) {
continue
}
s.signatureCount++
s.patternCount += len(sig.Magic)
// Register extractor
var ext *Extractor
if sig.Extractor != nil {
extCopy := *sig.Extractor
ext = &extCopy
}
s.extractorLookup[sig.Name] = ext
for _, pattern := range sig.Magic {
if sig.Short && !opts.FullSearch {
s.shortSignatures = append(s.shortSignatures, sig)
break
}
s.patternSigTable[len(s.patterns)] = sig
s.patterns = append(s.patterns, pattern)
}
}
s.compile()
return s, nil
}
// ScanBytes scans raw bytes for signatures and returns all matches.
func (s *Scanner) ScanBytes(data []byte) []SignatureResult {
return s.Scan(data)
}
// ScanFile reads a file from disk and scans it for signatures.
func (s *Scanner) ScanFile(path string) ([]SignatureResult, error) {
data, err := ReadFile(path)
if err != nil {
return nil, fmt.Errorf("binsnoop: read file: %w", err)
}
return s.Scan(data), nil
}
// AnalyzeBytes scans and optionally extracts from raw bytes.
func (s *Scanner) AnalyzeBytes(data []byte, filePath string, extract bool) *AnalysisResults {
results := &AnalysisResults{
FilePath: filePath,
Extractions: make(map[string]*ExtractionResult),
}
results.FileMap = s.Scan(data)
if extract && len(results.FileMap) > 0 {
results.Extractions = s.Extract(data, filePath, results.FileMap)
}
return results
}
// AnalyzeFile reads a file from disk, scans, and optionally extracts.
func (s *Scanner) AnalyzeFile(path string, extract bool) (*AnalysisResults, error) {
data, err := ReadFile(path)
if err != nil {
return nil, fmt.Errorf("binsnoop: read file: %w", err)
}
return s.AnalyzeBytes(data, path, extract), nil
}
// CarveResult extracts raw bytes for a specific signature result to a file.
func CarveResult(data []byte, result SignatureResult, outputDir string) error {
if result.Offset < 0 || result.Offset+result.Size > len(data) {
return fmt.Errorf("binsnoop: result offset/size exceeds data bounds")
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("binsnoop: mkdir: %w", err)
}
outPath := filepath.Join(outputDir, fmt.Sprintf("%x_%s.bin", result.Offset, result.Name))
return os.WriteFile(outPath, data[result.Offset:result.Offset+result.Size], 0644)
}
// SignatureCount returns the number of loaded signatures.
func (s *Scanner) SignatureCount() int { return s.signatureCount }
// PatternCount returns the total number of magic byte patterns.
func (s *Scanner) PatternCount() int { return s.patternCount }
// ListSignatures returns the names of all available signatures.
func ListSignatures() []string {
sigs := AllSignatures()
names := make([]string, len(sigs))
for i, s := range sigs {
names[i] = s.Name
}
return names
}
func includeSig(sig *Signature, include, exclude []string) bool {
if len(include) > 0 {
for _, inc := range include {
if strings.EqualFold(sig.Name, inc) {
return true
}
}
return false
}
if len(exclude) > 0 {
for _, exc := range exclude {
if strings.EqualFold(sig.Name, exc) {
return false
}
}
}
return true
}
+127
View File
@@ -0,0 +1,127 @@
package binsnoop
import (
"encoding/binary"
"fmt"
"hash/crc32"
"os"
"time"
)
const MaxFileReadBytes int64 = 64 << 20
// CRC32 calculates the CRC32 checksum using the IEEE polynomial.
func CRC32(data []byte) uint32 {
return crc32.ChecksumIEEE(data)
}
// EpochToString converts a Unix timestamp to a formatted time string.
func EpochToString(epoch uint32) string {
t := time.Unix(int64(epoch), 0).UTC()
return t.Format("2006-01-02 15:04:05")
}
// GetCString extracts a NULL-terminated C string from raw bytes.
func GetCString(data []byte) string {
for i, b := range data {
if b == 0 {
s := string(data[:i])
// Validate UTF-8 printability
for _, c := range s {
if c == 0 {
return ""
}
}
return s
}
}
return string(data)
}
// IsASCIINumber returns true if the byte is an ASCII digit (0-9).
func IsASCIINumber(b byte) bool {
return b >= '0' && b <= '9'
}
// IsPrintableASCII returns true if the byte is a printable ASCII character.
func IsPrintableASCII(b byte) bool {
return b >= 0x0A && b <= 0x7E
}
// IsOffsetSafe validates that the next offset is within bounds and progressing forward.
func IsOffsetSafe(availableData, nextOffset int, lastOffset *int) bool {
if lastOffset != nil {
if *lastOffset >= nextOffset {
return false
}
}
return nextOffset < availableData
}
// ReadFile reads a bounded file into memory for scanner APIs that require a
// byte slice. Larger callers should use a streaming document path.
func ReadFile(path string) ([]byte, error) {
return ReadFileLimited(path, MaxFileReadBytes)
}
func ReadFileLimited(path string, maxBytes int64) ([]byte, error) {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
if info.IsDir() {
return nil, fmt.Errorf("binsnoop: path is a directory")
}
if maxBytes > 0 && info.Size() > maxBytes {
return nil, fmt.Errorf("binsnoop: file is too large: %d bytes exceeds %d bytes", info.Size(), maxBytes)
}
return os.ReadFile(path)
}
// LE16 reads a little-endian uint16 from data at the given offset.
func LE16(data []byte, offset int) uint16 {
if offset+2 > len(data) {
return 0
}
return binary.LittleEndian.Uint16(data[offset:])
}
// BE16 reads a big-endian uint16 from data at the given offset.
func BE16(data []byte, offset int) uint16 {
if offset+2 > len(data) {
return 0
}
return binary.BigEndian.Uint16(data[offset:])
}
// LE32 reads a little-endian uint32 from data at the given offset.
func LE32(data []byte, offset int) uint32 {
if offset+4 > len(data) {
return 0
}
return binary.LittleEndian.Uint32(data[offset:])
}
// BE32 reads a big-endian uint32 from data at the given offset.
func BE32(data []byte, offset int) uint32 {
if offset+4 > len(data) {
return 0
}
return binary.BigEndian.Uint32(data[offset:])
}
// LE64 reads a little-endian uint64 from data at the given offset.
func LE64(data []byte, offset int) uint64 {
if offset+8 > len(data) {
return 0
}
return binary.LittleEndian.Uint64(data[offset:])
}
// BE64 reads a big-endian uint64 from data at the given offset.
func BE64(data []byte, offset int) uint64 {
if offset+8 > len(data) {
return 0
}
return binary.BigEndian.Uint64(data[offset:])
}
@@ -0,0 +1,22 @@
package binsnoop
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestReadFileLimitedRejectsOversizedFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "firmware.bin")
if err := os.WriteFile(path, []byte("abcd"), 0600); err != nil {
t.Fatalf("WriteFile() failed: %v", err)
}
_, err := ReadFileLimited(path, 3)
if err == nil {
t.Fatal("expected oversized file to be rejected")
}
if !strings.Contains(err.Error(), "file is too large") {
t.Fatalf("unexpected error: %v", err)
}
}
+124
View File
@@ -0,0 +1,124 @@
package binsnoop
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Carve writes a slice of data to a file on disk.
func Carve(outputPath string, data []byte, offset, size int) error {
if offset < 0 || offset+size > len(data) {
return fmt.Errorf("carve: offset %d + size %d exceeds data length %d", offset, size, len(data))
}
dir := filepath.Dir(outputPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("carve: mkdir: %w", err)
}
return os.WriteFile(outputPath, data[offset:offset+size], 0644)
}
// executeExternal runs an external extraction tool.
func executeExternal(ext *Extractor, carvedFilePath, outputDir string) *ExtractionResult {
result := &ExtractionResult{
ExtractorName: ext.Command,
OutputDirectory: outputDir,
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
return result
}
// Build arguments, replacing the source file placeholder
args := make([]string, len(ext.Arguments))
for i, arg := range ext.Arguments {
args[i] = strings.ReplaceAll(arg, SourceFilePlaceholder, carvedFilePath)
}
cmd := exec.Command(ext.Command, args...)
cmd.Dir = outputDir
output, err := cmd.CombinedOutput()
_ = output
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
return result
}
}
// Check if exit code is in the accepted list
for _, code := range ext.ExitCodes {
if exitCode == code {
result.Success = true
break
}
}
result.DoNotRecurse = ext.DoNotRecurse
return result
}
// executeInternal runs an internal extraction function.
func executeInternal(ext *Extractor, data []byte, offset int, outputDir string) *ExtractionResult {
if ext.Function == nil {
return &ExtractionResult{}
}
result := ext.Function(data, offset, outputDir)
if result == nil {
result = &ExtractionResult{}
}
result.DoNotRecurse = ext.DoNotRecurse
return result
}
// ExecuteExtraction performs extraction for a signature result.
func ExecuteExtraction(data []byte, filePath string, sig *SignatureResult, ext *Extractor) *ExtractionResult {
if ext == nil {
return &ExtractionResult{}
}
// Use preferred extractor if specified
actualExt := ext
if sig.PreferredExtractor != nil {
actualExt = sig.PreferredExtractor
}
// Build output directory path
baseName := filepath.Base(filePath)
outputDir := filepath.Join(filepath.Dir(filePath), baseName+".extracted",
fmt.Sprintf("%x", sig.Offset))
switch actualExt.Type {
case ExtractorTypeInternal:
return executeInternal(actualExt, data, sig.Offset, outputDir)
case ExtractorTypeExternal:
// Carve data to a temp file for external tools
if err := os.MkdirAll(outputDir, 0755); err != nil {
return &ExtractionResult{OutputDirectory: outputDir}
}
carvedFile := filepath.Join(outputDir, fmt.Sprintf("carved.%s", actualExt.Extension))
endOffset := sig.Offset + sig.Size
if endOffset > len(data) {
endOffset = len(data)
}
if err := os.WriteFile(carvedFile, data[sig.Offset:endOffset], 0644); err != nil {
return &ExtractionResult{OutputDirectory: outputDir}
}
result := executeExternal(actualExt, carvedFile, outputDir)
result.OutputDirectory = outputDir
result.ExtractorName = actualExt.Command
return result
default:
return &ExtractionResult{}
}
}
+268
View File
@@ -0,0 +1,268 @@
package binsnoop
// AllSignatures returns every supported signature definition.
// This is the equivalent of magic::patterns() in the Rust binwalk codebase.
func AllSignatures() []Signature {
return []Signature{
// ===================== Archives =====================
{Name: "gzip", Magic: gzipMagic(), Parser: gzipParser, Description: gzipDescription, Extractor: extGzip()},
{Name: "deb", Magic: debMagic(), Parser: debParser, Description: debDescription},
{Name: "7zip", Magic: sevenzipMagic(), Parser: sevenzipParser, Description: sevenzipDescription, Extractor: extSevenzip()},
{Name: "xz", Magic: XZMagic(), Parser: XZParser, Description: XZDescription, Extractor: extLzma()},
{Name: "tarball", Magic: tarballMagic(), Parser: tarballParser, Description: tarballDescription, Extractor: extTar()},
{Name: "zip", Magic: zipMagic(), Parser: zipParser, Description: zipDescription, Extractor: extSevenzip()},
{Name: "cpio", Magic: cpioMagic(), Parser: cpioParser, Description: cpioDescription, Extractor: extSevenzip()},
{Name: "iso9660", Magic: iso9660Magic(), Parser: iso9660Parser, Description: iso9660Description, Extractor: extSevenzip()},
{Name: "rar", Magic: rarMagic(), Parser: rarParser, Description: rarDescription, Extractor: extRar()},
{Name: "cab", Magic: cabMagic(), Parser: cabParser, Description: cabDescription, Extractor: extCab()},
{Name: "arj", Magic: arjMagic(), Parser: arjParser, Description: arjDescription, Extractor: extSevenzip()},
{Name: "dahua_zip", Magic: dahuaZipMagic(), Parser: dahuaZipParser, Description: dahuaZipDescription, Extractor: extSevenzip()},
// ===================== Compression =====================
{Name: "lzma", Magic: LZMAMagic(), Parser: LZMAParser, Description: LZMADescription, Extractor: extLzma()},
{Name: "bzip2", Magic: Bzip2Magic(), Parser: Bzip2Parser, Description: Bzip2Description, Extractor: extBzip2()},
{Name: "zstd", Magic: ZSTDMagic(), Parser: ZSTDParser, Description: ZSTDDescription, Extractor: extZstd()},
{Name: "lz4", Magic: LZ4Magic(), Parser: LZ4Parser, Description: LZ4Description, Extractor: extLz4()},
{Name: "lzop", Magic: LZOPMagic(), Parser: LZOPParser, Description: LZOPDescription, Extractor: extLzop()},
{Name: "lzfse", Magic: LZFSEMagic(), Parser: LZFSEParser, Description: LZFSEDescription, Extractor: extLzfse()},
{Name: "zlib", Short: true, Magic: ZlibMagic(), Parser: ZlibParser, Description: ZlibDescription, Extractor: extZlib()},
{Name: "compressd", Short: true, Magic: CompressdMagic(), Parser: CompressdParser, Description: CompressdDescription, Extractor: extSevenzip()},
// ===================== Filesystems =====================
{Name: "squashfs", Magic: squashfs_magic(), Parser: squashfs_parser, Description: SquashFSDescription, Extractor: extSquashfs()},
{Name: "cramfs", Magic: cramfs_magic(), Parser: cramfs_parser, Description: CramFSDescription, Extractor: extSevenzip()},
{Name: "jffs2", Magic: jffs2_magic(), Parser: jffs2_parser, Description: JFFS2Description, Extractor: extJffs2()},
{Name: "yaffs", Magic: yaffs_magic(), Parser: yaffs_parser, Description: YAFFSDescription, Extractor: extYaffs2()},
{Name: "romfs", Magic: romfs_magic(), Parser: romfs_parser, Description: RomFSDescription, Extractor: extRomfs()},
{Name: "ext", Magic: ext_magic(), Parser: ext_parser, Description: EXTDescription, Extractor: extTsk()},
{Name: "fat", Short: true, MagicOffset: FATMagicOffset, Magic: fat_magic(), Parser: fat_parser, Description: FATDescription, Extractor: extTsk()},
{Name: "ntfs", Magic: ntfs_magic(), Parser: ntfs_parser, Description: NTFSDescription, Extractor: extTsk()},
{Name: "apfs", Magic: apfs_magic(), Parser: apfs_parser, Description: APFSDescription, Extractor: extSevenzip()},
{Name: "btrfs", AlwaysDisplay: true, Magic: btrfs_magic(), Parser: btrfs_parser, Description: BTRFSDescription},
{Name: "logfs", Magic: logfs_magic(), Parser: logfs_parser, Description: LogFSDescription},
{Name: "ubi", Magic: ubi_magic(), Parser: ubi_parser, Description: UBIImageDescription, Extractor: extUbi()},
{Name: "ubifs", Magic: ubifs_magic(), Parser: ubifs_parser, Description: UBIFSDescription, Extractor: extUbifs()},
// ===================== Images & Media =====================
{Name: "png", Magic: PNGMagic(), Parser: PNGParser, Description: PNGDescription, Extractor: extPng()},
{Name: "jpeg", Magic: JPEGMagic(), Parser: JPEGParser, Description: JPEGDescription, Extractor: extJpeg()},
{Name: "bmp", Magic: BMPMagic(), Parser: BMPParser, Description: BMPDescription, Extractor: extBmp()},
{Name: "gif", Magic: GIFMagic(), Parser: GIFParser, Description: GIFDescription, Extractor: extGif()},
{Name: "svg", Magic: SVGMagic(), Parser: SVGParser, Description: SVGDescription, Extractor: extSvg()},
{Name: "riff", Magic: RIFFMagic(), Parser: RIFFParser, Description: RIFFDescription, Extractor: extRiff()},
{Name: "pdf", Magic: PDFMagic(), Parser: PDFParser, Description: PDFDescription},
// ===================== Executables =====================
{Name: "elf", Magic: ELFMagic(), Parser: ELFParser, Description: ELFDescription},
{Name: "pe", Magic: PEMagic(), Parser: PEParser, Description: PEDescription},
// ===================== Firmware Headers =====================
{Name: "uimage", Magic: uimageMagic(), Parser: uimageParser, Description: uimageDescription, Extractor: extUimage()},
{Name: "packimg", Magic: packimgMagic(), Parser: packimgParser, Description: packimgDescription},
{Name: "uboot", AlwaysDisplay: true, Magic: ubootMagic(), Parser: ubootParser, Description: ubootDescription},
{Name: "trx", Magic: trxMagic(), Parser: trxParser, Description: trxDescription, Extractor: extTrx()},
{Name: "tplink", Magic: tplinkMagic(), Parser: tplinkParser, Description: tplinkDescription},
{Name: "tplink_rtos", Magic: tplinkRTOSMagic(), Parser: tplinkRTOSParser, Description: tplinkRTOSDescription},
{Name: "dlob", Magic: dlobMagic(), Parser: dlobParser, Description: dlobDescription},
{Name: "arcadyan", Magic: arcadyanMagic(), Parser: arcadyanParser, Description: arcadyanDescription},
{Name: "chk", Magic: chkMagic(), Parser: chkParser, Description: chkDescription},
{Name: "seama", AlwaysDisplay: true, Magic: seamaMagic(), Parser: seamaParser, Description: seamaDescription},
{Name: "cfe", AlwaysDisplay: true, Magic: cfeMagic(), Parser: cfeParser, Description: cfeDescription},
{Name: "jboot_arm", Magic: jbootARMMagic(), Parser: jbootARMParser, Description: jbootARMDescription},
{Name: "jboot_stag", Magic: jbootSTAGMagic(), Parser: jbootSTAGParser, Description: jbootSTAGDescription},
{Name: "jboot_sch2", Magic: jbootSCH2Magic(), Parser: jbootSCH2Parser, Description: jbootSCH2Description, Extractor: extJboot()},
{Name: "binhdr", Magic: binhdrMagic(), Parser: binhdrParser, Description: binhdrDescription},
{Name: "autel", Magic: autelMagic(), Parser: autelParser, Description: autelDescription, Extractor: extAutel()},
{Name: "android_sparse", Magic: androidSparseMagic(), Parser: androidSparseParser, Description: androidSparseDescription, Extractor: extAndroidSparse()},
{Name: "dtb", Magic: dtbMagic(), Parser: dtbParser, Description: dtbDescription, Extractor: extDtb()},
{Name: "android_bootimg", Magic: androidBootimgMagic(), Parser: androidBootimgParser, Description: androidBootimgDescription},
{Name: "dlink_tlv", Magic: dlinkTLVMagic(), Parser: dlinkTLVParser, Description: dlinkTLVDescription, Extractor: extEncfw()},
{Name: "dlke", Magic: dlkeMagic(), Parser: dlkeParser, Description: dlkeDescription, Extractor: extEncfw()},
{Name: "shrs", Magic: shrsMagic(), Parser: shrsParser, Description: shrsDescription, Extractor: extEncfw()},
{Name: "dkbs", Magic: dkbsMagic(), Parser: dkbsParser, Description: dkbsDescription, Extractor: extEncfw()},
{Name: "dms", Magic: dmsMagic(), Parser: dmsParser, Description: dmsDescription},
{Name: "encfw", Short: true, AlwaysDisplay: true, Magic: encfwMagic(), Parser: encfwParser, Description: encfwDescription, Extractor: extEncfw()},
{Name: "encrpted_img", Short: true, Magic: encrptedImgMagic(), Parser: encrptedImgParser, Description: encrptedImgDescription, Extractor: extEncfw()},
{Name: "wince", Magic: winceMagic(), Parser: winceParser, Description: winceDescription},
{Name: "mh01", Magic: mh01Magic(), Parser: mh01Parser, Description: mh01Description},
{Name: "csman", Short: true, Magic: csmanMagic(), Parser: csmanParser, Description: csmanDescription},
{Name: "dxbc", Magic: dxbcMagic(), Parser: dxbcParser, Description: dxbcDescription},
{Name: "matter_ota", Short: true, Magic: matterOTAMagic(), Parser: matterOTAParser, Description: matterOTADescription},
{Name: "rtk", Short: true, Magic: rtkMagic(), Parser: rtkParser, Description: rtkDescription},
{Name: "dpapi", Short: true, AlwaysDisplay: true, Magic: dpapiMagic(), Parser: dpapiParser, Description: dpapiDescription},
{Name: "qcow", Short: true, AlwaysDisplay: true, Magic: qcowMagic(), Parser: qcowParser, Description: qcowDescription},
// ===================== Crypto & Hashes =====================
{Name: "aes_sbox", Magic: aesSBoxMagic(), Parser: aesSBoxParser, Description: DescriptionAESSBox},
{Name: "aes_forward_table", Magic: aesForwardTableMagic(), Parser: aesForwardTableParser, Description: DescriptionAESFT},
{Name: "aes_reverse_table", Magic: aesReverseTableMagic(), Parser: aesReverseTableParser, Description: DescriptionAESRT},
{Name: "aes_rcon", Magic: aesRconMagic(), Parser: aesRconParser, Description: DescriptionAESRCON},
{Name: "aes_acceleration_table", Magic: aesAccelerationTableMagic(), Parser: aesAccelerationTableParser, Description: DescriptionAESAcc},
{Name: "rsa", AlwaysDisplay: true, Magic: rsaMagic(), Parser: rsaParser, Description: DescriptionRSA},
{Name: "openssl", AlwaysDisplay: true, Magic: opensslMagic(), Parser: opensslParser, Description: DescriptionOpenSSL, Extractor: extEncfw()},
{Name: "gpg_signed", Short: true, Magic: gpgSignedMagic(), Parser: gpgSignedParser, Description: DescriptionGPGSigned, Extractor: extGpg()},
{Name: "pem_certificate", Magic: pemCertificateMagic(), Parser: pemParser, Description: DescriptionPEMCertificate, Extractor: extPemCert()},
{Name: "pem_public_key", AlwaysDisplay: true, Magic: pemPublicKeyMagic(), Parser: pemParser, Description: DescriptionPEMPublicKey, Extractor: extPemKey()},
{Name: "pem_private_key", AlwaysDisplay: true, Magic: pemPrivateKeyMagic(), Parser: pemParser, Description: DescriptionPEMPrivateKey, Extractor: extPemKey()},
{Name: "pkcs_der_hash", Magic: derHashMagic(), Parser: derHashParser, Description: DescriptionDERHash},
{Name: "luks", Magic: luksMagic(), Parser: luksParser, Description: DescriptionLUKS},
{Name: "crc32", Magic: crc32ConstMagic(), Parser: crc32Parser, Description: DescriptionCRC32},
{Name: "sha256", Magic: sha256ConstMagic(), Parser: sha256Parser, Description: DescriptionSHA256},
{Name: "md5", Magic: md5ConstMagic(), Parser: md5Parser, Description: DescriptionMD5},
// ===================== Misc =====================
{Name: "copyright", Magic: copyrightMagic(), Parser: copyrightParser, Description: DescriptionCopyright},
{Name: "wind_kernel", AlwaysDisplay: true, Magic: windKernelMagic(), Parser: windKernelParser, Description: DescriptionWINDKernelVersion},
{Name: "vxworks_symtab", AlwaysDisplay: true, Magic: vxworksSymtabMagic(), Parser: vxworksSymtabParser, Description: DescriptionVxWorksSymTab},
{Name: "ecos", AlwaysDisplay: true, Magic: ecosExceptionHandlerMagic(), Parser: ecosExceptionHandlerParser, Description: DescriptionECosExceptionHandler},
{Name: "linux_kernel", AlwaysDisplay: true, Magic: linuxKernelVersionMagic(), Parser: linuxKernelVersionParser, Description: DescriptionLinuxKernelVersion, Extractor: extLinuxKernel()},
{Name: "linux_boot_image", Magic: linuxBootImageMagic(), Parser: linuxBootImageParser, Description: DescriptionLinuxBootImage},
{Name: "linux_arm_zimage", Magic: linuxARMzImageMagic(), Parser: linuxARMzImageParser, Description: DescriptionLinuxARMzImage},
{Name: "linux_arm64_boot_image", Magic: linuxARM64BootImageMagic(), Parser: linuxARM64BootImageParser, Description: DescriptionLinuxARM64BootImage},
{Name: "efigpt", Magic: efigptMagic(), Parser: efigptParser, Description: DescriptionEFIGPT, Extractor: extSevenzip()},
{Name: "mbr", Short: true, MagicOffset: MBRMagicOffset, AlwaysDisplay: true, Magic: mbrMagic(), Parser: mbrParser, Description: DescriptionMBR, Extractor: extMbr()},
{Name: "uefi_pi_volume", Magic: uefiVolumeMagic(), Parser: uefiVolumeParser, Description: DescriptionUEFIVolume, Extractor: extUefi()},
{Name: "uefi_capsule", Magic: uefiCapsuleMagic(), Parser: uefiCapsuleParser, Description: DescriptionUEFICapsule, Extractor: extUefi()},
{Name: "pchrom", Magic: pchRomMagic(), Parser: pchRomParser, Description: DescriptionPCHRom, Extractor: extUefi()},
{Name: "pcapng", Magic: pcapngMagic(), Parser: pcapngParser, Description: DescriptionPcapNG, Extractor: extPcap()},
{Name: "pjl", Magic: pjlMagic(), Parser: pjlParser, Description: DescriptionPJL},
{Name: "srecord", Magic: srecordMagic(), Parser: srecordParser, Description: DescriptionSRecord, Extractor: extSrec()},
{Name: "srecord_generic", Short: true, Magic: srecordGenericMagic(), Parser: srecordParser, Description: DescriptionSRecordGeneric, Extractor: extSrec()},
{Name: "dmg", Magic: dmgMagic(), Parser: dmgParser, Description: DescriptionDMG, Extractor: extDmg()},
{Name: "qnx_ifs", Magic: qnxIFSMagic(), Parser: qnxIFSParser, Description: DescriptionQNXIFS, Extractor: extDumpifs()},
}
}
// ============================================================================
// External extractor definitions (command-line tools)
// ============================================================================
func extSevenzip() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "bin", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}}
}
func extGzip() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "gzip", Extension: "gz", Arguments: []string{"-d", "-k", "-f", "%e"}, ExitCodes: []int{0}}
}
func extLzma() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "xz", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}}
}
func extTar() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "tar", Extension: "tar", Arguments: []string{"xf", "%e"}, ExitCodes: []int{0}}
}
func extBzip2() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "bzip2", Extension: "bz2", Arguments: []string{"-d", "-k", "-f", "%e"}, ExitCodes: []int{0}}
}
func extZstd() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "zstd", Extension: "zst", Arguments: []string{"-d", "-f", "%e"}, ExitCodes: []int{0}}
}
func extLz4() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "lz4", Extension: "lz4", Arguments: []string{"-d", "-f", "%e", "decompressed.bin"}, ExitCodes: []int{0}}
}
func extLzop() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "lzop", Extension: "lzo", Arguments: []string{"-d", "-f", "%e"}, ExitCodes: []int{0}}
}
func extLzfse() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "lzfse", Extension: "lzfse", Arguments: []string{"-decode", "-i", "%e", "-o", "decompressed.bin"}, ExitCodes: []int{0}}
}
func extZlib() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "bin", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}}
}
func extSquashfs() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "unsquashfs", Extension: "sqsh", Arguments: []string{"-f", "-d", "squashfs-root", "%e"}, ExitCodes: []int{0}}
}
func extJffs2() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "jefferson", Extension: "jffs2", Arguments: []string{"-d", "jffs2-root", "%e"}, ExitCodes: []int{0}}
}
func extYaffs2() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "unyaffs", Extension: "yaffs", Arguments: []string{"%e", "yaffs-root"}, ExitCodes: []int{0}}
}
func extRomfs() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "romfs", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}}
}
func extTsk() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "tsk_recover", Extension: "img", Arguments: []string{"-a", "-e", "%e", "tsk-root"}, ExitCodes: []int{0}}
}
func extCab() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "cabextract", Extension: "cab", Arguments: []string{"%e"}, ExitCodes: []int{0}}
}
func extRar() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "unrar", Extension: "rar", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0}}
}
func extUbi() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "ubireader_extract_images", Extension: "ubi", Arguments: []string{"%e"}, ExitCodes: []int{0}}
}
func extUbifs() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "ubireader_extract_files", Extension: "ubifs", Arguments: []string{"%e"}, ExitCodes: []int{0}}
}
func extPng() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "png", Arguments: []string{"%e", "image.png"}, ExitCodes: []int{0}}
}
func extJpeg() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "jpg", Arguments: []string{"%e", "image.jpg"}, ExitCodes: []int{0}}
}
func extBmp() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "bmp", Arguments: []string{"%e", "image.bmp"}, ExitCodes: []int{0}}
}
func extGif() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "gif", Arguments: []string{"%e", "image.gif"}, ExitCodes: []int{0}}
}
func extSvg() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "svg", Arguments: []string{"%e", "image.svg"}, ExitCodes: []int{0}}
}
func extRiff() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "riff", Arguments: []string{"%e", "media.riff"}, ExitCodes: []int{0}}
}
func extUimage() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "bin", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}}
}
func extTrx() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "bin", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}}
}
func extJboot() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "bin", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}}
}
func extAutel() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "bin", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}}
}
func extAndroidSparse() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "simg2img", Extension: "simg", Arguments: []string{"%e", "output.img"}, ExitCodes: []int{0}}
}
func extDtb() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "dtc", Extension: "dtb", Arguments: []string{"-I", "dtb", "-O", "dts", "-o", "device_tree.dts", "%e"}, ExitCodes: []int{0}}
}
func extEncfw() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "bin", Arguments: []string{"%e", "encrypted_firmware.bin"}, ExitCodes: []int{0}}
}
func extGpg() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "gpg", Extension: "gpg", Arguments: []string{"--decrypt", "-o", "decrypted.bin", "%e"}, ExitCodes: []int{0}}
}
func extPemCert() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "pem", Arguments: []string{"%e", "certificate.pem"}, ExitCodes: []int{0}}
}
func extPemKey() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "pem", Arguments: []string{"%e", "key.pem"}, ExitCodes: []int{0}}
}
func extLinuxKernel() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "bin", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}}
}
func extMbr() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "bin", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}}
}
func extUefi() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "uefi-firmware-parser", Extension: "bin", Arguments: []string{"-e", "-O", "%e"}, ExitCodes: []int{0}}
}
func extPcap() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "cp", Extension: "pcapng", Arguments: []string{"%e", "capture.pcapng"}, ExitCodes: []int{0}}
}
func extSrec() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "srec_cat", Extension: "srec", Arguments: []string{"%e", "-o", "output.bin", "-binary"}, ExitCodes: []int{0}}
}
func extDmg() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "dmg", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}}
}
func extDumpifs() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "dumpifs", Extension: "ifs", Arguments: []string{"-x", "%e"}, ExitCodes: []int{0}}
}
func extIso9660() *Extractor {
return &Extractor{Type: ExtractorTypeExternal, Command: "7z", Extension: "iso", Arguments: []string{"x", "-y", "%e"}, ExitCodes: []int{0, 2}}
}
+134
View File
@@ -0,0 +1,134 @@
package binsnoop
// ahoCorasick implements the Aho-Corasick multi-pattern byte matching algorithm.
type ahoCorasick struct {
gotoFn []map[byte]int
failFn []int
outputFn [][]int
patterns [][]byte
}
// ahoMatch represents a single pattern match.
type ahoMatch struct {
PatternIndex int
Start int
}
// newAhoCorasick builds an Aho-Corasick automaton from the given byte patterns.
func newAhoCorasick(patterns [][]byte) *ahoCorasick {
ac := &ahoCorasick{
gotoFn: []map[byte]int{make(map[byte]int)},
failFn: []int{0},
outputFn: [][]int{nil},
patterns: patterns,
}
// Build the trie (goto function)
for i, pattern := range patterns {
state := 0
for _, b := range pattern {
if next, ok := ac.gotoFn[state][b]; ok {
state = next
} else {
newState := len(ac.gotoFn)
ac.gotoFn[state][b] = newState
ac.gotoFn = append(ac.gotoFn, make(map[byte]int))
ac.failFn = append(ac.failFn, 0)
ac.outputFn = append(ac.outputFn, nil)
state = newState
}
}
ac.outputFn[state] = append(ac.outputFn[state], i)
}
// Build failure links (BFS)
queue := make([]int, 0)
for _, next := range ac.gotoFn[0] {
queue = append(queue, next)
}
for len(queue) > 0 {
r := queue[0]
queue = queue[1:]
for b, s := range ac.gotoFn[r] {
queue = append(queue, s)
state := ac.failFn[r]
for {
if _, ok := ac.gotoFn[state][b]; ok || state == 0 {
break
}
state = ac.failFn[state]
}
if next, ok := ac.gotoFn[state][b]; ok && next != s {
ac.failFn[s] = next
} else {
ac.failFn[s] = 0
}
// Merge output from failure state
if ac.outputFn[ac.failFn[s]] != nil {
ac.outputFn[s] = append(ac.outputFn[s], ac.outputFn[ac.failFn[s]]...)
}
}
}
return ac
}
// findOverlapping searches data starting at startOffset and returns all overlapping matches.
func (ac *ahoCorasick) findOverlapping(data []byte, startOffset int) []ahoMatch {
var matches []ahoMatch
state := 0
for i := startOffset; i < len(data); i++ {
b := data[i]
for {
if _, ok := ac.gotoFn[state][b]; ok || state == 0 {
break
}
state = ac.failFn[state]
}
if next, ok := ac.gotoFn[state][b]; ok {
state = next
}
for _, patIdx := range ac.outputFn[state] {
matchStart := i - len(ac.patterns[patIdx]) + 1
matches = append(matches, ahoMatch{
PatternIndex: patIdx,
Start: matchStart,
})
}
}
return matches
}
// findOverlappingLimit is the bounded variant used by deep scans. It stops
// collecting once limit matches have been found so adversarial input cannot
// force an unbounded match slice.
func (ac *ahoCorasick) findOverlappingLimit(data []byte, startOffset, limit int) ([]ahoMatch, bool) {
if limit <= 0 {
return nil, false
}
matches := make([]ahoMatch, 0, min(limit, 256))
state := 0
for i := startOffset; i < len(data); i++ {
b := data[i]
for {
if _, ok := ac.gotoFn[state][b]; ok || state == 0 {
break
}
state = ac.failFn[state]
}
if next, ok := ac.gotoFn[state][b]; ok {
state = next
}
for _, patIdx := range ac.outputFn[state] {
matches = append(matches, ahoMatch{PatternIndex: patIdx, Start: i - len(ac.patterns[patIdx]) + 1})
if len(matches) >= limit {
return matches, true
}
}
}
return matches, false
}
+107
View File
@@ -0,0 +1,107 @@
package binsnoop
import (
"errors"
"fmt"
)
// ErrParse is returned when structure parsing fails.
var ErrParse = errors.New("structure parse error")
// StructField defines a single field in a binary structure.
type StructField struct {
Name string
Type string // "u8", "u16", "u24", "u32", "u64"
}
// typeSize returns the byte size for a type string.
func typeSize(ctype string) (int, error) {
switch ctype {
case "u8":
return 1, nil
case "u16":
return 2, nil
case "u24":
return 3, nil
case "u32":
return 4, nil
case "u64":
return 8, nil
default:
return 0, fmt.Errorf("unknown type: %s", ctype)
}
}
// StructSize returns the total byte size of a structure definition.
func StructSize(fields []StructField) int {
total := 0
for _, f := range fields {
sz, err := typeSize(f.Type)
if err != nil {
continue
}
total += sz
}
return total
}
// Parse parses a binary structure from data according to the field definitions.
// Endianness must be "big" or "little". All values are returned as int.
func Parse(data []byte, fields []StructField, endianness string) (map[string]int, error) {
structSize := StructSize(fields)
if len(data) < structSize {
return nil, ErrParse
}
result := make(map[string]int, len(fields))
offset := 0
for _, f := range fields {
sz, err := typeSize(f.Type)
if err != nil {
return nil, ErrParse
}
var value int
switch sz {
case 1:
value = int(data[offset])
case 2:
if endianness == "big" {
value = int(data[offset])<<8 | int(data[offset+1])
} else {
value = int(data[offset+1])<<8 | int(data[offset])
}
case 3:
if endianness == "big" {
value = int(data[offset])<<16 | int(data[offset+1])<<8 | int(data[offset+2])
} else {
value = int(data[offset+2])<<16 | int(data[offset+1])<<8 | int(data[offset])
}
case 4:
if endianness == "big" {
value = int(data[offset])<<24 | int(data[offset+1])<<16 | int(data[offset+2])<<8 | int(data[offset+3])
} else {
value = int(data[offset+3])<<24 | int(data[offset+2])<<16 | int(data[offset+1])<<8 | int(data[offset])
}
case 8:
if endianness == "big" {
value = int(uint64(data[offset])<<56 | uint64(data[offset+1])<<48 |
uint64(data[offset+2])<<40 | uint64(data[offset+3])<<32 |
uint64(data[offset+4])<<24 | uint64(data[offset+5])<<16 |
uint64(data[offset+6])<<8 | uint64(data[offset+7]))
} else {
value = int(uint64(data[offset+7])<<56 | uint64(data[offset+6])<<48 |
uint64(data[offset+5])<<40 | uint64(data[offset+4])<<32 |
uint64(data[offset+3])<<24 | uint64(data[offset+2])<<16 |
uint64(data[offset+1])<<8 | uint64(data[offset]))
}
}
offset += sz
result[f.Name] = value
}
return result, nil
}
+352
View File
@@ -0,0 +1,352 @@
package binsnoop
import "sort"
// Scanner is the core scanning engine. It holds compiled signature patterns
// and performs Aho-Corasick based multi-pattern matching on binary data.
type Scanner struct {
signatureCount int
patternCount int
shortSignatures []Signature
patterns [][]byte
patternSigTable map[int]Signature
extractorLookup map[string]*Extractor
ac *ahoCorasick
}
// compile builds the Aho-Corasick automaton from loaded patterns.
func (s *Scanner) compile() {
if len(s.patterns) > 0 {
s.ac = newAhoCorasick(s.patterns)
}
}
// Scan searches data for all known file signatures and returns validated results.
// This implements the same algorithm as the Rust binwalk scanner:
// 1. Check short signatures at file offset 0
// 2. Run Aho-Corasick scan with restart on high-confidence sized matches
// 3. Sort and de-conflict overlapping results
// 4. Estimate sizes for signatures with unknown size
func (s *Scanner) Scan(data []byte) []SignatureResult {
var fileMap []SignatureResult
nextValidOffset := 0
availableData := len(data)
// Phase 1: Check short signatures at offset 0
for i := range s.shortSignatures {
sig := &s.shortSignatures[i]
for _, magic := range sig.Magic {
magicStart := sig.MagicOffset
magicEnd := magicStart + len(magic)
if len(data) >= magicEnd && bytesEqual(data[magicStart:magicEnd], magic) {
result, err := sig.Parser(data, magicStart)
if err != nil {
continue
}
autoPopulate(result, sig)
fileMap = append(fileMap, *result)
if result.Confidence >= ConfidenceHigh {
nextValidOffset = result.Offset + result.Size
}
break
}
}
}
// Phase 2: Aho-Corasick scan.
//
// The automaton is run exactly once over the whole buffer; we then walk
// the matches in position order. When a high/medium-confidence sized
// signature is accepted, nextValidOffset jumps past it and any later
// matches that start inside that consumed region are skipped. This is the
// same result the binwalk scanner produces, but without re-running the
// search from every new offset — a previous implementation rescanned the
// tail of the file on each accepted match, which is O(n^2) on inputs that
// contain many closely-spaced signatures (e.g. real firmware images) and
// could appear to hang.
if s.ac == nil {
return s.postProcess(data, fileMap)
}
matches := s.ac.findOverlapping(data, nextValidOffset)
for _, m := range matches {
magicOffset := m.Start
if magicOffset < nextValidOffset {
continue
}
sig, ok := s.patternSigTable[m.PatternIndex]
if !ok {
continue
}
result, err := sig.Parser(data, magicOffset)
if err != nil {
continue
}
sigEnd := result.Offset + result.Size
if sigEnd > availableData {
continue
}
autoPopulate(result, &sig)
fileMap = append(fileMap, *result)
if result.Confidence >= ConfidenceMedium && result.Size > 0 {
nextValidOffset = sigEnd
}
}
return s.postProcess(data, fileMap)
}
// postProcess sorts results, removes conflicts, and estimates unknown sizes.
func (s *Scanner) postProcess(data []byte, fileMap []SignatureResult) []SignatureResult {
if len(fileMap) == 0 {
return fileMap
}
// Sort by offset
sort.Slice(fileMap, func(i, j int) bool {
return fileMap[i].Offset < fileMap[j].Offset
})
// De-conflict overlapping signatures in a single pass, filtering in place.
// (The previous implementation removed elements with
// append(fileMap[:i], fileMap[i+1:]...) inside the loop; each removal is
// O(n), so a file with many overlapping hits was O(n^2) and could appear
// to hang.) Removed entries are dropped; on a duplicate offset the
// higher-confidence signature wins, matching the original behaviour.
out := fileMap[:0]
nextValidOffset := 0
for _, thisSig := range fileMap {
// Duplicate offset: keep only the highest-confidence signature.
if len(out) > 0 && thisSig.Offset == out[len(out)-1].Offset {
if thisSig.Confidence > out[len(out)-1].Confidence {
out[len(out)-1] = thisSig
if thisSig.Confidence >= ConfidenceMedium {
nextValidOffset = thisSig.Offset + thisSig.Size
}
}
continue
}
// Drop signatures that fall inside a previously accepted sized region.
if thisSig.Offset < nextValidOffset {
continue
}
// Drop signatures whose declared size runs past EOF or wraps.
remainingSize := len(data) - thisSig.Offset
if thisSig.Size > remainingSize || (thisSig.Offset+thisSig.Size) < 0 {
continue
}
out = append(out, thisSig)
if thisSig.Confidence >= ConfidenceMedium {
nextValidOffset = thisSig.Offset + thisSig.Size
}
}
fileMap = out
// Estimate sizes for signatures with unknown size (0): extend each to the
// offset of the next medium-or-higher confidence signature, or to EOF.
// Computed in a single right-to-left pass to stay O(n).
nextMediumOffset := len(data)
for i := len(fileMap) - 1; i >= 0; i-- {
if fileMap[i].Size == 0 {
fileMap[i].Size = nextMediumOffset - fileMap[i].Offset
}
if fileMap[i].Confidence >= ConfidenceMedium {
nextMediumOffset = fileMap[i].Offset
}
}
return fileMap
}
// ScanDeep validates signatures without suppressing hits inside an already
// identified container. Results are bounded and annotated with containment.
func (s *Scanner) ScanDeep(data []byte, maxResults int) DeepScan {
if maxResults <= 0 || maxResults > 4096 {
maxResults = 4096
}
result := DeepScan{}
if len(data) == 0 {
return result
}
results := make([]SignatureResult, 0)
seen := make(map[struct {
offset int
name string
}]int)
add := func(parsed *SignatureResult, sig *Signature) {
if parsed == nil || parsed.Offset < 0 || parsed.Offset > len(data) {
return
}
if parsed.Size < 0 || parsed.Size > len(data)-parsed.Offset {
return
}
autoPopulate(parsed, sig)
key := struct {
offset int
name string
}{parsed.Offset, parsed.Name}
if idx, ok := seen[key]; ok {
if parsed.Confidence > results[idx].Confidence {
results[idx] = *parsed
}
return
}
if len(results) >= maxResults {
result.Truncated = true
return
}
seen[key] = len(results)
results = append(results, *parsed)
}
for i := range s.shortSignatures {
sig := &s.shortSignatures[i]
for _, magic := range sig.Magic {
start := sig.MagicOffset
end := start + len(magic)
if start >= 0 && end <= len(data) && bytesEqual(data[start:end], magic) {
parsed, err := sig.Parser(data, start)
if err == nil {
add(parsed, sig)
}
break
}
}
}
if s.ac != nil && len(results) < maxResults {
matchLimit := maxResults * 16
matches, truncated := s.ac.findOverlappingLimit(data, 0, matchLimit)
result.Truncated = result.Truncated || truncated
for _, match := range matches {
sig, ok := s.patternSigTable[match.PatternIndex]
if !ok {
continue
}
parsed, err := sig.Parser(data, match.Start)
if err != nil {
continue
}
add(parsed, &sig)
if len(results) >= maxResults {
result.Truncated = true
break
}
}
}
sort.Slice(results, func(i, j int) bool {
if results[i].Offset != results[j].Offset {
return results[i].Offset < results[j].Offset
}
if results[i].Confidence != results[j].Confidence {
return results[i].Confidence > results[j].Confidence
}
return results[i].Size > results[j].Size
})
unique := results[:0]
for _, hit := range results {
if len(unique) > 0 && unique[len(unique)-1].Offset == hit.Offset {
continue
}
unique = append(unique, hit)
}
results = unique
nextOffset := len(data)
for i := len(results) - 1; i >= 0; i-- {
if results[i].Size == 0 {
results[i].Size = nextOffset - results[i].Offset
}
nextOffset = results[i].Offset
}
active := make([]int, 0)
for _, hit := range results {
end := hit.Offset + hit.Size
kept := active[:0]
for _, idx := range active {
if result.Results[idx].End > hit.Offset {
kept = append(kept, idx)
}
}
active = kept
parent := -1
overlap := false
for i := len(active) - 1; i >= 0; i-- {
candidate := result.Results[active[i]]
if candidate.End >= end && candidate.Offset < hit.Offset {
parent = active[i]
break
}
overlap = true
}
deep := DeepScanResult{SignatureResult: hit, End: end, ParentOffset: -1, Overlap: overlap}
if parent >= 0 {
deep.ParentOffset = result.Results[parent].Offset
deep.Depth = result.Results[parent].Depth + 1
}
result.Results = append(result.Results, deep)
active = append(active, len(result.Results)-1)
}
return result
}
// Extract performs extraction on all extractable signatures in the file map.
func (s *Scanner) Extract(data []byte, filePath string, fileMap []SignatureResult) map[string]*ExtractionResult {
results := make(map[string]*ExtractionResult)
for _, sig := range fileMap {
if sig.ExtractionDeclined {
continue
}
ext, ok := s.extractorLookup[sig.Name]
if !ok || ext == nil {
continue
}
result := ExecuteExtraction(data, filePath, &sig, ext)
if !result.Success && sig.Size < len(data)-sig.Offset {
// Retry with all available data
expandedSig := sig
expandedSig.Size = len(data) - sig.Offset
result = ExecuteExtraction(data, filePath, &expandedSig, ext)
}
results[sig.ID] = result
}
return results
}
// bytesEqual compares two byte slices.
func bytesEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// autoPopulate fills in auto-generated fields on a SignatureResult.
func autoPopulate(result *SignatureResult, sig *Signature) {
result.ID = newUUID()
result.Name = sig.Name
result.AlwaysDisplay = sig.AlwaysDisplay
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+795
View File
@@ -0,0 +1,795 @@
package binsnoop
import (
"encoding/base64"
"fmt"
"strings"
)
// ---- AES descriptions ----
const (
DescriptionAESSBox = "AES S-Box"
DescriptionAESFT = "AES Forward Table"
DescriptionAESRT = "AES Reverse Table"
DescriptionAESRCON = "AES RCON"
DescriptionAESAcc = "AES Acceleration Table"
)
// aesSBoxMagic returns magic bytes for AES S-Box (forward and reverse).
func aesSBoxMagic() [][]byte {
return [][]byte{
{0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5}, // Forward S-Box
{0x52, 0x09, 0x6A, 0xD5, 0x30, 0x36, 0xA5, 0x38}, // Reverse S-Box
}
}
// aesSBoxParser validates AES S-Box signatures.
func aesSBoxParser(data []byte, offset int) (*SignatureResult, error) {
return &SignatureResult{
Offset: offset,
Description: DescriptionAESSBox,
Confidence: ConfidenceLow,
}, nil
}
// aesForwardTableMagic returns magic bytes for AES Forward Table.
func aesForwardTableMagic() [][]byte {
return [][]byte{
{0xC6, 0x63, 0x63, 0xA5, 0xF8, 0x7C, 0x7C, 0x84, 0xEE, 0x77, 0x77, 0x99, 0xF6, 0x7B, 0x7B, 0x8D},
}
}
// aesForwardTableParser validates AES Forward Table signatures.
func aesForwardTableParser(data []byte, offset int) (*SignatureResult, error) {
return &SignatureResult{
Offset: offset,
Description: DescriptionAESFT,
Confidence: ConfidenceLow,
}, nil
}
// aesReverseTableMagic returns magic bytes for AES Reverse Table.
func aesReverseTableMagic() [][]byte {
return [][]byte{
{0x51, 0xF4, 0xA7, 0x50, 0x7E, 0x41, 0x65, 0x53, 0x1A, 0x17, 0xA4, 0xC3, 0x3A, 0x27, 0x5E, 0x96},
}
}
// aesReverseTableParser validates AES Reverse Table signatures.
func aesReverseTableParser(data []byte, offset int) (*SignatureResult, error) {
return &SignatureResult{
Offset: offset,
Description: DescriptionAESRT,
Confidence: ConfidenceLow,
}, nil
}
// aesRconMagic returns magic bytes for AES RCON.
func aesRconMagic() [][]byte {
return [][]byte{
{0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36},
{0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00,
0x10, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00,
0x1B, 0x00, 0x00, 0x00, 0x36, 0x00, 0x00, 0x00},
}
}
// aesRconParser validates AES RCON signatures.
func aesRconParser(data []byte, offset int) (*SignatureResult, error) {
return &SignatureResult{
Offset: offset,
Description: DescriptionAESRCON,
Confidence: ConfidenceLow,
}, nil
}
// aesAccelerationTableMagic returns magic bytes for AES Acceleration Table.
func aesAccelerationTableMagic() [][]byte {
return [][]byte{
// combined sbox x2
{0xA5, 0x84, 0x99, 0x8D, 0x0D, 0xBD, 0xB1, 0x54, 0x50, 0x03, 0xA9, 0x7D, 0x19, 0x62, 0xE6, 0x9A},
// combined sbox x3
{0xC6, 0xF8, 0xEE, 0xF6, 0xFF, 0xD6, 0xDE, 0x91, 0x60, 0x02, 0xCE, 0x56, 0xE7, 0xB5, 0x4D, 0xEC},
// Galois mult 2
{0x00, 0x02, 0x04, 0x06, 0x08, 0x0a, 0x0c, 0x0e, 0x10, 0x12, 0x14, 0x16, 0x18, 0x1a, 0x1c, 0x1e,
0x20, 0x22, 0x24, 0x26, 0x28, 0x2a, 0x2c, 0x2e},
// Galois mult 3
{0x00, 0x03, 0x06, 0x05, 0x0c, 0x0f, 0x0a, 0x09, 0x18, 0x1b, 0x1e, 0x1d, 0x14, 0x17, 0x12, 0x11,
0x30, 0x33, 0x36, 0x35, 0x3c, 0x3f, 0x3a, 0x39},
// Galois mult 9
{0x00, 0x09, 0x12, 0x1b, 0x24, 0x2d, 0x36, 0x3f, 0x48, 0x41, 0x5a, 0x53, 0x6c, 0x65, 0x7e, 0x77,
0x90, 0x99, 0x82, 0x8b, 0xb4, 0xbd, 0xa6, 0xaf},
// Galois mult 11
{0x00, 0x0b, 0x16, 0x1d, 0x2c, 0x27, 0x3a, 0x31, 0x58, 0x53, 0x4e, 0x45, 0x74, 0x7f, 0x62, 0x69,
0xb0, 0xbb, 0xa6, 0xad, 0x9c, 0x97, 0x8a, 0x81},
// Galois mult 13
{0x00, 0x0d, 0x1a, 0x17, 0x34, 0x39, 0x2e, 0x23, 0x68, 0x65, 0x72, 0x7f, 0x5c, 0x51, 0x46, 0x4b,
0xd0, 0xdd, 0xca, 0xc7, 0xe4, 0xe9, 0xfe, 0xf3},
// Galois mult 14
{0x00, 0x0e, 0x1c, 0x12, 0x38, 0x36, 0x24, 0x2a, 0x70, 0x7e, 0x6c, 0x62, 0x48, 0x46, 0x54, 0x5a,
0xe0, 0xee, 0xfc, 0xf2, 0xd8, 0xd6, 0xc4, 0xca},
}
}
// aesAccelerationTableParser validates AES Acceleration Table signatures.
func aesAccelerationTableParser(data []byte, offset int) (*SignatureResult, error) {
return &SignatureResult{
Offset: offset,
Description: DescriptionAESAcc,
Confidence: ConfidenceLow,
}, nil
}
// ---- RSA ----
const DescriptionRSA = "RSA encrypted session key"
type rsaKeyDefinition struct {
magic []byte
keySize int
usageOffset int
keyIDOffset int
validChecks map[int][][]byte
terminatorOffset int
}
func rsaKeyDefinitions() []rsaKeyDefinition {
return []rsaKeyDefinition{
// 1024b RSA key
{
magic: []byte{0x84, 0x8C, 0x03},
keySize: 1024,
keyIDOffset: 3,
terminatorOffset: 142,
usageOffset: 11,
validChecks: map[int][][]byte{
12: {
{0x04, 0x00}, {0x03, 0xff}, {0x03, 0xfe}, {0x03, 0xfd},
{0x03, 0xfc}, {0x03, 0xfb}, {0x03, 0xfa}, {0x03, 0xf9},
},
},
},
// 2048b RSA key
{
magic: []byte{0x85, 0x01, 0x0c, 0x03},
keySize: 2048,
keyIDOffset: 4,
terminatorOffset: 271,
usageOffset: 12,
validChecks: map[int][][]byte{
13: {
{0x08, 0x00}, {0x07, 0xff}, {0x07, 0xfe}, {0x07, 0xfd},
{0x07, 0xfc}, {0x07, 0xfb}, {0x07, 0xfa}, {0x07, 0xf9},
},
},
},
// 3072b RSA key
{
magic: []byte{0x85, 0x01, 0x8c, 0x03},
keySize: 3072,
keyIDOffset: 4,
terminatorOffset: 399,
usageOffset: 12,
validChecks: map[int][][]byte{
13: {
{0x0c, 0x00}, {0x0b, 0xff}, {0x0b, 0xfe}, {0x0b, 0xfd},
{0x0b, 0xfc}, {0x0b, 0xfb}, {0x0b, 0xfa}, {0x0b, 0xf9},
},
},
},
// 4096b RSA key
{
magic: []byte{0x85, 0x02, 0x0c, 0x03},
keySize: 4096,
keyIDOffset: 4,
terminatorOffset: 527,
usageOffset: 12,
validChecks: map[int][][]byte{
13: {
{0x10, 0x00}, {0x0f, 0xff}, {0x0f, 0xfe}, {0x0f, 0xfd},
{0x0f, 0xfc}, {0x0f, 0xfb}, {0x0f, 0xfa}, {0x0f, 0xf9},
},
},
},
// 8192b RSA key
{
magic: []byte{0x85, 0x04, 0x0c, 0x03},
keySize: 8192,
keyIDOffset: 4,
terminatorOffset: 1039,
usageOffset: 12,
validChecks: map[int][][]byte{
13: {
{0x20, 0x00}, {0x1f, 0xff}, {0x1f, 0xfe}, {0x1f, 0xfd},
{0x1f, 0xfc}, {0x1f, 0xfb}, {0x1f, 0xfa}, {0x1f, 0xf9},
},
},
},
}
}
// rsaMagic returns magic bytes for all known RSA key sizes.
func rsaMagic() [][]byte {
var magics [][]byte
for _, def := range rsaKeyDefinitions() {
m := make([]byte, len(def.magic))
copy(m, def.magic)
magics = append(magics, m)
}
return magics
}
type rsaKeyInfo struct {
bits int
canSign bool
canEncrypt bool
keyID int
dataSize int
}
func rsaKeyParse(rawData []byte, def *rsaKeyDefinition) (*rsaKeyInfo, error) {
const terminatorByte byte = 0xD2
const encryptOnly byte = 2
const signAndEncrypt byte = 1
const validBytesSize = 2
keyIDFields := []StructField{{"id", "u64"}}
keyDataLen := def.terminatorOffset + 1
if len(rawData) < keyDataLen {
return nil, fmt.Errorf("insufficient RSA key data")
}
keyData := rawData[:keyDataLen]
if keyData[def.terminatorOffset] != terminatorByte {
return nil, fmt.Errorf("invalid RSA terminator byte")
}
if def.keyIDOffset+8 > len(keyData) {
return nil, fmt.Errorf("key ID offset out of bounds")
}
keyIDParsed, err := Parse(keyData[def.keyIDOffset:], keyIDFields, "big")
if err != nil {
return nil, fmt.Errorf("failed to parse RSA key ID")
}
info := &rsaKeyInfo{keyID: keyIDParsed["id"]}
info.canEncrypt = keyData[def.usageOffset] == encryptOnly
info.canSign = keyData[def.usageOffset] == signAndEncrypt
if info.canSign {
info.canEncrypt = true
}
if !info.canSign && !info.canEncrypt {
return nil, fmt.Errorf("RSA key cannot sign or encrypt")
}
for checkOffset, expectedList := range def.validChecks {
if checkOffset+validBytesSize > len(keyData) {
continue
}
actual := keyData[checkOffset : checkOffset+validBytesSize]
for _, expected := range expectedList {
if len(expected) == validBytesSize && actual[0] == expected[0] && actual[1] == expected[1] {
info.bits = def.keySize
info.dataSize = keyDataLen
return info, nil
}
}
}
return nil, fmt.Errorf("RSA key validation bytes mismatch")
}
// rsaParser validates RSA encrypted session key signatures.
func rsaParser(data []byte, offset int) (*SignatureResult, error) {
result := &SignatureResult{
Offset: offset,
Description: DescriptionRSA,
Confidence: ConfidenceMedium,
}
for _, def := range rsaKeyDefinitions() {
magicEnd := offset + len(def.magic)
if magicEnd > len(data) {
continue
}
match := true
for i, b := range def.magic {
if data[offset+i] != b {
match = false
break
}
}
if !match {
continue
}
info, err := rsaKeyParse(data[offset:], &def)
if err != nil {
break
}
result.Size = info.dataSize
result.Description = fmt.Sprintf("%s, %d bits, can sign: %t, can encrypt: %t, key ID: %#018X",
result.Description, info.bits, info.canSign, info.canEncrypt, info.keyID)
return result, nil
}
return nil, fmt.Errorf("invalid RSA signature")
}
// ---- OpenSSL ----
const DescriptionOpenSSL = "OpenSSL encryption"
// opensslMagic returns magic bytes for OpenSSL encrypted files.
func opensslMagic() [][]byte {
return [][]byte{
[]byte("Salted__"),
}
}
func isSaltInvalid(salt int) bool {
const saltLen = 8
badByteCount := 0
for i := 0; i < saltLen; i++ {
saltByte := byte((salt >> (8 * i)) & 0xFF)
if saltByte == 0 || IsPrintableASCII(saltByte) {
badByteCount++
}
}
return badByteCount == saltLen
}
// opensslParser validates OpenSSL encryption signatures.
func opensslParser(data []byte, offset int) (*SignatureResult, error) {
sslFields := []StructField{{"magic", "u64"}, {"salt", "u64"}}
result := &SignatureResult{
Offset: offset,
Description: DescriptionOpenSSL,
Confidence: ConfidenceLow,
}
if offset+StructSize(sslFields) > len(data) {
return nil, fmt.Errorf("insufficient OpenSSL data")
}
sslHeader, err := Parse(data[offset:], sslFields, "big")
if err != nil {
return nil, fmt.Errorf("failed to parse OpenSSL header")
}
salt := sslHeader["salt"]
if salt == 0 {
return nil, fmt.Errorf("OpenSSL salt is zero")
}
if isSaltInvalid(salt) {
return nil, fmt.Errorf("OpenSSL salt is invalid")
}
if offset == 0 {
result.Confidence = ConfidenceMedium
}
result.Description = fmt.Sprintf("%s, salt: %#X", result.Description, salt)
return result, nil
}
// ---- GPG ----
const DescriptionGPGSigned = "GPG signed file"
// gpgSignedMagic returns magic bytes for GPG signed files.
func gpgSignedMagic() [][]byte {
return [][]byte{
{0xA3, 0x01},
}
}
// gpgSignedParser validates GPG signed file signatures.
// The Rust version does a zlib decompression dry run; here we do a simplified validation.
func gpgSignedParser(data []byte, offset int) (*SignatureResult, error) {
result := &SignatureResult{
Offset: offset,
Confidence: ConfidenceHigh,
Description: DescriptionGPGSigned,
}
// GPG signed files are zlib compressed with magic bytes replaced.
// Validate there is enough data and the zlib-like stream follows.
if offset+4 > len(data) {
return nil, fmt.Errorf("insufficient GPG data")
}
// The third byte should be a valid zlib CMF byte (deflate method = 8)
cmf := data[offset+2]
if cmf&0x0F != 8 {
return nil, fmt.Errorf("GPG: invalid deflate method in underlying zlib stream")
}
result.Description = fmt.Sprintf("%s, compressed data at offset %d", result.Description, offset)
return result, nil
}
// ---- PEM ----
const (
DescriptionPEMCertificate = "PEM certificate"
DescriptionPEMPublicKey = "PEM public key"
DescriptionPEMPrivateKey = "PEM private key"
)
// pemCertificateMagic returns magic bytes for PEM certificates.
func pemCertificateMagic() [][]byte {
return [][]byte{
[]byte("-----BEGIN CERTIFICATE-----"),
}
}
// pemPublicKeyMagic returns magic bytes for PEM public keys.
func pemPublicKeyMagic() [][]byte {
return [][]byte{
[]byte("-----BEGIN PUBLIC KEY-----"),
[]byte("-----BEGIN RSA PUBLIC KEY-----"),
[]byte("-----BEGIN DSA PUBLIC KEY-----"),
[]byte("-----BEGIN ECDSA PUBLIC KEY-----"),
}
}
// pemPrivateKeyMagic returns magic bytes for PEM private keys.
func pemPrivateKeyMagic() [][]byte {
return [][]byte{
[]byte("-----BEGIN PRIVATE KEY-----"),
[]byte("-----BEGIN EC PRIVATE KEY-----"),
[]byte("-----BEGIN RSA PRIVATE KEY-----"),
[]byte("-----BEGIN DSA PRIVATE KEY-----"),
[]byte("-----BEGIN OPENSSH PRIVATE KEY-----"),
[]byte("-----BEGIN ANY PRIVATE KEY-----"),
[]byte("-----BEGIN ENCRYPTED PRIVATE KEY-----"),
[]byte("-----BEGIN TSS2 PRIVATE KEY-----"),
}
}
func decodePEMData(pemData []byte) (int, error) {
const delim = "--"
pemStr := string(pemData)
delimCount := 0
var base64Str strings.Builder
for _, line := range strings.Split(pemStr, "\n") {
line = strings.TrimRight(line, "\r")
if strings.HasPrefix(line, delim) {
delimCount++
if delimCount == 2 {
break
}
continue
}
base64Str.WriteString(line)
}
if base64Str.Len() == 0 {
return 0, fmt.Errorf("no PEM base64 data found")
}
decoded, err := base64.StdEncoding.DecodeString(base64Str.String())
if err != nil {
return 0, fmt.Errorf("PEM base64 decode failed: %v", err)
}
return len(decoded), nil
}
// pemFindEnd locates the end delimiter and returns the total PEM size.
func pemFindEnd(data []byte, offset int) (int, bool) {
const endPrefix = "-----END "
pemData := string(data[offset:])
idx := strings.Index(pemData, endPrefix)
if idx < 0 {
return 0, false
}
// Find end of the delimiter line
rest := pemData[idx:]
nlIdx := strings.Index(rest, "\n")
if nlIdx < 0 {
return idx + len(rest), true
}
return idx + nlIdx + 1, true
}
// pemParser validates PEM certificate and key signatures.
func pemParser(data []byte, offset int) (*SignatureResult, error) {
const minPEMLen = 26
result := &SignatureResult{
Offset: offset,
Confidence: ConfidenceHigh,
}
if offset+minPEMLen > len(data) {
return nil, fmt.Errorf("insufficient PEM data")
}
pemMagic := data[offset : offset+minPEMLen]
pemMagicStr := string(pemMagic)
// Determine type based on first 26 bytes
matched := false
for _, m := range pemPublicKeyMagic() {
if len(m) >= minPEMLen && strings.HasPrefix(string(m), pemMagicStr[:minPEMLen]) {
result.Description = DescriptionPEMPublicKey
matched = true
break
}
}
if !matched {
for _, m := range pemPrivateKeyMagic() {
if len(m) >= minPEMLen && strings.HasPrefix(string(m), pemMagicStr[:minPEMLen]) {
result.Description = DescriptionPEMPrivateKey
matched = true
break
}
}
}
if !matched {
for _, m := range pemCertificateMagic() {
if len(m) >= minPEMLen && strings.HasPrefix(string(m), pemMagicStr[:minPEMLen]) {
result.Description = DescriptionPEMCertificate
matched = true
break
}
}
}
if !matched {
return nil, fmt.Errorf("PEM magic not recognised")
}
// Find the end of PEM data
pemSize, found := pemFindEnd(data, offset)
if !found || pemSize == 0 {
return nil, fmt.Errorf("PEM end delimiter not found")
}
// Validate base64 content
if offset+pemSize > len(data) {
pemSize = len(data) - offset
}
if _, err := decodePEMData(data[offset : offset+pemSize]); err != nil {
return nil, fmt.Errorf("PEM validation failed: %v", err)
}
if offset == 0 && pemSize == len(data) {
result.ExtractionDeclined = true
}
result.Size = pemSize
return result, nil
}
// ---- PKCS DER ----
const DescriptionDERHash = "PKCS DER hash"
func derHashLookups() map[string][]byte {
return map[string][]byte{
"MD5": {0x30, 0x20, 0x30, 0x0c, 0x06, 0x08, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x02, 0x05, 0x05, 0x00, 0x04, 0x10},
"SHA1": {0x30, 0x21, 0x30, 0x09, 0x06, 0x05, 0x2b, 0x0e, 0x03, 0x02, 0x1a, 0x05, 0x00, 0x04, 0x14},
"SHA256": {0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, 0x05, 0x00, 0x04, 0x20},
"SHA384": {0x30, 0x41, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x02, 0x05, 0x00, 0x04, 0x30},
"SHA512": {0x30, 0x51, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x03, 0x05, 0x00, 0x04},
}
}
// derHashMagic returns magic bytes for all DER hash types.
func derHashMagic() [][]byte {
var magics [][]byte
for _, m := range derHashLookups() {
mc := make([]byte, len(m))
copy(mc, m)
magics = append(magics, mc)
}
return magics
}
// derHashParser validates PKCS DER hash signatures.
func derHashParser(data []byte, offset int) (*SignatureResult, error) {
result := &SignatureResult{
Offset: offset,
Description: DescriptionDERHash,
Confidence: ConfidenceMedium,
}
for name, magic := range derHashLookups() {
hashEnd := offset + len(magic)
if hashEnd > len(data) {
continue
}
match := true
for i, b := range magic {
if data[offset+i] != b {
match = false
break
}
}
if match {
result.Size = len(magic)
result.Description = fmt.Sprintf("%s, %s", result.Description, name)
return result, nil
}
}
return nil, fmt.Errorf("DER hash validation failed")
}
// ---- LUKS ----
const DescriptionLUKS = "LUKS header"
// luksMagic returns magic bytes for LUKS headers.
func luksMagic() [][]byte {
return [][]byte{
{0x4C, 0x55, 0x4B, 0x53, 0xBA, 0xBE}, // "LUKS\xBA\xBE"
}
}
// luksParser validates LUKS header signatures.
func luksParser(data []byte, offset int) (*SignatureResult, error) {
const cipherAlgoStart = 8
const cipherAlgoEnd = 40
const cipherModeStart = 40
const cipherModeEnd = 72
const hashFnStart = 72
const hashFnEnd = 104
const luks2MinHeaderSize = 4032
luksBaseFields := []StructField{
{"magic_1", "u32"},
{"magic_2", "u16"},
{"version", "u16"},
{"header_size", "u64"},
}
result := &SignatureResult{
Offset: offset,
Name: "luks",
Description: DescriptionLUKS,
Confidence: ConfidenceMedium,
}
if offset+StructSize(luksBaseFields) > len(data) {
return nil, fmt.Errorf("insufficient LUKS data")
}
luksBase, err := Parse(data[offset:], luksBaseFields, "big")
if err != nil {
return nil, fmt.Errorf("failed to parse LUKS header")
}
version := luksBase["version"]
// Hash function string
if offset+hashFnEnd > len(data) {
return nil, fmt.Errorf("LUKS hash function data out of bounds")
}
hashFn := GetCString(data[offset+hashFnStart : offset+hashFnEnd])
if hashFn == "" {
return nil, fmt.Errorf("LUKS hash function string empty")
}
if version == 1 {
if offset+cipherAlgoEnd > len(data) {
return nil, fmt.Errorf("LUKS cipher data out of bounds")
}
cipherAlgo := GetCString(data[offset+cipherAlgoStart : offset+cipherAlgoEnd])
cipherMode := GetCString(data[offset+cipherModeStart : offset+cipherModeEnd])
if cipherAlgo == "" || cipherMode == "" {
return nil, fmt.Errorf("LUKS v1 cipher strings empty")
}
result.Description = fmt.Sprintf("%s, version: %d, cipher algorithm: %s, cipher mode: %s, hash fn: %s",
result.Description, version, cipherAlgo, cipherMode, hashFn)
return result, nil
} else if version == 2 {
headerSize := luksBase["header_size"]
availableData := len(data) - offset
if headerSize <= luks2MinHeaderSize || headerSize >= availableData {
return nil, fmt.Errorf("LUKS v2 header size invalid")
}
result.Description = fmt.Sprintf("%s, version: %d, header size: %d bytes, hash fn: %s",
result.Description, version, headerSize, hashFn)
return result, nil
}
return nil, fmt.Errorf("unsupported LUKS version: %d", version)
}
// ---- Hash constants ----
const hashMagicLen = 16
const (
DescriptionCRC32 = "CRC32 polynomial table"
DescriptionSHA256 = "SHA256 hash constants"
DescriptionMD5 = "MD5 hash constants"
)
// crc32Magic returns magic bytes for CRC32 polynomial tables.
// Order matters: index 0 is big endian, index 1 is little endian.
func crc32ConstMagic() [][]byte {
return [][]byte{
// Big endian
{0x00, 0x00, 0x00, 0x00, 0x77, 0x07, 0x30, 0x96, 0xEE, 0x0E, 0x61, 0x2C, 0x99, 0x09, 0x51, 0xBA},
// Little endian
{0x00, 0x00, 0x00, 0x00, 0x96, 0x30, 0x07, 0x77, 0x2C, 0x61, 0x0E, 0xEE, 0xBA, 0x51, 0x09, 0x99},
}
}
// sha256Magic returns magic bytes for SHA256 hash constants.
func sha256ConstMagic() [][]byte {
return [][]byte{
// Big endian
{0x42, 0x8a, 0x2f, 0x98, 0x71, 0x37, 0x44, 0x91, 0xb5, 0xc0, 0xfb, 0xcf, 0xe9, 0xb5, 0xdb, 0xa5},
// Little endian
{0x98, 0x2f, 0x8a, 0x42, 0x91, 0x44, 0x37, 0x71, 0xcf, 0xfb, 0xc0, 0xb5, 0xa5, 0xdb, 0xb5, 0xe9},
}
}
// md5Magic returns magic bytes for MD5 hash constants.
func md5ConstMagic() [][]byte {
return [][]byte{
// Big endian
{0xd7, 0x6a, 0xa4, 0x78, 0xe8, 0xc7, 0xb7, 0x56, 0x24, 0x20, 0x70, 0xdb, 0xc1, 0xbd, 0xce, 0xee},
// Little endian
{0x78, 0xa4, 0x6a, 0xd7, 0x56, 0xb7, 0xc7, 0xe8, 0xdb, 0x70, 0x20, 0x24, 0xee, 0xce, 0xbd, 0xc1},
}
}
func hashEndianness(data []byte, offset int, magics [][]byte) string {
if offset+hashMagicLen > len(data) {
return "little"
}
thisMagic := data[offset : offset+hashMagicLen]
if len(magics) > 0 && len(magics[0]) == hashMagicLen {
match := true
for i := 0; i < hashMagicLen; i++ {
if thisMagic[i] != magics[0][i] {
match = false
break
}
}
if match {
return "big"
}
}
return "little"
}
// crc32Parser validates CRC32 polynomial table signatures.
func crc32Parser(data []byte, offset int) (*SignatureResult, error) {
return &SignatureResult{
Offset: offset,
Size: hashMagicLen,
Description: fmt.Sprintf("%s, %s endian", DescriptionCRC32, hashEndianness(data, offset, crc32ConstMagic())),
}, nil
}
// sha256Parser validates SHA256 hash constant signatures.
func sha256Parser(data []byte, offset int) (*SignatureResult, error) {
return &SignatureResult{
Offset: offset,
Size: hashMagicLen,
Description: fmt.Sprintf("%s, %s endian", DescriptionSHA256, hashEndianness(data, offset, sha256ConstMagic())),
}, nil
}
// md5Parser validates MD5 hash constant signatures.
func md5Parser(data []byte, offset int) (*SignatureResult, error) {
return &SignatureResult{
Offset: offset,
Size: hashMagicLen,
Description: fmt.Sprintf("%s, %s endian", DescriptionMD5, hashEndianness(data, offset, md5ConstMagic())),
}, nil
}
@@ -0,0 +1,485 @@
package binsnoop
import (
"fmt"
)
// ---------------------------------------------------------------------------
// ELF
// ---------------------------------------------------------------------------
const ELFDescription = "ELF binary"
func ELFMagic() [][]byte {
return [][]byte{
{0x7F, 0x45, 0x4C, 0x46}, // "\x7FELF"
}
}
func ELFParser(data []byte, offset int) (*SignatureResult, error) {
const elfIdentSize = 16
const elfInfoStructSize = 8
const expectedVersion = 1
elfIdentFields := []StructField{
{"magic", "u32"},
{"class", "u8"},
{"endianness", "u8"},
{"version", "u8"},
{"osabi", "u8"},
{"abiversion", "u8"},
{"padding_1", "u32"},
{"padding_2", "u24"},
}
elfInfoFields := []StructField{
{"type", "u16"},
{"machine", "u16"},
{"version", "u32"},
}
elfClasses := map[int]int{
1: 32,
2: 64,
}
elfEndianness := map[int]string{
1: "little",
2: "big",
}
elfOSABI := map[int]string{
0: "System-V (Unix)",
1: "HP-UX",
2: "NetBSD",
3: "Linux",
4: "GNU Hurd",
5: "86Open",
6: "Solaris",
7: "AIX",
8: "IRIX",
9: "FreeBSD",
10: "Tru64",
11: "Novell Modesto",
12: "OpenBSD",
13: "OpenVMS",
14: "NonStop Kernel",
15: "AROS",
16: "FenixOS",
17: "Nuxi CloudABI",
18: "OpenVOS",
97: "ARM ABI",
102: "Cell LV2",
202: "Cafe OS",
255: "embedded",
}
elfTypes := map[int]string{
0: "no file type",
1: "relocatable",
2: "executable",
3: "shared object",
4: "core file",
}
elfMachines := map[int]string{
0: "no machine",
1: "AT&T WE 32100",
2: "SPARC",
3: "x86",
4: "Motorola 68k",
5: "Motorola 88k",
6: "Intel MCU",
7: "Intel 80860",
8: "MIPS",
9: "IBM System/370",
10: "MIPS RS3000",
11: "RS6000",
15: "HP PA-RISC",
16: "nCUBE",
17: "Fujitsu VPP500",
18: "SPARC32PLUS",
19: "Intel 80960",
20: "PowerPC",
21: "PowerPC 64-bit",
22: "S390",
23: "IBM SPU/SPC",
24: "cisco SVIP",
25: "cisco 7200",
36: "NEC V800",
37: "Fujitsu FR20",
38: "TRW RH-32",
39: "Motorola RCE",
40: "ARM",
41: "Digital Alpha",
42: "SuperH",
43: "SPARCv9",
44: "Siemens TriCore embedded processor",
45: "Argonaut RISC Core",
46: "Hitachi H8/300",
47: "Hitachi H8/300H",
48: "Hitachi H8S",
49: "Hitachi H8/500",
50: "IA-64",
51: "Stanford MIPS-X",
52: "Motorola ColdFire",
53: "Motorola M68HC12",
54: "Fujitsu MMA Multimedia Accelerator",
55: "Siemens PCP",
56: "Sony nCPU embedded RISC processor",
57: "Denso NDR1 microprocessor",
58: "Motorola StarCore",
59: "Toyota ME16",
60: "STMicroelectronics ST100",
61: "Advanced Logic TinyJ embedded processor",
62: "AMD X86-64",
63: "Sony DSP processor",
64: "PDP-10",
65: "PDP-11",
66: "Siemens FX66",
67: "STMicroelectronics ST9+",
68: "STMicroelectronics ST7",
69: "Motorola MC68HC16",
70: "Motorola MC68HC11",
71: "Motorola MC68HC08",
72: "Motorola MC68HC05",
73: "Silicon Graphics SVx",
74: "STMicroelectonrics ST19",
75: "Digital VAX",
76: "Axis Communications 32-bit CPU",
77: "Infineon Technologies 32-bit CPU",
78: "Element 14 64-bit DSP",
79: "LSI Logic 16-bit DSP",
80: "MMIX",
81: "Harvard machine-independent",
82: "SiTera Prism",
83: "Atmel AVR 8-bit",
84: "Fujitsu FR30",
85: "Mitsubishi D10V",
86: "Mitsubishi D30V",
87: "NEC v850",
88: "Renesas M32R",
89: "Matsushita MN10300",
90: "Matsushita MN10200",
91: "picoJava",
92: "OpenRISC",
93: "Synopsys ARCompact ARC700 cores",
94: "Tensilica Xtensa",
95: "Alphamosaic VideoCore",
96: "Thompson Multimedia",
97: "NatSemi 32k",
98: "Tenor Network TPC",
99: "Trebia SNP 1000",
100: "STMicroelectronics ST200",
101: "Ubicom IP2022",
102: "MAX Processor",
103: "NatSemi CompactRISC",
104: "Fujitsu F2MC16",
105: "TI msp430",
106: "Analog Devices Blackfin",
107: "S1C33 Family of Seiko Epson",
108: "Sharp embedded",
109: "Arca RISC",
110: "PKU-Unity Ltd.",
111: "eXcess: 16/32/64-bit",
112: "Icera Deep Execution Processor",
113: "Altera Nios II",
114: "NatSemi CRX",
115: "Motorola XGATE",
116: "Infineon C16x/XC16x",
117: "Renesas M16C series",
118: "Microchip dsPIC30F",
119: "Freescale RISC core",
120: "Renesas M32C series",
131: "Altium TSK3000 core",
132: "Freescale RS08",
134: "Cyan Technology eCOG2",
135: "Sunplus S+core7 RISC",
136: "New Japan Radio (NJR) 24-bit DSP",
137: "Broadcom VideoCore III processor",
138: "LatticeMico32",
139: "Seiko Epson C17 family",
140: "TMS320C6000",
141: "TMS320C2000",
142: "TMS320C55x",
144: "TI Programmable Realtime Unit",
160: "STMicroelectronics 64bit VLIW DSP",
161: "Cypress M8C",
162: "Renesas R32C series",
163: "NXP TriMedia family",
164: "Qualcomm DSP6",
165: "Intel 8051 and variants",
166: "STMicroelectronics STxP7x family",
167: "Andes embedded RISC",
168: "Cyan eCOG1X family",
169: "Dallas MAXQ30",
170: "New Japan Radio (NJR) 16-bit DSP",
171: "M2000 Reconfigurable RISC",
172: "Cray NV2 vector architecture",
173: "Renesas RX family",
174: "META",
175: "MCST Elbrus e2k",
176: "Cyan Technology eCOG16 family",
177: "NatSemi CompactRISC",
178: "Freescale Extended Time Processing Unit",
179: "Infineon SLE9X",
180: "Intel L1OM",
181: "Intel K1OM",
183: "ARM 64-bit",
185: "Atmel 32-bit family",
186: "STMicroeletronics STM8 8-bit",
187: "Tilera TILE64",
188: "Tilera TILEPro",
189: "Xilinx MicroBlaze 32-bit RISC",
190: "NVIDIA CUDA architecture",
191: "Tilera TILE-Gx",
195: "Synopsys ARCv2/HS3x/HS4x cores",
197: "Renesas RL78 family",
199: "Renesas 78K0R",
200: "Freescale 56800EX",
201: "Beyond BA1",
202: "Beyond BA2",
203: "XMOS xCORE",
204: "Microchip 8-bit PIC(r)",
210: "KM211 KM32",
211: "KM211 KMX32",
212: "KM211 KMX16",
213: "KM211 KMX8",
214: "KM211 KVARC",
215: "Paneve CDP",
216: "Cognitive Smart Memory",
217: "iCelero CoolEngine",
218: "Nanoradio Optimized RISC",
219: "CSR Kalimba architecture family",
220: "Zilog Z80",
221: "Controls and Data Services VISIUMcore processor",
222: "FTDI Chip FT32 high performance 32-bit RISC architecture",
223: "Moxie processor family",
224: "AMD GPU architecture",
243: "RISC-V",
244: "Lanai 32-bit processor",
245: "CEVA Processor Architecture Family",
246: "CEVA X2 Processor Family",
247: "Berkeley Packet Filter",
248: "Graphcore Intelligent Processing Unit",
249: "Imagination Technologies",
250: "Netronome Flow Processor",
251: "NEC Vector Engine",
252: "C-SKY processor family",
253: "Synopsys ARCv3 64-bit ISA/HS6x cores",
254: "MOS Technology MCS 6502 processor",
255: "Synopsys ARCv3 32-bit",
256: "Kalray VLIW core of the MPPA family",
257: "WDC 65C816",
258: "LoongArch",
259: "ChipON KungFu32",
}
elfData := data[offset:]
if len(elfData) < elfIdentSize+elfInfoStructSize {
return nil, fmt.Errorf("ELF: not enough data")
}
// Parse the e_ident (endianness doesn't matter for single-byte fields)
eIdent, err := Parse(elfData, elfIdentFields, "little")
if err != nil {
return nil, fmt.Errorf("ELF: e_ident parse error: %w", err)
}
// Sanity check padding fields
if eIdent["padding_1"] != 0 || eIdent["padding_2"] != 0 {
return nil, fmt.Errorf("ELF: non-zero padding in e_ident")
}
if eIdent["version"] != expectedVersion {
return nil, fmt.Errorf("ELF: unexpected e_ident version %d", eIdent["version"])
}
classVal, classOK := elfClasses[eIdent["class"]]
if !classOK {
return nil, fmt.Errorf("ELF: unknown class %d", eIdent["class"])
}
endianStr, endianOK := elfEndianness[eIdent["endianness"]]
if !endianOK {
return nil, fmt.Errorf("ELF: unknown endianness %d", eIdent["endianness"])
}
osabiStr, osabiOK := elfOSABI[eIdent["osabi"]]
if !osabiOK {
return nil, fmt.Errorf("ELF: unknown OS/ABI %d", eIdent["osabi"])
}
// Parse the rest of the ELF header with the correct endianness
elfInfoData := elfData[elfIdentSize : elfIdentSize+elfInfoStructSize]
elfInfo, err := Parse(elfInfoData, elfInfoFields, endianStr)
if err != nil {
return nil, fmt.Errorf("ELF: info parse error: %w", err)
}
if elfInfo["version"] != expectedVersion {
return nil, fmt.Errorf("ELF: unexpected info version %d", elfInfo["version"])
}
typeStr, typeOK := elfTypes[elfInfo["type"]]
if !typeOK {
return nil, fmt.Errorf("ELF: unknown type %d", elfInfo["type"])
}
machineStr := "Unknown"
if m, ok := elfMachines[elfInfo["machine"]]; ok {
machineStr = m
}
return &SignatureResult{
Offset: offset,
Name: "elf",
Description: fmt.Sprintf("%s, %d-bit %s, %s for %s, %s endian", ELFDescription, classVal, typeStr, machineStr, osabiStr, endianStr),
Confidence: ConfidenceMedium,
}, nil
}
// ---------------------------------------------------------------------------
// PE
// ---------------------------------------------------------------------------
const PEDescription = "Windows PE binary"
func PEMagic() [][]byte {
// First 16 bytes of common DOS headers (e_magic through e_ss)
return [][]byte{
{0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00},
{0x4D, 0x5A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
}
}
func PEParser(data []byte, offset int) (*SignatureResult, error) {
const peMagicValue = 0x00004550 // "PE\x00\x00"
dosFields := []StructField{
{"e_magic", "u16"},
{"e_cblp", "u16"},
{"e_cp", "u16"},
{"e_crlc", "u16"},
{"e_cparhdr", "u16"},
{"e_minalloc", "u16"},
{"e_maxalloc", "u16"},
{"e_ss", "u16"},
{"e_sp", "u16"},
{"e_csum", "u16"},
{"e_ip", "u16"},
{"e_cs", "u16"},
{"e_lfarlc", "u16"},
{"e_ovno", "u16"},
{"e_res_1", "u16"},
{"e_res_2", "u16"},
{"e_res_3", "u16"},
{"e_res_4", "u16"},
{"e_oemid", "u16"},
{"e_oeminfo", "u16"},
{"e_res_5", "u16"},
{"e_res_6", "u16"},
{"e_res_7", "u16"},
{"e_res_8", "u16"},
{"e_res_9", "u16"},
{"e_res_10", "u16"},
{"e_res_11", "u16"},
{"e_res_12", "u16"},
{"e_res_13", "u16"},
{"e_res_14", "u16"},
{"e_lfanew", "u32"},
}
peFields := []StructField{
{"magic", "u32"},
{"machine", "u16"},
{"number_of_sections", "u16"},
{"timestamp", "u32"},
{"symbol_table_ptr", "u32"},
{"number_of_symbols", "u32"},
{"optional_header_size", "u16"},
{"characteristics", "u16"},
}
knownMachineTypes := map[int]string{
0x0: "Unknown",
0x184: "Alpha32",
0x284: "Alpha64",
0x1D3: "Matsushita AM33",
0x8664: "Intel x86-64",
0x1C0: "ARM",
0xAA64: "ARM-64",
0x1C4: "ARM Thumb2",
0xEBC: "EFI",
0x14C: "Intel x86",
0x200: "Intel Itanium",
0x6232: "LoongArch 32-bit",
0x6264: "LoongArch 64-bit",
0x9041: "Mitsubishi M32R",
0x266: "MIPS16",
0x366: "MIPS with FPU",
0x466: "MIPS16 with FPU",
0x1F0: "PowerPC",
0x1F1: "PowerPC with FPU",
0x5032: "RISC-V 32-bit",
0x5064: "RISC-V 64-bit",
0x5128: "RISC-V 128-bit",
0x1A2: "Hitachi SH3",
0x1A3: "Hitachi SH3 DSP",
0x1A6: "Hitachi SH4",
0x1A8: "Hitachi SH5",
0x1C2: "Thumb",
0x169: "MIPS WCEv2",
}
peData := data[offset:]
dosStructSize := StructSize(dosFields)
peStructSize := StructSize(peFields)
if len(peData) < dosStructSize {
return nil, fmt.Errorf("PE: not enough data for DOS header")
}
dosHeader, err := Parse(peData, dosFields, "little")
if err != nil {
return nil, fmt.Errorf("PE: DOS header parse error: %w", err)
}
// Sanity check the reserved header fields; they should all be 0
for _, field := range []string{
"e_res_1", "e_res_2", "e_res_3", "e_res_4",
"e_res_5", "e_res_6", "e_res_7", "e_res_8",
"e_res_9", "e_res_10", "e_res_11", "e_res_12",
"e_res_13", "e_res_14",
} {
if dosHeader[field] != 0 {
return nil, fmt.Errorf("PE: reserved field %s is non-zero", field)
}
}
peHeaderStart := dosHeader["e_lfanew"]
peHeaderEnd := peHeaderStart + peStructSize
if peHeaderEnd > len(peData) {
return nil, fmt.Errorf("PE: e_lfanew points beyond available data")
}
peHeader, err := Parse(peData[peHeaderStart:peHeaderEnd], peFields, "little")
if err != nil {
return nil, fmt.Errorf("PE: PE header parse error: %w", err)
}
if peHeader["magic"] != peMagicValue {
return nil, fmt.Errorf("PE: invalid PE magic 0x%08X", peHeader["magic"])
}
machineStr, machineOK := knownMachineTypes[peHeader["machine"]]
if !machineOK {
return nil, fmt.Errorf("PE: unknown machine type 0x%X", peHeader["machine"])
}
return &SignatureResult{
Offset: offset,
Description: fmt.Sprintf("%s, machine type: %s", PEDescription, machineStr),
Confidence: ConfidenceMedium,
}, nil
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More