26 Commits
Author SHA1 Message Date
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
Andy Dixon 1ea24bf7fa feat: add custom app icon replacement for macOS builds 2026-05-21 13:35:39 +01:00
Andy Dixon 0b6f611110 fix: update dependencies for improved stability and performance 2026-05-21 13:17:52 +01:00
Andy Dixon a7cf28b404 feat: implement local terminal support with shell configuration and UI integration 2026-05-21 13:13:06 +01:00
Andy Dixon 3ff04feb92 Refactor: Improve code readability and formatting across multiple files
- Enhanced formatting in triggers.js for better readability, including consistent line breaks and indentation.
- Updated utility functions in utils.js for improved clarity and organization, ensuring consistent formatting and spacing.
- Refactored VNC button creation in vnc.js to enhance readability with better structured code.
- Modified SQL migration script to improve readability by aligning SQL statements and enhancing the structure of the INSERT statement.
2026-05-21 12:53:08 +01:00
Andy Dixon 96fedb9348 feat: add VNC support with noVNC integration
- Introduced a new VNC viewer panel in the frontend, allowing users to connect to VNC sessions.
- Implemented VNC connection handling with optional SSH tunneling in the backend.
- Added a new SSH key manager for managing SSH keys within the terminal.
- Created a trigger rules engine to monitor terminal output and trigger actions based on defined patterns.
- Added CSS styles for the VNC viewer and its components.
- Updated database schema to support VNC protocol in profiles.
- Implemented WebSocket proxy for VNC connections to facilitate communication between the frontend and backend.
2026-05-21 12:52:57 +01:00
Andy Dixon 523d9b02b4 Refactor SnippetLibrary and State classes for improved readability and consistency
- Updated formatting and indentation in snippets.js for better code clarity.
- Enhanced error handling and notifications in sendToActiveSession method.
- Improved variable resolution logic for command snippets.
- Added new methods for managing session state in state.js.
- Introduced new split pane functionality in terminal.js for better user experience.
- Enhanced modal handling and user notifications across various components.
2026-05-21 12:15:49 +01:00
Andy Dixon 421bdb9ee8 feat: add latency indicator and broadcast target selection
- Added a latency indicator to the status bar, displaying round-trip time for SSH sessions.
- Implemented functionality to select specific sessions for broadcasting input, allowing users to manage broadcast targets.
- Enhanced SFTP functionality with bookmark management, enabling users to bookmark directories and navigate to them easily.
- Introduced a modal for user-defined snippet variables, allowing dynamic command execution with placeholders.
- Added port forwarding management for SSH sessions, including the ability to start and stop port forwards.
- Improved terminal output export functionality, allowing users to download or copy terminal output to clipboard.
2026-05-21 12:09:43 +01:00
Andy Dixon c9b5eb10eb Refactor CSS and JavaScript for improved readability and consistency
- Updated CSS styles for buttons, form controls, badges, and notifications to use consistent indentation and formatting.
- Enhanced JavaScript code in sftp.js for better readability by breaking long lines and ensuring consistent formatting.
2026-05-21 11:28:47 +01:00
Andy Dixon 96c603f3f4 Add context menus and SFTP transfer progress handling
- Implemented context menu styles in components.css.
- Added SFTP transfer progress queue and related styles in sftp.css.
- Enhanced app.js to handle SFTP transfer progress events.
- Updated sftp.js to manage transfer progress and render the transfer queue.
- Modified terminal.js to include context menu shortcuts.
- Adjusted manager.go to emit SFTP transfer progress events with direction.
- Updated sshclient.go to modify terminal modes for better performance.
2026-05-21 11:28:37 +01:00
Andy Dixon 23aaa1b53e Linux build stuff 2026-04-28 14:56:13 +01:00
165 changed files with 43636 additions and 2550 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"
+4 -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
@@ -29,6 +30,7 @@ If a session drops, use the **Reconnect** button in the disconnected overlay.
- Product overview: [docs/overview.md](docs/overview.md)
- Quick start (shareable): [docs/quick-start.md](docs/quick-start.md)
- User manual: [docs/user-manual.md](docs/user-manual.md)
- Build and release: [docs/build-and-release.md](docs/build-and-release.md)
## Keyboard shortcuts
@@ -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.
+177 -27
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,111 @@ 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 \
&& ! pkg-config --exists webkit2gtk-4.0; then
echo "webkit2_41"
fi
}
build_version() {
local tag
local commit
@@ -34,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() {
@@ -57,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() {
@@ -68,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"
@@ -97,27 +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
CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags"
CC="$cc" CGO_ENABLED=1 wails_cmd build "${build_args[@]}"
else
CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags"
CGO_ENABLED=1 wails_cmd build "${build_args[@]}"
fi
((BUILT_TARGETS += 1))
}
main() {
@@ -129,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\'
}
+178 -27
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,42 +18,154 @@ 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="$(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
@@ -61,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"
}
@@ -71,7 +188,7 @@ dev() {
check_deps
install_deps
log "Starting development mode…"
wails dev
wails_cmd dev
}
# Production build
@@ -81,14 +198,47 @@ build() {
log "Building production binary…"
local version
version="$(build_version)"
wails build -ldflags "$(build_ldflags "${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_cmd build "${build_args[@]}" -tags "$tags" -ldflags "$(build_ldflags "${version}")"
else
wails_cmd build "${build_args[@]}" -ldflags "$(build_ldflags "${version}")"
fi
# Replace Wails-generated icon with custom app icon on macOS.
if [[ "$OSTYPE" == darwin* ]]; then
local app_bundle="build/bin/nexus-terminal.app"
local custom_icns="build/darwin/AppIcon.icns"
if [ -d "$app_bundle" ] && [ -f "$custom_icns" ]; then
cp "$custom_icns" "$app_bundle/Contents/Resources/iconfile.icns"
log "Replaced app icon with custom AppIcon.icns"
fi
fi
log "Build complete: build/bin/"
}
# Run tests
run_tests() {
check_go
log "Running tests…"
go test ./... -v -count=1
(cd frontend && npm test)
}
# Clean build artefacts
@@ -97,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"
}
+77
View File
@@ -0,0 +1,77 @@
# Build and Release
This project uses [Wails](https://wails.io/) for desktop packaging.
## Ubuntu 24.04 prerequisites
Install the Linux build toolchain and Wails runtime headers with:
```bash
sudo apt-get update
sudo apt-get install -y \
build-essential \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
pkg-config \
zip
```
Notes:
- Ubuntu 24.04 does not ship `libwebkit2gtk-4.0-dev`.
- Wails `v2.12.0` supports Ubuntu 24.04 by building with the `webkit2_41` tag.
- The local build scripts detect that package layout automatically and add the tag when needed.
You will also need these tools available in `PATH`:
- `go`
- `node`
- `npm`
- `wails`
Install the Wails CLI with:
```bash
go install github.com/wailsapp/wails/v2/cmd/wails@latest
export PATH="$PATH:$(go env GOPATH)/bin"
```
## Build commands
Build the app binary for the current host:
```bash
./build.sh
```
Build a packaged release for the current host platform:
```bash
./release.sh
```
By default, `./release.sh` now builds only the current host OS/architecture. On this Ubuntu 24.04 `amd64` host that means `linux/amd64`.
## Optional cross-compilers
If you want to add extra Linux/Windows targets from Ubuntu, install the relevant cross-compiler packages first:
```bash
sudo apt-get install -y \
gcc-aarch64-linux-gnu \
gcc-mingw-w64-x86-64
```
Then request explicit targets:
```bash
NEXUS_RELEASE_TARGETS=linux/amd64,windows/amd64 ./release.sh
```
## Platform caveats
- macOS targets cannot be cross-built from Linux with Wails. Build those on macOS.
- `windows/arm64` is not covered by the default Ubuntu toolchain here.
- `linux/arm64` needs more than `gcc-aarch64-linux-gnu`; it also needs an `arm64` sysroot and target GTK/WebKit development libraries for CGO to link cleanly.
- Tested on Ubuntu 24.04 `amd64`: `linux/amd64` and `windows/amd64` build successfully; `linux/arm64` does not in the default host setup above.
+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.
+34 -1
View File
@@ -22,6 +22,8 @@
<link rel="stylesheet" href="/src/css/forms.css" />
<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/leviathan.css" />
</head>
<body>
<div id="app" class="app">
@@ -46,7 +48,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>
@@ -76,6 +93,13 @@
>
<span>+ New</span>
</button>
<button
class="btn btn-ghost btn-sm"
id="btn-command-palette"
title="Command Palette (⌘K)"
>
<span>⌘K</span>
</button>
<button
class="btn btn-ghost btn-icon btn-sm"
id="btn-import"
@@ -267,6 +291,7 @@
Disconnected
</span>
<span id="status-protocol"></span>
<span id="status-latency"></span>
<span id="status-session-count">0 sessions</span>
<span class="status-spacer"></span>
<button
@@ -293,6 +318,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>
@@ -304,6 +332,8 @@
<script src="/src/js/profiles.js"></script>
<script src="/src/js/raw.js"></script>
<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/settings.js"></script>
<script src="/src/js/commands.js"></script>
<script src="/src/js/secrets.js"></script>
@@ -311,6 +341,9 @@
<script src="/src/js/sftp.js"></script>
<script src="/src/js/splitpane.js"></script>
<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>
+525 -45
View File
@@ -9,58 +9,538 @@
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"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"
"@novnc/novnc": "^1.5.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.28.1"
}
},
"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.",
"node_modules/@esbuild/aix-ppc64": {
"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"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"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"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"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"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"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"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"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"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"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"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"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"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"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"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"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"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"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"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"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"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"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"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"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"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"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"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"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"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"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.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"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"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"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"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"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"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@novnc/novnc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
"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-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.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-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": {
"node_modules/@xterm/addon-unicode11": {
"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.",
"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",
"peerDependencies": {
"xterm": "^5.0.0"
"workspaces": [
"addons/*"
]
},
"node_modules/esbuild": {
"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",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@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"
}
}
}
+12 -6
View File
@@ -5,15 +5,21 @@
"description": "Frontend for Nexus Terminal desktop SSH client",
"scripts": {
"install:assets": "node scripts/copy-assets.js",
"bundle:novnc": "node scripts/bundle-novnc.js",
"build": "node scripts/build.js",
"dev": "node scripts/build.js --watch",
"postinstall": "npm run install:assets"
"test": "node scripts/test-regressions.js",
"postinstall": "npm run install:assets && npm run bundle:novnc"
},
"dependencies": {
"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"
"@novnc/novnc": "^1.5.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.28.1"
}
}
+1 -1
View File
@@ -1 +1 @@
ce0d75b28075a7688ba4535c94b4784c
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.');
+43
View File
@@ -0,0 +1,43 @@
/**
* bundle-novnc.js — Uses esbuild to bundle noVNC's RFB class into a single
* script that exposes window.noVNC.RFB for use in the frontend.
*
* noVNC 1.5+ uses top-level await which is incompatible with IIFE format,
* so we bundle as ESM then wrap in an async IIFE via banner/footer.
*/
"use strict";
const esbuild = require("esbuild");
const path = require("path");
const entryPoint = path.resolve(__dirname, "novnc-entry.js");
const outFile = path.resolve(
__dirname,
"..",
"src",
"js",
"lib",
"novnc.min.js",
);
esbuild
.build({
entryPoints: [entryPoint],
bundle: true,
minify: true,
format: "esm",
outfile: outFile,
target: ["safari15"],
logLevel: "info",
banner: { js: ";(async function() {" },
footer: { js: "})();" },
})
.then(() => {
console.log(
"[bundle-novnc] noVNC bundled successfully → src/js/lib/novnc.min.js",
);
})
.catch((err) => {
console.error("[bundle-novnc] Failed:", err.message);
process.exit(1);
});
+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",
},
];
+7
View File
@@ -0,0 +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 };
+438
View File
@@ -0,0 +1,438 @@
#!/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",
"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/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");
File diff suppressed because it is too large Load Diff
+10
View File
@@ -467,6 +467,16 @@
color: var(--danger);
}
.settings-shell-row {
display: flex;
gap: var(--space-2);
align-items: center;
}
.settings-shell-row .form-input {
flex: 1;
}
.form-success {
font-size: var(--text-xs);
color: var(--success);
+56 -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;
}
/* -----------------------------------------------------------------------
@@ -169,6 +209,20 @@
background: var(--text-tertiary);
}
/* Latency indicator */
#status-latency {
font-variant-numeric: tabular-nums;
}
#status-latency.latency-good {
color: var(--success);
}
#status-latency.latency-warn {
color: var(--warning);
}
#status-latency.latency-bad {
color: var(--error);
}
/* -----------------------------------------------------------------------
Resize handle (between sidebar and workspace)
----------------------------------------------------------------------- */
+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;
+93
View File
@@ -419,3 +419,96 @@
font-size: 10px;
color: var(--text-tertiary);
}
/* -----------------------------------------------------------------------
Transfer progress queue
----------------------------------------------------------------------- */
.sftp-transfer-queue {
flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-elevated);
max-height: 140px;
overflow-y: auto;
}
.sftp-transfer-header {
font-size: 10px;
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
padding: var(--space-1) var(--space-2) 0;
}
.sftp-transfer-row {
display: flex;
align-items: center;
gap: var(--space-1);
padding: 4px var(--space-2);
}
.sftp-transfer-icon {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--accent);
flex-shrink: 0;
width: 16px;
text-align: center;
}
.sftp-transfer-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.sftp-transfer-name {
font-size: var(--text-xs);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sftp-transfer-progress {
height: 3px;
background: var(--border-subtle);
border-radius: 2px;
overflow: hidden;
}
.sftp-transfer-bar {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.15s ease-out;
}
.sftp-transfer-meta {
font-size: 10px;
color: var(--text-tertiary);
}
/* -----------------------------------------------------------------------
Bookmark list
----------------------------------------------------------------------- */
.sftp-bookmark-list {
max-height: 250px;
overflow-y: auto;
}
.sftp-bookmark-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition-fast);
}
.sftp-bookmark-row:hover {
background: var(--bg-hover);
}
+115
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
----------------------------------------------------------------------- */
@@ -253,6 +301,12 @@
.sidebar-item .protocol-indicator.proto-github::before {
content: "GH";
}
.sidebar-item .protocol-indicator.proto-vnc::before {
content: "⊞";
}
.sidebar-item .protocol-indicator.proto-local::before {
content: "›_";
}
.sidebar-item .protocol-indicator.proto-ssh {
background: rgba(78, 205, 196, 0.18);
@@ -270,6 +324,21 @@
background: rgba(124, 138, 255, 0.18);
color: var(--protocol-github);
}
.sidebar-item .protocol-indicator.proto-vnc {
background: rgba(232, 93, 74, 0.18);
color: var(--protocol-vnc);
}
.sidebar-item .protocol-indicator.proto-local {
background: rgba(139, 195, 74, 0.18);
color: var(--protocol-local);
}
/* Local machine item separator */
.sidebar-item-local {
border-bottom: 1px solid var(--border-subtle);
margin: var(--space-2) var(--space-2) var(--space-2);
padding-bottom: var(--space-2);
}
/* Text block (name + host) */
.sidebar-item-text {
@@ -380,6 +449,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);
+84 -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);
}
@@ -114,6 +114,14 @@
background: rgba(124, 138, 255, 0.15);
color: var(--protocol-github);
}
.tab-badge.badge-vnc {
background: rgba(232, 93, 74, 0.15);
color: var(--protocol-vnc);
}
.tab-badge.badge-local {
background: rgba(139, 195, 74, 0.15);
color: var(--protocol-local);
}
/* -----------------------------------------------------------------------
Status dot (connection state)
@@ -163,6 +171,49 @@
background: var(--text-tertiary);
}
/* -----------------------------------------------------------------------
Activity indicator — pulses when background tab has new output
----------------------------------------------------------------------- */
.tab-activity {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background: var(--accent);
opacity: 0;
transition: opacity 150ms ease;
}
.tab-has-activity .tab-activity {
opacity: 1;
animation: pulse-dot 1.5s ease infinite;
}
.tab-active .tab-activity {
opacity: 0;
}
/* Trigger flash — brief glow when a trigger rule matches */
.tab-trigger-flash {
animation: trigger-flash 2s ease;
}
@keyframes trigger-flash {
0%,
100% {
background: var(--bg-hover);
}
15% {
background: rgba(239, 83, 80, 0.25);
}
30% {
background: var(--bg-hover);
}
45% {
background: rgba(239, 83, 80, 0.15);
}
}
/* -----------------------------------------------------------------------
SFTP toggle button — revealed on hover (SSH tabs only)
----------------------------------------------------------------------- */
@@ -189,7 +240,7 @@
}
.tab:hover .tab-sftp,
.tab.active .tab-sftp {
.tab.tab-active .tab-sftp {
opacity: 1;
}
@@ -217,11 +268,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;
}
@@ -405,6 +460,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)
----------------------------------------------------------------------- */
+103 -98
View File
@@ -5,149 +5,154 @@
========================================================================== */
:root {
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Colour palette — dark charcoal theme
----------------------------------------------------------------------- */
--bg-base: #1c1c1c; /* deepest background */
--bg-surface: #232323; /* primary surface (sidebar, bars) */
--bg-elevated: #2a2a2a; /* elevated panels, cards */
--bg-overlay: #303030; /* overlays, dropdowns */
--bg-hover: #363636; /* hover state backgrounds */
--bg-active: #3c3c3c; /* active / pressed state */
--bg-input: #1e1e1e; /* input field backgrounds */
--bg-base: #1c1c1c; /* deepest background */
--bg-surface: #232323; /* primary surface (sidebar, bars) */
--bg-elevated: #2a2a2a; /* elevated panels, cards */
--bg-overlay: #303030; /* overlays, dropdowns */
--bg-hover: #363636; /* hover state backgrounds */
--bg-active: #3c3c3c; /* active / pressed state */
--bg-input: #1e1e1e; /* input field backgrounds */
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Border colours
----------------------------------------------------------------------- */
--border-subtle: rgba(255, 255, 255, 0.07);
--border-default: rgba(255, 255, 255, 0.12);
--border-strong: rgba(255, 255, 255, 0.18);
--border-focus: var(--accent);
--border-subtle: rgba(255, 255, 255, 0.07);
--border-default: rgba(255, 255, 255, 0.12);
--border-strong: rgba(255, 255, 255, 0.18);
--border-focus: var(--accent);
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Text colours
----------------------------------------------------------------------- */
--text-primary: #e4e4e4;
--text-secondary: #a0a0a0;
--text-tertiary: #707070;
--text-disabled: #4a4a4a;
--text-inverse: #1c1c1c;
--sidebar-group-title: #b89d5f;
--text-primary: #e4e4e4;
--text-secondary: #a0a0a0;
--text-tertiary: #707070;
--text-disabled: #4a4a4a;
--text-inverse: #1c1c1c;
--sidebar-group-title: #b89d5f;
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Accent colours — teal / cyan
----------------------------------------------------------------------- */
--accent: #4ecdc4;
--accent-hover: #45b7af;
--accent-muted: rgba(78, 205, 196, 0.12);
--accent-strong: rgba(78, 205, 196, 0.22);
--accent: #4ecdc4;
--accent-hover: #45b7af;
--accent-muted: rgba(78, 205, 196, 0.12);
--accent-strong: rgba(78, 205, 196, 0.22);
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Status colours
----------------------------------------------------------------------- */
--success: #66bb6a;
--warning: #ffa726;
--error: #ef5350;
--info: #42a5f5;
--success: #66bb6a;
--warning: #ffa726;
--error: #ef5350;
--info: #42a5f5;
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Protocol badge colours
----------------------------------------------------------------------- */
--protocol-ssh: #4ecdc4;
--protocol-telnet: #ffa726;
--protocol-raw: #ab47bc;
--protocol-github: #7c8aff;
--protocol-ssh: #4ecdc4;
--protocol-telnet: #ffa726;
--protocol-raw: #ab47bc;
--protocol-github: #7c8aff;
--protocol-vnc: #e85d4a;
--protocol-local: #8bc34a;
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Spacing scale (4 px base unit)
----------------------------------------------------------------------- */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Typography
----------------------------------------------------------------------- */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI',
system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco,
'Cascadia Code', monospace;
--font-sans:
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
sans-serif;
--font-mono:
"JetBrains Mono", "Fira Code", "SF Mono", Monaco, "Cascadia Code", monospace;
--text-xs: 11px;
--text-sm: 12px;
--text-base: 13px;
--text-md: 14px;
--text-lg: 16px;
--text-xl: 18px;
--text-2xl: 22px;
--text-xs: 11px;
--text-sm: 12px;
--text-base: 13px;
--text-md: 14px;
--text-lg: 16px;
--text-xl: 18px;
--text-2xl: 22px;
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
--weight-normal: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-normal: 400;
--weight-medium: 500;
--weight-semibold: 600;
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Border radius
----------------------------------------------------------------------- */
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-full: 9999px;
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-full: 9999px;
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Shadows
----------------------------------------------------------------------- */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.30);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.40);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.50);
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.60);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.5);
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.6);
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Transitions
----------------------------------------------------------------------- */
--transition-fast: 100ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease;
--transition-fast: 100ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease;
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Z-index layers
----------------------------------------------------------------------- */
--z-base: 0;
--z-sticky: 100;
--z-dropdown: 200;
--z-modal-backdrop: 300;
--z-modal: 400;
--z-notification: 500;
--z-tooltip: 600;
--z-base: 0;
--z-sticky: 100;
--z-dropdown: 200;
--z-modal-backdrop: 300;
--z-modal: 400;
--z-notification: 500;
--z-tooltip: 600;
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
Layout
----------------------------------------------------------------------- */
--sidebar-width: 260px;
--sidebar-width-collapsed: 48px;
--tab-bar-height: 36px;
--toolbar-height: 40px;
--status-bar-height: 24px;
--sidebar-width: 260px;
--sidebar-width-collapsed: 48px;
--tab-bar-height: 44px;
--toolbar-height: 40px;
--status-bar-height: 24px;
}
/* -----------------------------------------------------------------------
Global input overrides — disable macOS/WebKit auto-capitalisation,
autocorrect, and spellcheck on all text fields.
----------------------------------------------------------------------- */
input, textarea, select {
autocapitalize: none;
autocorrect: off;
spellcheck: false;
text-transform: none;
input,
textarea,
select {
autocapitalize: none;
autocorrect: off;
spellcheck: false;
text-transform: none;
}
+78
View File
@@ -0,0 +1,78 @@
/* VNC viewer styles */
.vnc-viewer-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--bg-base);
}
.vnc-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
background: var(--bg-elevated);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.vnc-status {
font-size: 0.75rem;
color: var(--text-secondary);
margin-right: auto;
}
.vnc-status-connected {
color: var(--protocol-ssh);
}
.vnc-status-error {
color: var(--danger);
}
.btn-vnc-action {
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.btn-vnc-action:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.vnc-display {
flex: 1;
position: relative;
overflow: hidden;
background: #000;
}
.vnc-display canvas {
width: 100% !important;
height: 100% !important;
object-fit: contain;
}
/* Protocol colour for VNC */
.proto-vnc {
color: var(--protocol-vnc);
}
.badge-vnc {
background: var(--protocol-vnc);
color: #fff;
}
.sidebar-item-indicator--vnc {
background: var(--protocol-vnc);
}
+444 -34
View File
@@ -20,6 +20,9 @@
ExportImportUI,
SFTPBrowser,
SnippetLibrary,
SSHKeyManager,
TriggerEngine,
LeviathanWorkbench,
stateLabel,
} = window.NexusTerminal;
@@ -40,7 +43,11 @@
this.exportImport = new ExportImportUI(this);
this.sftp = new SFTPBrowser(this);
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";
}
/**
@@ -66,10 +73,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
*/
@@ -78,7 +84,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);
@@ -91,7 +101,9 @@
resolve();
}
};
check();
Promise.resolve(window.NexusTerminal?.wailsServiceReady)
.catch(() => null)
.finally(check);
});
}
@@ -102,7 +114,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) {
@@ -115,7 +127,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) {
@@ -130,10 +142,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);
@@ -159,6 +171,14 @@
// Incoming session data (terminal output)
window.runtime.EventsOn("session:data", (payload) => {
this.terminals.handleData(payload.sessionID, payload.data);
this.tabs.markActivity(payload.sessionID);
// Check trigger rules against decoded text
try {
const raw = atob(payload.data);
this.triggers.check(payload.sessionID, raw);
} catch (_) {
/* noop */
}
});
// Session state transitions (connecting → connected → reconnecting → disconnected)
@@ -203,7 +223,7 @@
if (entry) {
const { cols, rows } = entry.term;
try {
window.go.api.Service.ResizeSession(
window.NexusTerminal.wailsService.ResizeSession(
payload.sessionID,
cols,
rows,
@@ -213,15 +233,19 @@
}
}
// Also re-fit to catch any layout changes that occurred while connecting.
// Multiple delays help byobu/tmux render correctly after its status
// bar initialisation completes.
const fit = () => this.terminals.fitSession(payload.sessionID);
setTimeout(fit, 50);
setTimeout(fit, 300);
setTimeout(fit, 800);
}
});
// Session closed by backend
window.runtime.EventsOn("session:closed", (payload) => {
this.sftp.close(payload.sessionID);
this.triggers.cleanup(payload.sessionID);
this.tabs.handleSessionClosed(payload.sessionID);
this.updateStatusBar();
});
@@ -256,6 +280,14 @@
);
});
// VNC WebSocket proxy ready — connect the viewer
window.runtime.EventsOn("session:vnc:ready", (payload) => {
window.NexusTerminal.VNCViewer.connect(
payload.sessionID,
payload.wsURL,
);
});
// App-wide commands from the native menu
window.runtime.EventsOn("app:open-about", () => {
this.settings.openTab("about");
@@ -289,14 +321,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
@@ -308,17 +338,24 @@
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,
});
}
});
// SFTP transfer progress events
window.runtime.EventsOn("session:sftp:progress", (payload) => {
this.sftp.handleTransferProgress(payload);
});
// Password/passphrase prompt requests from the backend
window.runtime.EventsOn("session:secret:prompt", async (payload) => {
try {
@@ -346,7 +383,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.
}
@@ -354,8 +391,9 @@
this.terminals.destroyTerminal(sessionId);
this.rawViewer.destroyViewer(sessionId);
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
this.state.removeSession(sessionId);
window.NexusTerminal.VNCViewer.destroyViewer(sessionId);
this.tabs.removeTab(sessionId);
this.state.removeSession(sessionId);
this.updateStatusBar();
}
@@ -501,12 +539,29 @@
setupUI() {
var btnEl = document.getElementById("btn-new-connection");
this._bindClick("btn-new-connection", () => this.profileEditor.openNew());
this._bindClick("btn-command-palette", () =>
this.commandPalette.toggle(),
);
this._bindClick("btn-settings", () => this.settings.open());
this._bindClick("btn-import", () => this.importProfiles());
this._bindClick("btn-export", () => this.exportProfiles());
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();
// Right-click on broadcast button opens target selector
const broadcastBtn = document.getElementById("btn-broadcast");
if (broadcastBtn) {
broadcastBtn.addEventListener("contextmenu", (e) => {
e.preventDefault();
this.selectBroadcastTargets();
});
}
this._bindClick("btn-empty-connect", () => this.profileEditor.openNew());
this._bindClick("btn-empty-quick", () => this.quickConnect());
@@ -542,6 +597,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 ─────────────────────────────────────────────────
/**
@@ -565,6 +647,9 @@
*/
toggleBroadcast() {
this.state.broadcastMode = !this.state.broadcastMode;
if (!this.state.broadcastMode) {
this.state.broadcastTargets = null;
}
const btn = document.getElementById("btn-broadcast");
if (btn) {
btn.classList.toggle("is-active", this.state.broadcastMode);
@@ -579,13 +664,122 @@
);
}
/**
* Opens a modal to select which sessions receive broadcast input.
*/
selectBroadcastTargets() {
const { createElement, escapeHtml } = window.NexusTerminal;
const sessions = this.state
.getAllSessions()
.filter(
(s) => s.state === "connected" && s.id !== this.state.activeSessionId,
);
if (sessions.length === 0) {
this.notifications.error("No other connected sessions to broadcast to");
return;
}
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-sm");
const currentTargets = this.state.broadcastTargets || new Set();
let listHtml = "";
for (const s of sessions) {
const profile = this.state.getProfile(s.profileId);
const label = profile
? profile.displayName || profile.host
: s.id.substring(0, 8);
const checked =
currentTargets.size === 0 || currentTargets.has(s.id)
? "checked"
: "";
listHtml += `
<label style="display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) 0;font-size:var(--text-sm);cursor:pointer;">
<input type="checkbox" class="broadcast-target-check" data-session-id="${s.id}" ${checked} />
<span style="color:var(--text-primary);">${escapeHtml(label)}</span>
</label>
`;
}
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Broadcast Targets</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<p style="font-size:var(--text-xs);color:var(--text-tertiary);margin-bottom:var(--space-2);">
Select which sessions should receive broadcast input:
</p>
${listHtml}
</div>
<div class="modal-footer">
<button class="btn btn-sm btn-ghost broadcast-cancel">Cancel</button>
<button class="btn btn-sm btn-secondary broadcast-all">Select All</button>
<button class="btn btn-sm btn-primary broadcast-apply">Apply & Enable</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const close = () => {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => backdrop.remove(), 200);
};
modal.querySelector(".modal-close").addEventListener("click", close);
modal.querySelector(".broadcast-cancel").addEventListener("click", close);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
modal.querySelector(".broadcast-all").addEventListener("click", () => {
modal.querySelectorAll(".broadcast-target-check").forEach((cb) => {
cb.checked = true;
});
});
modal.querySelector(".broadcast-apply").addEventListener("click", () => {
const checks = modal.querySelectorAll(".broadcast-target-check");
const selected = new Set();
checks.forEach((cb) => {
if (cb.checked) selected.add(cb.dataset.sessionId);
});
if (selected.size === 0) {
this.notifications.error("Select at least one target session");
return;
}
// If all are selected, use null (broadcast to all)
this.state.broadcastTargets =
selected.size === sessions.length ? null : selected;
if (!this.state.broadcastMode) {
this.toggleBroadcast();
} else {
const targetLabel =
selected.size === sessions.length
? "all sessions"
: selected.size + " sessions";
this.notifications.info("Broadcasting to " + targetLabel);
}
close();
});
}
// ── Connection lifecycle ─────────────────────────────────────────
/**
* 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");
@@ -615,9 +809,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);
@@ -634,6 +836,15 @@
profile.displayName || profile.host,
);
}
} else if (profile.protocol === "vnc") {
const container = document.getElementById(panelId);
if (container) {
window.NexusTerminal.VNCViewer.createViewer(
sessionInfo.id,
container,
profile,
);
}
} else {
this.terminals.createTerminal(sessionInfo.id, panelId, profile);
}
@@ -646,6 +857,30 @@
}
}
/**
* Opens a local terminal session.
*/
async connectLocal() {
try {
const sessionInfo = await window.NexusTerminal.wailsService.ConnectLocal();
this.state.addSession(sessionInfo);
this.tabs.addTab(sessionInfo);
const panelId = `panel-${sessionInfo.id}`;
const prefs = this.state.preferences || {};
this.terminals.createTerminal(sessionInfo.id, panelId, {
terminalFontFamily: prefs.defaultFontFamily,
terminalFontSize: prefs.defaultFontSize,
terminalScrollback: prefs.defaultScrollback,
terminalCursorStyle: prefs.defaultCursorStyle,
});
this.notifications.info("Opening local terminal…");
} catch (err) {
this.notifications.error("Local terminal failed: " + err);
}
}
/**
* Opens a quick connect dialog for ad-hoc connections without saving a profile.
*/
@@ -754,7 +989,7 @@
displayName: result.host,
tags: [],
};
const sessionInfo = await window.go.api.Service.ConnectQuick(profile);
const sessionInfo = await window.NexusTerminal.wailsService.ConnectQuick(profile);
this.state.addSession(sessionInfo);
this.tabs.addTab(sessionInfo);
@@ -785,7 +1020,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";
@@ -841,6 +1078,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 ──────────────────────────────────────────────
/**
@@ -865,6 +1170,7 @@
updateStatusBar() {
const connEl = document.getElementById("status-connection");
const countEl = document.getElementById("status-session-count");
const latencyEl = document.getElementById("status-latency");
const vaultEl = document.getElementById("status-vault");
const updateEl = document.getElementById("status-update");
if (!connEl) return;
@@ -884,6 +1190,20 @@
'<span class="status-dot is-disconnected"></span> No active session';
}
// Latency indicator — only show for connected SSH sessions.
if (latencyEl) {
if (
activeSession &&
activeSession.state === "connected" &&
activeSession.protocol === "ssh"
) {
this._startLatencyPing(activeSession.id);
} else {
this._stopLatencyPing();
latencyEl.textContent = "";
}
}
if (countEl) countEl.textContent = `${connected}/${total} sessions`;
if (vaultEl) {
vaultEl.textContent = this.state.secretStoreUnlocked
@@ -905,13 +1225,56 @@
}
}
_startLatencyPing(sessionID) {
// Don't restart if already pinging the same session.
if (this._latencySessionID === sessionID && this._latencyTimer) return;
this._stopLatencyPing();
this._latencySessionID = sessionID;
const doPing = async () => {
if (!window.NexusTerminal?.wailsService) return;
try {
const ms = await window.NexusTerminal.wailsService.PingSession(sessionID);
const el = document.getElementById("status-latency");
if (el && this._latencySessionID === sessionID) {
el.textContent = ms + " ms";
el.className =
ms < 100
? "latency-good"
: ms < 250
? "latency-warn"
: "latency-bad";
}
} catch (_) {
// Session may have disconnected — clear indicator.
const el = document.getElementById("status-latency");
if (el) {
el.textContent = "";
el.className = "";
}
this._stopLatencyPing();
}
};
doPing();
this._latencyTimer = setInterval(doPing, 10000);
}
_stopLatencyPing() {
if (this._latencyTimer) {
clearInterval(this._latencyTimer);
this._latencyTimer = null;
}
this._latencySessionID = null;
}
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(
@@ -1183,29 +1546,76 @@
const searchOpts = () => ({ caseSensitive, regex: useRegex });
let matchIndex = 0;
let totalMatches = 0;
const updateCount = (found) => {
if (!input.value) {
count.textContent = "";
matchIndex = 0;
totalMatches = 0;
return;
}
if (found) {
matchIndex++;
// Count total matches by searching the full buffer
const entry = this.terminals.terminals.get(sessionId);
if (entry) {
const buffer = entry.term.buffer.active;
let total = 0;
const query = input.value;
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i);
if (!line) continue;
const text = line.translateToString(true);
if (useRegex) {
try {
const re = new RegExp(query, caseSensitive ? "g" : "gi");
const m = text.match(re);
if (m) total += m.length;
} catch (_) {
/* invalid regex */
}
} else {
const searchText = caseSensitive ? text : text.toLowerCase();
const searchQuery = caseSensitive ? query : query.toLowerCase();
let pos = 0;
while ((pos = searchText.indexOf(searchQuery, pos)) !== -1) {
total++;
pos += searchQuery.length;
}
}
}
totalMatches = total;
if (matchIndex > totalMatches) matchIndex = 1;
count.textContent = matchIndex + " of " + totalMatches;
} else {
count.textContent = "Match found";
}
} else {
matchIndex = 0;
totalMatches = 0;
count.textContent = "No matches";
}
count.style.color = found ? "" : "var(--error)";
};
const doSearch = () => {
const found = this.terminals.search(
sessionId,
input.value,
searchOpts(),
);
count.textContent = input.value
? found
? "Match found"
: "No matches"
: "";
updateCount(found);
};
const doPrev = () => {
matchIndex = Math.max(0, matchIndex - 2);
const found = this.terminals.searchPrevious(
sessionId,
input.value,
searchOpts(),
);
count.textContent = input.value
? found
? "Match found"
: "No matches"
: "";
updateCount(found);
};
const closeSearch = () => {
+339 -280
View File
@@ -1,27 +1,27 @@
(function() {
/**
* Nexus Terminal — command palette (Ctrl+K).
* Provides a searchable overlay for quick access to application commands,
* profile connections, and utility actions.
*/
(function () {
/**
* Nexus Terminal — command palette (Ctrl+K).
* Provides a searchable overlay for quick access to application commands,
* profile connections, and utility actions.
*/
const { createElement, escapeHtml, fuzzyMatch, $ } = window.NexusTerminal;
const { createElement, escapeHtml, fuzzyMatch, $ } = window.NexusTerminal;
class CommandPalette {
class CommandPalette {
/**
* @param {App} app
*/
constructor(app) {
this.app = app;
this.app = app;
/** @type {HTMLElement|null} */
this._overlay = null;
/** @type {HTMLElement|null} */
this._overlay = null;
/** @type {string} Current filter query. */
this._query = '';
/** @type {string} Current filter query. */
this._query = "";
/** @type {number} Index of the highlighted command. */
this._selectedIndex = 0;
/** @type {number} Index of the highlighted command. */
this._selectedIndex = 0;
}
// ── Public API ───────────────────────────────────────────────────
@@ -30,31 +30,31 @@ class CommandPalette {
* Toggles the command palette open/closed.
*/
toggle() {
if (this._overlay) {
this.close();
} else {
this.open();
}
if (this._overlay) {
this.close();
} else {
this.open();
}
}
/**
* Opens the command palette.
*/
open() {
if (this._overlay) return;
this._query = '';
this._selectedIndex = 0;
this._render();
if (this._overlay) return;
this._query = "";
this._selectedIndex = 0;
this._render();
}
/**
* Closes the command palette.
*/
close() {
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
}
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
}
}
// ── Command definitions ──────────────────────────────────────────
@@ -64,151 +64,192 @@ class CommandPalette {
* @returns {Array<{id: string, label: string, shortcut?: string, section?: string, action: Function}>}
*/
getCommands() {
const cmds = [];
const cmds = [];
// Profile connections
const profiles = this.app.state.profiles || [];
for (const p of profiles) {
cmds.push({
id: `connect-${p.id}`,
label: `Connect to ${p.displayName || p.host}`,
section: 'Connections',
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
action: () => this.app.connectProfile(p.id),
});
}
// Profile connections
const profiles = this.app.state.profiles || [];
for (const p of profiles) {
cmds.push({
id: `connect-${p.id}`,
label: `Connect to ${p.displayName || p.host}`,
section: "Connections",
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
action: () => this.app.connectProfile(p.id),
});
}
// New connections
cmds.push({
id: 'new-ssh',
label: 'New SSH Connection',
section: 'Actions',
shortcut: '⌘N',
action: () => this.app.profileEditor.openNew('ssh'),
});
cmds.push({
id: 'new-telnet',
label: 'New Telnet Connection',
section: 'Actions',
action: () => this.app.profileEditor.openNew('telnet'),
});
cmds.push({
id: 'new-raw',
label: 'New RAW Connection',
section: 'Actions',
action: () => this.app.profileEditor.openNew('raw'),
});
// New connections
cmds.push({
id: "new-ssh",
label: "New SSH Connection",
section: "Actions",
shortcut: "⌘N",
action: () => this.app.profileEditor.openNew("ssh"),
});
cmds.push({
id: "new-telnet",
label: "New Telnet Connection",
section: "Actions",
action: () => this.app.profileEditor.openNew("telnet"),
});
cmds.push({
id: "new-raw",
label: "New RAW Connection",
section: "Actions",
action: () => this.app.profileEditor.openNew("raw"),
});
// Quick connect
cmds.push({
id: 'quick-connect',
label: 'Quick Connect…',
section: 'Actions',
action: () => this.app.quickConnect(),
});
// Quick connect
cmds.push({
id: "quick-connect",
label: "Quick Connect…",
section: "Actions",
action: () => this.app.quickConnect(),
});
// Tab actions
cmds.push({
id: 'close-tab',
label: 'Close Tab',
section: 'Tabs',
shortcut: '⌘W',
action: () => this.app.tabs.closeActive(),
});
cmds.push({
id: 'next-tab',
label: 'Next Tab',
section: 'Tabs',
shortcut: '⌘Tab',
action: () => {
const next = this.app.tabs.getNextTab();
if (next) this.app.tabs.setActive(next);
},
});
cmds.push({
id: 'prev-tab',
label: 'Previous Tab',
section: 'Tabs',
shortcut: '⌘⇧Tab',
action: () => {
const prev = this.app.tabs.getPrevTab();
if (prev) this.app.tabs.setActive(prev);
},
});
// Local terminal
cmds.push({
id: "local-terminal",
label: "Open Local Terminal",
section: "Actions",
action: () => this.app.connectLocal(),
});
// Settings & utility
// Tab actions
cmds.push({
id: "close-tab",
label: "Close Tab",
section: "Tabs",
shortcut: "⌘W",
action: () => this.app.tabs.closeActive(),
});
cmds.push({
id: "next-tab",
label: "Next Tab",
section: "Tabs",
shortcut: "⌘Tab",
action: () => {
const next = this.app.tabs.getNextTab();
if (next) this.app.tabs.setActive(next);
},
});
cmds.push({
id: "prev-tab",
label: "Previous Tab",
section: "Tabs",
shortcut: "⌘⇧Tab",
action: () => {
const prev = this.app.tabs.getPrevTab();
if (prev) this.app.tabs.setActive(prev);
},
});
// Settings & utility
cmds.push({
id: "settings",
label: "Settings",
section: "Application",
action: () => this.app.settings.open(),
});
cmds.push({
id: "import-profiles",
label: "Import Profiles",
section: "Application",
action: () => this.app.importProfiles(),
});
cmds.push({
id: "export-profiles",
label: "Export Profiles",
section: "Application",
action: () => this.app.exportProfiles(),
});
// Secret store
if (this.app.state.secretStoreUnlocked) {
cmds.push({
id: 'settings',
label: 'Settings',
section: 'Application',
action: () => this.app.settings.open(),
id: "lock-secrets",
label: "Lock Secret Store",
section: "Security",
action: async () => {
try {
await window.NexusTerminal.wailsService.LockSecretStore();
this.app.state.setSecretStoreState(false);
this.app.notifications.info("Secret store locked");
} catch (err) {
this.app.notifications.error("Failed to lock: " + err);
}
},
});
} else {
cmds.push({
id: "unlock-secrets",
label: "Unlock Secret Store",
section: "Security",
action: async () => {
try {
const pw =
await this.app.secretPrompt.promptMasterPassword(false);
await window.NexusTerminal.wailsService.UnlockSecretStore(pw);
this.app.state.setSecretStoreState(true);
this.app.notifications.success("Secret store unlocked");
} catch (err) {
if (err.message !== "User cancelled") {
this.app.notifications.error("Unlock failed: " + err);
}
}
},
});
}
// SSH Key Manager
cmds.push({
id: "ssh-keys",
label: "SSH Key Manager",
section: "Security",
action: () => this.app.sshKeys.open(),
});
// Trigger Rules
cmds.push({
id: "trigger-rules",
label: "Trigger Rules",
section: "Terminal",
action: () => this.app.triggers.open(),
});
// Terminal actions (only when a session is active)
if (this.app.state.activeSessionId) {
cmds.push({
id: "split-vertical",
label: "Split Pane Right",
section: "Terminal",
shortcut: "⌘⇧D",
action: () => this.app.splitPane("vertical"),
});
cmds.push({
id: 'import-profiles',
label: 'Import Profiles',
section: 'Application',
action: () => this.app.importProfiles(),
id: "split-horizontal",
label: "Split Pane Down",
section: "Terminal",
shortcut: "⌘⇧E",
action: () => this.app.splitPane("horizontal"),
});
cmds.push({
id: 'export-profiles',
label: 'Export Profiles',
section: 'Application',
action: () => this.app.exportProfiles(),
id: "clear-terminal",
label: "Clear Terminal",
section: "Terminal",
action: () =>
this.app.terminals.clearTerminal(this.app.state.activeSessionId),
});
cmds.push({
id: "reset-terminal",
label: "Reset Terminal",
section: "Terminal",
action: () =>
this.app.terminals.resetTerminal(this.app.state.activeSessionId),
});
}
// Secret store
if (this.app.state.secretStoreUnlocked) {
cmds.push({
id: 'lock-secrets',
label: 'Lock Secret Store',
section: 'Security',
action: async () => {
try {
await window.go.api.Service.LockSecretStore();
this.app.state.setSecretStoreState(false);
this.app.notifications.info('Secret store locked');
} catch (err) {
this.app.notifications.error('Failed to lock: ' + err);
}
},
});
} else {
cmds.push({
id: 'unlock-secrets',
label: 'Unlock Secret Store',
section: 'Security',
action: async () => {
try {
const pw = await this.app.secretPrompt.promptMasterPassword(false);
await window.go.api.Service.UnlockSecretStore(pw);
this.app.state.setSecretStoreState(true);
this.app.notifications.success('Secret store unlocked');
} catch (err) {
if (err.message !== 'User cancelled') {
this.app.notifications.error('Unlock failed: ' + err);
}
}
},
});
}
// Terminal actions (only when a session is active)
if (this.app.state.activeSessionId) {
cmds.push({
id: 'clear-terminal',
label: 'Clear Terminal',
section: 'Terminal',
action: () => this.app.terminals.clearTerminal(this.app.state.activeSessionId),
});
cmds.push({
id: 'reset-terminal',
label: 'Reset Terminal',
section: 'Terminal',
action: () => this.app.terminals.resetTerminal(this.app.state.activeSessionId),
});
}
return cmds;
return cmds;
}
/**
@@ -217,14 +258,17 @@ class CommandPalette {
* @returns {Array<Object>}
*/
filter(query) {
if (!query) return this.getCommands();
return this.getCommands()
.map((cmd) => {
const result = fuzzyMatch(cmd.label + ' ' + (cmd.detail || ''), query);
return { ...cmd, ...result };
})
.filter((c) => c.match)
.sort((a, b) => b.score - a.score);
if (!query) return this.getCommands();
return this.getCommands()
.map((cmd) => {
const result = fuzzyMatch(
cmd.label + " " + (cmd.detail || ""),
query,
);
return { ...cmd, ...result };
})
.filter((c) => c.match)
.sort((a, b) => b.score - a.score);
}
/**
@@ -232,82 +276,85 @@ class CommandPalette {
* @param {Object} command
*/
execute(command) {
this.close();
if (command && typeof command.action === 'function') {
command.action();
}
this.close();
if (command && typeof command.action === "function") {
command.action();
}
}
// ── Rendering ────────────────────────────────────────────────────
/** @private */
_render() {
const overlay = createElement('div', 'palette-overlay');
const palette = createElement('div', 'palette');
const overlay = createElement("div", "palette-overlay");
const palette = createElement("div", "palette");
// Search input
const input = createElement('input', 'palette-input', {
type: 'text',
placeholder: 'Type a command…',
autocomplete: 'off',
spellcheck: 'false',
});
// Search input
const input = createElement("input", "palette-input", {
type: "text",
placeholder: "Type a command…",
autocomplete: "off",
spellcheck: "false",
});
palette.appendChild(input);
palette.appendChild(input);
// Results list
const list = createElement('div', 'palette-list');
palette.appendChild(list);
// Results list
const list = createElement("div", "palette-list");
palette.appendChild(list);
overlay.appendChild(palette);
document.body.appendChild(overlay);
this._overlay = overlay;
overlay.appendChild(palette);
document.body.appendChild(overlay);
this._overlay = overlay;
// Initial render
this._renderList(list, this.filter(''));
// Initial render
this._renderList(list, this.filter(""));
// Focus the input
input.focus();
// Focus the input
input.focus();
// Event handlers
input.addEventListener('input', () => {
this._query = input.value;
this._selectedIndex = 0;
this._renderList(list, this.filter(this._query));
});
// Event handlers
input.addEventListener("input", () => {
this._query = input.value;
this._selectedIndex = 0;
this._renderList(list, this.filter(this._query));
});
input.addEventListener('keydown', (e) => {
const items = list.querySelectorAll('.palette-item');
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this._selectedIndex = Math.min(this._selectedIndex + 1, items.length - 1);
this._highlightItem(items);
break;
case 'ArrowUp':
e.preventDefault();
this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
this._highlightItem(items);
break;
case 'Enter': {
e.preventDefault();
const selected = items[this._selectedIndex];
if (selected && selected._command) {
this.execute(selected._command);
}
break;
}
case 'Escape':
e.preventDefault();
this.close();
break;
input.addEventListener("keydown", (e) => {
const items = list.querySelectorAll(".palette-item");
switch (e.key) {
case "ArrowDown":
e.preventDefault();
this._selectedIndex = Math.min(
this._selectedIndex + 1,
items.length - 1,
);
this._highlightItem(items);
break;
case "ArrowUp":
e.preventDefault();
this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
this._highlightItem(items);
break;
case "Enter": {
e.preventDefault();
const selected = items[this._selectedIndex];
if (selected && selected._command) {
this.execute(selected._command);
}
});
break;
}
case "Escape":
e.preventDefault();
this.close();
break;
}
});
// Click outside to close
overlay.addEventListener('click', (e) => {
if (e.target === overlay) this.close();
});
// Click outside to close
overlay.addEventListener("click", (e) => {
if (e.target === overlay) this.close();
});
}
/**
@@ -317,52 +364,65 @@ class CommandPalette {
* @private
*/
_renderList(list, commands) {
list.innerHTML = '';
let currentSection = null;
list.innerHTML = "";
let currentSection = null;
if (commands.length === 0) {
const empty = createElement('div', 'palette-empty', { textContent: 'No commands found' });
list.appendChild(empty);
return;
if (commands.length === 0) {
const empty = createElement("div", "palette-empty", {
textContent: "No commands found",
});
list.appendChild(empty);
return;
}
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i];
// Section header
if (cmd.section && cmd.section !== currentSection) {
currentSection = cmd.section;
const sectionEl = createElement("div", "palette-section", {
textContent: cmd.section,
});
list.appendChild(sectionEl);
}
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i];
const item = createElement(
"div",
`palette-item ${i === this._selectedIndex ? "palette-item-active" : ""}`,
);
item._command = cmd;
// Section header
if (cmd.section && cmd.section !== currentSection) {
currentSection = cmd.section;
const sectionEl = createElement('div', 'palette-section', { textContent: cmd.section });
list.appendChild(sectionEl);
}
// Label
const label = createElement("span", "palette-item-label", {
textContent: cmd.label,
});
item.appendChild(label);
const item = createElement('div', `palette-item ${i === this._selectedIndex ? 'palette-item-active' : ''}`);
item._command = cmd;
// Label
const label = createElement('span', 'palette-item-label', { textContent: cmd.label });
item.appendChild(label);
// Detail
if (cmd.detail) {
const detail = createElement('span', 'palette-item-detail', { textContent: cmd.detail });
item.appendChild(detail);
}
// Shortcut badge
if (cmd.shortcut) {
const badge = createElement('span', 'palette-item-shortcut', { textContent: cmd.shortcut });
item.appendChild(badge);
}
item.addEventListener('click', () => this.execute(cmd));
item.addEventListener('mouseenter', () => {
this._selectedIndex = i;
this._highlightItem(list.querySelectorAll('.palette-item'));
});
list.appendChild(item);
// Detail
if (cmd.detail) {
const detail = createElement("span", "palette-item-detail", {
textContent: cmd.detail,
});
item.appendChild(detail);
}
// Shortcut badge
if (cmd.shortcut) {
const badge = createElement("span", "palette-item-shortcut", {
textContent: cmd.shortcut,
});
item.appendChild(badge);
}
item.addEventListener("click", () => this.execute(cmd));
item.addEventListener("mouseenter", () => {
this._selectedIndex = i;
this._highlightItem(list.querySelectorAll(".palette-item"));
});
list.appendChild(item);
}
}
/**
@@ -371,17 +431,16 @@ class CommandPalette {
* @private
*/
_highlightItem(items) {
items.forEach((el, i) => {
el.classList.toggle('palette-item-active', i === this._selectedIndex);
});
const active = items[this._selectedIndex];
if (active) {
active.scrollIntoView({ block: 'nearest' });
}
items.forEach((el, i) => {
el.classList.toggle("palette-item-active", i === this._selectedIndex);
});
const active = items[this._selectedIndex];
if (active) {
active.scrollIntoView({ block: "nearest" });
}
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.CommandPalette = CommandPalette;
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.CommandPalette = CommandPalette;
})();
+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");
}
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
+215 -22
View File
@@ -65,6 +65,27 @@
refreshIntervalSecs: 300,
},
},
vnc: {
port: 5900,
protocol: "vnc",
vnc: {
vncHost: "",
vncPort: 5900,
secretMode: "prompt",
viewOnly: false,
scaleToFit: true,
quality: 6,
sharedSession: true,
sshTunnel: false,
sshHost: "",
sshPort: 22,
sshUsername: "",
sshAuthMethod: "publickey",
sshSecretMode: "saved",
sshPrivateKey: "",
sshAgentAuth: false,
},
},
};
class ProfileEditor {
@@ -141,7 +162,7 @@
drawer.appendChild(header);
// ── Protocol selector tabs ──────────────────────────────
const protocols = ["ssh", "telnet", "raw", "github"];
const protocols = ["ssh", "telnet", "raw", "github", "vnc"];
const tabBar = createElement("div", "drawer-protocol-tabs");
for (const proto of protocols) {
const label = proto === "github" ? "GitHub" : proto.toUpperCase();
@@ -184,10 +205,17 @@
body.appendChild(s),
);
break;
case "vnc":
this._renderVNCSections(profile).forEach((s) => body.appendChild(s));
break;
}
// Terminal section (SSH and Telnet only)
if (profile.protocol !== "raw" && profile.protocol !== "github") {
if (
profile.protocol !== "raw" &&
profile.protocol !== "github" &&
profile.protocol !== "vnc"
) {
body.appendChild(this._renderTerminalSection(profile));
}
@@ -409,10 +437,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;
@@ -922,6 +950,131 @@
return sections;
}
/** @private */
_renderVNCSections(profile) {
const sections = [];
const vnc = profile.vnc || DEFAULTS.vnc.vnc;
// VNC connection settings
sections.push(
this._renderSection("VNC Server", [
this._field("text", "vnc.vncHost", "VNC Host", vnc.vncHost, {
placeholder: "192.168.1.100",
}),
this._field("number", "vnc.vncPort", "VNC Port", vnc.vncPort, {
min: 1,
max: 65535,
}),
this._field(
"select",
"vnc.secretMode",
"Password Storage",
vnc.secretMode,
{
options: [
{ value: "saved", label: "Save to keychain" },
{ value: "session-only", label: "Remember for session" },
{ value: "prompt", label: "Ask every time" },
],
},
),
]),
);
// Display settings
sections.push(
this._renderSection("Display", [
this._field("checkbox", "vnc.viewOnly", "View Only", vnc.viewOnly),
this._field(
"checkbox",
"vnc.scaleToFit",
"Scale to Fit",
vnc.scaleToFit,
),
this._field(
"checkbox",
"vnc.sharedSession",
"Shared Session",
vnc.sharedSession,
),
this._field("number", "vnc.quality", "Quality (1-9)", vnc.quality, {
min: 1,
max: 9,
}),
]),
);
// SSH tunnel section
sections.push(
this._renderSection("SSH Tunnel", [
this._field(
"checkbox",
"vnc.sshTunnel",
"Enable SSH Tunnel",
vnc.sshTunnel,
),
this._field("text", "vnc.sshHost", "SSH Host", vnc.sshHost, {
placeholder: "jump.example.com",
}),
this._field("number", "vnc.sshPort", "SSH Port", vnc.sshPort, {
min: 1,
max: 65535,
}),
this._field(
"text",
"vnc.sshUsername",
"SSH Username",
vnc.sshUsername,
{
placeholder: "admin",
},
),
this._field(
"select",
"vnc.sshAuthMethod",
"SSH Auth Method",
vnc.sshAuthMethod,
{
options: [
{ value: "publickey", label: "Public Key" },
{ value: "password", label: "Password" },
],
},
),
this._field(
"text",
"vnc.sshPrivateKey",
"SSH Private Key Path",
vnc.sshPrivateKey,
{
placeholder: "~/.ssh/id_ed25519",
},
),
this._field(
"checkbox",
"vnc.sshAgentAuth",
"Use SSH Agent",
vnc.sshAgentAuth,
),
this._field(
"select",
"vnc.sshSecretMode",
"SSH Secret Storage",
vnc.sshSecretMode,
{
options: [
{ value: "saved", label: "Save to keychain" },
{ value: "session-only", label: "Remember for session" },
{ value: "prompt", label: "Ask every time" },
],
},
),
]),
);
return sections;
}
/** @private */
_renderTerminalSection(profile) {
return this._renderSection("Terminal", [
@@ -1062,7 +1215,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";
@@ -1101,7 +1254,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(
@@ -1112,7 +1265,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",
@@ -1142,7 +1295,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)";
@@ -1166,7 +1319,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)";
}
@@ -1610,8 +1763,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;
})
@@ -1678,8 +1831,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);
@@ -1755,10 +1908,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.";
@@ -1769,7 +1922,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";
@@ -2102,6 +2255,26 @@
p.github.secretMode = val("github.secretMode") || "saved";
p.github.refreshIntervalSecs = val("github.refreshIntervalSecs") || 300;
}
// VNC-specific
if (p.protocol === "vnc") {
if (!p.vnc) p.vnc = {};
p.vnc.vncHost = val("vnc.vncHost") || "";
p.vnc.vncPort = val("vnc.vncPort") || 5900;
p.vnc.secretMode = val("vnc.secretMode") || "prompt";
p.vnc.viewOnly = val("vnc.viewOnly") || false;
p.vnc.scaleToFit = val("vnc.scaleToFit") !== false;
p.vnc.quality = val("vnc.quality") || 6;
p.vnc.sharedSession = val("vnc.sharedSession") !== false;
p.vnc.sshTunnel = val("vnc.sshTunnel") || false;
p.vnc.sshHost = val("vnc.sshHost") || "";
p.vnc.sshPort = val("vnc.sshPort") || 22;
p.vnc.sshUsername = val("vnc.sshUsername") || "";
p.vnc.sshAuthMethod = val("vnc.sshAuthMethod") || "publickey";
p.vnc.sshPrivateKey = val("vnc.sshPrivateKey") || "";
p.vnc.sshAgentAuth = val("vnc.sshAgentAuth") || false;
p.vnc.sshSecretMode = val("vnc.sshSecretMode") || "saved";
}
}
// ── Validation ───────────────────────────────────────────────────
@@ -2114,7 +2287,14 @@
*/
_validate(profile) {
const errors = [];
if (!profile.host || !profile.host.trim()) {
if (profile.protocol === "vnc") {
const vncHost = (profile.vnc && profile.vnc.vncHost) || profile.host;
if (!vncHost || !vncHost.trim()) {
errors.push(
"VNC Host is required (in General or VNC Server section).",
);
}
} else if (!profile.host || !profile.host.trim()) {
errors.push("Host is required.");
}
if (!profile.port || profile.port < 1 || profile.port > 65535) {
@@ -2134,14 +2314,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");
}
@@ -2149,7 +2340,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);
@@ -2162,7 +2353,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");
@@ -2186,7 +2377,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`,
);
@@ -2283,6 +2474,8 @@
ssh: null,
telnet: null,
raw: null,
github: null,
vnc: null,
};
const defaults = DEFAULTS[protocol] || {};
+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();
+145 -18
View File
@@ -48,11 +48,17 @@
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.NexusTerminal.wailsService.GetDefaultShell();
} catch (_) {
this._defaultShell = "";
}
this._render();
}
@@ -82,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.
@@ -281,6 +287,45 @@
{ min: 180, max: 500 },
),
);
// Local shell path with browse button
const shellGroup = createElement("div", "form-group settings-field");
const shellLabel = createElement("label", "form-label", {
textContent: "Local shell",
for: "setting-localShell",
});
shellGroup.appendChild(shellLabel);
const shellRow = createElement("div", "settings-shell-row");
const shellInput = createElement("input", "form-input", {
type: "text",
id: "setting-localShell",
name: "setting-localShell",
value: p.localShell || "",
placeholder: this._defaultShell || "/bin/zsh",
});
shellRow.appendChild(shellInput);
const browseBtn = createElement("button", "btn btn-sm btn-secondary", {
textContent: "Browse…",
type: "button",
});
browseBtn.addEventListener("click", async () => {
try {
const path = await window.NexusTerminal.wailsService.BrowseForShell();
if (path) {
shellInput.value = path;
}
} catch (_) {}
});
shellRow.appendChild(browseBtn);
shellGroup.appendChild(shellRow);
const shellHint = createElement("span", "form-hint", {
textContent: "Leave empty to use system default",
});
shellGroup.appendChild(shellHint);
body.appendChild(shellGroup);
}
/**
@@ -384,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");
@@ -403,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);
@@ -420,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);
@@ -477,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");
@@ -504,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>
@@ -586,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",
@@ -601,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",
@@ -610,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>
@@ -752,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>
@@ -866,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}`;
@@ -886,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.";
}
@@ -916,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",
);
@@ -1050,6 +1176,7 @@
"defaultScrollback",
"defaultCursorStyle",
"useNativePaste",
"localShell",
];
for (const field of fields) {
+432 -38
View File
@@ -231,6 +231,16 @@
var actions = createElement("div", "sftp-toolbar-actions");
var goToBtn = this._toolbarBtn("⤳", "Go to path", "");
goToBtn.addEventListener("click", () => this._promptGoToPath(sessionId));
actions.appendChild(goToBtn);
var bookmarkBtn = this._toolbarBtn("★", "Bookmarks", "");
bookmarkBtn.addEventListener("click", () =>
this._showBookmarks(sessionId),
);
actions.appendChild(bookmarkBtn);
var mkdirBtn = this._toolbarBtn("+", "New folder", "is-accent");
mkdirBtn.addEventListener("click", () => this._promptMkdir(sessionId));
actions.appendChild(mkdirBtn);
@@ -271,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;
@@ -426,7 +436,7 @@
if (!isParent) {
row.addEventListener("click", function (e) {
e.stopPropagation();
self._selectFile(sessionId, entry, row);
self._selectFile(sessionId, entry, row, e);
});
}
@@ -468,65 +478,123 @@
/* ── Selection & context actions ──────────────────────────────── */
_selectFile(sessionId, entry, rowEl) {
_selectFile(sessionId, entry, rowEl, event) {
var state = this.panels.get(sessionId);
if (!state) return;
// Deselect previous
var prev = state.el.querySelector(".sftp-file-row.is-selected");
if (prev) prev.classList.remove("is-selected");
// Initialise multi-select set if needed
if (!state.selectedFiles) state.selectedFiles = new Map();
// If clicking the same entry, deselect
if (state.selected === entry.path) {
state.selected = null;
var ctxBar = this._getCtxBar(sessionId);
if (ctxBar) ctxBar.style.display = "none";
return;
var multiKey = event && (event.metaKey || event.ctrlKey);
if (multiKey) {
// Toggle this entry in the multi-selection
if (state.selectedFiles.has(entry.path)) {
state.selectedFiles.delete(entry.path);
rowEl.classList.remove("is-selected");
} else {
state.selectedFiles.set(entry.path, entry);
rowEl.classList.add("is-selected");
}
} else {
// Single click without modifier — clear all, select this one
state.el
.querySelectorAll(".sftp-file-row.is-selected")
.forEach(function (r) {
r.classList.remove("is-selected");
});
// If clicking the same entry (and it was the only selection), deselect
if (
state.selectedFiles.size === 1 &&
state.selectedFiles.has(entry.path)
) {
state.selectedFiles.clear();
state.selected = null;
var ctxBar = this._getCtxBar(sessionId);
if (ctxBar) ctxBar.style.display = "none";
return;
}
state.selectedFiles.clear();
state.selectedFiles.set(entry.path, entry);
rowEl.classList.add("is-selected");
}
state.selected = entry.path;
rowEl.classList.add("is-selected");
this._showContextBar(sessionId, entry);
state.selected = state.selectedFiles.size > 0 ? entry.path : null;
this._showContextBar(sessionId, null);
}
_showContextBar(sessionId, entry) {
_showContextBar(sessionId, _unused) {
var state = this.panels.get(sessionId);
if (!state) return;
var ctxBar = this._getCtxBar(sessionId);
if (!ctxBar) return;
if (!state.selectedFiles || state.selectedFiles.size === 0) {
ctxBar.style.display = "none";
return;
}
ctxBar.innerHTML = "";
ctxBar.style.display = "flex";
var self = this;
var entries = Array.from(state.selectedFiles.values());
var count = entries.length;
var isSingle = count === 1;
var entry = entries[0];
var label = createElement("span", "sftp-ctx-label", {
textContent: entry.name,
title: entry.path,
textContent: isSingle ? entry.name : count + " items selected",
title: isSingle
? entry.path
: entries
.map(function (e) {
return e.name;
})
.join(", "),
});
ctxBar.appendChild(label);
var self = this;
if (!entry.isDir) {
// Download button — only for files (single or batch)
var downloadableFiles = entries.filter(function (e) {
return !e.isDir;
});
if (downloadableFiles.length > 0) {
var dlLabel =
downloadableFiles.length === 1
? "↓ Download"
: "↓ Download (" + downloadableFiles.length + ")";
var dlBtn = createElement("button", "sftp-ctx-btn is-accent", {
textContent: "↓ Download",
textContent: dlLabel,
});
dlBtn.addEventListener("click", function () {
self._download(sessionId, entry.path);
self._downloadBatch(sessionId, downloadableFiles);
});
ctxBar.appendChild(dlBtn);
}
var renameBtn = createElement("button", "sftp-ctx-btn", {
textContent: "✎ Rename",
});
renameBtn.addEventListener("click", function () {
self._promptRename(sessionId, entry);
});
ctxBar.appendChild(renameBtn);
if (isSingle) {
var renameBtn = createElement("button", "sftp-ctx-btn", {
textContent: "✎ Rename",
});
renameBtn.addEventListener("click", function () {
self._promptRename(sessionId, entry);
});
ctxBar.appendChild(renameBtn);
}
var delLabel = isSingle ? "✕ Delete" : "✕ Delete (" + count + ")";
var delBtn = createElement("button", "sftp-ctx-btn is-danger", {
textContent: "✕ Delete",
textContent: delLabel,
});
delBtn.addEventListener("click", function () {
self._confirmDelete(sessionId, entry);
if (isSingle) {
self._confirmDelete(sessionId, entry);
} else {
self._confirmDeleteBatch(sessionId, entries);
}
});
ctxBar.appendChild(delBtn);
}
@@ -535,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(
@@ -548,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) {
@@ -565,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) {
@@ -578,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) {
@@ -588,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",
);
@@ -598,6 +666,59 @@
}
}
async _downloadBatch(sessionId, files) {
this.app.notifications.info("Downloading " + files.length + " files…");
var failed = 0;
for (var i = 0; i < files.length; i++) {
try {
await window.NexusTerminal.wailsService.SFTPDownload(sessionId, files[i].path);
} catch (err) {
failed++;
this.app.notifications.error(
"Download failed: " + files[i].name + " — " + (err.message || err),
);
}
}
if (failed === 0) {
this.app.notifications.success(
"All " + files.length + " files downloaded",
);
}
}
_confirmDeleteBatch(sessionId, entries) {
this._showConfirmModal({
title: "Delete " + entries.length + " Items",
message:
"Permanently delete <strong>" +
entries.length +
" selected items</strong>?",
confirmLabel: "Delete All",
isDanger: true,
onConfirm: function () {
this._deleteBatch(sessionId, entries);
}.bind(this),
});
}
async _deleteBatch(sessionId, entries) {
var failed = 0;
for (var i = 0; i < entries.length; i++) {
try {
await window.NexusTerminal.wailsService.SFTPRemove(sessionId, entries[i].path);
} catch (err) {
failed++;
this.app.notifications.error(
"Delete failed: " + entries[i].name + " — " + (err.message || err),
);
}
}
if (failed === 0) {
this.app.notifications.success("Deleted " + entries.length + " items");
}
await this._refresh(sessionId);
}
/* ── Modal dialogs (no prompt/confirm/alert) ──────────────────── */
_promptMkdir(sessionId) {
@@ -784,6 +905,279 @@
});
}
/* ── Go to path & Bookmarks ───────────────────────────────────── */
_promptGoToPath(sessionId) {
var state = this.panels.get(sessionId);
if (!state) return;
this._showInputModal({
title: "Go to Path",
placeholder: "/path/to/directory",
initialValue: state.cwd,
confirmLabel: "Navigate",
onConfirm: function (value) {
var path = value.trim();
if (path) this._navigate(sessionId, path);
}.bind(this),
});
}
_showBookmarks(sessionId) {
var state = this.panels.get(sessionId);
if (!state) return;
var self = this;
var bookmarks = this._getBookmarks(sessionId);
var backdrop = createElement("div", "modal-backdrop");
var modal = createElement("div", "modal modal-sm");
var listHtml = "";
if (bookmarks.length === 0) {
listHtml =
'<div style="color:var(--text-tertiary);font-size:var(--text-sm);text-align:center;padding:var(--space-3);">No bookmarks yet. Navigate to a directory and click "Bookmark Current".</div>';
} else {
for (var i = 0; i < bookmarks.length; i++) {
listHtml +=
'<div class="sftp-bookmark-row" data-index="' +
i +
'" data-path="' +
escapeHtml(bookmarks[i]) +
'">';
listHtml +=
'<span style="flex:1;font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' +
escapeHtml(bookmarks[i]) +
"</span>";
listHtml +=
'<button class="btn btn-xs btn-ghost sftp-bookmark-del" data-index="' +
i +
'" title="Remove">✕</button>';
listHtml += "</div>";
}
}
modal.innerHTML =
'<div class="modal-header">' +
'<span class="modal-title">Directory Bookmarks</span>' +
'<button class="modal-close btn btn-icon btn-xs btn-ghost">✕</button>' +
"</div>" +
'<div class="modal-body">' +
'<div class="sftp-bookmark-list">' +
listHtml +
"</div>" +
"</div>" +
'<div class="modal-footer">' +
'<button class="btn btn-sm btn-ghost sftp-modal-cancel">Close</button>' +
'<button class="btn btn-sm btn-primary sftp-bookmark-add">★ Bookmark Current</button>' +
"</div>";
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(function () {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
var closed = false;
function close() {
if (closed) return;
closed = true;
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(function () {
backdrop.remove();
}, 200);
}
modal.querySelector(".modal-close").addEventListener("click", close);
modal
.querySelector(".sftp-modal-cancel")
.addEventListener("click", close);
backdrop.addEventListener("click", function (e) {
if (e.target === backdrop) close();
});
// Navigate to bookmark on click
modal.querySelectorAll(".sftp-bookmark-row").forEach(function (row) {
row.addEventListener("click", function (e) {
if (e.target.classList.contains("sftp-bookmark-del")) return;
var path = row.dataset.path;
close();
self._navigate(sessionId, path);
});
row.addEventListener("mousedown", function (e) {
e.preventDefault();
});
});
// Delete bookmark
modal.querySelectorAll(".sftp-bookmark-del").forEach(function (btn) {
btn.addEventListener("click", function (e) {
e.stopPropagation();
var idx = parseInt(btn.dataset.index, 10);
self._removeBookmark(sessionId, idx);
close();
self._showBookmarks(sessionId);
});
});
// Add current directory as bookmark
modal
.querySelector(".sftp-bookmark-add")
.addEventListener("click", function () {
self._addBookmark(sessionId, state.cwd);
close();
self._showBookmarks(sessionId);
});
}
_getBookmarks(sessionId) {
try {
var key = "sftp-bookmarks-" + sessionId;
var stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : [];
} catch (_) {
return [];
}
}
_addBookmark(sessionId, path) {
var bookmarks = this._getBookmarks(sessionId);
if (bookmarks.indexOf(path) === -1) {
bookmarks.push(path);
localStorage.setItem(
"sftp-bookmarks-" + sessionId,
JSON.stringify(bookmarks),
);
this.app.notifications.success("Bookmarked: " + path);
} else {
this.app.notifications.info("Already bookmarked");
}
}
_removeBookmark(sessionId, index) {
var bookmarks = this._getBookmarks(sessionId);
bookmarks.splice(index, 1);
localStorage.setItem(
"sftp-bookmarks-" + sessionId,
JSON.stringify(bookmarks),
);
}
/* ── Transfer progress queue ─────────────────────────────────── */
/**
* Handles a transfer progress event from the backend.
* @param {Object} payload - { sessionID, direction, transferred, total, path, done }
*/
handleTransferProgress(payload) {
var state = this.panels.get(payload.sessionID);
if (!state) {
// Panel not open — still track so we can show a notification on completion
if (payload.done) {
var dir =
payload.direction === "upload" ? "↑ Uploaded" : "↓ Downloaded";
var name = payload.path.split("/").pop() || payload.path;
this.app.notifications.success(dir + " " + name);
}
return;
}
// Initialise transfers map if needed
if (!state.transfers) state.transfers = new Map();
var key = payload.direction + ":" + payload.path;
if (payload.done) {
state.transfers.delete(key);
this._renderTransferQueue(payload.sessionID);
// Refresh file list after upload completes
if (payload.direction === "upload") {
this._refresh(payload.sessionID);
}
return;
}
state.transfers.set(key, {
direction: payload.direction,
path: payload.path,
name: payload.path.split("/").pop() || payload.path,
transferred: payload.transferred,
total: payload.total,
});
this._renderTransferQueue(payload.sessionID);
}
/**
* Renders the transfer queue panel showing active transfers.
*/
_renderTransferQueue(sessionId) {
var state = this.panels.get(sessionId);
if (!state) return;
var queue = state.el.querySelector(".sftp-transfer-queue");
if (!state.transfers || state.transfers.size === 0) {
if (queue) queue.remove();
return;
}
if (!queue) {
queue = createElement("div", "sftp-transfer-queue");
// Insert between toolbar and file-list
var fileList = state.el.querySelector(".sftp-file-list");
state.el.insertBefore(queue, fileList);
}
queue.innerHTML = "";
var header = createElement("div", "sftp-transfer-header");
header.textContent = "Transfers (" + state.transfers.size + ")";
queue.appendChild(header);
var self = this;
state.transfers.forEach(function (transfer) {
var row = createElement("div", "sftp-transfer-row");
var icon = createElement("span", "sftp-transfer-icon");
icon.textContent = transfer.direction === "upload" ? "↑" : "↓";
row.appendChild(icon);
var info = createElement("div", "sftp-transfer-info");
var nameEl = createElement("span", "sftp-transfer-name", {
textContent: transfer.name,
title: transfer.path,
});
info.appendChild(nameEl);
var progress = createElement("div", "sftp-transfer-progress");
var bar = createElement("div", "sftp-transfer-bar");
var pct =
transfer.total > 0
? Math.min(
100,
Math.round((transfer.transferred / transfer.total) * 100),
)
: 0;
bar.style.width = pct + "%";
progress.appendChild(bar);
info.appendChild(progress);
var meta = createElement("span", "sftp-transfer-meta");
var transferredStr = formatBytes(transfer.transferred);
var totalStr = transfer.total > 0 ? formatBytes(transfer.total) : "?";
meta.textContent =
transferredStr + " / " + totalStr + " (" + pct + "%)";
info.appendChild(meta);
row.appendChild(info);
queue.appendChild(row);
});
}
/* ── Resize handle ────────────────────────────────────────────── */
_setupResize(sessionId, state) {
+90 -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();
@@ -68,6 +72,9 @@
this.el.appendChild(this._renderBulkActions());
}
// Local Machine quick-connect button
this.el.appendChild(this._renderLocalMachineItem());
// Favourites
const favourites = profiles.filter((p) => p.favourite);
if (favourites.length > 0) {
@@ -114,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());
}
}
@@ -159,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
@@ -391,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();
@@ -407,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}"`);
@@ -428,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");
@@ -531,6 +581,36 @@
);
}
/** @private */
_renderLocalMachineItem() {
const item = createElement("div", "sidebar-item sidebar-item-local");
item.addEventListener("mousedown", (e) => e.preventDefault());
const indicator = createElement(
"span",
"protocol-indicator proto-local",
{
title: "Local",
},
);
item.appendChild(indicator);
const textWrapper = createElement("div", "sidebar-item-text");
const nameEl = createElement("span", "sidebar-item-name", {
textContent: "Local Machine",
});
textWrapper.appendChild(nameEl);
item.appendChild(textWrapper);
item.addEventListener("click", () => {
if (this.onConnectLocal) {
this.onConnectLocal();
}
});
return item;
}
/** @private */
_renderBulkActions() {
const bar = createElement("div", "sidebar-bulk-actions");
@@ -607,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);
@@ -628,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);
+341 -193
View File
@@ -1,20 +1,21 @@
(function() {
/**
* Nexus Terminal — snippet / command library.
* Provides a panel for managing and sending reusable commands to active sessions.
*/
(function () {
/**
* Nexus Terminal — snippet / command library.
* Provides a panel for managing and sending reusable commands to active sessions.
*/
const { createElement, escapeHtml, dismissContextMenus } = window.NexusTerminal;
const { createElement, escapeHtml, dismissContextMenus } =
window.NexusTerminal;
class SnippetLibrary {
class SnippetLibrary {
/**
* @param {App} app
*/
constructor(app) {
this.app = app;
/** @type {Array<Object>} */
this.snippets = [];
this.searchQuery = '';
this.app = app;
/** @type {Array<Object>} */
this.snippets = [];
this.searchQuery = "";
}
// ── Public API ───────────────────────────────────────────────────
@@ -23,54 +24,168 @@ class SnippetLibrary {
* Opens the snippet library modal.
*/
async open() {
await this._loadSnippets();
this._showModal();
await this._loadSnippets();
this._showModal();
}
/**
* Sends a snippet command to the active session.
* Supports {{variable}} placeholders that prompt the user for values.
* Built-in variables: {{hostname}}, {{date}}, {{time}}, {{timestamp}}.
* @param {string} command
*/
sendToActiveSession(command) {
const sessionId = this.app.state.activeSessionId;
if (!sessionId) {
this.app.notifications.error('No active session');
return;
const sessionId = this.app.state.activeSessionId;
if (!sessionId) {
this.app.notifications.error("No active session");
return;
}
const session = this.app.state.getSession(sessionId);
if (!session || session.state !== "connected") {
this.app.notifications.error("Session is not connected");
return;
}
// Check for template variables
const varPattern = /\{\{([^}]+)\}\}/g;
const matches = [];
let match;
while ((match = varPattern.exec(command)) !== null) {
matches.push(match[1].trim());
}
// Resolve built-in variables first
const builtins = {
hostname: session.host || "unknown",
date: new Date().toISOString().split("T")[0],
time: new Date().toTimeString().split(" ")[0],
timestamp: Math.floor(Date.now() / 1000).toString(),
};
let resolved = command;
const userVars = [];
for (const varName of matches) {
const lower = varName.toLowerCase();
if (builtins[lower] !== undefined) {
resolved = resolved.replace("{{" + varName + "}}", builtins[lower]);
} else if (!userVars.includes(varName)) {
userVars.push(varName);
}
const session = this.app.state.getSession(sessionId);
if (!session || session.state !== 'connected') {
this.app.notifications.error('Session is not connected');
return;
}
if (userVars.length === 0) {
this._sendCommand(sessionId, resolved);
return;
}
// Prompt for user-defined variables
this._promptVariables(userVars, (values) => {
let final = resolved;
for (let i = 0; i < userVars.length; i++) {
final = final.split("{{" + userVars[i] + "}}").join(values[i] || "");
}
try {
const b64 = btoa(command + '\n');
window.go.api.Service.WriteSession(sessionId, b64);
this.app.notifications.info('Snippet sent');
} catch (err) {
this.app.notifications.error('Failed to send snippet: ' + err);
this._sendCommand(sessionId, final);
});
}
/** @private */
_sendCommand(sessionId, command) {
try {
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);
}
}
/** @private */
_promptVariables(varNames, onConfirm) {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-sm");
let inputsHtml = "";
for (const v of varNames) {
inputsHtml += `
<div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">${escapeHtml(v)}</label>
<input type="text" class="form-input snippet-var-input" data-var="${escapeHtml(v)}" placeholder="Enter value for ${escapeHtml(v)}" />
</div>
`;
}
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Snippet Variables</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">${inputsHtml}</div>
<div class="modal-footer">
<button class="btn btn-sm btn-ghost snippet-var-cancel">Cancel</button>
<button class="btn btn-sm btn-primary snippet-var-send">Send</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
const firstInput = modal.querySelector(".snippet-var-input");
if (firstInput) firstInput.focus();
});
const close = () => {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => backdrop.remove(), 200);
};
modal.querySelector(".modal-close").addEventListener("click", close);
modal
.querySelector(".snippet-var-cancel")
.addEventListener("click", close);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
modal.querySelector(".snippet-var-send").addEventListener("click", () => {
const inputs = modal.querySelectorAll(".snippet-var-input");
const values = Array.from(inputs).map((inp) => inp.value);
close();
onConfirm(values);
});
// Enter to send
modal.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
modal.querySelector(".snippet-var-send").click();
}
if (e.key === "Escape") close();
});
}
// ── Data ─────────────────────────────────────────────────────────
/** @private */
async _loadSnippets() {
try {
this.snippets = await window.go.api.Service.ListSnippets() || [];
} catch (err) {
this.snippets = [];
console.error('Failed to load snippets:', err);
}
try {
this.snippets = (await window.NexusTerminal.wailsService.ListSnippets()) || [];
} catch (err) {
this.snippets = [];
console.error("Failed to load snippets:", err);
}
}
// ── Modal ────────────────────────────────────────────────────────
/** @private */
_showModal() {
const backdrop = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-lg');
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-lg");
modal.innerHTML = `
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Command Library</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
@@ -91,155 +206,176 @@ class SnippetLibrary {
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add('is-visible');
modal.classList.add('is-visible');
});
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const close = () => {
backdrop.classList.remove('is-visible');
modal.classList.remove('is-visible');
setTimeout(() => backdrop.remove(), 200);
};
const close = () => {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => backdrop.remove(), 200);
};
modal.querySelector('.modal-close').addEventListener('click', close);
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); });
// Search
const searchInput = modal.querySelector('.snippet-search');
searchInput.addEventListener('input', () => {
this.searchQuery = searchInput.value.trim().toLowerCase();
this._renderList(modal, close);
});
// New snippet button
modal.querySelector('.snippet-add-btn').addEventListener('click', () => {
this._showEditor(modal, null, close);
});
modal.querySelector(".modal-close").addEventListener("click", close);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
// Search
const searchInput = modal.querySelector(".snippet-search");
searchInput.addEventListener("input", () => {
this.searchQuery = searchInput.value.trim().toLowerCase();
this._renderList(modal, close);
});
// New snippet button
modal.querySelector(".snippet-add-btn").addEventListener("click", () => {
this._showEditor(modal, null, close);
});
this._renderList(modal, close);
}
/** @private */
_renderList(modal, closeFn) {
const listEl = modal.querySelector('.snippet-list');
if (!listEl) return;
listEl.innerHTML = '';
const listEl = modal.querySelector(".snippet-list");
if (!listEl) return;
listEl.innerHTML = "";
const filtered = this.snippets.filter((sn) => {
if (!this.searchQuery) return true;
return sn.name.toLowerCase().includes(this.searchQuery) ||
sn.command.toLowerCase().includes(this.searchQuery) ||
(sn.description || '').toLowerCase().includes(this.searchQuery);
const filtered = this.snippets.filter((sn) => {
if (!this.searchQuery) return true;
return (
sn.name.toLowerCase().includes(this.searchQuery) ||
sn.command.toLowerCase().includes(this.searchQuery) ||
(sn.description || "").toLowerCase().includes(this.searchQuery)
);
});
if (filtered.length === 0) {
listEl.innerHTML =
'<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-3);text-align:center;">No snippets found</div>';
return;
}
for (const sn of filtered) {
const item = createElement("div", "snippet-item", {
dataset: { id: sn.id },
});
if (filtered.length === 0) {
listEl.innerHTML = '<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-3);text-align:center;">No snippets found</div>';
return;
}
for (const sn of filtered) {
const item = createElement('div', 'snippet-item', {
dataset: { id: sn.id },
});
item.style.cssText = 'padding:var(--space-2);border-radius:var(--radius-md);cursor:pointer;border:1px solid var(--border-subtle);';
item.innerHTML = `
item.style.cssText =
"padding:var(--space-2);border-radius:var(--radius-md);cursor:pointer;border:1px solid var(--border-subtle);";
item.innerHTML = `
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);color:var(--text-primary);">${escapeHtml(sn.name)}</div>
<div style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-secondary);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(sn.command)}</div>
`;
item.addEventListener('click', () => this._showDetail(modal, sn, closeFn));
item.addEventListener('mousedown', (e) => e.preventDefault());
item.addEventListener('mouseenter', () => { item.style.background = 'var(--bg-hover)'; });
item.addEventListener('mouseleave', () => { item.style.background = ''; });
listEl.appendChild(item);
}
item.addEventListener("click", () =>
this._showDetail(modal, sn, closeFn),
);
item.addEventListener("mousedown", (e) => e.preventDefault());
item.addEventListener("mouseenter", () => {
item.style.background = "var(--bg-hover)";
});
item.addEventListener("mouseleave", () => {
item.style.background = "";
});
listEl.appendChild(item);
}
}
/** @private */
_showDetail(modal, snippet, closeFn) {
const detailPanel = modal.querySelector('.snippet-detail-panel');
if (!detailPanel) return;
detailPanel.innerHTML = '';
const detailPanel = modal.querySelector(".snippet-detail-panel");
if (!detailPanel) return;
detailPanel.innerHTML = "";
const nameEl = createElement('div', '', {
innerHTML: `<strong style="font-size:var(--text-base);">${escapeHtml(snippet.name)}</strong>`,
const nameEl = createElement("div", "", {
innerHTML: `<strong style="font-size:var(--text-base);">${escapeHtml(snippet.name)}</strong>`,
});
detailPanel.appendChild(nameEl);
if (snippet.description) {
const descEl = createElement("div", "", {
textContent: snippet.description,
});
detailPanel.appendChild(nameEl);
descEl.style.cssText =
"font-size:var(--text-sm);color:var(--text-secondary);";
detailPanel.appendChild(descEl);
}
if (snippet.description) {
const descEl = createElement('div', '', {
textContent: snippet.description,
});
descEl.style.cssText = 'font-size:var(--text-sm);color:var(--text-secondary);';
detailPanel.appendChild(descEl);
const cmdEl = createElement("pre", "", {
textContent: snippet.command,
});
cmdEl.style.cssText =
"flex:1;font-family:var(--font-mono);font-size:var(--text-sm);background:var(--bg-base);padding:var(--space-3);border-radius:var(--radius-md);overflow:auto;color:var(--text-primary);margin:0;white-space:pre-wrap;word-break:break-all;";
detailPanel.appendChild(cmdEl);
// Action buttons
const actions = createElement("div", "", {});
actions.style.cssText = "display:flex;gap:var(--space-2);";
const sendBtn = createElement("button", "btn btn-sm btn-primary", {
textContent: "Send to Session",
});
sendBtn.addEventListener("click", () => {
this.sendToActiveSession(snippet.command);
});
const editBtn = createElement("button", "btn btn-sm btn-secondary", {
textContent: "Edit",
});
editBtn.addEventListener("click", () => {
this._showEditor(modal, snippet, closeFn);
});
const deleteBtn = createElement("button", "btn btn-sm btn-danger", {
textContent: "Delete",
});
deleteBtn.addEventListener("click", async () => {
try {
await window.NexusTerminal.wailsService.DeleteSnippet(snippet.id);
this.snippets = this.snippets.filter((s) => s.id !== snippet.id);
this._renderList(modal, closeFn);
detailPanel.innerHTML =
'<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Snippet deleted</div>';
} catch (err) {
this.app.notifications.error("Failed to delete: " + err);
}
});
const cmdEl = createElement('pre', '', {
textContent: snippet.command,
});
cmdEl.style.cssText = 'flex:1;font-family:var(--font-mono);font-size:var(--text-sm);background:var(--bg-base);padding:var(--space-3);border-radius:var(--radius-md);overflow:auto;color:var(--text-primary);margin:0;white-space:pre-wrap;word-break:break-all;';
detailPanel.appendChild(cmdEl);
// Action buttons
const actions = createElement('div', '', {});
actions.style.cssText = 'display:flex;gap:var(--space-2);';
const sendBtn = createElement('button', 'btn btn-sm btn-primary', { textContent: 'Send to Session' });
sendBtn.addEventListener('click', () => {
this.sendToActiveSession(snippet.command);
});
const editBtn = createElement('button', 'btn btn-sm btn-secondary', { textContent: 'Edit' });
editBtn.addEventListener('click', () => {
this._showEditor(modal, snippet, closeFn);
});
const deleteBtn = createElement('button', 'btn btn-sm btn-danger', { textContent: 'Delete' });
deleteBtn.addEventListener('click', async () => {
try {
await window.go.api.Service.DeleteSnippet(snippet.id);
this.snippets = this.snippets.filter((s) => s.id !== snippet.id);
this._renderList(modal, closeFn);
detailPanel.innerHTML = '<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Snippet deleted</div>';
} catch (err) {
this.app.notifications.error('Failed to delete: ' + err);
}
});
actions.appendChild(sendBtn);
actions.appendChild(editBtn);
actions.appendChild(deleteBtn);
detailPanel.appendChild(actions);
actions.appendChild(sendBtn);
actions.appendChild(editBtn);
actions.appendChild(deleteBtn);
detailPanel.appendChild(actions);
}
/** @private */
_showEditor(modal, snippet, closeFn) {
const detailPanel = modal.querySelector('.snippet-detail-panel');
if (!detailPanel) return;
detailPanel.innerHTML = '';
const detailPanel = modal.querySelector(".snippet-detail-panel");
if (!detailPanel) return;
detailPanel.innerHTML = "";
const isNew = !snippet;
const title = isNew ? 'New Snippet' : 'Edit Snippet';
const isNew = !snippet;
const title = isNew ? "New Snippet" : "Edit Snippet";
detailPanel.innerHTML = `
detailPanel.innerHTML = `
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);">${title}</div>
<div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">Name</label>
<input type="text" class="form-input snippet-ed-name" value="${isNew ? '' : escapeHtml(snippet.name)}" />
<input type="text" class="form-input snippet-ed-name" value="${isNew ? "" : escapeHtml(snippet.name)}" />
</div>
<div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">Description</label>
<input type="text" class="form-input snippet-ed-desc" value="${isNew ? '' : escapeHtml(snippet.description || '')}" />
<input type="text" class="form-input snippet-ed-desc" value="${isNew ? "" : escapeHtml(snippet.description || "")}" />
</div>
<div class="form-group" style="flex:1;display:flex;flex-direction:column;">
<label class="form-label">Command</label>
<textarea class="form-input snippet-ed-cmd" style="flex:1;font-family:var(--font-mono);font-size:var(--text-sm);resize:none;">${isNew ? '' : escapeHtml(snippet.command)}</textarea>
<textarea class="form-input snippet-ed-cmd" style="flex:1;font-family:var(--font-mono);font-size:var(--text-sm);resize:none;">${isNew ? "" : escapeHtml(snippet.command)}</textarea>
</div>
<div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">Folder</label>
<input type="text" class="form-input snippet-ed-folder" value="${isNew ? '' : escapeHtml(snippet.folder || '')}" placeholder="e.g. Networking, Debugging" />
<input type="text" class="form-input snippet-ed-folder" value="${isNew ? "" : escapeHtml(snippet.folder || "")}" placeholder="e.g. Networking, Debugging" />
</div>
<div style="display:flex;gap:var(--space-2);">
<button class="btn btn-sm btn-primary snippet-save-btn">Save</button>
@@ -247,60 +383,72 @@ class SnippetLibrary {
</div>
`;
detailPanel.querySelector('.snippet-save-btn').addEventListener('click', async () => {
const name = detailPanel.querySelector('.snippet-ed-name').value.trim();
const command = detailPanel.querySelector('.snippet-ed-cmd').value;
const description = detailPanel.querySelector('.snippet-ed-desc').value.trim();
const folder = detailPanel.querySelector('.snippet-ed-folder').value.trim();
detailPanel
.querySelector(".snippet-save-btn")
.addEventListener("click", async () => {
const name = detailPanel
.querySelector(".snippet-ed-name")
.value.trim();
const command = detailPanel.querySelector(".snippet-ed-cmd").value;
const description = detailPanel
.querySelector(".snippet-ed-desc")
.value.trim();
const folder = detailPanel
.querySelector(".snippet-ed-folder")
.value.trim();
if (!name) {
this.app.notifications.error('Name is required');
return;
}
if (!command) {
this.app.notifications.error('Command is required');
return;
if (!name) {
this.app.notifications.error("Name is required");
return;
}
if (!command) {
this.app.notifications.error("Command is required");
return;
}
try {
const data = {
id: isNew ? "" : snippet.id,
name,
command,
description,
folder,
tags: isNew ? [] : snippet.tags || [],
};
let saved;
if (isNew) {
saved = await window.NexusTerminal.wailsService.CreateSnippet(data);
this.snippets.push(saved);
} else {
saved = await window.NexusTerminal.wailsService.UpdateSnippet(data);
const idx = this.snippets.findIndex((s) => s.id === saved.id);
if (idx !== -1) this.snippets[idx] = saved;
}
try {
const data = {
id: isNew ? '' : snippet.id,
name,
command,
description,
folder,
tags: isNew ? [] : (snippet.tags || []),
};
let saved;
if (isNew) {
saved = await window.go.api.Service.CreateSnippet(data);
this.snippets.push(saved);
} else {
saved = await window.go.api.Service.UpdateSnippet(data);
const idx = this.snippets.findIndex((s) => s.id === saved.id);
if (idx !== -1) this.snippets[idx] = saved;
}
this._renderList(modal, closeFn);
this._showDetail(modal, saved, closeFn);
this.app.notifications.info(isNew ? 'Snippet created' : 'Snippet updated');
} catch (err) {
this.app.notifications.error('Failed to save snippet: ' + err);
}
this._renderList(modal, closeFn);
this._showDetail(modal, saved, closeFn);
this.app.notifications.info(
isNew ? "Snippet created" : "Snippet updated",
);
} catch (err) {
this.app.notifications.error("Failed to save snippet: " + err);
}
});
detailPanel.querySelector('.snippet-cancel-btn').addEventListener('click', () => {
if (snippet) {
this._showDetail(modal, snippet, closeFn);
} else {
detailPanel.innerHTML = '<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Select a snippet or create a new one</div>';
}
detailPanel
.querySelector(".snippet-cancel-btn")
.addEventListener("click", () => {
if (snippet) {
this._showDetail(modal, snippet, closeFn);
} else {
detailPanel.innerHTML =
'<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Select a snippet or create a new one</div>';
}
});
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.SnippetLibrary = SnippetLibrary;
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.SnippetLibrary = SnippetLibrary;
})();
+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
+310
View File
@@ -0,0 +1,310 @@
(function () {
/**
* Nexus Terminal — SSH Key Manager.
* Panel for listing, generating, copying, and deleting SSH key pairs.
*/
const { createElement, dismissContextMenus } = window.NexusTerminal;
class SSHKeyManager {
constructor(app) {
this.app = app;
this._backdrop = null;
}
// ── Public API ───────────────────────────────────────────────────
open() {
if (this._backdrop) return;
this._render();
this._loadKeys();
}
close() {
if (this._backdrop) {
this._backdrop.classList.remove("is-visible");
const modal = this._backdrop.querySelector(".modal");
if (modal) modal.classList.remove("is-visible");
setTimeout(() => {
if (this._backdrop) {
this._backdrop.remove();
this._backdrop = null;
}
}, 200);
}
}
// ── Rendering ────────────────────────────────────────────────────
_render() {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-lg");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">SSH Key Manager</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="sshkey-toolbar">
<button class="btn btn-sm btn-accent sshkey-generate-btn">Generate New Key</button>
</div>
<div class="sshkey-list"></div>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
this._backdrop = backdrop;
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
// Events
modal
.querySelector(".modal-close")
.addEventListener("click", () => this.close());
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) this.close();
});
modal
.querySelector(".sshkey-generate-btn")
.addEventListener("click", () => this._showGenerateForm());
}
async _loadKeys() {
const list = this._backdrop.querySelector(".sshkey-list");
if (!list) return;
list.innerHTML = '<div class="sshkey-loading">Loading keys…</div>';
try {
const keys = await window.NexusTerminal.wailsService.ListSSHKeyPairs();
if (keys.length === 0) {
list.innerHTML =
'<div class="sshkey-empty">No SSH keys found in ~/.ssh/</div>';
return;
}
list.innerHTML = "";
for (const key of keys) {
list.appendChild(this._renderKeyRow(key));
}
} catch (err) {
list.innerHTML = `<div class="sshkey-error">Failed to load keys: ${err}</div>`;
}
}
_renderKeyRow(key) {
const row = createElement("div", "sshkey-row");
const info = createElement("div", "sshkey-info");
const name = createElement("div", "sshkey-name", {
textContent: key.name,
});
const meta = createElement("div", "sshkey-meta", {
textContent: key.type.toUpperCase() + (key.bits ? ` ${key.bits}` : ""),
});
info.appendChild(name);
info.appendChild(meta);
const actions = createElement("div", "sshkey-actions");
const copyBtn = createElement("button", "btn btn-xs", {
textContent: "Copy Public Key",
});
copyBtn.addEventListener("mousedown", (e) => e.preventDefault());
copyBtn.addEventListener("click", () => this._copyPublicKey(key));
const deleteBtn = createElement("button", "btn btn-xs btn-danger", {
textContent: "Delete",
});
deleteBtn.addEventListener("mousedown", (e) => e.preventDefault());
deleteBtn.addEventListener("click", () => this._confirmDelete(key));
actions.appendChild(copyBtn);
actions.appendChild(deleteBtn);
row.appendChild(info);
row.appendChild(actions);
return row;
}
async _copyPublicKey(key) {
try {
const pubKey = await window.NexusTerminal.wailsService.GetSSHPublicKey(key.name);
if (window.runtime && window.runtime.ClipboardSetText) {
window.runtime.ClipboardSetText(pubKey);
} else {
await navigator.clipboard.writeText(pubKey);
}
this.app.notifications.success("Public key copied to clipboard");
} catch (err) {
this.app.notifications.error("Failed to copy: " + err);
}
}
_confirmDelete(key) {
const confirmed = this._showConfirm(
"Delete Key",
`Delete key pair "${key.name}"? This cannot be undone.`,
"Delete",
);
confirmed.then((ok) => {
if (ok) this._deleteKey(key);
});
}
async _deleteKey(key) {
try {
await window.NexusTerminal.wailsService.DeleteSSHKey(key.name);
this.app.notifications.success(`Key "${key.name}" deleted`);
this._loadKeys();
} catch (err) {
this.app.notifications.error("Delete failed: " + err);
}
}
// ── Generate form ────────────────────────────────────────────────
_showGenerateForm() {
const list = this._backdrop.querySelector(".sshkey-list");
if (!list) return;
const form = createElement("div", "sshkey-generate-form");
form.innerHTML = `
<div class="form-group">
<label class="form-label">Key Name</label>
<input type="text" class="form-input sshkey-name-input" placeholder="id_ed25519_nexus" autocomplete="off" spellcheck="false" />
</div>
<div class="form-group">
<label class="form-label">Type</label>
<select class="form-input sshkey-type-select">
<option value="ed25519" selected>Ed25519 (recommended)</option>
<option value="rsa">RSA</option>
<option value="ecdsa">ECDSA</option>
</select>
</div>
<div class="form-group sshkey-bits-group" style="display:none">
<label class="form-label">Bits</label>
<select class="form-input sshkey-bits-select">
<option value="4096" selected>4096</option>
<option value="2048">2048</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Passphrase (optional)</label>
<input type="password" class="form-input sshkey-pass-input" placeholder="Leave blank for no passphrase" autocomplete="off" />
</div>
<div class="sshkey-form-actions">
<button class="btn btn-sm sshkey-cancel-btn">Cancel</button>
<button class="btn btn-sm btn-accent sshkey-create-btn">Generate</button>
</div>
`;
// Insert form above the key list
list.parentNode.insertBefore(form, list);
list.style.display = "none";
// Show bits field for RSA
const typeSelect = form.querySelector(".sshkey-type-select");
const bitsGroup = form.querySelector(".sshkey-bits-group");
typeSelect.addEventListener("change", () => {
bitsGroup.style.display = typeSelect.value === "rsa" ? "" : "none";
});
form.querySelector(".sshkey-cancel-btn").addEventListener("click", () => {
form.remove();
list.style.display = "";
});
form
.querySelector(".sshkey-create-btn")
.addEventListener("click", async () => {
const nameInput = form.querySelector(".sshkey-name-input");
const name = nameInput.value.trim();
const keyType = typeSelect.value;
const bits = parseInt(
form.querySelector(".sshkey-bits-select").value,
10,
);
const passphrase = form.querySelector(".sshkey-pass-input").value;
if (!name) {
this.app.notifications.error("Key name is required");
return;
}
try {
await window.NexusTerminal.wailsService.GenerateSSHKey(
name,
keyType,
bits,
passphrase,
);
this.app.notifications.success(
`Key "${name}" generated successfully`,
);
form.remove();
list.style.display = "";
this._loadKeys();
} catch (err) {
this.app.notifications.error("Generation failed: " + err);
}
});
form.querySelector(".sshkey-name-input").focus();
}
// ── Confirmation helper ──────────────────────────────────────────
_showConfirm(title, message, confirmLabel) {
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>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const close = (val) => {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => backdrop.remove(), 200);
resolve(val);
};
modal
.querySelector(".modal-close")
.addEventListener("click", () => close(false));
modal
.querySelector(".confirm-cancel")
.addEventListener("click", () => close(false));
modal
.querySelector(".confirm-ok")
.addEventListener("click", () => close(true));
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close(false);
});
});
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.SSHKeyManager = SSHKeyManager;
})();
+89 -86
View File
@@ -1,35 +1,38 @@
(function() {
/**
* Nexus Terminal — application state management.
* Provides a centralised store for profiles, sessions, and preferences,
* emitting change events so the UI stays in sync.
*/
(function () {
/**
* Nexus Terminal — application state management.
* Provides a centralised store for profiles, sessions, and preferences,
* emitting change events so the UI stays in sync.
*/
class State {
class State {
constructor() {
/** @type {Array<Object>} */
this.profiles = [];
/** @type {Array<Object>} */
this.profiles = [];
/** @type {Map<string, Object>} sessionId → SessionInfo */
this.sessions = new Map();
/** @type {Map<string, Object>} sessionId → SessionInfo */
this.sessions = new Map();
/** @type {string|null} */
this.activeSessionId = null;
/** @type {string|null} */
this.activeSessionId = null;
/** @type {Object} */
this.preferences = {};
/** @type {Object} */
this.preferences = {};
/** @type {boolean} */
this.secretStoreUnlocked = false;
/** @type {boolean} */
this.secretStoreUnlocked = false;
/** @type {boolean} */
this.secretStoreInitialised = false;
/** @type {boolean} */
this.secretStoreInitialised = false;
/** @type {boolean} Whether broadcast mode is active (keystrokes to all sessions). */
this.broadcastMode = false;
/** @type {boolean} Whether broadcast mode is active (keystrokes to all sessions). */
this.broadcastMode = false;
/** @type {Map<string, Set<Function>>} event name → callbacks */
this._listeners = new Map();
/** @type {Set<string>|null} Session IDs to broadcast to. Null means all. */
this.broadcastTargets = null;
/** @type {Map<string, Set<Function>>} event name → callbacks */
this._listeners = new Map();
}
// ── Event emitter ────────────────────────────────────────────────
@@ -40,10 +43,10 @@ class State {
* @param {Function} callback
*/
on(event, callback) {
if (!this._listeners.has(event)) {
this._listeners.set(event, new Set());
}
this._listeners.get(event).add(callback);
if (!this._listeners.has(event)) {
this._listeners.set(event, new Set());
}
this._listeners.get(event).add(callback);
}
/**
@@ -52,10 +55,10 @@ class State {
* @param {Function} callback
*/
off(event, callback) {
const set = this._listeners.get(event);
if (set) {
set.delete(callback);
}
const set = this._listeners.get(event);
if (set) {
set.delete(callback);
}
}
/**
@@ -64,16 +67,16 @@ class State {
* @param {*} data
*/
emit(event, data) {
const set = this._listeners.get(event);
if (set) {
for (const cb of set) {
try {
cb(data);
} catch (err) {
console.error(`State listener error [${event}]:`, err);
}
}
const set = this._listeners.get(event);
if (set) {
for (const cb of set) {
try {
cb(data);
} catch (err) {
console.error(`State listener error [${event}]:`, err);
}
}
}
}
// ── Profiles ─────────────────────────────────────────────────────
@@ -83,8 +86,8 @@ class State {
* @param {Array<Object>} profiles
*/
setProfiles(profiles) {
this.profiles = profiles;
this.emit('profiles:changed', this.profiles);
this.profiles = profiles;
this.emit("profiles:changed", this.profiles);
}
/**
@@ -92,9 +95,9 @@ class State {
* @param {Object} profile
*/
addProfile(profile) {
this.profiles.push(profile);
this.emit('profiles:changed', this.profiles);
this.emit('profile:added', profile);
this.profiles.push(profile);
this.emit("profiles:changed", this.profiles);
this.emit("profile:added", profile);
}
/**
@@ -102,12 +105,12 @@ class State {
* @param {Object} profile
*/
updateProfile(profile) {
const idx = this.profiles.findIndex((p) => p.id === profile.id);
if (idx !== -1) {
this.profiles[idx] = profile;
}
this.emit('profiles:changed', this.profiles);
this.emit('profile:updated', profile);
const idx = this.profiles.findIndex((p) => p.id === profile.id);
if (idx !== -1) {
this.profiles[idx] = profile;
}
this.emit("profiles:changed", this.profiles);
this.emit("profile:updated", profile);
}
/**
@@ -115,9 +118,9 @@ class State {
* @param {string} id
*/
removeProfile(id) {
this.profiles = this.profiles.filter((p) => p.id !== id);
this.emit('profiles:changed', this.profiles);
this.emit('profile:removed', id);
this.profiles = this.profiles.filter((p) => p.id !== id);
this.emit("profiles:changed", this.profiles);
this.emit("profile:removed", id);
}
/**
@@ -126,7 +129,7 @@ class State {
* @returns {Object|undefined}
*/
getProfile(id) {
return this.profiles.find((p) => p.id === id);
return this.profiles.find((p) => p.id === id);
}
// ── Sessions ─────────────────────────────────────────────────────
@@ -136,9 +139,9 @@ class State {
* @param {Object} sessionInfo
*/
addSession(sessionInfo) {
this.sessions.set(sessionInfo.id, sessionInfo);
this.emit('sessions:changed', this.sessions);
this.emit('session:added', sessionInfo);
this.sessions.set(sessionInfo.id, sessionInfo);
this.emit("sessions:changed", this.sessions);
this.emit("session:added", sessionInfo);
}
/**
@@ -147,12 +150,12 @@ class State {
* @param {Object} updates
*/
updateSession(id, updates) {
const existing = this.sessions.get(id);
if (existing) {
Object.assign(existing, updates);
this.emit('sessions:changed', this.sessions);
this.emit('session:updated', existing);
}
const existing = this.sessions.get(id);
if (existing) {
Object.assign(existing, updates);
this.emit("sessions:changed", this.sessions);
this.emit("session:updated", existing);
}
}
/**
@@ -160,14 +163,15 @@ class State {
* @param {string} id
*/
removeSession(id) {
this.sessions.delete(id);
if (this.activeSessionId === id) {
// Activate the most recent remaining session, or null
const remaining = [...this.sessions.keys()];
this.activeSessionId = remaining.length > 0 ? remaining[remaining.length - 1] : null;
}
this.emit('sessions:changed', this.sessions);
this.emit('session:removed', id);
this.sessions.delete(id);
if (this.activeSessionId === id) {
// Activate the most recent remaining session, or null
const remaining = [...this.sessions.keys()];
this.activeSessionId =
remaining.length > 0 ? remaining[remaining.length - 1] : null;
}
this.emit("sessions:changed", this.sessions);
this.emit("session:removed", id);
}
/**
@@ -176,7 +180,7 @@ class State {
* @returns {Object|undefined}
*/
getSession(id) {
return this.sessions.get(id);
return this.sessions.get(id);
}
/**
@@ -184,8 +188,8 @@ class State {
* @param {string|null} id
*/
setActiveSession(id) {
this.activeSessionId = id;
this.emit('session:activated', id);
this.activeSessionId = id;
this.emit("session:activated", id);
}
/**
@@ -193,7 +197,7 @@ class State {
* @returns {Array<Object>}
*/
getAllSessions() {
return [...this.sessions.values()];
return [...this.sessions.values()];
}
// ── Preferences ──────────────────────────────────────────────────
@@ -203,8 +207,8 @@ class State {
* @param {Object} prefs
*/
setPreferences(prefs) {
this.preferences = prefs;
this.emit('preferences:changed', this.preferences);
this.preferences = prefs;
this.emit("preferences:changed", this.preferences);
}
// ── Secret store ─────────────────────────────────────────────────
@@ -214,8 +218,8 @@ class State {
* @param {boolean} unlocked
*/
setSecretStoreState(unlocked) {
this.secretStoreUnlocked = unlocked;
this.emit('secretStore:changed', unlocked);
this.secretStoreUnlocked = unlocked;
this.emit("secretStore:changed", unlocked);
}
/**
@@ -223,12 +227,11 @@ class State {
* @param {boolean} initialised
*/
setSecretStoreInitialised(initialised) {
this.secretStoreInitialised = initialised;
this.emit('secretStore:init', initialised);
this.secretStoreInitialised = initialised;
this.emit("secretStore:init", initialised);
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.State = State;
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.State = State;
})();
+273 -21
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();
}
/**
@@ -100,9 +110,12 @@
setActive(sessionId) {
this.app.state.setActiveSession(sessionId);
// Highlight active tab
// Highlight active tab and clear activity indicator
this.tabList.querySelectorAll(".tab").forEach((t) => {
t.classList.toggle("tab-active", t.dataset.sessionId === sessionId);
if (t.dataset.sessionId === sessionId) {
t.classList.remove("tab-has-activity");
}
});
// Show corresponding panel, hide others
@@ -115,11 +128,20 @@
if (session) {
this.app.sidebar.highlightActive(session.profileId);
}
this.app.leviathan?.handleTabFocus?.(sessionId);
this._scrollTabIntoView(sessionId);
// Fit the terminal for the newly active session
this.app.terminals.fitActive();
// Fit and refresh the terminal for the newly active session.
// Double-RAF ensures the panel has layout dimensions before fitting.
requestAnimationFrame(() => {
this.app.terminals.focusTerminal(sessionId);
this.app.terminals.fitSession(sessionId);
const entry = this.app.terminals.terminals.get(sessionId);
if (entry) {
entry.term.refresh(0, entry.term.rows - 1);
}
requestAnimationFrame(() => {
this.app.terminals.focusTerminal(sessionId);
});
});
}
@@ -145,12 +167,14 @@
* @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" &&
this.app.state.preferences.confirmOnDisconnect
) {
const ok = await this._confirmClose(sessionId);
@@ -158,7 +182,7 @@
}
try {
await window.go.api.Service.DisconnectSession(sessionId);
await window.NexusTerminal.wailsService.DisconnectSession(sessionId);
} catch (_) {
// Session may already be closed — carry on
}
@@ -166,8 +190,9 @@
this.app.terminals.destroyTerminal(sessionId);
this.app.rawViewer.destroyViewer(sessionId);
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
this.app.state.removeSession(sessionId);
this.app.leviathan?.destroyPanel(sessionId);
this.removeTab(sessionId);
this.app.state.removeSession(sessionId);
}
/**
@@ -178,7 +203,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");
@@ -418,6 +443,21 @@
return [...this.order];
}
/**
* Marks a background tab as having new activity.
* Does nothing if the session is currently active.
* @param {string} sessionId
*/
markActivity(sessionId) {
if (sessionId === this.app.state.activeSessionId) return;
const tabEl = this.tabList.querySelector(
`[data-session-id="${sessionId}"]`,
);
if (tabEl && !tabEl.classList.contains("tab-has-activity")) {
tabEl.classList.add("tab-has-activity");
}
}
/**
* Returns the session ID of the next tab (wrapping round).
* @returns {string|null}
@@ -451,11 +491,25 @@
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"
? "GitHub"
: sessionInfo.protocol.toUpperCase();
: sessionInfo.protocol === "local"
? "Local"
: sessionInfo.protocol.toUpperCase();
const badge = createElement(
"span",
`tab-badge badge-${sessionInfo.protocol}`,
@@ -481,6 +535,10 @@
);
tab.appendChild(status);
// Activity indicator (hidden by default, shown when background tab has output)
const activity = createElement("span", "tab-activity");
tab.appendChild(activity);
// Pin indicator
if (sessionInfo.pinned) {
tab.classList.add("tab-pinned");
@@ -500,17 +558,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));
@@ -545,6 +592,7 @@
const el = this.tabList.querySelector(`[data-session-id="${id}"]`);
if (el) this.tabList.appendChild(el);
}
this._updateScrollState();
}
/**
@@ -562,6 +610,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
+416 -45
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
@@ -47,6 +63,8 @@
allowProposedApi: true,
convertEol: false,
windowsMode: false,
scrollOnUserInput: true,
altClickMovesCursor: true,
});
// Load addons
@@ -110,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 (
@@ -128,36 +182,88 @@
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 all other connected sessions if enabled
// Broadcast to other connected sessions if enabled
if (this.app.state.broadcastMode) {
const targets = this.app.state.broadcastTargets;
for (const [otherId] of this.terminals) {
if (otherId === sessionId) continue;
// If targets are set, only broadcast to selected sessions
if (targets && !targets.has(otherId)) continue;
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 */
}
@@ -225,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);
@@ -247,6 +351,9 @@
searchAddon,
webLinksAddon,
observer,
scheduleFit,
cancelScheduledFit,
refreshTerminal,
decoder:
typeof TextDecoder !== "undefined" ? new TextDecoder("utf-8") : null,
});
@@ -289,6 +396,7 @@
if (!entry) return;
entry.observer.disconnect();
entry.cancelScheduledFit();
entry.term.dispose();
this.terminals.delete(sessionId);
}
@@ -308,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);
}
}
@@ -333,13 +435,7 @@
fitSession(sessionId) {
const entry = this.terminals.get(sessionId);
if (entry) {
requestAnimationFrame(() => {
try {
entry.fitAddon.fit();
} catch (_) {
/* noop */
}
});
entry.scheduleFit(true);
}
}
@@ -504,6 +600,8 @@
const { createElement, dismissContextMenus } = window.NexusTerminal;
dismissContextMenus();
const isMac = navigator.platform.indexOf("Mac") !== -1;
const modKey = isMac ? "⌘" : "Ctrl+";
const hasSelection = term.hasSelection();
const menu = createElement("div", "context-menu");
menu.style.left = `${x}px`;
@@ -512,6 +610,7 @@
const items = [
{
label: "Copy",
shortcut: modKey + "C",
disabled: !hasSelection,
action: () => {
const text = term.getSelection();
@@ -527,6 +626,7 @@
},
{
label: "Paste",
shortcut: modKey + "V",
action: () => {
if (window.runtime && window.runtime.ClipboardGetText) {
window.runtime
@@ -548,19 +648,41 @@
{ divider: true },
{
label: "Select All",
shortcut: modKey + "A",
action: () => term.selectAll(),
},
{
label: "Find…",
shortcut: modKey + "F",
action: () => this.app._openTerminalSearch(),
},
{
label: "Send Snippet…",
action: () => this.app.snippets.open(),
},
{
label: "Port Forwards…",
action: () => this._showPortForwardPanel(sessionId),
},
{ divider: true },
{
label: "Split Right",
shortcut: modKey + "⇧D",
action: () => this.app.splitPane("vertical"),
},
{
label: "Split Down",
shortcut: modKey + "⇧E",
action: () => this.app.splitPane("horizontal"),
},
{ divider: true },
{
label: "Export Output",
action: () => this._exportTerminalOutput(sessionId, term),
},
{
label: "Clear Terminal",
shortcut: modKey + "K",
action: () => term.clear(),
},
{
@@ -574,9 +696,17 @@
menu.appendChild(createElement("div", "context-menu-divider"));
continue;
}
const btn = createElement("button", "context-menu-item", {
const btn = createElement("button", "context-menu-item");
const labelSpan = createElement("span", "context-menu-label", {
textContent: entry.label,
});
btn.appendChild(labelSpan);
if (entry.shortcut) {
const shortcutSpan = createElement("span", "context-menu-shortcut", {
textContent: entry.shortcut,
});
btn.appendChild(shortcutSpan);
}
if (entry.disabled) {
btn.disabled = true;
btn.classList.add("context-menu-disabled");
@@ -627,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");
@@ -647,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"}.
@@ -738,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(
@@ -768,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();
@@ -1004,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,
);
@@ -1169,6 +1332,214 @@
return btoa(binary);
}
/**
* Exports the terminal scrollback buffer as plain text.
* @param {string} sessionId
* @param {Terminal} term
* @private
*/
_exportTerminalOutput(sessionId, term) {
const { createElement } = window.NexusTerminal;
const buffer = term.buffer.active;
const lines = [];
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i);
if (line) lines.push(line.translateToString(true));
}
const text = lines.join("\n").trimEnd();
// Use Wails save dialog if available
if (window.runtime && window.runtime.SaveFileDialog) {
// Copy to clipboard as a fallback
}
// Copy to clipboard
if (window.runtime && window.runtime.ClipboardSetText) {
window.runtime.ClipboardSetText(text);
} else {
navigator.clipboard.writeText(text).catch(function () {});
}
// Also offer download via blob
const blob = new Blob([text], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "terminal-output-" + sessionId.substring(0, 8) + ".txt";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.app.notifications.success(
"Terminal output exported & copied to clipboard",
);
}
/**
* Shows the port forwarding management panel for an SSH session.
* @param {string} sessionId
* @private
*/
_showPortForwardPanel(sessionId) {
const { createElement, escapeHtml } = window.NexusTerminal;
const session = this.app.state.getSession(sessionId);
if (!session || session.protocol !== "ssh") {
this.app.notifications.error(
"Port forwarding is only available for SSH sessions",
);
return;
}
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-lg");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Port Forwards</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body" style="min-height:200px;">
<div class="portfwd-list" style="margin-bottom:var(--space-3);"></div>
<div class="portfwd-add" style="border-top:1px solid var(--border-subtle);padding-top:var(--space-3);">
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);margin-bottom:var(--space-2);">Add New Forward</div>
<div style="display:grid;grid-template-columns:auto 1fr 60px 20px 1fr 60px;gap:var(--space-2);align-items:center;">
<select class="form-input pf-direction" style="height:28px;font-size:var(--text-xs);">
<option value="local">Local →</option>
<option value="remote">Remote ←</option>
</select>
<input class="form-input pf-bind-host" type="text" placeholder="127.0.0.1" value="127.0.0.1" style="height:28px;font-size:var(--text-xs);" />
<input class="form-input pf-bind-port" type="number" placeholder="Port" style="height:28px;font-size:var(--text-xs);" />
<span style="text-align:center;color:var(--text-tertiary);">→</span>
<input class="form-input pf-dest-host" type="text" placeholder="localhost" value="localhost" style="height:28px;font-size:var(--text-xs);" />
<input class="form-input pf-dest-port" type="number" placeholder="Port" style="height:28px;font-size:var(--text-xs);" />
</div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);">
<input class="form-input pf-label" type="text" placeholder="Label (optional)" style="flex:1;height:28px;font-size:var(--text-xs);" />
<button class="btn btn-sm btn-primary pf-add-btn">Start Forward</button>
</div>
</div>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const close = () => {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => backdrop.remove(), 200);
};
modal.querySelector(".modal-close").addEventListener("click", close);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
const listEl = modal.querySelector(".portfwd-list");
const self = this;
const renderForwards = async () => {
try {
const forwards =
(await window.NexusTerminal.wailsService.ListPortForwards(sessionId)) || [];
listEl.innerHTML = "";
if (forwards.length === 0) {
listEl.innerHTML =
'<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-2);">No active port forwards</div>';
return;
}
for (const fwd of forwards) {
const row = createElement("div", "", {});
row.style.cssText =
"display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) 0;border-bottom:1px solid var(--border-subtle);";
const dirLabel = fwd.direction === "local" ? "L" : "R";
const dirColor =
fwd.direction === "local" ? "var(--accent)" : "var(--warning)";
row.innerHTML = `
<span style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:${dirColor};width:16px;">${dirLabel}</span>
<span style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-primary);flex:1;">
${escapeHtml(fwd.bindHost || "127.0.0.1")}:${fwd.bindPort} → ${escapeHtml(fwd.destHost || "localhost")}:${fwd.destPort}
</span>
${fwd.label ? `<span style="font-size:var(--text-xs);color:var(--text-tertiary);">${escapeHtml(fwd.label)}</span>` : ""}
`;
const stopBtn = createElement("button", "btn btn-xs btn-danger", {
textContent: "Stop",
});
stopBtn.addEventListener("click", async () => {
try {
await window.NexusTerminal.wailsService.StopPortForward(sessionId, fwd.id);
self.app.notifications.info("Forward stopped");
renderForwards();
} catch (err) {
self.app.notifications.error(
"Failed to stop: " + (err.message || err),
);
}
});
row.appendChild(stopBtn);
listEl.appendChild(row);
}
} catch (err) {
listEl.innerHTML =
'<div style="color:var(--error);font-size:var(--text-sm);">Failed to load forwards</div>';
}
};
renderForwards();
// Add forward button
modal.querySelector(".pf-add-btn").addEventListener("click", async () => {
const direction = modal.querySelector(".pf-direction").value;
const bindHost =
modal.querySelector(".pf-bind-host").value.trim() || "127.0.0.1";
const bindPort = parseInt(
modal.querySelector(".pf-bind-port").value,
10,
);
const destHost =
modal.querySelector(".pf-dest-host").value.trim() || "localhost";
const destPort = parseInt(
modal.querySelector(".pf-dest-port").value,
10,
);
const label = modal.querySelector(".pf-label").value.trim();
if (!bindPort || !destPort) {
self.app.notifications.error(
"Bind port and destination port are required",
);
return;
}
try {
await window.NexusTerminal.wailsService.StartPortForward(sessionId, {
id: "",
direction: direction,
bindHost: bindHost,
bindPort: bindPort,
destHost: destHost,
destPort: destPort,
enabled: true,
label: label,
});
self.app.notifications.success("Forward started");
modal.querySelector(".pf-bind-port").value = "";
modal.querySelector(".pf-dest-port").value = "";
modal.querySelector(".pf-label").value = "";
renderForwards();
} catch (err) {
self.app.notifications.error(
"Failed to start forward: " + (err.message || err),
);
}
});
}
/**
* Builds the xterm.js colour theme.
* @returns {Object}
+320 -237
View File
@@ -1,264 +1,347 @@
(function() {
/**
* Nexus Terminal — terminal colour themes.
* Provides a collection of preset xterm.js colour schemes and a manager
* for applying them to terminal instances.
*/
(function () {
/**
* Nexus Terminal — terminal colour themes.
* Provides a collection of preset xterm.js colour schemes and a manager
* for applying them to terminal instances.
*/
/**
* Preset terminal colour themes.
* Each entry contains the full xterm.js theme object.
*/
const THEMES = {
/**
* Preset terminal colour themes.
* Each entry contains the full xterm.js theme object.
*/
const THEMES = {
charcoal: {
label: 'Charcoal (Default)',
theme: {
background: '#1c1c1c',
foreground: '#e4e4e4',
cursor: '#4ecdc4',
cursorAccent: '#1c1c1c',
selectionBackground: 'rgba(78, 205, 196, 0.3)',
selectionForeground: '#ffffff',
black: '#1c1c1c',
red: '#ef5350',
green: '#66bb6a',
yellow: '#ffa726',
blue: '#42a5f5',
magenta: '#ab47bc',
cyan: '#4ecdc4',
white: '#e4e4e4',
brightBlack: '#555555',
brightRed: '#ff7043',
brightGreen: '#81c784',
brightYellow: '#ffcc02',
brightBlue: '#64b5f6',
brightMagenta: '#ce93d8',
brightCyan: '#80deea',
brightWhite: '#ffffff',
},
label: "Charcoal (Default)",
theme: {
background: "#1c1c1c",
foreground: "#e4e4e4",
cursor: "#4ecdc4",
cursorAccent: "#1c1c1c",
selectionBackground: "rgba(78, 205, 196, 0.3)",
selectionForeground: "#ffffff",
black: "#1c1c1c",
red: "#ef5350",
green: "#66bb6a",
yellow: "#ffa726",
blue: "#42a5f5",
magenta: "#ab47bc",
cyan: "#4ecdc4",
white: "#e4e4e4",
brightBlack: "#555555",
brightRed: "#ff7043",
brightGreen: "#81c784",
brightYellow: "#ffcc02",
brightBlue: "#64b5f6",
brightMagenta: "#ce93d8",
brightCyan: "#80deea",
brightWhite: "#ffffff",
},
},
'solarized-dark': {
label: 'Solarized Dark',
theme: {
background: '#002b36',
foreground: '#839496',
cursor: '#93a1a1',
cursorAccent: '#002b36',
selectionBackground: 'rgba(147, 161, 161, 0.3)',
selectionForeground: '#fdf6e3',
black: '#073642',
red: '#dc322f',
green: '#859900',
yellow: '#b58900',
blue: '#268bd2',
magenta: '#d33682',
cyan: '#2aa198',
white: '#eee8d5',
brightBlack: '#586e75',
brightRed: '#cb4b16',
brightGreen: '#586e75',
brightYellow: '#657b83',
brightBlue: '#839496',
brightMagenta: '#6c71c4',
brightCyan: '#93a1a1',
brightWhite: '#fdf6e3',
},
"solarized-dark": {
label: "Solarized Dark",
theme: {
background: "#002b36",
foreground: "#839496",
cursor: "#93a1a1",
cursorAccent: "#002b36",
selectionBackground: "rgba(147, 161, 161, 0.3)",
selectionForeground: "#fdf6e3",
black: "#073642",
red: "#dc322f",
green: "#859900",
yellow: "#b58900",
blue: "#268bd2",
magenta: "#d33682",
cyan: "#2aa198",
white: "#eee8d5",
brightBlack: "#586e75",
brightRed: "#cb4b16",
brightGreen: "#586e75",
brightYellow: "#657b83",
brightBlue: "#839496",
brightMagenta: "#6c71c4",
brightCyan: "#93a1a1",
brightWhite: "#fdf6e3",
},
},
monokai: {
label: 'Monokai',
theme: {
background: '#272822',
foreground: '#f8f8f2',
cursor: '#f8f8f0',
cursorAccent: '#272822',
selectionBackground: 'rgba(73, 72, 62, 0.6)',
selectionForeground: '#f8f8f2',
black: '#272822',
red: '#f92672',
green: '#a6e22e',
yellow: '#f4bf75',
blue: '#66d9ef',
magenta: '#ae81ff',
cyan: '#a1efe4',
white: '#f8f8f2',
brightBlack: '#75715e',
brightRed: '#f92672',
brightGreen: '#a6e22e',
brightYellow: '#f4bf75',
brightBlue: '#66d9ef',
brightMagenta: '#ae81ff',
brightCyan: '#a1efe4',
brightWhite: '#f9f8f5',
},
label: "Monokai",
theme: {
background: "#272822",
foreground: "#f8f8f2",
cursor: "#f8f8f0",
cursorAccent: "#272822",
selectionBackground: "rgba(73, 72, 62, 0.6)",
selectionForeground: "#f8f8f2",
black: "#272822",
red: "#f92672",
green: "#a6e22e",
yellow: "#f4bf75",
blue: "#66d9ef",
magenta: "#ae81ff",
cyan: "#a1efe4",
white: "#f8f8f2",
brightBlack: "#75715e",
brightRed: "#f92672",
brightGreen: "#a6e22e",
brightYellow: "#f4bf75",
brightBlue: "#66d9ef",
brightMagenta: "#ae81ff",
brightCyan: "#a1efe4",
brightWhite: "#f9f8f5",
},
},
nord: {
label: 'Nord',
theme: {
background: '#2e3440',
foreground: '#d8dee9',
cursor: '#d8dee9',
cursorAccent: '#2e3440',
selectionBackground: 'rgba(136, 192, 208, 0.3)',
selectionForeground: '#eceff4',
black: '#3b4252',
red: '#bf616a',
green: '#a3be8c',
yellow: '#ebcb8b',
blue: '#81a1c1',
magenta: '#b48ead',
cyan: '#88c0d0',
white: '#e5e9f0',
brightBlack: '#4c566a',
brightRed: '#bf616a',
brightGreen: '#a3be8c',
brightYellow: '#ebcb8b',
brightBlue: '#81a1c1',
brightMagenta: '#b48ead',
brightCyan: '#8fbcbb',
brightWhite: '#eceff4',
},
label: "Nord",
theme: {
background: "#2e3440",
foreground: "#d8dee9",
cursor: "#d8dee9",
cursorAccent: "#2e3440",
selectionBackground: "rgba(136, 192, 208, 0.3)",
selectionForeground: "#eceff4",
black: "#3b4252",
red: "#bf616a",
green: "#a3be8c",
yellow: "#ebcb8b",
blue: "#81a1c1",
magenta: "#b48ead",
cyan: "#88c0d0",
white: "#e5e9f0",
brightBlack: "#4c566a",
brightRed: "#bf616a",
brightGreen: "#a3be8c",
brightYellow: "#ebcb8b",
brightBlue: "#81a1c1",
brightMagenta: "#b48ead",
brightCyan: "#8fbcbb",
brightWhite: "#eceff4",
},
},
dracula: {
label: 'Dracula',
theme: {
background: '#282a36',
foreground: '#f8f8f2',
cursor: '#f8f8f2',
cursorAccent: '#282a36',
selectionBackground: 'rgba(68, 71, 90, 0.6)',
selectionForeground: '#f8f8f2',
black: '#21222c',
red: '#ff5555',
green: '#50fa7b',
yellow: '#f1fa8c',
blue: '#bd93f9',
magenta: '#ff79c6',
cyan: '#8be9fd',
white: '#f8f8f2',
brightBlack: '#6272a4',
brightRed: '#ff6e6e',
brightGreen: '#69ff94',
brightYellow: '#ffffa5',
brightBlue: '#d6acff',
brightMagenta: '#ff92df',
brightCyan: '#a4ffff',
brightWhite: '#ffffff',
},
label: "Dracula",
theme: {
background: "#282a36",
foreground: "#f8f8f2",
cursor: "#f8f8f2",
cursorAccent: "#282a36",
selectionBackground: "rgba(68, 71, 90, 0.6)",
selectionForeground: "#f8f8f2",
black: "#21222c",
red: "#ff5555",
green: "#50fa7b",
yellow: "#f1fa8c",
blue: "#bd93f9",
magenta: "#ff79c6",
cyan: "#8be9fd",
white: "#f8f8f2",
brightBlack: "#6272a4",
brightRed: "#ff6e6e",
brightGreen: "#69ff94",
brightYellow: "#ffffa5",
brightBlue: "#d6acff",
brightMagenta: "#ff92df",
brightCyan: "#a4ffff",
brightWhite: "#ffffff",
},
},
'one-dark': {
label: 'One Dark',
theme: {
background: '#282c34',
foreground: '#abb2bf',
cursor: '#528bff',
cursorAccent: '#282c34',
selectionBackground: 'rgba(82, 139, 255, 0.3)',
selectionForeground: '#abb2bf',
black: '#282c34',
red: '#e06c75',
green: '#98c379',
yellow: '#e5c07b',
blue: '#61afef',
magenta: '#c678dd',
cyan: '#56b6c2',
white: '#abb2bf',
brightBlack: '#5c6370',
brightRed: '#e06c75',
brightGreen: '#98c379',
brightYellow: '#e5c07b',
brightBlue: '#61afef',
brightMagenta: '#c678dd',
brightCyan: '#56b6c2',
brightWhite: '#ffffff',
},
"one-dark": {
label: "One Dark",
theme: {
background: "#282c34",
foreground: "#abb2bf",
cursor: "#528bff",
cursorAccent: "#282c34",
selectionBackground: "rgba(82, 139, 255, 0.3)",
selectionForeground: "#abb2bf",
black: "#282c34",
red: "#e06c75",
green: "#98c379",
yellow: "#e5c07b",
blue: "#61afef",
magenta: "#c678dd",
cyan: "#56b6c2",
white: "#abb2bf",
brightBlack: "#5c6370",
brightRed: "#e06c75",
brightGreen: "#98c379",
brightYellow: "#e5c07b",
brightBlue: "#61afef",
brightMagenta: "#c678dd",
brightCyan: "#56b6c2",
brightWhite: "#ffffff",
},
},
'gruvbox-dark': {
label: 'Gruvbox Dark',
theme: {
background: '#282828',
foreground: '#ebdbb2',
cursor: '#ebdbb2',
cursorAccent: '#282828',
selectionBackground: 'rgba(214, 93, 14, 0.3)',
selectionForeground: '#ebdbb2',
black: '#282828',
red: '#cc241d',
green: '#98971a',
yellow: '#d79921',
blue: '#458588',
magenta: '#b16286',
cyan: '#689d6a',
white: '#a89984',
brightBlack: '#928374',
brightRed: '#fb4934',
brightGreen: '#b8bb26',
brightYellow: '#fabd2f',
brightBlue: '#83a598',
brightMagenta: '#d3869b',
brightCyan: '#8ec07c',
brightWhite: '#ebdbb2',
},
"gruvbox-dark": {
label: "Gruvbox Dark",
theme: {
background: "#282828",
foreground: "#ebdbb2",
cursor: "#ebdbb2",
cursorAccent: "#282828",
selectionBackground: "rgba(214, 93, 14, 0.3)",
selectionForeground: "#ebdbb2",
black: "#282828",
red: "#cc241d",
green: "#98971a",
yellow: "#d79921",
blue: "#458588",
magenta: "#b16286",
cyan: "#689d6a",
white: "#a89984",
brightBlack: "#928374",
brightRed: "#fb4934",
brightGreen: "#b8bb26",
brightYellow: "#fabd2f",
brightBlue: "#83a598",
brightMagenta: "#d3869b",
brightCyan: "#8ec07c",
brightWhite: "#ebdbb2",
},
},
'tokyo-night': {
label: 'Tokyo Night',
theme: {
background: '#1a1b26',
foreground: '#a9b1d6',
cursor: '#c0caf5',
cursorAccent: '#1a1b26',
selectionBackground: 'rgba(41, 46, 66, 0.9)',
selectionForeground: '#c0caf5',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5',
},
"tokyo-night": {
label: "Tokyo Night",
theme: {
background: "#1a1b26",
foreground: "#a9b1d6",
cursor: "#c0caf5",
cursorAccent: "#1a1b26",
selectionBackground: "rgba(41, 46, 66, 0.9)",
selectionForeground: "#c0caf5",
black: "#15161e",
red: "#f7768e",
green: "#9ece6a",
yellow: "#e0af68",
blue: "#7aa2f7",
magenta: "#bb9af7",
cyan: "#7dcfff",
white: "#a9b1d6",
brightBlack: "#414868",
brightRed: "#f7768e",
brightGreen: "#9ece6a",
brightYellow: "#e0af68",
brightBlue: "#7aa2f7",
brightMagenta: "#bb9af7",
brightCyan: "#7dcfff",
brightWhite: "#c0caf5",
},
},
};
/**
* Returns the theme object for the given theme name.
* Falls back to 'charcoal' if not found.
* @param {string} name
* @returns {Object} xterm.js theme object
*/
function getTerminalTheme(name) {
const entry = THEMES[name || 'charcoal'];
"catppuccin-mocha": {
label: "Catppuccin Mocha",
theme: {
background: "#1e1e2e",
foreground: "#cdd6f4",
cursor: "#f5e0dc",
cursorAccent: "#1e1e2e",
selectionBackground: "rgba(88, 91, 112, 0.5)",
selectionForeground: "#cdd6f4",
black: "#45475a",
red: "#f38ba8",
green: "#a6e3a1",
yellow: "#f9e2af",
blue: "#89b4fa",
magenta: "#f5c2e7",
cyan: "#94e2d5",
white: "#bac2de",
brightBlack: "#585b70",
brightRed: "#f38ba8",
brightGreen: "#a6e3a1",
brightYellow: "#f9e2af",
brightBlue: "#89b4fa",
brightMagenta: "#f5c2e7",
brightCyan: "#94e2d5",
brightWhite: "#a6adc8",
},
},
"rose-pine": {
label: "Rosé Pine",
theme: {
background: "#191724",
foreground: "#e0def4",
cursor: "#524f67",
cursorAccent: "#e0def4",
selectionBackground: "rgba(110, 106, 134, 0.4)",
selectionForeground: "#e0def4",
black: "#26233a",
red: "#eb6f92",
green: "#31748f",
yellow: "#f6c177",
blue: "#9ccfd8",
magenta: "#c4a7e7",
cyan: "#ebbcba",
white: "#e0def4",
brightBlack: "#6e6a86",
brightRed: "#eb6f92",
brightGreen: "#31748f",
brightYellow: "#f6c177",
brightBlue: "#9ccfd8",
brightMagenta: "#c4a7e7",
brightCyan: "#ebbcba",
brightWhite: "#e0def4",
},
},
"solarized-light": {
label: "Solarized Light",
theme: {
background: "#fdf6e3",
foreground: "#657b83",
cursor: "#586e75",
cursorAccent: "#fdf6e3",
selectionBackground: "rgba(7, 54, 66, 0.2)",
selectionForeground: "#073642",
black: "#073642",
red: "#dc322f",
green: "#859900",
yellow: "#b58900",
blue: "#268bd2",
magenta: "#d33682",
cyan: "#2aa198",
white: "#eee8d5",
brightBlack: "#002b36",
brightRed: "#cb4b16",
brightGreen: "#586e75",
brightYellow: "#657b83",
brightBlue: "#839496",
brightMagenta: "#6c71c4",
brightCyan: "#93a1a1",
brightWhite: "#fdf6e3",
},
},
};
/**
* Returns the theme object for the given theme name.
* Falls back to 'charcoal' if not found.
* @param {string} name
* @returns {Object} xterm.js theme object
*/
function getTerminalTheme(name) {
const entry = THEMES[name || "charcoal"];
return entry ? entry.theme : THEMES.charcoal.theme;
}
}
/**
* Returns all theme names and labels for building a selector.
* @returns {Array<{value: string, label: string}>}
*/
function listTerminalThemes() {
/**
* Returns all theme names and labels for building a selector.
* @returns {Array<{value: string, label: string}>}
*/
function listTerminalThemes() {
return Object.entries(THEMES).map(([value, entry]) => ({
value,
label: entry.label,
value,
label: entry.label,
}));
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.getTerminalTheme = getTerminalTheme;
window.NexusTerminal.listTerminalThemes = listTerminalThemes;
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.getTerminalTheme = getTerminalTheme;
window.NexusTerminal.listTerminalThemes = listTerminalThemes;
})();
+381
View File
@@ -0,0 +1,381 @@
(function () {
/**
* Nexus Terminal — Trigger Rules Engine.
* Monitors terminal output and fires actions (highlight, notify, sound)
* when patterns match. Rules are stored in localStorage.
*/
const { createElement } = window.NexusTerminal;
const STORAGE_KEY = "nexus:trigger-rules";
class TriggerEngine {
constructor(app) {
this.app = app;
this._rules = this._load();
this._backdrop = null;
// Buffer per session to accumulate partial lines
this._buffers = new Map();
}
// ── Public API ───────────────────────────────────────────────────
/**
* Checks incoming terminal data against all enabled rules.
* Called from the session:data event handler.
* @param {string} sessionId
* @param {string} text - decoded terminal text
*/
check(sessionId, text) {
if (this._rules.length === 0) return;
// Accumulate text and check line by line
const buf = (this._buffers.get(sessionId) || "") + text;
const lines = buf.split("\n");
// Keep the last incomplete line in the buffer
this._buffers.set(sessionId, lines.pop());
for (const line of lines) {
this._checkLine(sessionId, line);
}
}
/**
* Opens the trigger rules management panel.
*/
open() {
if (this._backdrop) return;
this._render();
}
/**
* Closes the trigger rules panel.
*/
close() {
if (this._backdrop) {
this._backdrop.classList.remove("is-visible");
const modal = this._backdrop.querySelector(".modal");
if (modal) modal.classList.remove("is-visible");
setTimeout(() => {
if (this._backdrop) {
this._backdrop.remove();
this._backdrop = null;
}
}, 200);
}
}
/**
* Cleans up session buffer on disconnect.
* @param {string} sessionId
*/
cleanup(sessionId) {
this._buffers.delete(sessionId);
}
// ── Rule matching ────────────────────────────────────────────────
_checkLine(sessionId, line) {
// Strip ANSI escape sequences for matching
const clean = line.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
for (const rule of this._rules) {
if (!rule.enabled) continue;
let matched = false;
if (rule.isRegex) {
try {
const re = new RegExp(rule.pattern, rule.caseSensitive ? "" : "i");
matched = re.test(clean);
} catch (_) {
// Invalid regex — skip
}
} else {
const haystack = rule.caseSensitive ? clean : clean.toLowerCase();
const needle = rule.caseSensitive
? rule.pattern
: rule.pattern.toLowerCase();
matched = haystack.includes(needle);
}
if (!matched) continue;
// Fire actions
if (rule.action === "notify" || rule.action === "both") {
const session = this.app.state.getSession(sessionId);
const label = session
? session.tabName || session.profileName
: sessionId;
this.app.notifications.info(
`[${label}] Trigger: ${rule.name || rule.pattern}`,
);
}
if (rule.action === "highlight" || rule.action === "both") {
// Flash the tab briefly
const tabEl = document.querySelector(
`.tab[data-session-id="${sessionId}"]`,
);
if (tabEl) {
tabEl.classList.add("tab-trigger-flash");
setTimeout(() => tabEl.classList.remove("tab-trigger-flash"), 2000);
}
}
if (rule.action === "sound") {
this._playBeep();
}
}
}
_playBeep() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
gain.gain.value = 0.1;
osc.start();
osc.stop(ctx.currentTime + 0.15);
} catch (_) {
// Audio not available — silent
}
}
// ── Storage ──────────────────────────────────────────────────────
_load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (_) {
return [];
}
}
_save() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this._rules));
} catch (_) {
// localStorage full or unavailable
}
}
// ── UI ───────────────────────────────────────────────────────────
_render() {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-lg");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Trigger Rules</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<p style="font-size:var(--text-xs);color:var(--text-tertiary);margin-bottom:var(--space-3)">
Rules match against terminal output and fire actions when a pattern is detected.
</p>
<div class="trigger-toolbar">
<button class="btn btn-sm btn-accent trigger-add-btn">Add Rule</button>
</div>
<div class="trigger-list"></div>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
this._backdrop = backdrop;
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
modal
.querySelector(".modal-close")
.addEventListener("click", () => this.close());
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) this.close();
});
modal
.querySelector(".trigger-add-btn")
.addEventListener("click", () => this._addRule());
this._renderList();
}
_renderList() {
const list = this._backdrop.querySelector(".trigger-list");
if (!list) return;
list.innerHTML = "";
if (this._rules.length === 0) {
list.innerHTML =
'<div class="trigger-empty">No trigger rules defined</div>';
return;
}
for (let i = 0; i < this._rules.length; i++) {
list.appendChild(this._renderRuleRow(this._rules[i], i));
}
}
_renderRuleRow(rule, index) {
const row = createElement("div", "trigger-row");
const toggle = createElement("input", "trigger-toggle");
toggle.type = "checkbox";
toggle.checked = rule.enabled;
toggle.addEventListener("change", () => {
this._rules[index].enabled = toggle.checked;
this._save();
});
const info = createElement("div", "trigger-info");
const name = createElement("div", "trigger-name", {
textContent: rule.name || rule.pattern,
});
const meta = createElement("div", "trigger-meta", {
textContent: `${rule.isRegex ? "Regex" : "Text"} · ${rule.action} · ${rule.caseSensitive ? "Case-sensitive" : "Case-insensitive"}`,
});
info.appendChild(name);
info.appendChild(meta);
const pattern = createElement("code", "trigger-pattern", {
textContent: rule.pattern,
});
const actions = createElement("div", "trigger-actions");
const editBtn = createElement("button", "btn btn-xs", {
textContent: "Edit",
});
editBtn.addEventListener("mousedown", (e) => e.preventDefault());
editBtn.addEventListener("click", () => this._editRule(index));
const delBtn = createElement("button", "btn btn-xs btn-danger", {
textContent: "✕",
});
delBtn.addEventListener("mousedown", (e) => e.preventDefault());
delBtn.addEventListener("click", () => {
this._rules.splice(index, 1);
this._save();
this._renderList();
});
actions.appendChild(editBtn);
actions.appendChild(delBtn);
row.appendChild(toggle);
row.appendChild(info);
row.appendChild(pattern);
row.appendChild(actions);
return row;
}
_addRule() {
this._showRuleForm(null, (rule) => {
this._rules.push(rule);
this._save();
this._renderList();
});
}
_editRule(index) {
this._showRuleForm(this._rules[index], (rule) => {
this._rules[index] = rule;
this._save();
this._renderList();
});
}
_showRuleForm(existing, onSave) {
const list = this._backdrop.querySelector(".trigger-list");
const toolbar = this._backdrop.querySelector(".trigger-toolbar");
if (!list) return;
// Hide list and toolbar
list.style.display = "none";
if (toolbar) toolbar.style.display = "none";
const form = createElement("div", "trigger-form");
form.innerHTML = `
<div class="form-group">
<label class="form-label">Name (optional)</label>
<input type="text" class="form-input trigger-name-input" placeholder="e.g. Error alert" value="${existing ? existing.name || "" : ""}" />
</div>
<div class="form-group">
<label class="form-label">Pattern</label>
<input type="text" class="form-input trigger-pattern-input" placeholder="e.g. ERROR|FATAL|panic" value="${existing ? existing.pattern : ""}" />
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">Match Type</label>
<select class="form-input trigger-type-select">
<option value="text"${existing && !existing.isRegex ? " selected" : ""}>Plain text</option>
<option value="regex"${existing && existing.isRegex ? " selected" : ""}>Regex</option>
</select>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Action</label>
<select class="form-input trigger-action-select">
<option value="notify"${existing && existing.action === "notify" ? " selected" : ""}>Notify</option>
<option value="highlight"${existing && existing.action === "highlight" ? " selected" : ""}>Highlight tab</option>
<option value="both"${existing && existing.action === "both" ? " selected" : ""}>Notify + Highlight</option>
<option value="sound"${existing && existing.action === "sound" ? " selected" : ""}>Sound</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">
<input type="checkbox" class="trigger-case-check"${existing && existing.caseSensitive ? " checked" : ""} />
Case-sensitive
</label>
</div>
<div class="trigger-form-actions">
<button class="btn btn-sm trigger-cancel-btn">Cancel</button>
<button class="btn btn-sm btn-accent trigger-save-btn">${existing ? "Save" : "Add"}</button>
</div>
`;
list.parentNode.insertBefore(form, list);
form
.querySelector(".trigger-cancel-btn")
.addEventListener("click", () => {
form.remove();
list.style.display = "";
if (toolbar) toolbar.style.display = "";
});
form.querySelector(".trigger-save-btn").addEventListener("click", () => {
const pattern = form
.querySelector(".trigger-pattern-input")
.value.trim();
if (!pattern) {
this.app.notifications.error("Pattern is required");
return;
}
const rule = {
name: form.querySelector(".trigger-name-input").value.trim(),
pattern: pattern,
isRegex: form.querySelector(".trigger-type-select").value === "regex",
action: form.querySelector(".trigger-action-select").value,
caseSensitive: form.querySelector(".trigger-case-check").checked,
enabled: existing ? existing.enabled : true,
};
form.remove();
list.style.display = "";
if (toolbar) toolbar.style.display = "";
onSave(rule);
});
form.querySelector(".trigger-pattern-input").focus();
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.TriggerEngine = TriggerEngine;
})();
+320 -211
View File
@@ -1,120 +1,178 @@
(function() {
/**
* Nexus Terminal — utility functions.
* General-purpose helpers used throughout the application.
*/
(function () {
/**
* Nexus Terminal — utility functions.
* General-purpose helpers used throughout the application.
*/
/**
* Shorthand for document.querySelector.
* @param {string} selector - CSS selector.
* @returns {Element|null}
*/
function $(selector) {
/**
* Shorthand for document.querySelector.
* @param {string} selector - CSS selector.
* @returns {Element|null}
*/
function $(selector) {
return document.querySelector(selector);
}
}
/**
* Shorthand for document.querySelectorAll.
* @param {string} selector - CSS selector.
* @returns {NodeListOf<Element>}
*/
function $$(selector) {
/**
* Shorthand for document.querySelectorAll.
* @param {string} selector - CSS selector.
* @returns {NodeListOf<Element>}
*/
function $$(selector) {
return document.querySelectorAll(selector);
}
}
/**
* Creates a DOM element with optional class name and attributes.
* @param {string} tag - HTML tag name.
* @param {string} [className] - Space-separated class names.
* @param {Object} [attrs] - Attribute key/value pairs.
* @returns {HTMLElement}
*/
function createElement(tag, className, attrs = {}) {
/**
* Creates a DOM element with optional class name and attributes.
* @param {string} tag - HTML tag name.
* @param {string} [className] - Space-separated class names.
* @param {Object} [attrs] - Attribute key/value pairs.
* @returns {HTMLElement}
*/
function createElement(tag, className, attrs = {}) {
const el = document.createElement(tag);
if (className) {
el.className = className;
el.className = className;
}
for (const [key, value] of Object.entries(attrs)) {
if (key === 'textContent') {
el.textContent = value;
} else if (key === 'innerHTML') {
el.innerHTML = value;
} else if (key === 'dataset') {
for (const [dKey, dVal] of Object.entries(value)) {
el.dataset[dKey] = dVal;
}
} else if (key.startsWith('on') && typeof value === 'function') {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else {
el.setAttribute(key, value);
if (key === "textContent") {
el.textContent = value;
} else if (key === "innerHTML") {
el.innerHTML = value;
} else if (key === "dataset") {
for (const [dKey, dVal] of Object.entries(value)) {
el.dataset[dKey] = dVal;
}
} else if (key.startsWith("on") && typeof value === "function") {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else {
el.setAttribute(key, value);
}
}
return el;
}
}
/**
* Escapes HTML special characters to prevent XSS.
* @param {string} str - Raw string.
* @returns {string} Escaped string safe for innerHTML.
*/
function escapeHtml(str) {
if (!str) return '';
/**
* 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.
* @returns {string} Escaped string safe for innerHTML.
*/
function escapeHtml(str) {
if (!str) return "";
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
};
return str.replace(/[&<>"']/g, (ch) => map[ch]);
}
}
/**
* Formats a Date (or ISO string) into a concise timestamp.
* @param {Date|string} date
* @returns {string} e.g. "14:32:07" or "2 Jan 14:32".
*/
function formatTimestamp(date) {
if (!date) return '';
/**
* Formats a Date (or ISO string) into a concise timestamp.
* @param {Date|string} date
* @returns {string} e.g. "14:32:07" or "2 Jan 14:32".
*/
function formatTimestamp(date) {
if (!date) return "";
const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return '';
if (isNaN(d.getTime())) return "";
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
const hours = String(d.getHours()).padStart(2, '0');
const mins = String(d.getMinutes()).padStart(2, '0');
const secs = String(d.getSeconds()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, "0");
const mins = String(d.getMinutes()).padStart(2, "0");
const secs = String(d.getSeconds()).padStart(2, "0");
if (isToday) {
return `${hours}:${mins}:${secs}`;
return `${hours}:${mins}:${secs}`;
}
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
return `${d.getDate()} ${months[d.getMonth()]} ${hours}:${mins}`;
}
}
/**
* Returns a debounced version of the given function.
* @param {Function} fn
* @param {number} ms - Delay in milliseconds.
* @returns {Function}
*/
function debounce(fn, ms) {
/**
* Returns a debounced version of the given function.
* @param {Function} fn
* @param {number} ms - Delay in milliseconds.
* @returns {Function}
*/
function debounce(fn, ms) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
};
}
}
/**
* Performs a simple fuzzy match — checks whether every character in
* the query appears in order within the text (case-insensitive).
* @param {string} text
* @param {string} query
* @returns {{ match: boolean, score: number }}
*/
function fuzzyMatch(text, query) {
/**
* Performs a simple fuzzy match — checks whether every character in
* the query appears in order within the text (case-insensitive).
* @param {string} text
* @param {string} query
* @returns {{ match: boolean, score: number }}
*/
function fuzzyMatch(text, query) {
if (!query) return { match: true, score: 1 };
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
@@ -124,168 +182,219 @@ function fuzzyMatch(text, query) {
let consecutiveBonus = 0;
for (let qi = 0; qi < lowerQuery.length; qi++) {
const ch = lowerQuery[qi];
const foundAt = lowerText.indexOf(ch, textIdx);
if (foundAt === -1) {
return { match: false, score: 0 };
}
// Reward consecutive character matches and early matches
if (foundAt === textIdx) {
consecutiveBonus += 1;
} else {
consecutiveBonus = 0;
}
score += 1 + consecutiveBonus + (foundAt === 0 ? 2 : 0);
textIdx = foundAt + 1;
const ch = lowerQuery[qi];
const foundAt = lowerText.indexOf(ch, textIdx);
if (foundAt === -1) {
return { match: false, score: 0 };
}
// Reward consecutive character matches and early matches
if (foundAt === textIdx) {
consecutiveBonus += 1;
} else {
consecutiveBonus = 0;
}
score += 1 + consecutiveBonus + (foundAt === 0 ? 2 : 0);
textIdx = foundAt + 1;
}
return { match: true, score };
}
}
/**
* Base64-encodes a string.
* @param {string} str
* @returns {string}
*/
function base64Encode(str) {
/**
* Base64-encodes a string.
* @param {string} str
* @returns {string}
*/
function base64Encode(str) {
return btoa(str);
}
}
/**
* Base64-decodes a string.
* @param {string} str
* @returns {string}
*/
function base64Decode(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
* @returns {string}
*/
function base64Decode(str) {
return atob(str);
}
}
/**
* Generates a new UUID v4 using the browser crypto API.
* @returns {string}
*/
function generateId() {
/**
* Generates a new UUID v4 using the browser crypto API.
* @returns {string}
*/
function generateId() {
return crypto.randomUUID();
}
}
/**
* Formats a byte count into a human-friendly string.
* @param {number} bytes
* @returns {string} e.g. "1.23 KB"
*/
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
/**
* Formats a byte count into a human-friendly string.
* @param {number} bytes
* @returns {string} e.g. "1.23 KB"
*/
function formatBytes(bytes) {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / Math.pow(k, i);
return `${value.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
}
}
/**
* Truncates a string to the given length, appending an ellipsis if needed.
* @param {string} str
* @param {number} len
* @returns {string}
*/
function truncate(str, len) {
if (!str || str.length <= len) return str || '';
return str.slice(0, len) + '…';
}
/**
* Truncates a string to the given length, appending an ellipsis if needed.
* @param {string} str
* @param {number} len
* @returns {string}
*/
function truncate(str, len) {
if (!str || str.length <= len) return str || "";
return str.slice(0, len) + "…";
}
/**
* Removes any open context menus from the DOM.
*/
function dismissContextMenus() {
document.querySelectorAll('.context-menu').forEach((m) => m.remove());
}
/**
* Removes any open context menus from the DOM.
*/
function dismissContextMenus() {
document.querySelectorAll(".context-menu").forEach((m) => m.remove());
}
/**
* Returns the protocol badge colour class.
* @param {string} protocol - 'ssh', 'telnet', 'raw', or 'github'.
* @returns {string}
*/
function protocolColour(protocol) {
/**
* Returns the protocol badge colour class.
* @param {string} protocol - 'ssh', 'telnet', 'raw', or 'github'.
* @returns {string}
*/
function protocolColour(protocol) {
switch (protocol) {
case 'ssh': return 'proto-ssh';
case 'telnet': return 'proto-telnet';
case 'raw': return 'proto-raw';
case 'github': return 'proto-github';
default: return '';
case "ssh":
return "proto-ssh";
case "telnet":
return "proto-telnet";
case "raw":
return "proto-raw";
case "github":
return "proto-github";
case "vnc":
return "proto-vnc";
case "local":
return "proto-local";
default:
return "";
}
}
}
/**
* Returns a human-friendly relative time string for an ISO date.
* @param {string} isoDate - ISO 8601 date string.
* @returns {string} e.g. "3 hours ago", "2 days ago".
*/
function relativeTime(isoDate) {
if (!isoDate) return '';
/**
* Returns a human-friendly relative time string for an ISO date.
* @param {string} isoDate - ISO 8601 date string.
* @returns {string} e.g. "3 hours ago", "2 days ago".
*/
function relativeTime(isoDate) {
if (!isoDate) return "";
const d = new Date(isoDate);
if (isNaN(d.getTime())) return '';
if (isNaN(d.getTime())) return "";
const now = Date.now();
const diff = now - d.getTime();
const secs = Math.floor(diff / 1000);
if (secs < 60) return 'just now';
if (secs < 60) return "just now";
const mins = Math.floor(secs / 60);
if (mins < 60) return mins === 1 ? '1 minute ago' : `${mins} minutes ago`;
if (mins < 60) return mins === 1 ? "1 minute ago" : `${mins} minutes ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`;
if (hrs < 24) return hrs === 1 ? "1 hour ago" : `${hrs} hours ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return days === 1 ? '1 day ago' : `${days} days ago`;
if (days < 30) return days === 1 ? "1 day ago" : `${days} days ago`;
const months = Math.floor(days / 30);
if (months < 12) return months === 1 ? '1 month ago' : `${months} months ago`;
if (months < 12)
return months === 1 ? "1 month ago" : `${months} months ago`;
const years = Math.floor(months / 12);
return years === 1 ? '1 year ago' : `${years} years ago`;
}
return years === 1 ? "1 year ago" : `${years} years ago`;
}
/**
* Returns a human-friendly label for a session state.
* @param {string} state
* @returns {string}
*/
function stateLabel(state) {
/**
* Returns a human-friendly label for a session state.
* @param {string} state
* @returns {string}
*/
function stateLabel(state) {
switch (state) {
case 'connecting': return 'Connecting';
case 'connected': return 'Connected';
case 'disconnected': return 'Disconnected';
case 'reconnecting': return 'Reconnecting';
case 'error': return 'Error';
default: return state || 'Unknown';
case "connecting":
return "Connecting";
case "connected":
return "Connected";
case "disconnected":
return "Disconnected";
case "reconnecting":
return "Reconnecting";
case "error":
return "Error";
default:
return state || "Unknown";
}
}
}
window.NexusTerminal = window.NexusTerminal || {};
Object.assign(window.NexusTerminal, { $, $$, createElement, escapeHtml, formatTimestamp, debounce, fuzzyMatch, base64Encode, base64Decode, generateId, formatBytes, truncate, dismissContextMenus, protocolColour, stateLabel, relativeTime });
window.NexusTerminal = window.NexusTerminal || {};
Object.assign(window.NexusTerminal, {
$,
$$,
createElement,
createModalShell,
escapeHtml,
formatTimestamp,
debounce,
fuzzyMatch,
base64Encode,
utf8ToBase64,
base64Decode,
generateId,
formatBytes,
truncate,
dismissContextMenus,
protocolColour,
stateLabel,
relativeTime,
});
/* ── Disable macOS/WebKit auto-capitalisation on all text inputs ───────── */
function disableAutoCapitalise(el) {
el.setAttribute('autocapitalize', 'none');
el.setAttribute('autocorrect', 'off');
el.setAttribute('spellcheck', 'false');
}
/* ── Disable macOS/WebKit auto-capitalisation on all text inputs ───────── */
function disableAutoCapitalise(el) {
el.setAttribute("autocapitalize", "none");
el.setAttribute("autocorrect", "off");
el.setAttribute("spellcheck", "false");
}
// Apply to existing inputs
document.querySelectorAll('input, textarea').forEach(disableAutoCapitalise);
// Apply to existing inputs
document.querySelectorAll("input, textarea").forEach(disableAutoCapitalise);
// Observe for dynamically added inputs
var inputObserver = new MutationObserver(function(mutations) {
// Observe for dynamically added inputs
var inputObserver = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var nodes = mutations[i].addedNodes;
for (var j = 0; j < nodes.length; j++) {
var node = nodes[j];
if (node.nodeType !== 1) continue;
if (node.tagName === 'INPUT' || node.tagName === 'TEXTAREA') {
disableAutoCapitalise(node);
}
if (node.querySelectorAll) {
node.querySelectorAll('input, textarea').forEach(disableAutoCapitalise);
}
var nodes = mutations[i].addedNodes;
for (var j = 0; j < nodes.length; j++) {
var node = nodes[j];
if (node.nodeType !== 1) continue;
if (node.tagName === "INPUT" || node.tagName === "TEXTAREA") {
disableAutoCapitalise(node);
}
if (node.querySelectorAll) {
node
.querySelectorAll("input, textarea")
.forEach(disableAutoCapitalise);
}
}
}
});
inputObserver.observe(document.body, { childList: true, subtree: true });
});
inputObserver.observe(document.body, { childList: true, subtree: true });
})();
+295
View File
@@ -0,0 +1,295 @@
(function () {
/**
* Nexus Terminal — VNC viewer.
* Renders a noVNC canvas inside a session panel, connecting to the local
* WebSocket proxy started by the Go backend.
*/
const { createElement, $ } = window.NexusTerminal;
class VNCViewer {
constructor() {
/** @type {Map<string, VNCViewerInstance>} sessionID → viewer */
this._viewers = new Map();
}
/**
* Creates a VNC viewer panel for a session inside the given container.
* @param {string} sessionID
* @param {HTMLElement} container - The .session-panel element.
* @param {object} profile - The profile object with VNC config.
*/
createViewer(sessionID, container, profile) {
if (this._viewers.has(sessionID)) return;
if (!container) return;
const viewer = new VNCViewerInstance(sessionID, container, profile);
this._viewers.set(sessionID, viewer);
}
/**
* Connects the viewer to the WebSocket URL from the backend.
* @param {string} sessionID
* @param {string} wsURL
*/
connect(sessionID, wsURL) {
const viewer = this._viewers.get(sessionID);
if (viewer) {
viewer.connect(wsURL);
}
}
/**
* Destroys a viewer for the given session.
* @param {string} sessionID
*/
destroyViewer(sessionID) {
const viewer = this._viewers.get(sessionID);
if (viewer) {
viewer.destroy();
this._viewers.delete(sessionID);
}
}
/**
* Resizes the VNC display for the given session.
* @param {string} sessionID
*/
resize(sessionID) {
const viewer = this._viewers.get(sessionID);
if (viewer) {
viewer.resize();
}
}
}
class VNCViewerInstance {
/**
* @param {string} sessionID
* @param {HTMLElement} container
* @param {object} profile
*/
constructor(sessionID, container, profile) {
this.sessionID = sessionID;
this.container = container;
this.profile = profile;
this.rfb = null;
this._buildUI();
}
_buildUI() {
this.container.innerHTML = "";
this.container.classList.add("vnc-viewer-panel");
// Toolbar
this.toolbar = createElement("div", "vnc-toolbar");
const ctrlAltDel = createElement("button", "btn btn-sm btn-vnc-action", {
textContent: "Ctrl+Alt+Del",
title: "Send Ctrl+Alt+Del",
});
ctrlAltDel.addEventListener("click", () => this._sendCtrlAltDel());
const clipboardBtn = createElement(
"button",
"btn btn-sm btn-vnc-action",
{
textContent: "Clipboard",
title: "Sync clipboard",
},
);
clipboardBtn.addEventListener("click", () => this._syncClipboard());
const fullscreenBtn = createElement(
"button",
"btn btn-sm btn-vnc-action",
{
textContent: "Fullscreen",
title: "Toggle fullscreen",
},
);
fullscreenBtn.addEventListener("click", () => this._toggleFullscreen());
this.statusEl = createElement("span", "vnc-status", {
textContent: "Connecting…",
});
this.toolbar.appendChild(this.statusEl);
this.toolbar.appendChild(ctrlAltDel);
this.toolbar.appendChild(clipboardBtn);
this.toolbar.appendChild(fullscreenBtn);
// VNC display container
this.displayEl = createElement("div", "vnc-display");
this.container.appendChild(this.toolbar);
this.container.appendChild(this.displayEl);
}
/**
* Connects to the VNC WebSocket proxy.
* @param {string} wsURL - WebSocket URL (ws://127.0.0.1:<port>)
*/
connect(wsURL) {
if (!window.noVNC || !window.noVNC.RFB) {
this.statusEl.textContent = "Error: noVNC not loaded";
this.statusEl.classList.add("vnc-status-error");
return;
}
try {
const vncConfig = this.profile.vnc || {};
this.rfb = new window.noVNC.RFB(this.displayEl, wsURL, {
shared: vncConfig.sharedSession !== false,
credentials: {},
});
this.rfb.viewOnly = !!vncConfig.viewOnly;
this.rfb.scaleViewport = vncConfig.scaleToFit !== false;
this.rfb.resizeSession = false;
this.rfb.clipViewport = false;
this.rfb.qualityLevel = vncConfig.quality || 6;
this.rfb.compressionLevel = 2;
// Event handlers
this.rfb.addEventListener("connect", () => {
this.statusEl.textContent = "Connected";
this.statusEl.classList.remove("vnc-status-error");
this.statusEl.classList.add("vnc-status-connected");
});
this.rfb.addEventListener("disconnect", (ev) => {
const clean = ev.detail.clean;
this.statusEl.textContent = clean
? "Disconnected"
: "Connection lost";
this.statusEl.classList.remove("vnc-status-connected");
if (!clean) {
this.statusEl.classList.add("vnc-status-error");
}
});
this.rfb.addEventListener("credentialsrequired", () => {
this.statusEl.textContent = "Credentials required";
// TODO: prompt for VNC password via modal
this._promptCredentials();
});
this.rfb.addEventListener("desktopname", (ev) => {
this.statusEl.textContent = "Connected — " + ev.detail.name;
});
} catch (err) {
this.statusEl.textContent = "Error: " + err.message;
this.statusEl.classList.add("vnc-status-error");
}
}
_sendCtrlAltDel() {
if (this.rfb) {
this.rfb.sendCtrlAltDel();
}
}
async _syncClipboard() {
if (!this.rfb) return;
try {
const text = await navigator.clipboard.readText();
if (text) {
this.rfb.clipboardPasteFrom(text);
}
} catch (_) {
// Clipboard API may not be available in WKWebView
}
}
_toggleFullscreen() {
if (!document.fullscreenElement) {
this.container.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
}
_promptCredentials() {
// Build a simple modal for VNC password
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-sm");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">VNC Password</h3>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" class="form-input" id="vnc-password-input" autocomplete="off">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="vnc-password-cancel">Cancel</button>
<button class="btn btn-primary" id="vnc-password-submit">Connect</button>
</div>
`;
document.body.appendChild(backdrop);
document.body.appendChild(modal);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const input = modal.querySelector("#vnc-password-input");
const submit = modal.querySelector("#vnc-password-submit");
const cancel = modal.querySelector("#vnc-password-cancel");
const close = () => {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => {
backdrop.remove();
modal.remove();
}, 200);
};
submit.addEventListener("click", () => {
const pw = input.value;
close();
if (this.rfb && pw) {
this.rfb.sendCredentials({ password: pw });
}
});
cancel.addEventListener("click", () => {
close();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") submit.click();
if (e.key === "Escape") cancel.click();
});
setTimeout(() => input.focus(), 50);
}
resize() {
// noVNC handles resizing internally via ResizeObserver on its canvas,
// but we can trigger a re-scale if needed.
if (this.rfb && this.rfb.scaleViewport) {
this.rfb.scaleViewport = true;
}
}
destroy() {
if (this.rfb) {
this.rfb.disconnect();
this.rfb = null;
}
this.container.innerHTML = "";
this.container.classList.remove("vnc-viewer-panel");
}
}
// Export to global namespace.
window.NexusTerminal.VNCViewer = new VNCViewer();
})();
+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());
})();
+55
View File
@@ -1,16 +1,23 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {models} from '../models';
import {api} from '../models';
import {context} from '../models';
export function BrowseForShell():Promise<string>;
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>;
export function ConnectLocal():Promise<models.SessionInfo>;
export function ConnectQuick(arg1:models.Profile):Promise<models.SessionInfo>;
export function ConnectSession(arg1:string):Promise<models.SessionInfo>;
@@ -27,6 +34,8 @@ export function DeleteProfileSecrets(arg1:string):Promise<void>;
export function DeleteRecording(arg1:string):Promise<void>;
export function DeleteSSHKey(arg1:string):Promise<void>;
export function DeleteSnippet(arg1:string):Promise<void>;
export function DeleteTOTPSecret(arg1:string):Promise<void>;
@@ -45,20 +54,30 @@ export function FileExists(arg1:string):Promise<boolean>;
export function FileIsPrivateKey(arg1:string):Promise<boolean>;
export function GenerateSSHKey(arg1:string,arg2:string,arg3:number,arg4:string):Promise<void>;
export function GetAppInfo():Promise<Record<string, string>>;
export function GetDefaultShell():Promise<string>;
export function GetPreferences():Promise<models.AppPreferences>;
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>;
export function GetSessionHistory(arg1:string):Promise<Array<Record<string, any>>>;
export function GetTOTPCode(arg1:string):Promise<string>;
export function GetVNCWebSocketURL(arg1:string):Promise<string>;
export function HasTOTPSecret(arg1:string):Promise<boolean>;
export function ImportProfiles(arg1:string,arg2:string):Promise<number>;
@@ -75,6 +94,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>>;
@@ -93,6 +136,8 @@ export function ListProfilesByTag(arg1:string):Promise<Array<models.Profile>>;
export function ListRecordings():Promise<Array<Record<string, any>>>;
export function ListSSHKeyPairs():Promise<Array<api.SSHKeyInfo>>;
export function ListSSHKeys():Promise<Array<string>>;
export function ListSessions():Promise<Array<models.SessionInfo>>;
@@ -105,6 +150,8 @@ export function NewProfile(arg1:string):Promise<models.Profile>;
export function PinSession(arg1:string,arg2:boolean):Promise<void>;
export function PingSession(arg1:string):Promise<number>;
export function ReconnectSession(arg1:string):Promise<void>;
export function RefreshGitHubRepos(arg1:string):Promise<Array<models.GitHubRepo>>;
@@ -131,8 +178,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>;
+108
View File
@@ -2,6 +2,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function BrowseForShell() {
return window['go']['api']['Service']['BrowseForShell']();
}
export function CancelReconnect(arg1) {
return window['go']['api']['Service']['CancelReconnect'](arg1);
}
@@ -10,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);
}
@@ -18,6 +26,10 @@ export function CloseAllSessions() {
return window['go']['api']['Service']['CloseAllSessions']();
}
export function ConnectLocal() {
return window['go']['api']['Service']['ConnectLocal']();
}
export function ConnectQuick(arg1) {
return window['go']['api']['Service']['ConnectQuick'](arg1);
}
@@ -50,6 +62,10 @@ export function DeleteRecording(arg1) {
return window['go']['api']['Service']['DeleteRecording'](arg1);
}
export function DeleteSSHKey(arg1) {
return window['go']['api']['Service']['DeleteSSHKey'](arg1);
}
export function DeleteSnippet(arg1) {
return window['go']['api']['Service']['DeleteSnippet'](arg1);
}
@@ -86,10 +102,18 @@ export function FileIsPrivateKey(arg1) {
return window['go']['api']['Service']['FileIsPrivateKey'](arg1);
}
export function GenerateSSHKey(arg1, arg2, arg3, arg4) {
return window['go']['api']['Service']['GenerateSSHKey'](arg1, arg2, arg3, arg4);
}
export function GetAppInfo() {
return window['go']['api']['Service']['GetAppInfo']();
}
export function GetDefaultShell() {
return window['go']['api']['Service']['GetDefaultShell']();
}
export function GetPreferences() {
return window['go']['api']['Service']['GetPreferences']();
}
@@ -102,6 +126,14 @@ 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);
}
export function GetSessionCwd(arg1) {
return window['go']['api']['Service']['GetSessionCwd'](arg1);
}
@@ -114,6 +146,10 @@ export function GetTOTPCode(arg1) {
return window['go']['api']['Service']['GetTOTPCode'](arg1);
}
export function GetVNCWebSocketURL(arg1) {
return window['go']['api']['Service']['GetVNCWebSocketURL'](arg1);
}
export function HasTOTPSecret(arg1) {
return window['go']['api']['Service']['HasTOTPSecret'](arg1);
}
@@ -146,6 +182,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);
}
@@ -182,6 +266,10 @@ export function ListRecordings() {
return window['go']['api']['Service']['ListRecordings']();
}
export function ListSSHKeyPairs() {
return window['go']['api']['Service']['ListSSHKeyPairs']();
}
export function ListSSHKeys() {
return window['go']['api']['Service']['ListSSHKeys']();
}
@@ -206,6 +294,10 @@ export function PinSession(arg1, arg2) {
return window['go']['api']['Service']['PinSession'](arg1, arg2);
}
export function PingSession(arg1) {
return window['go']['api']['Service']['PingSession'](arg1);
}
export function ReconnectSession(arg1) {
return window['go']['api']['Service']['ReconnectSession'](arg1);
}
@@ -258,10 +350,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);
}
+112
View File
@@ -1,3 +1,70 @@
export namespace api {
export class SSHKeyInfo {
name: string;
type: string;
bits: string;
publicKey: string;
privateKeyPath: string;
static createFrom(source: any = {}) {
return new SSHKeyInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.type = source["type"];
this.bits = source["bits"];
this.publicKey = source["publicKey"];
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;
}
}
}
export namespace models {
export class AppPreferences {
@@ -13,6 +80,7 @@ export namespace models {
defaultScrollback: number;
defaultCursorStyle: string;
useNativePaste: boolean;
localShell: string;
static createFrom(source: any = {}) {
return new AppPreferences(source);
@@ -32,6 +100,7 @@ export namespace models {
this.defaultScrollback = source["defaultScrollback"];
this.defaultCursorStyle = source["defaultCursorStyle"];
this.useNativePaste = source["useNativePaste"];
this.localShell = source["localShell"];
}
}
export class AuditEntry {
@@ -222,6 +291,46 @@ export namespace models {
this.label = source["label"];
}
}
export class VNCConfig {
vncHost: string;
vncPort: number;
secretMode: string;
viewOnly: boolean;
scaleToFit: boolean;
quality: number;
sharedSession: boolean;
sshTunnel: boolean;
sshHost: string;
sshPort: number;
sshUsername: string;
sshAuthMethod: string;
sshSecretMode: string;
sshPrivateKeyPath: string;
sshAgentAuth: boolean;
static createFrom(source: any = {}) {
return new VNCConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.vncHost = source["vncHost"];
this.vncPort = source["vncPort"];
this.secretMode = source["secretMode"];
this.viewOnly = source["viewOnly"];
this.scaleToFit = source["scaleToFit"];
this.quality = source["quality"];
this.sharedSession = source["sharedSession"];
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"];
}
}
export class RAWConfig {
autoReconnect: boolean;
lineEnding: string;
@@ -410,6 +519,7 @@ export namespace models {
telnet?: TelnetConfig;
raw?: RAWConfig;
github?: GitHubConfig;
vnc?: VNCConfig;
static createFrom(source: any = {}) {
return new Profile(source);
@@ -442,6 +552,7 @@ export namespace models {
this.telnet = this.convertValues(source["telnet"], TelnetConfig);
this.raw = this.convertValues(source["raw"], RAWConfig);
this.github = this.convertValues(source["github"], GitHubConfig);
this.vnc = this.convertValues(source["vnc"], VNCConfig);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -522,6 +633,7 @@ export namespace models {
this.updatedAt = source["updatedAt"];
}
}
}
+21 -18
View File
@@ -3,44 +3,47 @@ module github.com/andydixon/nexus
go 1.25.0
require (
github.com/creack/pty v1.1.24
github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/gorilla/websocket v1.5.3
github.com/hajimehoshi/go-mp3 v0.3.4
github.com/klauspost/compress v1.17.11
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.5
golang.org/x/crypto v0.50.0
golang.org/x/net v0.53.0
golang.org/x/text v0.36.0
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.52.0
golang.org/x/net v0.55.0
golang.org/x/text v0.37.0
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/alessio/shellescape v1.4.1 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/danieljoos/wincred v1.2.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/labstack/echo/v4 v4.15.2 // indirect
github.com/labstack/gommon v0.5.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/sys v0.45.0 // indirect
)
+48 -42
View File
@@ -1,32 +1,38 @@
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
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/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
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.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
@@ -40,13 +46,12 @@ github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQc
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=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -60,47 +65,48 @@ github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
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=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8=
github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
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.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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.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.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
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=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+553 -3
View File
@@ -8,8 +8,10 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
@@ -23,6 +25,7 @@ import (
"github.com/andydixon/nexus/internal/audit"
"github.com/andydixon/nexus/internal/exportimport"
"github.com/andydixon/nexus/internal/localshell"
"github.com/andydixon/nexus/internal/models"
"github.com/andydixon/nexus/internal/preferences"
"github.com/andydixon/nexus/internal/secrets"
@@ -30,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"
)
@@ -39,6 +43,14 @@ 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
)
// Service is the main API surface exposed to the frontend via Wails bindings.
type Service struct {
store *storage.Store
@@ -52,6 +64,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.
@@ -70,6 +95,8 @@ func NewService(
sessions: sess,
auditLog: auditLog,
prefs: prefs,
uploads: make(map[string]*sftpUpload),
leviathan: &leviathanState{},
}
}
@@ -88,6 +115,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
@@ -96,6 +124,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)
}
@@ -114,7 +145,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)
}
@@ -368,6 +403,24 @@ func (s *Service) ConnectQuick(profile models.Profile) (models.SessionInfo, erro
return *info, nil
}
// ConnectLocal opens a local shell session using the configured (or default) shell.
func (s *Service) ConnectLocal() (models.SessionInfo, error) {
shell := ""
if s.prefs != nil {
prefs := s.prefs.Get()
shell = prefs.LocalShell
}
sessionID, err := s.sessions.ConnectLocal(shell)
if err != nil {
return models.SessionInfo{}, err
}
info := s.sessions.GetSessionInfo(sessionID)
if info == nil {
return models.SessionInfo{}, fmt.Errorf("session created but info not found")
}
return *info, nil
}
// DisconnectSession closes a session by ID.
func (s *Service) DisconnectSession(sessionID string) error {
return s.sessions.Disconnect(sessionID)
@@ -660,6 +713,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 {
@@ -676,11 +732,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 {
@@ -912,6 +978,39 @@ func (s *Service) DownloadGitHubRepoArchive(sessionID string, fullName string, r
return path, nil
}
// ---------------------------------------------------------------------------
// VNC
// ---------------------------------------------------------------------------
// GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session.
// The frontend connects noVNC to this URL for display.
func (s *Service) GetVNCWebSocketURL(sessionID string) (string, error) {
return s.sessions.GetVNCWebSocketURL(sessionID)
}
// ---------------------------------------------------------------------------
// Local shell
// ---------------------------------------------------------------------------
// GetDefaultShell returns the system's default shell path.
func (s *Service) GetDefaultShell() string {
return localshell.DefaultShell()
}
// BrowseForShell opens a file dialog to select a shell binary.
func (s *Service) BrowseForShell() (string, error) {
if s.ctx == nil {
return "", fmt.Errorf("application context not set")
}
path, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{
Title: "Select Shell Binary",
})
if err != nil {
return "", fmt.Errorf("file dialog failed: %w", err)
}
return path, nil
}
// ---------------------------------------------------------------------------
// Port forwarding
// ---------------------------------------------------------------------------
@@ -931,6 +1030,11 @@ func (s *Service) StopPortForward(sessionID string, forwardID string) error {
return s.sessions.StopPortForward(sessionID, forwardID)
}
// PingSession sends a keepalive request and returns the round-trip time in milliseconds.
func (s *Service) PingSession(sessionID string) (int64, error) {
return s.sessions.PingSession(sessionID)
}
// ---------------------------------------------------------------------------
// Session recording
// ---------------------------------------------------------------------------
@@ -1003,6 +1107,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
// ---------------------------------------------------------------------------
@@ -1093,6 +1230,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 {
@@ -1241,21 +1389,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)
@@ -1452,3 +1711,294 @@ func derefProfiles(ptrs []*models.Profile) []models.Profile {
}
return result
}
// ---------------------------------------------------------------------------
// SSH Key Management
// ---------------------------------------------------------------------------
// SSHKeyInfo describes a key found in ~/.ssh/.
type SSHKeyInfo struct {
Name string `json:"name"`
Type string `json:"type"`
Bits string `json:"bits"`
PublicKey string `json:"publicKey"`
PrivateKey string `json:"privateKeyPath"`
}
// ListSSHKeyPairs returns detailed info about SSH key pairs in ~/.ssh/.
func (s *Service) ListSSHKeyPairs() ([]SSHKeyInfo, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("unable to determine home directory: %w", err)
}
sshDir := filepath.Join(home, ".ssh")
entries, err := os.ReadDir(sshDir)
if err != nil {
if os.IsNotExist(err) {
return []SSHKeyInfo{}, nil
}
return nil, fmt.Errorf("unable to read .ssh directory: %w", err)
}
var keys []SSHKeyInfo
seen := make(map[string]bool)
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
// Look for .pub files and match them with private keys
if !strings.HasSuffix(name, ".pub") {
continue
}
baseName := strings.TrimSuffix(name, ".pub")
privPath := filepath.Join(sshDir, baseName)
// Verify private key exists
if _, err := os.Stat(privPath); err != nil {
continue
}
if seen[baseName] {
continue
}
seen[baseName] = true
// Read public key content
pubBytes, err := readSSHPublicKeyFile(filepath.Join(sshDir, name))
if err != nil {
continue
}
pubContent := strings.TrimSpace(string(pubBytes))
// Determine key type from public key content
keyType := "unknown"
parts := strings.Fields(pubContent)
if len(parts) >= 1 {
switch {
case strings.Contains(parts[0], "ed25519"):
keyType = "ed25519"
case strings.Contains(parts[0], "ecdsa"):
keyType = "ecdsa"
case strings.Contains(parts[0], "rsa"):
keyType = "rsa"
case strings.Contains(parts[0], "dsa"):
keyType = "dsa"
}
}
keys = append(keys, SSHKeyInfo{
Name: baseName,
Type: keyType,
PublicKey: pubContent,
PrivateKey: privPath,
})
}
if keys == nil {
keys = []SSHKeyInfo{}
}
return keys, nil
}
// GenerateSSHKey generates a new SSH key pair.
func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphrase string) error {
keyPath, _, err := sshKeyPairPaths(name)
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)
}
// 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 != "" {
args = append(args, "-N", passphrase)
} else {
args = append(args, "-N", "")
}
if keyType == "rsa" && bits > 0 {
args = append(args, "-b", fmt.Sprintf("%d", bits))
}
// #nosec G204 — arguments are sanitised above
cmd := exec.Command("ssh-keygen", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("ssh-keygen failed: %s", stderr.String())
}
return nil
}
// GetSSHPublicKey returns the content of a public key file.
func (s *Service) GetSSHPublicKey(name string) (string, error) {
_, pubPath, err := sshKeyPairPaths(name)
if err != nil {
return "", err
}
data, err := readSSHPublicKeyFile(pubPath)
if err != nil {
return "", fmt.Errorf("unable to read public key: %w", err)
}
return strings.TrimSpace(string(data)), nil
}
// DeleteSSHKey removes an SSH key pair.
func (s *Service) DeleteSSHKey(name string) error {
privPath, pubPath, err := sshKeyPairPaths(name)
if err != nil {
return err
}
var errs []string
if err := os.Remove(privPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, "private key: "+err.Error())
}
if err := os.Remove(pubPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, "public key: "+err.Error())
}
if len(errs) > 0 {
return fmt.Errorf("failed to delete: %s", strings.Join(errs, "; "))
}
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 == "/" || strings.ContainsRune(safeName, 0) {
return "", fmt.Errorf("invalid file name")
}
return safeName, nil
}
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
}
+277
View File
@@ -0,0 +1,277 @@
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 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")
}
}
+62 -32
View File
@@ -19,6 +19,11 @@ import (
"github.com/andydixon/nexus/internal/models"
)
const (
maxGitHubJSONBodyBytes = 4 << 20
maxGitHubErrorBodyBytes = 64 << 10
)
// Client wraps the GitHub REST API for repository listing.
type Client struct {
baseURL string
@@ -41,19 +46,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 +95,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 +133,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),
}
}
@@ -208,8 +213,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 +229,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)
}
@@ -270,8 +278,7 @@ 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))
}
file, err := os.Create(destPath)
@@ -310,8 +317,31 @@ 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))
}
@@ -0,0 +1,26 @@
package githubclient
import (
"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))
}
}
+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

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