Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1381e66d7 | ||
|
|
0e6f54901b | ||
|
|
cb2fbf70eb |
@@ -0,0 +1,138 @@
|
|||||||
|
# Functionality and Next Steps Report
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
This fix pass improved robustness around local proxying, file imports, remote downloads, SSH key management, and frontend rendering. The app now fails earlier and more predictably when inputs are malformed or too large.
|
||||||
|
|
||||||
|
Highest-impact next steps:
|
||||||
|
|
||||||
|
- Manually verify packaged VNC connections after the new Origin allowlist.
|
||||||
|
- Add UI copy for newly enforced limits.
|
||||||
|
- Add CI coverage for the security checks run in this pass.
|
||||||
|
- Continue frontend cleanup around dynamic `innerHTML`.
|
||||||
|
|
||||||
|
## 2. Current Functional Risks
|
||||||
|
|
||||||
|
VNC runtime compatibility:
|
||||||
|
|
||||||
|
- The backend now allows missing, `wails://`, and loopback HTTP(S) origins. This should work for Wails/noVNC, but packaged app validation is still needed.
|
||||||
|
|
||||||
|
Large-file and large-download behavior:
|
||||||
|
|
||||||
|
- GitHub archive downloads now stop at 2 GiB. Very large repositories will fail fast.
|
||||||
|
- SSH config imports now reject files above 5 MiB or more than 5000 Host blocks.
|
||||||
|
|
||||||
|
SSH key generation:
|
||||||
|
|
||||||
|
- RSA key generation is limited to 2048, 3072, and 4096 bits and times out after 30 seconds.
|
||||||
|
- Slow systems should be checked with RSA 4096 to ensure the timeout is not too tight.
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
|
||||||
|
- SSH key manager dynamic rendering is safer. Other static `innerHTML` templates remain and should be reviewed when they take dynamic data.
|
||||||
|
|
||||||
|
## 3. Code Quality Improvements
|
||||||
|
|
||||||
|
Completed:
|
||||||
|
|
||||||
|
- Added focused helper functions for VNC Origin validation.
|
||||||
|
- Centralized GitHub endpoint construction and owner/repo parsing.
|
||||||
|
- Added reusable archive stream limiting.
|
||||||
|
- Added backend SSH key generation option validation.
|
||||||
|
- Added import bounds to the SSH config parser.
|
||||||
|
- Replaced SSH key manager dynamic modal HTML with DOM construction.
|
||||||
|
|
||||||
|
Recommended refactors:
|
||||||
|
|
||||||
|
- Move file-size and stream-size caps into named settings or a shared limits package if more subsystems need them.
|
||||||
|
- Add a small frontend helper for safe modal construction so feature code stops hand-rolling modal `innerHTML`.
|
||||||
|
- Consider a common backend "safe basename" helper for local and remote file operations.
|
||||||
|
|
||||||
|
## 4. Frontend Interaction and Error-Handling Improvements
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
|
||||||
|
- Show a specific VNC connection error if the proxy rejects an unexpected Origin in packaged builds.
|
||||||
|
- Show clear messages for SSH config files that exceed size or Host-block limits.
|
||||||
|
- Show clear messages for GitHub archives above the cap.
|
||||||
|
- Add a hostile-filename E2E test for the SSH key delete confirmation modal.
|
||||||
|
- Continue converting dynamic modal content to DOM nodes or `textContent`.
|
||||||
|
|
||||||
|
## 5. Testing Roadmap
|
||||||
|
|
||||||
|
Added in this pass:
|
||||||
|
|
||||||
|
- VNC Origin allowlist tests.
|
||||||
|
- SFTP upload parent-directory rejection tests.
|
||||||
|
- SSH config file and Host-block limit tests.
|
||||||
|
- SSH key generation option validation tests.
|
||||||
|
- GitHub path escaping and archive cap tests.
|
||||||
|
- Frontend regression checks for SSH key manager text rendering.
|
||||||
|
|
||||||
|
Next tests:
|
||||||
|
|
||||||
|
- Packaged Wails VNC smoke test on macOS, Windows, and Linux.
|
||||||
|
- Browser/E2E test for SSH key filenames containing `<`, `>`, quotes, and ampersands.
|
||||||
|
- HTTP integration test for GitHub archive download caps and escaped branch refs.
|
||||||
|
- UI tests for limit-aware error messages.
|
||||||
|
- CI job that runs Go tests, frontend tests, frontend build, `npm audit --omit=dev`, `govulncheck`, and `git diff --check`.
|
||||||
|
|
||||||
|
## 6. Recommended New Functionality
|
||||||
|
|
||||||
|
Useful additions:
|
||||||
|
|
||||||
|
- Preferences for maximum GitHub archive download size.
|
||||||
|
- A pre-download confirmation for large GitHub archives when Content-Length is known.
|
||||||
|
- Import preview for OpenSSH config showing Host count before persistence.
|
||||||
|
- A diagnostics panel for VNC proxy status, including local URL redaction, Origin decisions, and connection state.
|
||||||
|
- A unified "Limits" help page that lists SFTP upload caps, GitHub archive cap, SSH config import cap, and Leviathan file caps.
|
||||||
|
|
||||||
|
## 7. Next-Level Opportunities
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
|
||||||
|
- Centralize resource limits and expose them to both Go and frontend UI copy.
|
||||||
|
- Add a secure modal builder and migrate risky frontend dialogs gradually.
|
||||||
|
- Add structured security regression tests for every Wails method that accepts file paths, filenames, URLs, or base64 payloads.
|
||||||
|
|
||||||
|
Operations:
|
||||||
|
|
||||||
|
- Generate checksums for release artifacts.
|
||||||
|
- Generate an SBOM during release.
|
||||||
|
- Publish a short security hardening checklist in `docs/build-and-release.md`.
|
||||||
|
|
||||||
|
Developer experience:
|
||||||
|
|
||||||
|
- Add a `make security-check` target that runs the same commands used in this audit.
|
||||||
|
- Add pre-commit or CI checks for `gofmt`, `go test`, frontend regression tests, and `git diff --check`.
|
||||||
|
|
||||||
|
## 8. Prioritised Action Plan
|
||||||
|
|
||||||
|
Immediate:
|
||||||
|
|
||||||
|
- Manually test packaged VNC on all supported platforms.
|
||||||
|
- Keep the security patch tests in CI before release.
|
||||||
|
|
||||||
|
Short-term:
|
||||||
|
|
||||||
|
- Add user-facing messages for the new SSH config, GitHub archive, and SSH keygen limits.
|
||||||
|
- Add E2E coverage for hostile SSH key filenames.
|
||||||
|
- Add an HTTP test around GitHub archive download behavior.
|
||||||
|
|
||||||
|
Medium-term:
|
||||||
|
|
||||||
|
- Centralize app resource limits and expose them to UI/help.
|
||||||
|
- Migrate high-risk frontend dynamic HTML patterns to safe modal helpers.
|
||||||
|
- Add release checksums and SBOM generation.
|
||||||
|
|
||||||
|
Long-term:
|
||||||
|
|
||||||
|
- Add progressive/streaming UX for large GitHub and SFTP transfers.
|
||||||
|
- Build a cross-platform packaged-app smoke test suite for Wails integrations.
|
||||||
|
|
||||||
|
## 9. Caveats and Validation Needed
|
||||||
|
|
||||||
|
- VNC Origin compatibility must be validated in packaged builds, not only unit tests.
|
||||||
|
- The 2 GiB GitHub archive cap may need product tuning for users with very large repositories.
|
||||||
|
- RSA 4096 key generation timeout should be checked on lower-powered machines.
|
||||||
|
- The frontend build passed, but this pass did not interactively click through the SSH key modal in a real browser/WebView.
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
# Security Audit Report
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
This fix-mode audit reviewed the Wails API surface, local proxy behavior, file import paths, SSH key management, GitHub integration, frontend DOM sinks, dependency manifests, tests, and release workflow.
|
||||||
|
|
||||||
|
Six concrete issues were fixed:
|
||||||
|
|
||||||
|
- The local VNC WebSocket proxy accepted any browser `Origin` and did not cap client-to-proxy WebSocket message size.
|
||||||
|
- SFTP browser uploads accepted the special filename `..`, allowing a Wails caller to escape the selected remote directory when the name was joined.
|
||||||
|
- SSH config import parsed line-by-line but had no total file or Host-block cap.
|
||||||
|
- Backend SSH key generation trusted caller-supplied key type and RSA bit count and had no process timeout.
|
||||||
|
- GitHub repo/ref values were interpolated into API paths without escaping, and archive downloads had no explicit stream cap.
|
||||||
|
- SSH key manager UI interpolated backend errors and filesystem-derived key names into `innerHTML`.
|
||||||
|
|
||||||
|
Verification passed with targeted Go tests, full Go tests, frontend regression checks, frontend build, `npm audit --omit=dev`, `govulncheck`, and `git diff --check`.
|
||||||
|
|
||||||
|
## 2. Scope Reviewed
|
||||||
|
|
||||||
|
Reviewed:
|
||||||
|
|
||||||
|
- Go backend packages under `internal/`: API bindings, VNC proxy, GitHub client, SSH config import, SSH key management, SFTP upload helpers, validation, sessions, storage, secrets, updater, recording, Leviathan parser bounds, and tests.
|
||||||
|
- Browser-facing JavaScript under `frontend/src/js`, especially SSH key management, SFTP upload flows, VNC integration, and dynamic HTML usage.
|
||||||
|
- Frontend package manifest, lockfile, build scripts, and regression script.
|
||||||
|
- Go module dependencies and reachable vulnerability state.
|
||||||
|
- GitHub Actions release workflow.
|
||||||
|
|
||||||
|
Not fully live-tested:
|
||||||
|
|
||||||
|
- A packaged Wails desktop binary.
|
||||||
|
- Real noVNC connections inside the packaged WebView after the new Origin policy.
|
||||||
|
- Real GitHub Enterprise archive downloads above normal repository sizes.
|
||||||
|
|
||||||
|
## 3. Methodology
|
||||||
|
|
||||||
|
The review used static analysis, targeted source searches, dependency/advisory tooling, and focused regression tests. I mapped attacker-controlled inputs from the renderer and local files into Go methods, network clients, local HTTP/WebSocket services, subprocesses, remote SFTP paths, and DOM sinks. I prioritized issues that could be fixed with small behavior-preserving changes and backed by tests.
|
||||||
|
|
||||||
|
Commands run:
|
||||||
|
|
||||||
|
- `go test ./internal/vncclient ./internal/api ./internal/sshconfig ./internal/githubclient -count=1`
|
||||||
|
- `npm --prefix frontend test`
|
||||||
|
- `git diff --check`
|
||||||
|
- `go test ./... -count=1`
|
||||||
|
- `npm --prefix frontend run build`
|
||||||
|
- `npm --prefix frontend audit --omit=dev`
|
||||||
|
- `go run golang.org/x/vuln/cmd/govulncheck@latest ./...`
|
||||||
|
|
||||||
|
## 4. Risk Rating Method
|
||||||
|
|
||||||
|
Severity reflects exploit prerequisite, affected trust boundary, likely impact, and availability/confidentiality/integrity outcome:
|
||||||
|
|
||||||
|
- Critical: remote unauthenticated compromise or secret disclosure.
|
||||||
|
- High: practical compromise of credentials, host access, or arbitrary code execution.
|
||||||
|
- Medium: meaningful local, renderer, remote service, or connected-server abuse with realistic prerequisites.
|
||||||
|
- Low: local robustness issues, bounded DoS, or defense-in-depth gaps.
|
||||||
|
- Informational: observations and hardening opportunities.
|
||||||
|
|
||||||
|
Confidence reflects direct code evidence and testability. Findings fixed here are High confidence because they map to concrete files and new regression tests.
|
||||||
|
|
||||||
|
## 5. Attack Surface Map
|
||||||
|
|
||||||
|
Entry points:
|
||||||
|
|
||||||
|
- Wails-bound `api.Service` methods callable from the renderer.
|
||||||
|
- Local VNC WebSocket proxy bound to `127.0.0.1` on a random port and random token path.
|
||||||
|
- SSH, Telnet, RAW TCP, VNC, SFTP, GitHub, updater, and Leviathan binary-analysis workflows.
|
||||||
|
- Profile import/export, encrypted `.nexus` import, OpenSSH config import, recording playback, SSH key generation/deletion, and drag/drop upload flows.
|
||||||
|
|
||||||
|
Trust boundaries:
|
||||||
|
|
||||||
|
- Renderer to Go backend.
|
||||||
|
- Local browser/WebView to loopback VNC proxy.
|
||||||
|
- Local filesystem files selected or scanned by the app.
|
||||||
|
- Remote SSH/SFTP/VNC/GitHub/update endpoints.
|
||||||
|
- OS keychain/encrypted vault to profile storage and session runtime.
|
||||||
|
|
||||||
|
Attacker starting positions considered:
|
||||||
|
|
||||||
|
- Malicious local webpage attempting loopback WebSocket access.
|
||||||
|
- Malicious or compromised renderer script.
|
||||||
|
- Malicious local filename or selected config/import file.
|
||||||
|
- Malicious GitHub Enterprise/API endpoint or unusual repo/ref metadata.
|
||||||
|
- Malicious connected SSH/SFTP/VNC host.
|
||||||
|
|
||||||
|
## 6. Findings Summary Table
|
||||||
|
|
||||||
|
| ID | Severity | Confidence | Title | Component | Status |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| SEC-20260616-1702-001 | Medium | High | VNC proxy allowed all origins and uncapped WebSocket frames | `internal/vncclient` | Fixed |
|
||||||
|
| SEC-20260616-1702-002 | Medium | High | SFTP upload name `..` escaped selected remote directory | `internal/api` SFTP upload helpers | Fixed |
|
||||||
|
| SEC-20260616-1702-003 | Low | High | SSH config import lacked total file and Host-block bounds | `internal/sshconfig` | Fixed |
|
||||||
|
| SEC-20260616-1702-004 | Low | High | SSH key generation allowed expensive caller-controlled options without timeout | `internal/api` SSH key management | Fixed |
|
||||||
|
| SEC-20260616-1702-005 | Medium | High | GitHub repo/ref path interpolation and uncapped archive stream | `internal/githubclient` | Fixed |
|
||||||
|
| SEC-20260616-1702-006 | Medium | High | SSH key manager rendered key names/errors through dynamic `innerHTML` | `frontend/src/js/sshkeys.js` | Fixed |
|
||||||
|
|
||||||
|
## 7. Detailed Findings
|
||||||
|
|
||||||
|
### Finding ID: SEC-20260616-1702-001
|
||||||
|
|
||||||
|
#### Title
|
||||||
|
|
||||||
|
VNC proxy allowed all origins and uncapped WebSocket frames
|
||||||
|
|
||||||
|
#### Severity
|
||||||
|
|
||||||
|
Medium
|
||||||
|
|
||||||
|
#### Confidence
|
||||||
|
|
||||||
|
High
|
||||||
|
|
||||||
|
#### Affected Files / Components
|
||||||
|
|
||||||
|
- `internal/vncclient/vncclient.go`
|
||||||
|
- `internal/vncclient/vncclient_test.go`
|
||||||
|
- Local noVNC WebSocket proxy
|
||||||
|
|
||||||
|
#### Description
|
||||||
|
|
||||||
|
The VNC proxy was bound to loopback and protected by a random token path, but its Gorilla WebSocket upgrader accepted every `Origin`. It also read WebSocket messages without an explicit message size limit. If a proxy URL token leaked through logs, UI state, injected renderer code, or another local channel, a non-local browser origin could attempt to drive the active VNC TCP connection or send oversized frames that force memory allocation.
|
||||||
|
|
||||||
|
#### Evidence
|
||||||
|
|
||||||
|
Before the fix, `websocket.Upgrader.CheckOrigin` returned `true` for every request, and the proxy called `ws.ReadMessage()` without `SetReadLimit`.
|
||||||
|
|
||||||
|
#### Attack Scenario
|
||||||
|
|
||||||
|
A malicious local webpage or injected renderer script obtains an active `ws://127.0.0.1:<port>/<token>` URL. Because all origins were accepted, it could connect from a non-app origin and send very large WebSocket messages or VNC protocol bytes into the bridged TCP connection.
|
||||||
|
|
||||||
|
#### Impact
|
||||||
|
|
||||||
|
Potential local cross-origin abuse of active VNC sessions if the token is exposed, and avoidable memory pressure from oversized client-to-proxy WebSocket frames.
|
||||||
|
|
||||||
|
#### Recommendation
|
||||||
|
|
||||||
|
Allow only Wails and loopback browser origins, keep accepting missing origins for non-browser clients, and cap inbound WebSocket message size.
|
||||||
|
|
||||||
|
#### Safer Example
|
||||||
|
|
||||||
|
Implemented `allowProxyOrigin`, `isLoopbackHost`, and `ws.SetReadLimit(maxWebSocketMessageBytes)`.
|
||||||
|
|
||||||
|
#### Suggested Tests
|
||||||
|
|
||||||
|
Added `TestAllowProxyOrigin` for missing, Wails, loopback, remote, lookalike, and malformed origins.
|
||||||
|
|
||||||
|
#### References
|
||||||
|
|
||||||
|
- CWE-346: Origin Validation Error
|
||||||
|
- CWE-400: Uncontrolled Resource Consumption
|
||||||
|
- OWASP ASVS 14.4: HTTP security configuration
|
||||||
|
|
||||||
|
#### Assumptions / Limitations
|
||||||
|
|
||||||
|
The packaged Wails WebView should be manually tested to confirm its runtime Origin is covered by the `wails://` allow rule.
|
||||||
|
|
||||||
|
### Finding ID: SEC-20260616-1702-002
|
||||||
|
|
||||||
|
#### Title
|
||||||
|
|
||||||
|
SFTP upload name `..` escaped selected remote directory
|
||||||
|
|
||||||
|
#### Severity
|
||||||
|
|
||||||
|
Medium
|
||||||
|
|
||||||
|
#### Confidence
|
||||||
|
|
||||||
|
High
|
||||||
|
|
||||||
|
#### Affected Files / Components
|
||||||
|
|
||||||
|
- `internal/api/api.go`
|
||||||
|
- `internal/api/api_test.go`
|
||||||
|
- `SFTPUploadData`
|
||||||
|
- `SFTPUploadStart`
|
||||||
|
|
||||||
|
#### Description
|
||||||
|
|
||||||
|
`safeSFTPUploadName` normalized slash variants and used `path.Base`, but did not reject the special basename `..`. `path.Join(remoteDir, "..")` resolves to the parent directory, so a Wails caller could escape the selected upload directory.
|
||||||
|
|
||||||
|
#### Evidence
|
||||||
|
|
||||||
|
The previous rejection covered empty, `.`, `/`, and NUL, but not `..`. The upload methods then used `path.Join(remoteDir, safeName)`.
|
||||||
|
|
||||||
|
#### Attack Scenario
|
||||||
|
|
||||||
|
A malicious renderer call invokes chunked upload with `remoteDir="/home/user/upload"` and `fileName=".."`. The destination path resolves to `/home/user`, outside the selected directory.
|
||||||
|
|
||||||
|
#### Impact
|
||||||
|
|
||||||
|
Unexpected remote writes/truncation attempts outside the user's selected SFTP directory, depending on server permissions and target type.
|
||||||
|
|
||||||
|
#### Recommendation
|
||||||
|
|
||||||
|
Reject `..` after basename normalization.
|
||||||
|
|
||||||
|
#### Safer Example
|
||||||
|
|
||||||
|
Implemented `safeName == ".."` rejection.
|
||||||
|
|
||||||
|
#### Suggested Tests
|
||||||
|
|
||||||
|
Added `TestSafeSFTPUploadNameRejectsParentDirectory`.
|
||||||
|
|
||||||
|
#### References
|
||||||
|
|
||||||
|
- CWE-22: Path Traversal
|
||||||
|
- OWASP ASVS 12.3: File path control
|
||||||
|
|
||||||
|
#### Assumptions / Limitations
|
||||||
|
|
||||||
|
The browser `File.name` API normally does not produce `..`, but Wails API methods are still a trust boundary and should validate independently.
|
||||||
|
|
||||||
|
### Finding ID: SEC-20260616-1702-003
|
||||||
|
|
||||||
|
#### Title
|
||||||
|
|
||||||
|
SSH config import lacked total file and Host-block bounds
|
||||||
|
|
||||||
|
#### Severity
|
||||||
|
|
||||||
|
Low
|
||||||
|
|
||||||
|
#### Confidence
|
||||||
|
|
||||||
|
High
|
||||||
|
|
||||||
|
#### Affected Files / Components
|
||||||
|
|
||||||
|
- `internal/sshconfig/sshconfig.go`
|
||||||
|
- `internal/sshconfig/sshconfig_test.go`
|
||||||
|
- `ImportSSHConfig`
|
||||||
|
|
||||||
|
#### Description
|
||||||
|
|
||||||
|
The OpenSSH config parser scanned files line-by-line, but it did not cap total file size or the number of accumulated Host blocks. A very large selected config could allocate a large slice of parsed blocks and profiles.
|
||||||
|
|
||||||
|
#### Evidence
|
||||||
|
|
||||||
|
`ParseFile` opened the file and appended Host blocks to an in-memory slice until EOF.
|
||||||
|
|
||||||
|
#### Attack Scenario
|
||||||
|
|
||||||
|
A user imports a hostile or accidentally huge SSH config containing thousands of Host blocks. The app spends unnecessary CPU and memory parsing and converting entries.
|
||||||
|
|
||||||
|
#### Impact
|
||||||
|
|
||||||
|
Local denial of service or UI unresponsiveness during import.
|
||||||
|
|
||||||
|
#### Recommendation
|
||||||
|
|
||||||
|
Reject oversized config files, set a scanner line cap, and reject excessive Host-block counts.
|
||||||
|
|
||||||
|
#### Safer Example
|
||||||
|
|
||||||
|
Implemented `maxConfigFileBytes`, `maxConfigLineBytes`, and `maxHostBlocks`.
|
||||||
|
|
||||||
|
#### Suggested Tests
|
||||||
|
|
||||||
|
Added oversized-file and excessive-Host-block tests.
|
||||||
|
|
||||||
|
#### References
|
||||||
|
|
||||||
|
- CWE-400: Uncontrolled Resource Consumption
|
||||||
|
|
||||||
|
#### Assumptions / Limitations
|
||||||
|
|
||||||
|
The selected caps are conservative for normal OpenSSH config files. Extremely large enterprise configs may need batch import support later.
|
||||||
|
|
||||||
|
### Finding ID: SEC-20260616-1702-004
|
||||||
|
|
||||||
|
#### Title
|
||||||
|
|
||||||
|
SSH key generation allowed expensive caller-controlled options without timeout
|
||||||
|
|
||||||
|
#### Severity
|
||||||
|
|
||||||
|
Low
|
||||||
|
|
||||||
|
#### Confidence
|
||||||
|
|
||||||
|
High
|
||||||
|
|
||||||
|
#### Affected Files / Components
|
||||||
|
|
||||||
|
- `internal/api/api.go`
|
||||||
|
- `internal/api/api_test.go`
|
||||||
|
- `GenerateSSHKey`
|
||||||
|
|
||||||
|
#### Description
|
||||||
|
|
||||||
|
The frontend exposes fixed key-type and bit-size choices, but the backend method accepted caller-supplied values directly and ran `ssh-keygen` without a context timeout. A malformed renderer call could request unsupported types or excessive RSA bit sizes, tying up the subprocess.
|
||||||
|
|
||||||
|
#### Evidence
|
||||||
|
|
||||||
|
`GenerateSSHKey` passed `keyType` to `ssh-keygen -t` and only checked `bits > 0` for RSA before adding `-b`.
|
||||||
|
|
||||||
|
#### Attack Scenario
|
||||||
|
|
||||||
|
A malicious renderer call requests a very large RSA key size. `ssh-keygen` consumes CPU for an extended period or fails slowly.
|
||||||
|
|
||||||
|
#### Impact
|
||||||
|
|
||||||
|
Local CPU denial of service and poor recovery if the subprocess hangs.
|
||||||
|
|
||||||
|
#### Recommendation
|
||||||
|
|
||||||
|
Validate allowed key types and RSA sizes server-side, and execute `ssh-keygen` with a timeout.
|
||||||
|
|
||||||
|
#### Safer Example
|
||||||
|
|
||||||
|
Implemented `validateSSHKeyGenerationOptions` and `exec.CommandContext` with a 30 second timeout.
|
||||||
|
|
||||||
|
#### Suggested Tests
|
||||||
|
|
||||||
|
Added validation tests for defaults, RSA defaults, oversized RSA sizes, and unsupported key types.
|
||||||
|
|
||||||
|
#### References
|
||||||
|
|
||||||
|
- CWE-400: Uncontrolled Resource Consumption
|
||||||
|
- CWE-20: Improper Input Validation
|
||||||
|
|
||||||
|
#### Assumptions / Limitations
|
||||||
|
|
||||||
|
The timeout is a safety net, not a substitute for platform-level process controls. Real key generation should still be manually tested on slower hardware.
|
||||||
|
|
||||||
|
### Finding ID: SEC-20260616-1702-005
|
||||||
|
|
||||||
|
#### Title
|
||||||
|
|
||||||
|
GitHub repo/ref path interpolation and uncapped archive stream
|
||||||
|
|
||||||
|
#### Severity
|
||||||
|
|
||||||
|
Medium
|
||||||
|
|
||||||
|
#### Confidence
|
||||||
|
|
||||||
|
High
|
||||||
|
|
||||||
|
#### Affected Files / Components
|
||||||
|
|
||||||
|
- `internal/githubclient/githubclient.go`
|
||||||
|
- `internal/githubclient/githubclient_test.go`
|
||||||
|
- `FetchLatestCommit`
|
||||||
|
- `DownloadRepoArchive`
|
||||||
|
|
||||||
|
#### Description
|
||||||
|
|
||||||
|
Repository full names and refs were interpolated into REST API paths without escaping. The archive download also streamed response bodies to disk without an explicit maximum. A malformed renderer call, unusual branch name, or compromised GitHub Enterprise endpoint could create surprising request paths or consume disk.
|
||||||
|
|
||||||
|
#### Evidence
|
||||||
|
|
||||||
|
The client built paths with `fmt.Sprintf("%s/repos/%s/commits/%s", ...)` and copied archives with `io.Copy(file, resp.Body)`.
|
||||||
|
|
||||||
|
#### Attack Scenario
|
||||||
|
|
||||||
|
A renderer calls archive download with a malformed repo full name containing extra path segments, or a malicious GitHub Enterprise endpoint returns an unbounded archive response.
|
||||||
|
|
||||||
|
#### Impact
|
||||||
|
|
||||||
|
Potential confused API requests using the user's GitHub token and local disk exhaustion from unbounded archive streams.
|
||||||
|
|
||||||
|
#### Recommendation
|
||||||
|
|
||||||
|
Require `owner/repo` format, URL-escape each API path segment, and cap archive streams.
|
||||||
|
|
||||||
|
#### Safer Example
|
||||||
|
|
||||||
|
Implemented `splitRepoFullName`, `endpointURL`, `copyLimitedArchive`, and a 2 GiB archive cap.
|
||||||
|
|
||||||
|
#### Suggested Tests
|
||||||
|
|
||||||
|
Added tests for escaped refs, malformed repo names, archive limit rejection, and at-limit copies.
|
||||||
|
|
||||||
|
#### References
|
||||||
|
|
||||||
|
- CWE-20: Improper Input Validation
|
||||||
|
- CWE-22: Path Traversal style path confusion
|
||||||
|
- CWE-400: Uncontrolled Resource Consumption
|
||||||
|
|
||||||
|
#### Assumptions / Limitations
|
||||||
|
|
||||||
|
The 2 GiB archive cap may reject unusually large repositories. A future preferences setting could make this user-configurable.
|
||||||
|
|
||||||
|
### Finding ID: SEC-20260616-1702-006
|
||||||
|
|
||||||
|
#### Title
|
||||||
|
|
||||||
|
SSH key manager rendered key names/errors through dynamic `innerHTML`
|
||||||
|
|
||||||
|
#### Severity
|
||||||
|
|
||||||
|
Medium
|
||||||
|
|
||||||
|
#### Confidence
|
||||||
|
|
||||||
|
High
|
||||||
|
|
||||||
|
#### Affected Files / Components
|
||||||
|
|
||||||
|
- `frontend/src/js/sshkeys.js`
|
||||||
|
- `frontend/scripts/test-regressions.js`
|
||||||
|
- SSH key manager modal
|
||||||
|
|
||||||
|
#### Description
|
||||||
|
|
||||||
|
The SSH key manager interpolated backend error text and confirmation title/message/button text into `innerHTML`. Key names are derived from local `~/.ssh/*.pub` filenames, so a malicious local filename could inject HTML into the delete confirmation modal.
|
||||||
|
|
||||||
|
#### Evidence
|
||||||
|
|
||||||
|
The previous code used `list.innerHTML = `<div ...>${err}</div>`` and built confirmation modal HTML with `${title}`, `${message}`, and `${confirmLabel}`.
|
||||||
|
|
||||||
|
#### Attack Scenario
|
||||||
|
|
||||||
|
A local attacker creates an SSH public key file with an HTML-like filename. When the user opens the key manager and deletes it, the filename is interpolated into modal HTML.
|
||||||
|
|
||||||
|
#### Impact
|
||||||
|
|
||||||
|
DOM XSS in the Wails renderer, with possible access to app UI actions exposed to the renderer.
|
||||||
|
|
||||||
|
#### Recommendation
|
||||||
|
|
||||||
|
Render dynamic strings with `textContent` or DOM node construction.
|
||||||
|
|
||||||
|
#### Safer Example
|
||||||
|
|
||||||
|
Implemented text-node rendering for key load errors and confirmation modal dynamic text.
|
||||||
|
|
||||||
|
#### Suggested Tests
|
||||||
|
|
||||||
|
Added frontend regression checks to prevent the vulnerable interpolation pattern from returning.
|
||||||
|
|
||||||
|
#### References
|
||||||
|
|
||||||
|
- CWE-79: Cross-Site Scripting
|
||||||
|
- OWASP ASVS 5.3: Output encoding
|
||||||
|
|
||||||
|
#### Assumptions / Limitations
|
||||||
|
|
||||||
|
Other static-template `innerHTML` use remains in the frontend and should continue to be reviewed when it accepts dynamic values.
|
||||||
|
|
||||||
|
## 8. Blackhat-Style Abuse Paths
|
||||||
|
|
||||||
|
Path 1: Token leak to VNC abuse
|
||||||
|
|
||||||
|
- Prerequisite: an active VNC proxy URL leaks to a non-app webpage or injected script.
|
||||||
|
- Old path: any Origin could connect and send arbitrary WebSocket frames to the VNC TCP bridge.
|
||||||
|
- Breakpoint added: Wails/loopback Origin allowlist plus message cap.
|
||||||
|
|
||||||
|
Path 2: Renderer-to-remote path escape
|
||||||
|
|
||||||
|
- Prerequisite: malicious renderer code or direct Wails call.
|
||||||
|
- Old path: call SFTP upload with filename `..`, causing `path.Join(remoteDir, "..")`.
|
||||||
|
- Breakpoint added: `..` is rejected after basename normalization.
|
||||||
|
|
||||||
|
Path 3: Local filename to renderer script injection
|
||||||
|
|
||||||
|
- Prerequisite: crafted key filename in `~/.ssh`.
|
||||||
|
- Old path: key name appears in confirmation modal HTML.
|
||||||
|
- Breakpoint added: key names and errors use `textContent`.
|
||||||
|
|
||||||
|
Path 4: Malicious GitHub Enterprise response to disk pressure
|
||||||
|
|
||||||
|
- Prerequisite: user connects to compromised or malicious API base URL allowed by profile validation.
|
||||||
|
- Old path: archive stream was copied until EOF.
|
||||||
|
- Breakpoint added: Content-Length precheck and streaming cap.
|
||||||
|
|
||||||
|
## 9. Denial-of-Service Review
|
||||||
|
|
||||||
|
Fixed resource-control issues:
|
||||||
|
|
||||||
|
- VNC inbound WebSocket message cap.
|
||||||
|
- SSH config file size, line size, and Host-block caps.
|
||||||
|
- SSH key generation input validation and subprocess timeout.
|
||||||
|
- GitHub archive stream cap.
|
||||||
|
|
||||||
|
Previously fixed and rechecked from recent audit context:
|
||||||
|
|
||||||
|
- GitHub/update JSON response caps.
|
||||||
|
- Leviathan bounded reads and chunked save/export paths.
|
||||||
|
- SFTP inline/chunk upload caps.
|
||||||
|
- Secret vault and SSH key file read caps.
|
||||||
|
|
||||||
|
Residual DoS considerations:
|
||||||
|
|
||||||
|
- SFTP directory listings depend on remote server behavior and can still return large lists.
|
||||||
|
- GitHub repository enrichment intentionally caps total repositories but still performs concurrent commit lookups.
|
||||||
|
- Very large legitimate GitHub archives above 2 GiB now fail fast.
|
||||||
|
|
||||||
|
## 10. Dependency and Supply-Chain Review
|
||||||
|
|
||||||
|
Results:
|
||||||
|
|
||||||
|
- `npm --prefix frontend audit --omit=dev`: 0 vulnerabilities.
|
||||||
|
- `govulncheck ./...`: no vulnerabilities found.
|
||||||
|
|
||||||
|
Reviewed manifests:
|
||||||
|
|
||||||
|
- `go.mod`
|
||||||
|
- `go.sum`
|
||||||
|
- `frontend/package.json`
|
||||||
|
- `frontend/package-lock.json`
|
||||||
|
- `.github/workflows/release-builds.yml`
|
||||||
|
|
||||||
|
No high-confidence dependency findings were found in this pass.
|
||||||
|
|
||||||
|
## 11. Secrets and Sensitive Data Review
|
||||||
|
|
||||||
|
Checked:
|
||||||
|
|
||||||
|
- No obvious private keys, GitHub tokens, cloud keys, or hardcoded credentials were found in source outside documentation/examples.
|
||||||
|
- Non-secret profile export redaction was already covered by tests.
|
||||||
|
- Secret access logging records secret type, not value.
|
||||||
|
- GitHub Authorization headers are set only in HTTP requests and not logged by this code.
|
||||||
|
|
||||||
|
Residual:
|
||||||
|
|
||||||
|
- Plain exports without secrets are safer, but encrypted export remains the recommended path for portable backups.
|
||||||
|
- Local audit logs should continue to avoid host/user secrets in future features.
|
||||||
|
|
||||||
|
## 12. Authentication and Authorisation Review
|
||||||
|
|
||||||
|
This is a local desktop application, so there are no server-side user roles or tenant boundaries. Relevant controls reviewed:
|
||||||
|
|
||||||
|
- SSH host-key callback requirement for VNC SSH tunnels.
|
||||||
|
- Known-host storage and verification paths.
|
||||||
|
- Secret retrieval via keychain/vault and prompt callbacks.
|
||||||
|
- GitHub token validation before repo fetch.
|
||||||
|
|
||||||
|
No missing multi-user authorization checks apply to the current architecture.
|
||||||
|
|
||||||
|
## 13. Input Validation and Output Encoding Review
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- SFTP upload basename `..`.
|
||||||
|
- SSH key generation key type and RSA bit sizes.
|
||||||
|
- GitHub repo full-name format and path segment escaping.
|
||||||
|
- SSH key manager dynamic output encoding.
|
||||||
|
|
||||||
|
Previously present controls:
|
||||||
|
|
||||||
|
- Profile validation rejects invalid hosts, ports, secret modes, GitHub HTTP non-loopback base URLs, and malformed protocol configs.
|
||||||
|
- Import profiles are size-capped and validated before persistence.
|
||||||
|
|
||||||
|
## 14. Frontend HTML/JavaScript Robustness Review
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- SSH key manager load errors now render via `textContent`.
|
||||||
|
- SSH key deletion confirmation modal dynamic strings now render via DOM nodes/text.
|
||||||
|
- Regression script now checks these patterns.
|
||||||
|
|
||||||
|
Checked:
|
||||||
|
|
||||||
|
- Wails service bridge ordering remains covered by regression checks.
|
||||||
|
- SFTP drag/drop upload still uses chunked methods.
|
||||||
|
- Direct VNC warning flow remains covered by regression checks.
|
||||||
|
|
||||||
|
Residual:
|
||||||
|
|
||||||
|
- Frontend still contains many static `innerHTML` templates. Future reviews should focus on any template that accepts local filesystem, remote host, profile, update, or GitHub data.
|
||||||
|
|
||||||
|
## 15. Infrastructure and Deployment Review
|
||||||
|
|
||||||
|
Reviewed:
|
||||||
|
|
||||||
|
- GitHub Actions release workflow.
|
||||||
|
- Wails build config.
|
||||||
|
- Frontend build scripts.
|
||||||
|
|
||||||
|
No new high-confidence release workflow finding was fixed. The workflow uses pinned major versions for common actions and default GitHub token permissions scoped to release attachment needs.
|
||||||
|
|
||||||
|
Residual:
|
||||||
|
|
||||||
|
- Consider adding checksums/SBOM generation for release artifacts.
|
||||||
|
- Consider a CI job for `go test ./...`, frontend regression tests, `npm audit --omit=dev`, and `govulncheck`.
|
||||||
|
|
||||||
|
## 16. Logging, Monitoring, and Error Handling Review
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- SSH key manager no longer injects raw error strings into HTML.
|
||||||
|
- GitHub malformed repo names now fail before request construction.
|
||||||
|
- SSH keygen timeout returns a specific timeout error.
|
||||||
|
|
||||||
|
Checked:
|
||||||
|
|
||||||
|
- Error messages added in this pass do not include tokens, passwords, or key material.
|
||||||
|
- Existing audit logger records profile/session actions and secret access metadata.
|
||||||
|
|
||||||
|
## 17. Test Coverage and Security Regression Gaps
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- VNC Origin allowlist tests.
|
||||||
|
- SFTP parent-directory upload name tests.
|
||||||
|
- SSH key generation option validation tests.
|
||||||
|
- SSH config oversized file and Host-block cap tests.
|
||||||
|
- GitHub URL escaping, malformed full-name, and archive cap tests.
|
||||||
|
- Frontend static regression checks for SSH key manager safe rendering.
|
||||||
|
|
||||||
|
Remaining gaps:
|
||||||
|
|
||||||
|
- No packaged Wails/noVNC integration test for Origin behavior.
|
||||||
|
- No E2E browser test for SSH key modal rendering with hostile filenames.
|
||||||
|
- No integration test for GitHub archive cap against an HTTP test server.
|
||||||
|
|
||||||
|
## 18. Prioritised Remediation Plan
|
||||||
|
|
||||||
|
Immediate:
|
||||||
|
|
||||||
|
- Manually test VNC connections in a packaged Wails app to verify the Origin allowlist.
|
||||||
|
- Keep the new security tests in CI.
|
||||||
|
|
||||||
|
Short-term:
|
||||||
|
|
||||||
|
- Add an E2E test for SSH key manager hostile filenames.
|
||||||
|
- Add an HTTP integration test for GitHub archive caps and escaped refs.
|
||||||
|
- Surface user-friendly errors for new caps in the UI.
|
||||||
|
|
||||||
|
Medium-term:
|
||||||
|
|
||||||
|
- Add CI security checks for `govulncheck`, `npm audit --omit=dev`, and `git diff --check`.
|
||||||
|
- Add release artifact checksums and an SBOM.
|
||||||
|
|
||||||
|
Long-term:
|
||||||
|
|
||||||
|
- Continue replacing dynamic frontend `innerHTML` where data provenance is mixed.
|
||||||
|
- Add configurable large-download limits for GitHub archives and SFTP operations.
|
||||||
|
|
||||||
|
## 19. False Positives / Needs Manual Verification
|
||||||
|
|
||||||
|
- VNC Origin policy should be validated in macOS, Windows, and Linux packaged Wails builds.
|
||||||
|
- The 2 GiB GitHub archive cap should be validated against expected user repository sizes.
|
||||||
|
- The SSH keygen 30 second timeout should be checked on slow hardware, especially for RSA 4096.
|
||||||
|
|
||||||
|
## 20. Final Notes
|
||||||
|
|
||||||
|
This pass focused on concrete, testable hardening rather than broad speculative rewrites. The remaining risk is mostly runtime validation and UX around newly explicit limits. The repo still has an unrelated sidebar CSS spacing change in the working tree that was not part of this security audit.
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
<link rel="stylesheet" href="/src/css/modals.css" />
|
<link rel="stylesheet" href="/src/css/modals.css" />
|
||||||
<link rel="stylesheet" href="/src/css/github.css" />
|
<link rel="stylesheet" href="/src/css/github.css" />
|
||||||
<link rel="stylesheet" href="/src/css/vnc.css" />
|
<link rel="stylesheet" href="/src/css/vnc.css" />
|
||||||
|
<link rel="stylesheet" href="/src/css/irc.css" />
|
||||||
<link rel="stylesheet" href="/src/css/leviathan.css" />
|
<link rel="stylesheet" href="/src/css/leviathan.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -334,6 +335,7 @@
|
|||||||
<script src="/src/js/github.js"></script>
|
<script src="/src/js/github.js"></script>
|
||||||
<script src="/src/js/lib/novnc.min.js"></script>
|
<script src="/src/js/lib/novnc.min.js"></script>
|
||||||
<script src="/src/js/vnc.js"></script>
|
<script src="/src/js/vnc.js"></script>
|
||||||
|
<script src="/src/js/irc.js"></script>
|
||||||
<script src="/src/js/settings.js"></script>
|
<script src="/src/js/settings.js"></script>
|
||||||
<script src="/src/js/commands.js"></script>
|
<script src="/src/js/commands.js"></script>
|
||||||
<script src="/src/js/secrets.js"></script>
|
<script src="/src/js/secrets.js"></script>
|
||||||
|
|||||||
@@ -179,6 +179,11 @@ assertContains(
|
|||||||
"session.protocol !== \"leviathan\"",
|
"session.protocol !== \"leviathan\"",
|
||||||
"Leviathan tabs must close without connected-session confirmation",
|
"Leviathan tabs must close without connected-session confirmation",
|
||||||
);
|
);
|
||||||
|
assertContains(
|
||||||
|
"frontend/src/js/tabs.js",
|
||||||
|
"session.protocol !== \"github\"",
|
||||||
|
"GitHub tabs must close without connected-session confirmation",
|
||||||
|
);
|
||||||
assertContains(
|
assertContains(
|
||||||
"frontend/src/js/tabs.js",
|
"frontend/src/js/tabs.js",
|
||||||
"Connected since:",
|
"Connected since:",
|
||||||
@@ -324,6 +329,21 @@ assertContains(
|
|||||||
"window.go?.api?.Service",
|
"window.go?.api?.Service",
|
||||||
"Wails service bridge must use the normal named Wails API",
|
"Wails service bridge must use the normal named Wails API",
|
||||||
);
|
);
|
||||||
|
assertContains(
|
||||||
|
"frontend/src/js/sshkeys.js",
|
||||||
|
"textContent: \"Failed to load keys: \" + String(err)",
|
||||||
|
"SSH key manager errors must be rendered as text rather than injected HTML",
|
||||||
|
);
|
||||||
|
assertContains(
|
||||||
|
"frontend/src/js/sshkeys.js",
|
||||||
|
"textContent: message",
|
||||||
|
"SSH key delete confirmation messages must be rendered as text",
|
||||||
|
);
|
||||||
|
assertNotContains(
|
||||||
|
"frontend/src/js/sshkeys.js",
|
||||||
|
"Failed to load keys: ${err}",
|
||||||
|
"SSH key manager must not interpolate backend errors into innerHTML",
|
||||||
|
);
|
||||||
assertContains(
|
assertContains(
|
||||||
"frontend/package.json",
|
"frontend/package.json",
|
||||||
'"@xterm/xterm":',
|
'"@xterm/xterm":',
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
Nexus Terminal - IRC viewer
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.irc-viewer-panel {
|
||||||
|
background: var(--bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-viewer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 260px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 34px minmax(0, 1fr) 42px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-room-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-room-tab {
|
||||||
|
height: 24px;
|
||||||
|
max-width: 180px;
|
||||||
|
min-width: 44px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: 22px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-room-tab:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-room-tab.active {
|
||||||
|
background: var(--bg-active);
|
||||||
|
border-color: var(--border-default);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-room-tab.has-unread::after {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
margin-left: var(--space-1);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--protocol-irc);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-terminal {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-terminal .xterm {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-composer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-input {
|
||||||
|
min-width: 0;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-input:focus,
|
||||||
|
.irc-join-input:focus,
|
||||||
|
.irc-filter-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-send {
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-sidebar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(88px, auto) minmax(120px, 1fr) minmax(120px, 1fr);
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-left: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-sidebar-section {
|
||||||
|
min-height: 0;
|
||||||
|
padding: var(--space-2);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-users-section {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-sidebar-title,
|
||||||
|
.irc-sidebar-title-row {
|
||||||
|
min-height: 24px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-sidebar-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-join-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-join-input,
|
||||||
|
.irc-filter-input {
|
||||||
|
min-width: 0;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-filter-input {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-sidebar-list,
|
||||||
|
.irc-room-list,
|
||||||
|
.irc-user-list {
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-room-row,
|
||||||
|
.irc-user-row,
|
||||||
|
.irc-listed-room-main {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-room-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-room-row:hover,
|
||||||
|
.irc-user-row:hover,
|
||||||
|
.irc-listed-room:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-room-row.active {
|
||||||
|
background: var(--bg-active);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-room-name,
|
||||||
|
.irc-listed-room-name,
|
||||||
|
.irc-listed-room-topic {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-room-count,
|
||||||
|
.irc-listed-room-count {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 10px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-listed-room {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-height: 34px;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-listed-room-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-listed-room-name {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-listed-room-topic {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-user-row {
|
||||||
|
display: block;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-empty {
|
||||||
|
padding: var(--space-2);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.irc-viewer {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.irc-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
padding: var(--space-2) var(--space-2);
|
padding: var(--space-2) var(--space-2);
|
||||||
margin-bottom: 1px;
|
margin-bottom: 0.5px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
@@ -304,6 +304,9 @@
|
|||||||
.sidebar-item .protocol-indicator.proto-vnc::before {
|
.sidebar-item .protocol-indicator.proto-vnc::before {
|
||||||
content: "⊞";
|
content: "⊞";
|
||||||
}
|
}
|
||||||
|
.sidebar-item .protocol-indicator.proto-irc::before {
|
||||||
|
content: "#";
|
||||||
|
}
|
||||||
.sidebar-item .protocol-indicator.proto-local::before {
|
.sidebar-item .protocol-indicator.proto-local::before {
|
||||||
content: "›_";
|
content: "›_";
|
||||||
}
|
}
|
||||||
@@ -328,6 +331,10 @@
|
|||||||
background: rgba(232, 93, 74, 0.18);
|
background: rgba(232, 93, 74, 0.18);
|
||||||
color: var(--protocol-vnc);
|
color: var(--protocol-vnc);
|
||||||
}
|
}
|
||||||
|
.sidebar-item .protocol-indicator.proto-irc {
|
||||||
|
background: rgba(216, 178, 76, 0.18);
|
||||||
|
color: var(--protocol-irc);
|
||||||
|
}
|
||||||
.sidebar-item .protocol-indicator.proto-local {
|
.sidebar-item .protocol-indicator.proto-local {
|
||||||
background: rgba(139, 195, 74, 0.18);
|
background: rgba(139, 195, 74, 0.18);
|
||||||
color: var(--protocol-local);
|
color: var(--protocol-local);
|
||||||
|
|||||||
@@ -118,6 +118,10 @@
|
|||||||
background: rgba(232, 93, 74, 0.15);
|
background: rgba(232, 93, 74, 0.15);
|
||||||
color: var(--protocol-vnc);
|
color: var(--protocol-vnc);
|
||||||
}
|
}
|
||||||
|
.tab-badge.badge-irc {
|
||||||
|
background: rgba(216, 178, 76, 0.15);
|
||||||
|
color: var(--protocol-irc);
|
||||||
|
}
|
||||||
.tab-badge.badge-local {
|
.tab-badge.badge-local {
|
||||||
background: rgba(139, 195, 74, 0.15);
|
background: rgba(139, 195, 74, 0.15);
|
||||||
color: var(--protocol-local);
|
color: var(--protocol-local);
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
--protocol-raw: #ab47bc;
|
--protocol-raw: #ab47bc;
|
||||||
--protocol-github: #7c8aff;
|
--protocol-github: #7c8aff;
|
||||||
--protocol-vnc: #e85d4a;
|
--protocol-vnc: #e85d4a;
|
||||||
|
--protocol-irc: #d8b24c;
|
||||||
--protocol-local: #8bc34a;
|
--protocol-local: #8bc34a;
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------
|
/* -----------------------------------------------------------------------
|
||||||
|
|||||||
+50
-3
@@ -35,6 +35,7 @@
|
|||||||
this.terminals = new TerminalManager(this);
|
this.terminals = new TerminalManager(this);
|
||||||
this.profileEditor = new ProfileEditor(this);
|
this.profileEditor = new ProfileEditor(this);
|
||||||
this.rawViewer = new RawViewer(this);
|
this.rawViewer = new RawViewer(this);
|
||||||
|
window.NexusTerminal.IRCViewer?.setApp(this);
|
||||||
this.settings = new Settings(this);
|
this.settings = new Settings(this);
|
||||||
this.commandPalette = new CommandPalette(this);
|
this.commandPalette = new CommandPalette(this);
|
||||||
this.secretPrompt = new SecretPrompt(this);
|
this.secretPrompt = new SecretPrompt(this);
|
||||||
@@ -239,6 +240,7 @@
|
|||||||
setTimeout(fit, 50);
|
setTimeout(fit, 50);
|
||||||
setTimeout(fit, 300);
|
setTimeout(fit, 300);
|
||||||
setTimeout(fit, 800);
|
setTimeout(fit, 800);
|
||||||
|
setTimeout(() => window.NexusTerminal.IRCViewer?.fit(payload.sessionID), 50);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -288,6 +290,12 @@
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// IRC events
|
||||||
|
window.runtime.EventsOn("session:irc:event", (payload) => {
|
||||||
|
window.NexusTerminal.IRCViewer.handleEvent(payload);
|
||||||
|
this.tabs.markActivity(payload.sessionID);
|
||||||
|
});
|
||||||
|
|
||||||
// App-wide commands from the native menu
|
// App-wide commands from the native menu
|
||||||
window.runtime.EventsOn("app:open-about", () => {
|
window.runtime.EventsOn("app:open-about", () => {
|
||||||
this.settings.openTab("about");
|
this.settings.openTab("about");
|
||||||
@@ -801,7 +809,12 @@
|
|||||||
const decision = await this._confirmReuseSession(profile, preferred);
|
const decision = await this._confirmReuseSession(profile, preferred);
|
||||||
if (decision === "existing") {
|
if (decision === "existing") {
|
||||||
this.tabs.setActive(preferred.id);
|
this.tabs.setActive(preferred.id);
|
||||||
if (preferred.protocol !== "raw" && preferred.protocol !== "github") {
|
if (
|
||||||
|
preferred.protocol !== "raw" &&
|
||||||
|
preferred.protocol !== "github" &&
|
||||||
|
preferred.protocol !== "vnc" &&
|
||||||
|
preferred.protocol !== "irc"
|
||||||
|
) {
|
||||||
this.terminals.focusTerminal(preferred.id);
|
this.terminals.focusTerminal(preferred.id);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -845,6 +858,15 @@
|
|||||||
profile,
|
profile,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (profile.protocol === "irc") {
|
||||||
|
const container = document.getElementById(panelId);
|
||||||
|
if (container) {
|
||||||
|
window.NexusTerminal.IRCViewer.createViewer(
|
||||||
|
sessionInfo.id,
|
||||||
|
container,
|
||||||
|
profile,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.terminals.createTerminal(sessionInfo.id, panelId, profile);
|
this.terminals.createTerminal(sessionInfo.id, panelId, profile);
|
||||||
}
|
}
|
||||||
@@ -901,6 +923,7 @@
|
|||||||
<option value="ssh">SSH</option>
|
<option value="ssh">SSH</option>
|
||||||
<option value="telnet">Telnet</option>
|
<option value="telnet">Telnet</option>
|
||||||
<option value="raw">RAW TCP</option>
|
<option value="raw">RAW TCP</option>
|
||||||
|
<option value="irc">IRC</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -915,6 +938,10 @@
|
|||||||
<label class="form-label">Username</label>
|
<label class="form-label">Username</label>
|
||||||
<input class="form-input qc-username" type="text" placeholder="root" />
|
<input class="form-input qc-username" type="text" placeholder="root" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group qc-nick-group" style="display:none">
|
||||||
|
<label class="form-label">Nick</label>
|
||||||
|
<input class="form-input qc-nick" type="text" value="nexus" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-sm qc-cancel">Cancel</button>
|
<button class="btn btn-sm qc-cancel">Cancel</button>
|
||||||
@@ -931,14 +958,17 @@
|
|||||||
const protocolSelect = modal.querySelector(".qc-protocol");
|
const protocolSelect = modal.querySelector(".qc-protocol");
|
||||||
const portInput = modal.querySelector(".qc-port");
|
const portInput = modal.querySelector(".qc-port");
|
||||||
const usernameGroup = modal.querySelector(".qc-username-group");
|
const usernameGroup = modal.querySelector(".qc-username-group");
|
||||||
|
const nickGroup = modal.querySelector(".qc-nick-group");
|
||||||
const hostInput = modal.querySelector(".qc-host");
|
const hostInput = modal.querySelector(".qc-host");
|
||||||
|
|
||||||
// Update port default and username visibility when protocol changes
|
// Update port default and username visibility when protocol changes
|
||||||
protocolSelect.addEventListener("change", () => {
|
protocolSelect.addEventListener("change", () => {
|
||||||
const proto = protocolSelect.value;
|
const proto = protocolSelect.value;
|
||||||
const defaultPorts = { ssh: 22, telnet: 23, raw: 23 };
|
const defaultPorts = { ssh: 22, telnet: 23, raw: 23, irc: 6667 };
|
||||||
portInput.value = defaultPorts[proto] || 22;
|
portInput.value = defaultPorts[proto] || 22;
|
||||||
usernameGroup.style.display = proto === "ssh" ? "" : "none";
|
usernameGroup.style.display =
|
||||||
|
proto === "ssh" || proto === "irc" ? "" : "none";
|
||||||
|
nickGroup.style.display = proto === "irc" ? "" : "none";
|
||||||
});
|
});
|
||||||
|
|
||||||
const close = (value) => {
|
const close = (value) => {
|
||||||
@@ -965,6 +995,7 @@
|
|||||||
host,
|
host,
|
||||||
port: parseInt(portInput.value, 10) || 22,
|
port: parseInt(portInput.value, 10) || 22,
|
||||||
username: modal.querySelector(".qc-username").value.trim(),
|
username: modal.querySelector(".qc-username").value.trim(),
|
||||||
|
nick: modal.querySelector(".qc-nick").value.trim(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
backdrop.addEventListener("click", (e) => {
|
backdrop.addEventListener("click", (e) => {
|
||||||
@@ -989,6 +1020,13 @@
|
|||||||
displayName: result.host,
|
displayName: result.host,
|
||||||
tags: [],
|
tags: [],
|
||||||
};
|
};
|
||||||
|
if (result.protocol === "irc") {
|
||||||
|
profile.irc = {
|
||||||
|
nick: result.nick || "nexus",
|
||||||
|
realName: "Nexus Terminal",
|
||||||
|
autoJoinRooms: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
const sessionInfo = await window.NexusTerminal.wailsService.ConnectQuick(profile);
|
const sessionInfo = await window.NexusTerminal.wailsService.ConnectQuick(profile);
|
||||||
this.state.addSession(sessionInfo);
|
this.state.addSession(sessionInfo);
|
||||||
this.tabs.addTab(sessionInfo);
|
this.tabs.addTab(sessionInfo);
|
||||||
@@ -999,6 +1037,15 @@
|
|||||||
protocol: "raw",
|
protocol: "raw",
|
||||||
raw: {},
|
raw: {},
|
||||||
});
|
});
|
||||||
|
} else if (result.protocol === "irc") {
|
||||||
|
const container = document.getElementById(panelId);
|
||||||
|
if (container) {
|
||||||
|
window.NexusTerminal.IRCViewer.createViewer(
|
||||||
|
sessionInfo.id,
|
||||||
|
container,
|
||||||
|
profile,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.terminals.createTerminal(sessionInfo.id, panelId, {
|
this.terminals.createTerminal(sessionInfo.id, panelId, {
|
||||||
protocol: result.protocol,
|
protocol: result.protocol,
|
||||||
|
|||||||
@@ -0,0 +1,617 @@
|
|||||||
|
(function () {
|
||||||
|
/**
|
||||||
|
* Nexus Terminal - IRC viewer.
|
||||||
|
* Uses xterm.js as the chat stream, with room subtabs, a composer, and
|
||||||
|
* room/user sidebars.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* global Terminal, FitAddon, SearchAddon, WebLinksAddon */
|
||||||
|
|
||||||
|
const { createElement } = window.NexusTerminal;
|
||||||
|
const SERVER_TARGET = "server";
|
||||||
|
const MAX_LINES_PER_TARGET = 2000;
|
||||||
|
const MAX_LISTED_ROOMS = 200;
|
||||||
|
|
||||||
|
class IRCViewer {
|
||||||
|
constructor() {
|
||||||
|
this._viewers = new Map();
|
||||||
|
this.app = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setApp(app) {
|
||||||
|
this.app = app;
|
||||||
|
}
|
||||||
|
|
||||||
|
createViewer(sessionID, container, profile) {
|
||||||
|
if (this._viewers.has(sessionID) || !container) return;
|
||||||
|
const viewer = new IRCViewerInstance(sessionID, container, profile, this.app);
|
||||||
|
this._viewers.set(sessionID, viewer);
|
||||||
|
viewer.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyViewer(sessionID) {
|
||||||
|
const viewer = this._viewers.get(sessionID);
|
||||||
|
if (!viewer) return;
|
||||||
|
viewer.destroy();
|
||||||
|
this._viewers.delete(sessionID);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEvent(payload) {
|
||||||
|
const sessionID = payload?.sessionID;
|
||||||
|
const event = payload?.event || payload;
|
||||||
|
const viewer = this._viewers.get(sessionID);
|
||||||
|
if (viewer) viewer.handleEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
fit(sessionID) {
|
||||||
|
const viewer = this._viewers.get(sessionID);
|
||||||
|
if (viewer) viewer.fit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IRCViewerInstance {
|
||||||
|
constructor(sessionID, container, profile, app) {
|
||||||
|
this.sessionID = sessionID;
|
||||||
|
this.container = container;
|
||||||
|
this.profile = profile || {};
|
||||||
|
this.app = app || null;
|
||||||
|
|
||||||
|
this.term = null;
|
||||||
|
this.fitAddon = null;
|
||||||
|
this.searchAddon = null;
|
||||||
|
this.observer = null;
|
||||||
|
|
||||||
|
this.activeTarget = SERVER_TARGET;
|
||||||
|
this.targets = new Map();
|
||||||
|
this.users = new Map();
|
||||||
|
this.joinedRooms = [];
|
||||||
|
this.roomList = [];
|
||||||
|
this.roomFilter = "";
|
||||||
|
this.unreadTargets = new Set();
|
||||||
|
|
||||||
|
this.tabsEl = null;
|
||||||
|
this.termHost = null;
|
||||||
|
this.inputEl = null;
|
||||||
|
this.roomsEl = null;
|
||||||
|
this.usersEl = null;
|
||||||
|
this.roomListEl = null;
|
||||||
|
this.roomFilterEl = null;
|
||||||
|
|
||||||
|
this._ensureTarget(SERVER_TARGET, "Server");
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.container.innerHTML = "";
|
||||||
|
this.container.classList.add("irc-viewer-panel");
|
||||||
|
|
||||||
|
const wrapper = createElement("div", "irc-viewer");
|
||||||
|
const main = createElement("div", "irc-main");
|
||||||
|
|
||||||
|
this.tabsEl = createElement("div", "irc-room-tabs");
|
||||||
|
main.appendChild(this.tabsEl);
|
||||||
|
|
||||||
|
this.termHost = createElement("div", "irc-terminal");
|
||||||
|
main.appendChild(this.termHost);
|
||||||
|
|
||||||
|
const composer = createElement("form", "irc-composer");
|
||||||
|
this.inputEl = createElement("input", "irc-input", {
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Message or /command",
|
||||||
|
autocomplete: "off",
|
||||||
|
});
|
||||||
|
const sendBtn = createElement("button", "btn btn-sm btn-primary irc-send", {
|
||||||
|
type: "submit",
|
||||||
|
textContent: "Send",
|
||||||
|
});
|
||||||
|
composer.appendChild(this.inputEl);
|
||||||
|
composer.appendChild(sendBtn);
|
||||||
|
composer.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
this._sendComposer();
|
||||||
|
});
|
||||||
|
this.inputEl.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Tab" && this._completeMention()) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
main.appendChild(composer);
|
||||||
|
|
||||||
|
const sidebar = this._buildSidebar();
|
||||||
|
wrapper.appendChild(main);
|
||||||
|
wrapper.appendChild(sidebar);
|
||||||
|
this.container.appendChild(wrapper);
|
||||||
|
|
||||||
|
this._openTerminal();
|
||||||
|
this._renderTabs();
|
||||||
|
this._renderRooms();
|
||||||
|
this._renderUsers();
|
||||||
|
this._renderRoomList();
|
||||||
|
this._appendLine(SERVER_TARGET, {
|
||||||
|
type: "system",
|
||||||
|
text: "Connecting...",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEvent(event) {
|
||||||
|
if (!event || !event.type) return;
|
||||||
|
|
||||||
|
if (event.type === "rooms") {
|
||||||
|
this.joinedRooms = Array.isArray(event.rooms) ? event.rooms : [];
|
||||||
|
for (const room of this.joinedRooms) {
|
||||||
|
this._ensureTarget(room.name, room.name);
|
||||||
|
}
|
||||||
|
this._renderTabs();
|
||||||
|
this._renderRooms();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "users") {
|
||||||
|
const target = event.target;
|
||||||
|
if (target) {
|
||||||
|
this.users.set(target, Array.isArray(event.users) ? event.users : []);
|
||||||
|
if (target === this.activeTarget) this._renderUsers();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "room-list") {
|
||||||
|
this.roomList = Array.isArray(event.rooms) ? event.rooms : [];
|
||||||
|
this._renderRoomList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = this._eventTarget(event);
|
||||||
|
this._ensureTarget(target, this._targetLabel(target));
|
||||||
|
this._appendLine(target, event);
|
||||||
|
|
||||||
|
if (target !== this.activeTarget) {
|
||||||
|
this.unreadTargets.add(target);
|
||||||
|
this._renderTabs();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["join", "part", "topic"].includes(event.type)) {
|
||||||
|
this._renderRooms();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fit() {
|
||||||
|
if (!this.fitAddon) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
this.fitAddon.fit();
|
||||||
|
} catch (_) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.observer) {
|
||||||
|
this.observer.disconnect();
|
||||||
|
this.observer = null;
|
||||||
|
}
|
||||||
|
if (this.term) {
|
||||||
|
this.term.dispose();
|
||||||
|
this.term = null;
|
||||||
|
}
|
||||||
|
this.container.classList.remove("irc-viewer-panel");
|
||||||
|
this.container.innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildSidebar() {
|
||||||
|
const sidebar = createElement("aside", "irc-sidebar");
|
||||||
|
|
||||||
|
const joinSection = createElement("div", "irc-sidebar-section");
|
||||||
|
joinSection.appendChild(
|
||||||
|
createElement("div", "irc-sidebar-title", { textContent: "Rooms" }),
|
||||||
|
);
|
||||||
|
const joinRow = createElement("form", "irc-join-row");
|
||||||
|
const joinInput = createElement("input", "irc-join-input", {
|
||||||
|
type: "text",
|
||||||
|
placeholder: "#room",
|
||||||
|
autocomplete: "off",
|
||||||
|
});
|
||||||
|
const joinBtn = createElement("button", "btn btn-xs btn-secondary", {
|
||||||
|
type: "submit",
|
||||||
|
textContent: "Join",
|
||||||
|
});
|
||||||
|
joinRow.appendChild(joinInput);
|
||||||
|
joinRow.appendChild(joinBtn);
|
||||||
|
joinRow.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const room = joinInput.value.trim();
|
||||||
|
if (!room) return;
|
||||||
|
try {
|
||||||
|
await window.NexusTerminal.wailsService.IRCJoin(this.sessionID, room);
|
||||||
|
joinInput.value = "";
|
||||||
|
} catch (err) {
|
||||||
|
this._notifyError("Join failed: " + err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
joinSection.appendChild(joinRow);
|
||||||
|
this.roomsEl = createElement("div", "irc-sidebar-list");
|
||||||
|
joinSection.appendChild(this.roomsEl);
|
||||||
|
sidebar.appendChild(joinSection);
|
||||||
|
|
||||||
|
const listSection = createElement("div", "irc-sidebar-section");
|
||||||
|
const listTitleRow = createElement("div", "irc-sidebar-title-row");
|
||||||
|
listTitleRow.appendChild(
|
||||||
|
createElement("div", "irc-sidebar-title", { textContent: "Room List" }),
|
||||||
|
);
|
||||||
|
const listBtn = createElement("button", "btn btn-xs btn-outline", {
|
||||||
|
type: "button",
|
||||||
|
textContent: "List",
|
||||||
|
});
|
||||||
|
listBtn.addEventListener("click", async () => {
|
||||||
|
this.roomList = [];
|
||||||
|
this._renderRoomList("Loading rooms...");
|
||||||
|
try {
|
||||||
|
await window.NexusTerminal.wailsService.IRCListRooms(this.sessionID);
|
||||||
|
} catch (err) {
|
||||||
|
this._renderRoomList("Room list failed");
|
||||||
|
this._notifyError("Room list failed: " + err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
listTitleRow.appendChild(listBtn);
|
||||||
|
listSection.appendChild(listTitleRow);
|
||||||
|
this.roomFilterEl = createElement("input", "irc-filter-input", {
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Filter rooms",
|
||||||
|
autocomplete: "off",
|
||||||
|
});
|
||||||
|
this.roomFilterEl.addEventListener("input", () => {
|
||||||
|
this.roomFilter = this.roomFilterEl.value.toLowerCase();
|
||||||
|
this._renderRoomList();
|
||||||
|
});
|
||||||
|
listSection.appendChild(this.roomFilterEl);
|
||||||
|
this.roomListEl = createElement("div", "irc-room-list");
|
||||||
|
listSection.appendChild(this.roomListEl);
|
||||||
|
sidebar.appendChild(listSection);
|
||||||
|
|
||||||
|
const usersSection = createElement("div", "irc-sidebar-section irc-users-section");
|
||||||
|
usersSection.appendChild(
|
||||||
|
createElement("div", "irc-sidebar-title", { textContent: "Users" }),
|
||||||
|
);
|
||||||
|
this.usersEl = createElement("div", "irc-user-list");
|
||||||
|
usersSection.appendChild(this.usersEl);
|
||||||
|
sidebar.appendChild(usersSection);
|
||||||
|
|
||||||
|
return sidebar;
|
||||||
|
}
|
||||||
|
|
||||||
|
_openTerminal() {
|
||||||
|
const themeName =
|
||||||
|
(this.app?.state?.preferences || {}).terminalTheme || "charcoal";
|
||||||
|
this.term = new Terminal({
|
||||||
|
theme: window.NexusTerminal.getTerminalTheme(themeName),
|
||||||
|
fontFamily:
|
||||||
|
this.profile.terminalFontFamily ||
|
||||||
|
'JetBrains Mono, Menlo, Monaco, Consolas, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace',
|
||||||
|
fontSize: this.profile.terminalFontSize || 14,
|
||||||
|
scrollback: this.profile.terminalScrollback || 10000,
|
||||||
|
cursorBlink: false,
|
||||||
|
convertEol: true,
|
||||||
|
disableStdin: true,
|
||||||
|
});
|
||||||
|
this.fitAddon = new FitAddon.FitAddon();
|
||||||
|
this.searchAddon = new SearchAddon.SearchAddon();
|
||||||
|
this.term.loadAddon(this.fitAddon);
|
||||||
|
this.term.loadAddon(this.searchAddon);
|
||||||
|
try {
|
||||||
|
this.term.loadAddon(new WebLinksAddon.WebLinksAddon((_event, uri) => {
|
||||||
|
window.runtime.BrowserOpenURL(uri);
|
||||||
|
}));
|
||||||
|
} catch (_) {
|
||||||
|
/* optional */
|
||||||
|
}
|
||||||
|
this.term.open(this.termHost);
|
||||||
|
this.fit();
|
||||||
|
this.observer = new ResizeObserver(() => this.fit());
|
||||||
|
this.observer.observe(this.termHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderTabs() {
|
||||||
|
if (!this.tabsEl) return;
|
||||||
|
this.tabsEl.innerHTML = "";
|
||||||
|
for (const [target, state] of this.targets) {
|
||||||
|
const btn = createElement(
|
||||||
|
"button",
|
||||||
|
`irc-room-tab ${target === this.activeTarget ? "active" : ""} ${
|
||||||
|
this.unreadTargets.has(target) ? "has-unread" : ""
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
textContent: state.label,
|
||||||
|
title: state.label,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
btn.addEventListener("click", () => this._setActiveTarget(target));
|
||||||
|
this.tabsEl.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderRooms() {
|
||||||
|
if (!this.roomsEl) return;
|
||||||
|
this.roomsEl.innerHTML = "";
|
||||||
|
if (this.joinedRooms.length === 0) {
|
||||||
|
this.roomsEl.appendChild(
|
||||||
|
createElement("div", "irc-empty", { textContent: "No joined rooms" }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const room of this.joinedRooms) {
|
||||||
|
const row = createElement("button", "irc-room-row", {
|
||||||
|
type: "button",
|
||||||
|
title: room.topic || room.name,
|
||||||
|
});
|
||||||
|
row.classList.toggle("active", room.name === this.activeTarget);
|
||||||
|
row.appendChild(createElement("span", "irc-room-name", { textContent: room.name }));
|
||||||
|
row.appendChild(
|
||||||
|
createElement("span", "irc-room-count", {
|
||||||
|
textContent: String(room.users || 0),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
row.addEventListener("click", () => this._setActiveTarget(room.name));
|
||||||
|
this.roomsEl.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderUsers() {
|
||||||
|
if (!this.usersEl) return;
|
||||||
|
this.usersEl.innerHTML = "";
|
||||||
|
const users = this.users.get(this.activeTarget) || [];
|
||||||
|
if (!this._isRoom(this.activeTarget)) {
|
||||||
|
this.usersEl.appendChild(
|
||||||
|
createElement("div", "irc-empty", { textContent: "No room selected" }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (users.length === 0) {
|
||||||
|
this.usersEl.appendChild(
|
||||||
|
createElement("div", "irc-empty", { textContent: "No users yet" }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const user of users) {
|
||||||
|
const row = createElement("button", "irc-user-row", {
|
||||||
|
type: "button",
|
||||||
|
textContent: this._displayNick(user),
|
||||||
|
title: user,
|
||||||
|
});
|
||||||
|
row.addEventListener("click", () => {
|
||||||
|
this._insertMention(this._displayNick(user));
|
||||||
|
});
|
||||||
|
this.usersEl.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderRoomList(message = "") {
|
||||||
|
if (!this.roomListEl) return;
|
||||||
|
this.roomListEl.innerHTML = "";
|
||||||
|
if (message) {
|
||||||
|
this.roomListEl.appendChild(
|
||||||
|
createElement("div", "irc-empty", { textContent: message }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rooms = this.roomList;
|
||||||
|
if (this.roomFilter) {
|
||||||
|
rooms = rooms.filter((room) => {
|
||||||
|
const haystack = `${room.name || ""} ${room.topic || ""}`.toLowerCase();
|
||||||
|
return haystack.includes(this.roomFilter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rooms = rooms.slice(0, MAX_LISTED_ROOMS);
|
||||||
|
if (rooms.length === 0) {
|
||||||
|
this.roomListEl.appendChild(
|
||||||
|
createElement("div", "irc-empty", { textContent: "No rooms loaded" }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const room of rooms) {
|
||||||
|
const row = createElement("div", "irc-listed-room");
|
||||||
|
const meta = createElement("button", "irc-listed-room-main", {
|
||||||
|
type: "button",
|
||||||
|
title: room.topic || room.name,
|
||||||
|
});
|
||||||
|
meta.appendChild(
|
||||||
|
createElement("span", "irc-listed-room-name", {
|
||||||
|
textContent: room.name || "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
meta.appendChild(
|
||||||
|
createElement("span", "irc-listed-room-topic", {
|
||||||
|
textContent: room.topic || "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
meta.addEventListener("click", () => this._joinRoom(room.name));
|
||||||
|
const count = createElement("span", "irc-listed-room-count", {
|
||||||
|
textContent: String(room.users || 0),
|
||||||
|
});
|
||||||
|
row.appendChild(meta);
|
||||||
|
row.appendChild(count);
|
||||||
|
this.roomListEl.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setActiveTarget(target) {
|
||||||
|
this._ensureTarget(target, this._targetLabel(target));
|
||||||
|
this.activeTarget = target;
|
||||||
|
this.unreadTargets.delete(target);
|
||||||
|
this._renderTabs();
|
||||||
|
this._renderRooms();
|
||||||
|
this._renderUsers();
|
||||||
|
this._replayActiveTarget();
|
||||||
|
if (this.inputEl) this.inputEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
_appendLine(target, event) {
|
||||||
|
const state = this._ensureTarget(target, this._targetLabel(target));
|
||||||
|
const line = this._formatEvent(event);
|
||||||
|
if (!line) return;
|
||||||
|
state.lines.push(line);
|
||||||
|
if (state.lines.length > MAX_LINES_PER_TARGET) {
|
||||||
|
state.lines.splice(0, state.lines.length - MAX_LINES_PER_TARGET);
|
||||||
|
}
|
||||||
|
if (target === this.activeTarget && this.term) {
|
||||||
|
this.term.writeln(line);
|
||||||
|
this.term.scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_replayActiveTarget() {
|
||||||
|
if (!this.term) return;
|
||||||
|
this.term.clear();
|
||||||
|
const state = this.targets.get(this.activeTarget);
|
||||||
|
for (const line of state?.lines || []) {
|
||||||
|
this.term.writeln(line);
|
||||||
|
}
|
||||||
|
this.term.scrollToBottom();
|
||||||
|
this.fit();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _sendComposer() {
|
||||||
|
const text = this.inputEl.value;
|
||||||
|
if (!text.trim()) return;
|
||||||
|
this.inputEl.value = "";
|
||||||
|
try {
|
||||||
|
await window.NexusTerminal.wailsService.IRCHandleInput(
|
||||||
|
this.sessionID,
|
||||||
|
this.activeTarget,
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this._notifyError(String(err));
|
||||||
|
this._appendLine(this.activeTarget, {
|
||||||
|
type: "error",
|
||||||
|
text: String(err),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _joinRoom(room) {
|
||||||
|
if (!room) return;
|
||||||
|
try {
|
||||||
|
await window.NexusTerminal.wailsService.IRCJoin(this.sessionID, room);
|
||||||
|
} catch (err) {
|
||||||
|
this._notifyError("Join failed: " + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_completeMention() {
|
||||||
|
if (!this.inputEl || !this._isRoom(this.activeTarget)) return false;
|
||||||
|
const start = this.inputEl.selectionStart || 0;
|
||||||
|
const before = this.inputEl.value.slice(0, start);
|
||||||
|
const match = before.match(/(^|\s)@([^\s@]*)$/);
|
||||||
|
if (!match) return false;
|
||||||
|
const prefix = match[2].toLowerCase();
|
||||||
|
const users = this.users.get(this.activeTarget) || [];
|
||||||
|
const nick = users
|
||||||
|
.map((user) => this._displayNick(user))
|
||||||
|
.find((user) => user.toLowerCase().startsWith(prefix));
|
||||||
|
if (!nick) return false;
|
||||||
|
const replacement = `${match[1]}@${nick} `;
|
||||||
|
const nextValue =
|
||||||
|
this.inputEl.value.slice(0, start - match[0].length) +
|
||||||
|
replacement +
|
||||||
|
this.inputEl.value.slice(start);
|
||||||
|
const caret = start - match[0].length + replacement.length;
|
||||||
|
this.inputEl.value = nextValue;
|
||||||
|
this.inputEl.setSelectionRange(caret, caret);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_insertMention(nick) {
|
||||||
|
if (!this.inputEl) return;
|
||||||
|
const current = this.inputEl.value;
|
||||||
|
const spacer = current && !/\s$/.test(current) ? " " : "";
|
||||||
|
this.inputEl.value = `${current}${spacer}@${nick} `;
|
||||||
|
this.inputEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureTarget(target, label) {
|
||||||
|
const safeTarget = target || SERVER_TARGET;
|
||||||
|
if (!this.targets.has(safeTarget)) {
|
||||||
|
this.targets.set(safeTarget, {
|
||||||
|
label: label || this._targetLabel(safeTarget),
|
||||||
|
lines: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.targets.get(safeTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
_eventTarget(event) {
|
||||||
|
if (!event) return SERVER_TARGET;
|
||||||
|
if (event.target) return event.target;
|
||||||
|
return SERVER_TARGET;
|
||||||
|
}
|
||||||
|
|
||||||
|
_targetLabel(target) {
|
||||||
|
return target === SERVER_TARGET ? "Server" : target;
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatEvent(event) {
|
||||||
|
const time = this._time(event.timestamp);
|
||||||
|
const from = this._sanitize(event.from || "");
|
||||||
|
const text = this._sanitize(event.text || "");
|
||||||
|
switch (event.type) {
|
||||||
|
case "message":
|
||||||
|
return `[${time}] <${from}> ${text}`;
|
||||||
|
case "action":
|
||||||
|
return `[${time}] * ${from} ${text}`;
|
||||||
|
case "notice":
|
||||||
|
return `[${time}] -${from || "notice"}- ${text}`;
|
||||||
|
case "join":
|
||||||
|
return `[${time}] --> ${from || "user"} joined`;
|
||||||
|
case "part":
|
||||||
|
return `[${time}] <-- ${from || "user"} left${text ? ` (${text})` : ""}`;
|
||||||
|
case "quit":
|
||||||
|
return `[${time}] <-- ${from || "user"} quit${text ? ` (${text})` : ""}`;
|
||||||
|
case "nick":
|
||||||
|
return `[${time}] ${from || "user"} is now known as ${text}`;
|
||||||
|
case "topic":
|
||||||
|
return `[${time}] topic: ${text}`;
|
||||||
|
case "error":
|
||||||
|
return `[${time}] error: ${text}`;
|
||||||
|
case "system":
|
||||||
|
default:
|
||||||
|
return `[${time}] ${text}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_time(value) {
|
||||||
|
const date = value ? new Date(value) : new Date();
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
|
const mins = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
const secs = String(date.getSeconds()).padStart(2, "0");
|
||||||
|
return `${hours}:${mins}:${secs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_sanitize(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
||||||
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
|
||||||
|
.replace(/\x03(\d{1,2}(,\d{1,2})?)?/g, "")
|
||||||
|
.replace(/[\x02\x0f\x16\x1d\x1f]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
_displayNick(user) {
|
||||||
|
return String(user || "").replace(/^[~&@%+]+/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
_isRoom(target) {
|
||||||
|
return /^[#&+!]/.test(target || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
_notifyError(message) {
|
||||||
|
if (this.app?.notifications) {
|
||||||
|
this.app.notifications.error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.NexusTerminal = window.NexusTerminal || {};
|
||||||
|
window.NexusTerminal.IRCViewer = new IRCViewer();
|
||||||
|
})();
|
||||||
+270
-2
@@ -86,6 +86,31 @@
|
|||||||
sshAgentAuth: false,
|
sshAgentAuth: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
irc: {
|
||||||
|
port: 6667,
|
||||||
|
protocol: "irc",
|
||||||
|
irc: {
|
||||||
|
nick: "nexus",
|
||||||
|
realName: "Nexus Terminal",
|
||||||
|
useTLS: false,
|
||||||
|
serverPasswordRequired: false,
|
||||||
|
serverPasswordMode: "ask-every-time",
|
||||||
|
nickServIdentify: false,
|
||||||
|
nickServAccount: "",
|
||||||
|
nickServMode: "saved",
|
||||||
|
autoJoinRooms: [],
|
||||||
|
sshTunnel: false,
|
||||||
|
sshHost: "",
|
||||||
|
sshPort: 22,
|
||||||
|
sshUsername: "",
|
||||||
|
sshAuthMethod: "publickey",
|
||||||
|
sshSecretMode: "saved",
|
||||||
|
sshPrivateKey: "",
|
||||||
|
sshAgentAuth: false,
|
||||||
|
autoReconnect: true,
|
||||||
|
maxReconnectTries: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
class ProfileEditor {
|
class ProfileEditor {
|
||||||
@@ -162,10 +187,11 @@
|
|||||||
drawer.appendChild(header);
|
drawer.appendChild(header);
|
||||||
|
|
||||||
// ── Protocol selector tabs ──────────────────────────────
|
// ── Protocol selector tabs ──────────────────────────────
|
||||||
const protocols = ["ssh", "telnet", "raw", "github", "vnc"];
|
const protocols = ["ssh", "telnet", "raw", "github", "vnc", "irc"];
|
||||||
const tabBar = createElement("div", "drawer-protocol-tabs");
|
const tabBar = createElement("div", "drawer-protocol-tabs");
|
||||||
for (const proto of protocols) {
|
for (const proto of protocols) {
|
||||||
const label = proto === "github" ? "GitHub" : proto.toUpperCase();
|
const label =
|
||||||
|
proto === "github" ? "GitHub" : proto === "irc" ? "IRC" : proto.toUpperCase();
|
||||||
const btn = createElement(
|
const btn = createElement(
|
||||||
"button",
|
"button",
|
||||||
`drawer-proto-tab ${profile.protocol === proto ? "active" : ""}`,
|
`drawer-proto-tab ${profile.protocol === proto ? "active" : ""}`,
|
||||||
@@ -208,6 +234,10 @@
|
|||||||
case "vnc":
|
case "vnc":
|
||||||
this._renderVNCSections(profile).forEach((s) => body.appendChild(s));
|
this._renderVNCSections(profile).forEach((s) => body.appendChild(s));
|
||||||
break;
|
break;
|
||||||
|
case "irc":
|
||||||
|
this._renderIRCSections(profile).forEach((s) => body.appendChild(s));
|
||||||
|
body.appendChild(sshKeyDatalist);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal section (SSH and Telnet only)
|
// Terminal section (SSH and Telnet only)
|
||||||
@@ -1075,6 +1105,185 @@
|
|||||||
return sections;
|
return sections;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_renderIRCSections(profile) {
|
||||||
|
const sections = [];
|
||||||
|
const irc = profile.irc || DEFAULTS.irc.irc;
|
||||||
|
|
||||||
|
sections.push(
|
||||||
|
this._renderSection("IRC", [
|
||||||
|
this._field("text", "irc.nick", "Nick", irc.nick, {
|
||||||
|
placeholder: "yourNick",
|
||||||
|
}),
|
||||||
|
this._field("text", "irc.realName", "Real Name", irc.realName, {
|
||||||
|
placeholder: "Shown in WHOIS",
|
||||||
|
}),
|
||||||
|
this._field("toggle", "irc.useTLS", "Use TLS", irc.useTLS, {
|
||||||
|
help: "Use port 6697 for most TLS IRC networks.",
|
||||||
|
}),
|
||||||
|
this._field(
|
||||||
|
"toggle",
|
||||||
|
"irc.serverPasswordRequired",
|
||||||
|
"Server Password",
|
||||||
|
irc.serverPasswordRequired,
|
||||||
|
{
|
||||||
|
help: "Prompt for an IRC PASS secret before registration.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
this._field(
|
||||||
|
"select",
|
||||||
|
"irc.serverPasswordMode",
|
||||||
|
"Server Password Storage",
|
||||||
|
irc.serverPasswordMode,
|
||||||
|
{
|
||||||
|
options: [
|
||||||
|
{ value: "saved", label: "Save to keychain" },
|
||||||
|
{ value: "session-only", label: "Remember for session" },
|
||||||
|
{ value: "ask-every-time", label: "Ask every time" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
sections.push(
|
||||||
|
this._renderSection("NickServ", [
|
||||||
|
this._field(
|
||||||
|
"toggle",
|
||||||
|
"irc.nickServIdentify",
|
||||||
|
"Identify on Connect",
|
||||||
|
irc.nickServIdentify,
|
||||||
|
),
|
||||||
|
this._field(
|
||||||
|
"text",
|
||||||
|
"irc.nickServAccount",
|
||||||
|
"Account",
|
||||||
|
irc.nickServAccount,
|
||||||
|
{
|
||||||
|
placeholder: "Optional if it matches your nick",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
this._field(
|
||||||
|
"select",
|
||||||
|
"irc.nickServMode",
|
||||||
|
"Password Storage",
|
||||||
|
irc.nickServMode,
|
||||||
|
{
|
||||||
|
options: [
|
||||||
|
{ value: "saved", label: "Save to keychain" },
|
||||||
|
{ value: "session-only", label: "Remember for session" },
|
||||||
|
{ value: "ask-every-time", label: "Ask every time" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
sections.push(
|
||||||
|
this._renderSection("Rooms", [
|
||||||
|
this._field(
|
||||||
|
"textarea",
|
||||||
|
"irc.autoJoinRoomsText",
|
||||||
|
"Auto-Join Rooms",
|
||||||
|
this._roomsToText(irc.autoJoinRooms),
|
||||||
|
{
|
||||||
|
placeholder: "#general\n#ops",
|
||||||
|
rows: 4,
|
||||||
|
help: "One room per line or comma-separated.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
sections.push(
|
||||||
|
this._renderSection("SSH Tunnel", [
|
||||||
|
this._field(
|
||||||
|
"toggle",
|
||||||
|
"irc.sshTunnel",
|
||||||
|
"Enable SSH Tunnel",
|
||||||
|
irc.sshTunnel,
|
||||||
|
),
|
||||||
|
this._field("text", "irc.sshHost", "SSH Host", irc.sshHost, {
|
||||||
|
placeholder: "jump.example.com",
|
||||||
|
}),
|
||||||
|
this._field("number", "irc.sshPort", "SSH Port", irc.sshPort, {
|
||||||
|
min: 1,
|
||||||
|
max: 65535,
|
||||||
|
}),
|
||||||
|
this._field(
|
||||||
|
"text",
|
||||||
|
"irc.sshUsername",
|
||||||
|
"SSH Username",
|
||||||
|
irc.sshUsername,
|
||||||
|
{
|
||||||
|
placeholder: "admin",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
this._field(
|
||||||
|
"select",
|
||||||
|
"irc.sshAuthMethod",
|
||||||
|
"SSH Auth Method",
|
||||||
|
irc.sshAuthMethod,
|
||||||
|
{
|
||||||
|
options: [
|
||||||
|
{ value: "publickey", label: "Public Key" },
|
||||||
|
{ value: "password", label: "Password" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
this._field(
|
||||||
|
"text",
|
||||||
|
"irc.sshPrivateKey",
|
||||||
|
"SSH Private Key Path",
|
||||||
|
irc.sshPrivateKey,
|
||||||
|
{
|
||||||
|
placeholder: "~/.ssh/id_ed25519",
|
||||||
|
datalistId: "ssh-key-path-options",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
this._field(
|
||||||
|
"toggle",
|
||||||
|
"irc.sshAgentAuth",
|
||||||
|
"Use SSH Agent",
|
||||||
|
irc.sshAgentAuth,
|
||||||
|
),
|
||||||
|
this._field(
|
||||||
|
"select",
|
||||||
|
"irc.sshSecretMode",
|
||||||
|
"SSH Secret Storage",
|
||||||
|
irc.sshSecretMode,
|
||||||
|
{
|
||||||
|
options: [
|
||||||
|
{ value: "saved", label: "Save to keychain" },
|
||||||
|
{ value: "session-only", label: "Remember for session" },
|
||||||
|
{ value: "ask-every-time", label: "Ask every time" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
sections.push(
|
||||||
|
this._renderSection("Reconnection", [
|
||||||
|
this._field(
|
||||||
|
"toggle",
|
||||||
|
"irc.autoReconnect",
|
||||||
|
"Auto-Reconnect",
|
||||||
|
irc.autoReconnect,
|
||||||
|
),
|
||||||
|
this._field(
|
||||||
|
"number",
|
||||||
|
"irc.maxReconnectTries",
|
||||||
|
"Max Reconnect Attempts",
|
||||||
|
irc.maxReconnectTries || 5,
|
||||||
|
{ min: 1, max: 20 },
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
/** @private */
|
/** @private */
|
||||||
_renderTerminalSection(profile) {
|
_renderTerminalSection(profile) {
|
||||||
return this._renderSection("Terminal", [
|
return this._renderSection("Terminal", [
|
||||||
@@ -2275,6 +2484,34 @@
|
|||||||
p.vnc.sshAgentAuth = val("vnc.sshAgentAuth") || false;
|
p.vnc.sshAgentAuth = val("vnc.sshAgentAuth") || false;
|
||||||
p.vnc.sshSecretMode = val("vnc.sshSecretMode") || "saved";
|
p.vnc.sshSecretMode = val("vnc.sshSecretMode") || "saved";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IRC-specific
|
||||||
|
if (p.protocol === "irc") {
|
||||||
|
if (!p.irc) p.irc = {};
|
||||||
|
p.irc.nick = val("irc.nick") || "nexus";
|
||||||
|
p.irc.realName = val("irc.realName") || "Nexus Terminal";
|
||||||
|
p.irc.useTLS = val("irc.useTLS") || false;
|
||||||
|
p.irc.serverPasswordRequired =
|
||||||
|
val("irc.serverPasswordRequired") || false;
|
||||||
|
p.irc.serverPasswordMode =
|
||||||
|
val("irc.serverPasswordMode") || "ask-every-time";
|
||||||
|
p.irc.nickServIdentify = val("irc.nickServIdentify") || false;
|
||||||
|
p.irc.nickServAccount = val("irc.nickServAccount") || "";
|
||||||
|
p.irc.nickServMode = val("irc.nickServMode") || "saved";
|
||||||
|
p.irc.autoJoinRooms = this._parseRoomList(
|
||||||
|
val("irc.autoJoinRoomsText") || "",
|
||||||
|
);
|
||||||
|
p.irc.sshTunnel = val("irc.sshTunnel") || false;
|
||||||
|
p.irc.sshHost = val("irc.sshHost") || "";
|
||||||
|
p.irc.sshPort = val("irc.sshPort") || 22;
|
||||||
|
p.irc.sshUsername = val("irc.sshUsername") || "";
|
||||||
|
p.irc.sshAuthMethod = val("irc.sshAuthMethod") || "publickey";
|
||||||
|
p.irc.sshPrivateKey = val("irc.sshPrivateKey") || "";
|
||||||
|
p.irc.sshAgentAuth = val("irc.sshAgentAuth") || false;
|
||||||
|
p.irc.sshSecretMode = val("irc.sshSecretMode") || "saved";
|
||||||
|
p.irc.autoReconnect = val("irc.autoReconnect") !== false;
|
||||||
|
p.irc.maxReconnectTries = val("irc.maxReconnectTries") || 5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Validation ───────────────────────────────────────────────────
|
// ── Validation ───────────────────────────────────────────────────
|
||||||
@@ -2300,6 +2537,25 @@
|
|||||||
if (!profile.port || profile.port < 1 || profile.port > 65535) {
|
if (!profile.port || profile.port < 1 || profile.port > 65535) {
|
||||||
errors.push("Port must be between 1 and 65535.");
|
errors.push("Port must be between 1 and 65535.");
|
||||||
}
|
}
|
||||||
|
if (profile.protocol === "irc") {
|
||||||
|
const nick = profile.irc?.nick || "";
|
||||||
|
if (!nick.trim()) {
|
||||||
|
errors.push("IRC nick is required.");
|
||||||
|
}
|
||||||
|
for (const room of profile.irc?.autoJoinRooms || []) {
|
||||||
|
if (!/^[#&+!][^\s,]+$/.test(room)) {
|
||||||
|
errors.push(`Invalid IRC room "${room}".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (profile.irc?.sshTunnel) {
|
||||||
|
if (!profile.irc.sshHost?.trim()) {
|
||||||
|
errors.push("SSH Host is required when tunnelling IRC.");
|
||||||
|
}
|
||||||
|
if (!profile.irc.sshUsername?.trim()) {
|
||||||
|
errors.push("SSH Username is required when tunnelling IRC.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2476,6 +2732,7 @@
|
|||||||
raw: null,
|
raw: null,
|
||||||
github: null,
|
github: null,
|
||||||
vnc: null,
|
vnc: null,
|
||||||
|
irc: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaults = DEFAULTS[protocol] || {};
|
const defaults = DEFAULTS[protocol] || {};
|
||||||
@@ -2494,6 +2751,17 @@
|
|||||||
.map(([k, v]) => `${k}=${v}`)
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_roomsToText(rooms) {
|
||||||
|
return Array.isArray(rooms) ? rooms.join("\n") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseRoomList(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((room) => room.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.NexusTerminal = window.NexusTerminal || {};
|
window.NexusTerminal = window.NexusTerminal || {};
|
||||||
|
|||||||
@@ -926,6 +926,8 @@
|
|||||||
return "TELNET";
|
return "TELNET";
|
||||||
case "raw":
|
case "raw":
|
||||||
return "RAW";
|
return "RAW";
|
||||||
|
case "irc":
|
||||||
|
return "IRC";
|
||||||
default:
|
default:
|
||||||
return (protocol || "").toUpperCase() || "UNKNOWN";
|
return (protocol || "").toUpperCase() || "UNKNOWN";
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-14
@@ -92,7 +92,12 @@
|
|||||||
list.appendChild(this._renderKeyRow(key));
|
list.appendChild(this._renderKeyRow(key));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
list.innerHTML = `<div class="sshkey-error">Failed to load keys: ${err}</div>`;
|
list.innerHTML = "";
|
||||||
|
list.appendChild(
|
||||||
|
createElement("div", "sshkey-error", {
|
||||||
|
textContent: "Failed to load keys: " + String(err),
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,19 +268,41 @@
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const backdrop = createElement("div", "modal-backdrop");
|
const backdrop = createElement("div", "modal-backdrop");
|
||||||
const modal = createElement("div", "modal modal-sm");
|
const modal = createElement("div", "modal modal-sm");
|
||||||
modal.innerHTML = `
|
const header = createElement("div", "modal-header");
|
||||||
<div class="modal-header">
|
header.appendChild(
|
||||||
<h3 class="modal-title">${title}</h3>
|
createElement("h3", "modal-title", { textContent: title }),
|
||||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
);
|
||||||
</div>
|
header.appendChild(
|
||||||
<div class="modal-body">
|
createElement("button", "btn btn-icon btn-xs modal-close", {
|
||||||
<p style="font-size:var(--text-sm);color:var(--text-secondary)">${message}</p>
|
textContent: "x",
|
||||||
</div>
|
type: "button",
|
||||||
<div class="modal-footer">
|
"aria-label": "Close",
|
||||||
<button class="btn btn-sm confirm-cancel">Cancel</button>
|
}),
|
||||||
<button class="btn btn-sm btn-danger confirm-ok">${confirmLabel}</button>
|
);
|
||||||
</div>
|
|
||||||
`;
|
const body = createElement("div", "modal-body");
|
||||||
|
const messageEl = createElement("p", "", { textContent: message });
|
||||||
|
messageEl.style.fontSize = "var(--text-sm)";
|
||||||
|
messageEl.style.color = "var(--text-secondary)";
|
||||||
|
body.appendChild(messageEl);
|
||||||
|
|
||||||
|
const footer = createElement("div", "modal-footer");
|
||||||
|
footer.appendChild(
|
||||||
|
createElement("button", "btn btn-sm confirm-cancel", {
|
||||||
|
textContent: "Cancel",
|
||||||
|
type: "button",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
footer.appendChild(
|
||||||
|
createElement("button", "btn btn-sm btn-danger confirm-ok", {
|
||||||
|
textContent: confirmLabel,
|
||||||
|
type: "button",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.appendChild(header);
|
||||||
|
modal.appendChild(body);
|
||||||
|
modal.appendChild(footer);
|
||||||
backdrop.appendChild(modal);
|
backdrop.appendChild(modal);
|
||||||
document.body.appendChild(backdrop);
|
document.body.appendChild(backdrop);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
session &&
|
session &&
|
||||||
session.state === "connected" &&
|
session.state === "connected" &&
|
||||||
session.protocol !== "leviathan" &&
|
session.protocol !== "leviathan" &&
|
||||||
|
session.protocol !== "github" &&
|
||||||
this.app.state.preferences.confirmOnDisconnect
|
this.app.state.preferences.confirmOnDisconnect
|
||||||
) {
|
) {
|
||||||
const ok = await this._confirmClose(sessionId);
|
const ok = await this._confirmClose(sessionId);
|
||||||
@@ -190,6 +191,7 @@
|
|||||||
this.app.terminals.destroyTerminal(sessionId);
|
this.app.terminals.destroyTerminal(sessionId);
|
||||||
this.app.rawViewer.destroyViewer(sessionId);
|
this.app.rawViewer.destroyViewer(sessionId);
|
||||||
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
|
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
|
||||||
|
window.NexusTerminal.IRCViewer.destroyViewer(sessionId);
|
||||||
this.app.leviathan?.destroyPanel(sessionId);
|
this.app.leviathan?.destroyPanel(sessionId);
|
||||||
this.removeTab(sessionId);
|
this.removeTab(sessionId);
|
||||||
this.app.state.removeSession(sessionId);
|
this.app.state.removeSession(sessionId);
|
||||||
|
|||||||
@@ -290,6 +290,8 @@
|
|||||||
return "proto-github";
|
return "proto-github";
|
||||||
case "vnc":
|
case "vnc":
|
||||||
return "proto-vnc";
|
return "proto-vnc";
|
||||||
|
case "irc":
|
||||||
|
return "proto-irc";
|
||||||
case "local":
|
case "local":
|
||||||
return "proto-local";
|
return "proto-local";
|
||||||
default:
|
default:
|
||||||
|
|||||||
Vendored
+12
@@ -80,6 +80,18 @@ export function GetVNCWebSocketURL(arg1:string):Promise<string>;
|
|||||||
|
|
||||||
export function HasTOTPSecret(arg1:string):Promise<boolean>;
|
export function HasTOTPSecret(arg1:string):Promise<boolean>;
|
||||||
|
|
||||||
|
export function IRCHandleInput(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||||
|
|
||||||
|
export function IRCJoin(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function IRCListRooms(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function IRCPart(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function IRCRooms(arg1:string):Promise<Array<Record<string, any>>>;
|
||||||
|
|
||||||
|
export function IRCUsers(arg1:string,arg2:string):Promise<Array<string>>;
|
||||||
|
|
||||||
export function ImportProfiles(arg1:string,arg2:string):Promise<number>;
|
export function ImportProfiles(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
export function ImportProfilesEncrypted(arg1:string,arg2:string):Promise<number>;
|
export function ImportProfilesEncrypted(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|||||||
@@ -154,6 +154,30 @@ export function HasTOTPSecret(arg1) {
|
|||||||
return window['go']['api']['Service']['HasTOTPSecret'](arg1);
|
return window['go']['api']['Service']['HasTOTPSecret'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IRCHandleInput(arg1, arg2, arg3) {
|
||||||
|
return window['go']['api']['Service']['IRCHandleInput'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IRCJoin(arg1, arg2) {
|
||||||
|
return window['go']['api']['Service']['IRCJoin'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IRCListRooms(arg1) {
|
||||||
|
return window['go']['api']['Service']['IRCListRooms'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IRCPart(arg1, arg2) {
|
||||||
|
return window['go']['api']['Service']['IRCPart'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IRCRooms(arg1) {
|
||||||
|
return window['go']['api']['Service']['IRCRooms'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IRCUsers(arg1, arg2) {
|
||||||
|
return window['go']['api']['Service']['IRCUsers'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function ImportProfiles(arg1, arg2) {
|
export function ImportProfiles(arg1, arg2) {
|
||||||
return window['go']['api']['Service']['ImportProfiles'](arg1, arg2);
|
return window['go']['api']['Service']['ImportProfiles'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,54 @@ export namespace models {
|
|||||||
this.updatedAt = source["updatedAt"];
|
this.updatedAt = source["updatedAt"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class IRCConfig {
|
||||||
|
nick: string;
|
||||||
|
realName: string;
|
||||||
|
useTLS: boolean;
|
||||||
|
serverPasswordRequired: boolean;
|
||||||
|
serverPasswordMode: string;
|
||||||
|
nickServIdentify: boolean;
|
||||||
|
nickServAccount: string;
|
||||||
|
nickServMode: string;
|
||||||
|
autoJoinRooms: string[];
|
||||||
|
sshTunnel: boolean;
|
||||||
|
sshHost: string;
|
||||||
|
sshPort: number;
|
||||||
|
sshUsername: string;
|
||||||
|
sshAuthMethod: string;
|
||||||
|
sshSecretMode: string;
|
||||||
|
sshPrivateKeyPath: string;
|
||||||
|
sshAgentAuth: boolean;
|
||||||
|
autoReconnect: boolean;
|
||||||
|
maxReconnectTries: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new IRCConfig(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.nick = source["nick"];
|
||||||
|
this.realName = source["realName"];
|
||||||
|
this.useTLS = source["useTLS"];
|
||||||
|
this.serverPasswordRequired = source["serverPasswordRequired"];
|
||||||
|
this.serverPasswordMode = source["serverPasswordMode"];
|
||||||
|
this.nickServIdentify = source["nickServIdentify"];
|
||||||
|
this.nickServAccount = source["nickServAccount"];
|
||||||
|
this.nickServMode = source["nickServMode"];
|
||||||
|
this.autoJoinRooms = source["autoJoinRooms"];
|
||||||
|
this.sshTunnel = source["sshTunnel"];
|
||||||
|
this.sshHost = source["sshHost"];
|
||||||
|
this.sshPort = source["sshPort"];
|
||||||
|
this.sshUsername = source["sshUsername"];
|
||||||
|
this.sshAuthMethod = source["sshAuthMethod"];
|
||||||
|
this.sshSecretMode = source["sshSecretMode"];
|
||||||
|
this.sshPrivateKeyPath = source["sshPrivateKeyPath"];
|
||||||
|
this.sshAgentAuth = source["sshAgentAuth"];
|
||||||
|
this.autoReconnect = source["autoReconnect"];
|
||||||
|
this.maxReconnectTries = source["maxReconnectTries"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class JumpHost {
|
export class JumpHost {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -520,6 +568,7 @@ export namespace models {
|
|||||||
raw?: RAWConfig;
|
raw?: RAWConfig;
|
||||||
github?: GitHubConfig;
|
github?: GitHubConfig;
|
||||||
vnc?: VNCConfig;
|
vnc?: VNCConfig;
|
||||||
|
irc?: IRCConfig;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Profile(source);
|
return new Profile(source);
|
||||||
@@ -553,6 +602,7 @@ export namespace models {
|
|||||||
this.raw = this.convertValues(source["raw"], RAWConfig);
|
this.raw = this.convertValues(source["raw"], RAWConfig);
|
||||||
this.github = this.convertValues(source["github"], GitHubConfig);
|
this.github = this.convertValues(source["github"], GitHubConfig);
|
||||||
this.vnc = this.convertValues(source["vnc"], VNCConfig);
|
this.vnc = this.convertValues(source["vnc"], VNCConfig);
|
||||||
|
this.irc = this.convertValues(source["irc"], IRCConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ require (
|
|||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/hajimehoshi/go-mp3 v0.3.4
|
github.com/hajimehoshi/go-mp3 v0.3.4
|
||||||
github.com/klauspost/compress v1.17.11
|
github.com/klauspost/compress v1.17.11
|
||||||
|
github.com/lrstanley/girc v1.1.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.44
|
github.com/mattn/go-sqlite3 v1.14.44
|
||||||
github.com/pkg/sftp v1.13.10
|
github.com/pkg/sftp v1.13.10
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
|
|||||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||||
|
github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk=
|
||||||
|
github.com/lrstanley/girc v1.1.1/go.mod h1:lgrnhcF8bg/Bd5HA5DOb4Z+uGqUqGnp4skr+J2GwVgI=
|
||||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
github.com/matryer/is v1.4.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 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
|
|||||||
+82
-6
@@ -49,6 +49,7 @@ const (
|
|||||||
maxSFTPUploadChunkBytes = 1024 * 1024
|
maxSFTPUploadChunkBytes = 1024 * 1024
|
||||||
maxStreamingSFTPUploadSize = int64(2 * 1024 * 1024 * 1024)
|
maxStreamingSFTPUploadSize = int64(2 * 1024 * 1024 * 1024)
|
||||||
maxSSHPublicKeyBytes = 64 * 1024
|
maxSSHPublicKeyBytes = 64 * 1024
|
||||||
|
sshKeygenTimeout = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service is the main API surface exposed to the frontend via Wails bindings.
|
// Service is the main API surface exposed to the frontend via Wails bindings.
|
||||||
@@ -392,6 +393,7 @@ func (s *Service) ConnectQuick(profile models.Profile) (models.SessionInfo, erro
|
|||||||
if profile.ID == "" {
|
if profile.ID == "" {
|
||||||
profile.ID = uuid.New().String()
|
profile.ID = uuid.New().String()
|
||||||
}
|
}
|
||||||
|
normaliseProfileForSave(&profile)
|
||||||
sessionID, err := s.sessions.Connect(&profile, s.promptSecret)
|
sessionID, err := s.sessions.Connect(&profile, s.promptSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.SessionInfo{}, err
|
return models.SessionInfo{}, err
|
||||||
@@ -526,6 +528,52 @@ func (s *Service) SendBreak(sessionID string) error {
|
|||||||
return s.sessions.SendBreak(sessionID)
|
return s.sessions.SendBreak(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// IRC-specific
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// IRCHandleInput sends an IRC composer line, including slash commands.
|
||||||
|
func (s *Service) IRCHandleInput(sessionID string, target string, input string) error {
|
||||||
|
return s.sessions.IRCHandleInput(sessionID, target, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCJoin joins an IRC room.
|
||||||
|
func (s *Service) IRCJoin(sessionID string, room string) error {
|
||||||
|
return s.sessions.IRCJoin(sessionID, room)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCPart leaves an IRC room.
|
||||||
|
func (s *Service) IRCPart(sessionID string, room string) error {
|
||||||
|
return s.sessions.IRCPart(sessionID, room)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCListRooms requests the server room list. Results are emitted as events.
|
||||||
|
func (s *Service) IRCListRooms(sessionID string) error {
|
||||||
|
return s.sessions.IRCListRooms(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCRooms returns currently joined IRC rooms.
|
||||||
|
func (s *Service) IRCRooms(sessionID string) ([]map[string]interface{}, error) {
|
||||||
|
rooms, err := s.sessions.IRCRooms(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]map[string]interface{}, len(rooms))
|
||||||
|
for i, room := range rooms {
|
||||||
|
result[i] = map[string]interface{}{
|
||||||
|
"name": room.Name,
|
||||||
|
"topic": room.Topic,
|
||||||
|
"users": room.Users,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCUsers returns the known users in a joined IRC room.
|
||||||
|
func (s *Service) IRCUsers(sessionID string, room string) ([]string, error) {
|
||||||
|
return s.sessions.IRCUsers(sessionID, room)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Secret store
|
// Secret store
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1809,6 +1857,10 @@ func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphra
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
keyType, bits, err = validateSSHKeyGenerationOptions(keyType, bits)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
sshDir := filepath.Dir(keyPath)
|
sshDir := filepath.Dir(keyPath)
|
||||||
|
|
||||||
// Ensure .ssh directory exists
|
// Ensure .ssh directory exists
|
||||||
@@ -1821,10 +1873,6 @@ func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphra
|
|||||||
return fmt.Errorf("key %q already exists", name)
|
return fmt.Errorf("key %q already exists", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
if keyType == "" {
|
|
||||||
keyType = "ed25519"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build ssh-keygen arguments
|
// Build ssh-keygen arguments
|
||||||
args := []string{"-t", keyType, "-f", keyPath, "-C", name + "@nexus-terminal"}
|
args := []string{"-t", keyType, "-f", keyPath, "-C", name + "@nexus-terminal"}
|
||||||
if passphrase != "" {
|
if passphrase != "" {
|
||||||
@@ -1837,10 +1885,15 @@ func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphra
|
|||||||
}
|
}
|
||||||
|
|
||||||
// #nosec G204 — arguments are sanitised above
|
// #nosec G204 — arguments are sanitised above
|
||||||
cmd := exec.Command("ssh-keygen", args...)
|
ctx, cancel := context.WithTimeout(context.Background(), sshKeygenTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, "ssh-keygen", args...)
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return fmt.Errorf("ssh-keygen timed out after %s", sshKeygenTimeout)
|
||||||
|
}
|
||||||
return fmt.Errorf("ssh-keygen failed: %s", stderr.String())
|
return fmt.Errorf("ssh-keygen failed: %s", stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1929,12 +1982,35 @@ func safeSFTPUploadName(fileName string) (string, error) {
|
|||||||
}
|
}
|
||||||
name = strings.ReplaceAll(name, "\\", "/")
|
name = strings.ReplaceAll(name, "\\", "/")
|
||||||
safeName := path.Base(name)
|
safeName := path.Base(name)
|
||||||
if safeName == "" || safeName == "." || safeName == "/" || strings.ContainsRune(safeName, 0) {
|
if safeName == "" || safeName == "." || safeName == ".." || safeName == "/" || strings.ContainsRune(safeName, 0) {
|
||||||
return "", fmt.Errorf("invalid file name")
|
return "", fmt.Errorf("invalid file name")
|
||||||
}
|
}
|
||||||
return safeName, nil
|
return safeName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateSSHKeyGenerationOptions(keyType string, bits int) (string, int, error) {
|
||||||
|
keyType = strings.ToLower(strings.TrimSpace(keyType))
|
||||||
|
if keyType == "" {
|
||||||
|
keyType = "ed25519"
|
||||||
|
}
|
||||||
|
switch keyType {
|
||||||
|
case "ed25519", "ecdsa":
|
||||||
|
return keyType, bits, nil
|
||||||
|
case "rsa":
|
||||||
|
if bits == 0 {
|
||||||
|
bits = 4096
|
||||||
|
}
|
||||||
|
switch bits {
|
||||||
|
case 2048, 3072, 4096:
|
||||||
|
return keyType, bits, nil
|
||||||
|
default:
|
||||||
|
return "", 0, fmt.Errorf("unsupported RSA key size %d (use 2048, 3072, or 4096)", bits)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", 0, fmt.Errorf("unsupported SSH key type %q", keyType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) lookupSFTPUpload(uploadID string) (*sftpUpload, error) {
|
func (s *Service) lookupSFTPUpload(uploadID string) (*sftpUpload, error) {
|
||||||
s.uploadMu.Lock()
|
s.uploadMu.Lock()
|
||||||
defer s.uploadMu.Unlock()
|
defer s.uploadMu.Unlock()
|
||||||
|
|||||||
@@ -138,6 +138,44 @@ func TestSafeSFTPUploadNamePreservesHTMLCharacters(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSafeSFTPUploadNameRejectsParentDirectory(t *testing.T) {
|
||||||
|
for _, name := range []string{"..", "folder/..", `folder\..`} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if _, err := safeSFTPUploadName(name); err == nil {
|
||||||
|
t.Fatal("expected parent directory upload name to be rejected")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSSHKeyGenerationOptions(t *testing.T) {
|
||||||
|
keyType, bits, err := validateSSHKeyGenerationOptions("", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("default options error = %v", err)
|
||||||
|
}
|
||||||
|
if keyType != "ed25519" {
|
||||||
|
t.Fatalf("default key type = %q, want ed25519", keyType)
|
||||||
|
}
|
||||||
|
if bits != 0 {
|
||||||
|
t.Fatalf("default bits = %d, want 0", bits)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyType, bits, err = validateSSHKeyGenerationOptions("rsa", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa default options error = %v", err)
|
||||||
|
}
|
||||||
|
if keyType != "rsa" || bits != 4096 {
|
||||||
|
t.Fatalf("rsa defaults = %q/%d, want rsa/4096", keyType, bits)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := validateSSHKeyGenerationOptions("rsa", 999999); err == nil {
|
||||||
|
t.Fatal("expected oversized RSA key size to be rejected")
|
||||||
|
}
|
||||||
|
if _, _, err := validateSSHKeyGenerationOptions("dsa", 0); err == nil {
|
||||||
|
t.Fatal("expected unsupported key type to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestImportProfilesNormalizesSparseRAWProfile(t *testing.T) {
|
func TestImportProfilesNormalizesSparseRAWProfile(t *testing.T) {
|
||||||
svc := newTestService(t)
|
svc := newTestService(t)
|
||||||
data := `[
|
data := `[
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -22,6 +23,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
maxGitHubJSONBodyBytes = 4 << 20
|
maxGitHubJSONBodyBytes = 4 << 20
|
||||||
maxGitHubErrorBodyBytes = 64 << 10
|
maxGitHubErrorBodyBytes = 64 << 10
|
||||||
|
maxGitHubArchiveBytes = int64(2 << 30)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client wraps the GitHub REST API for repository listing.
|
// Client wraps the GitHub REST API for repository listing.
|
||||||
@@ -190,9 +192,16 @@ func (c *Client) fetchRepos(ctx context.Context, progress func(done int, total i
|
|||||||
|
|
||||||
// FetchLatestCommit returns the committer name and commit time for a repo/ref.
|
// FetchLatestCommit returns the committer name and commit time for a repo/ref.
|
||||||
func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref string) (string, string, error) {
|
func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref string) (string, string, error) {
|
||||||
url := fmt.Sprintf("%s/repos/%s/commits/%s", c.baseURL, fullName, ref)
|
|
||||||
if ref == "" {
|
if ref == "" {
|
||||||
url = fmt.Sprintf("%s/repos/%s/commits/HEAD", c.baseURL, fullName)
|
ref = "HEAD"
|
||||||
|
}
|
||||||
|
owner, repo, err := splitRepoFullName(fullName)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
url, err := c.endpointURL("repos", owner, repo, "commits", ref)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
@@ -252,12 +261,17 @@ func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref str
|
|||||||
|
|
||||||
// DownloadRepoArchive downloads a repository zip archive to the given path.
|
// DownloadRepoArchive downloads a repository zip archive to the given path.
|
||||||
func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref string, destPath string) error {
|
func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref string, destPath string) error {
|
||||||
if fullName == "" {
|
owner, repo, err := splitRepoFullName(fullName)
|
||||||
return fmt.Errorf("repository name cannot be empty")
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("%s/repos/%s/zipball", c.baseURL, fullName)
|
segments := []string{"repos", owner, repo, "zipball"}
|
||||||
if ref != "" {
|
if ref != "" {
|
||||||
url = url + "/" + ref
|
segments = append(segments, ref)
|
||||||
|
}
|
||||||
|
url, err := c.endpointURL(segments...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
@@ -280,6 +294,9 @@ func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref s
|
|||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, readGitHubErrorBody(resp.Body))
|
return fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, readGitHubErrorBody(resp.Body))
|
||||||
}
|
}
|
||||||
|
if resp.ContentLength > maxGitHubArchiveBytes {
|
||||||
|
return fmt.Errorf("archive is too large: %d bytes exceeds %d bytes", resp.ContentLength, maxGitHubArchiveBytes)
|
||||||
|
}
|
||||||
|
|
||||||
file, err := os.Create(destPath)
|
file, err := os.Create(destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -287,7 +304,7 @@ func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref s
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
if _, err := io.Copy(file, resp.Body); err != nil {
|
if _, err := copyLimitedArchive(file, resp.Body, maxGitHubArchiveBytes); err != nil {
|
||||||
return fmt.Errorf("writing archive: %w", err)
|
return fmt.Errorf("writing archive: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -345,3 +362,47 @@ func readGitHubErrorBody(r io.Reader) string {
|
|||||||
}
|
}
|
||||||
return strings.TrimSpace(string(body))
|
return strings.TrimSpace(string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func splitRepoFullName(fullName string) (string, string, error) {
|
||||||
|
parts := strings.Split(strings.TrimSpace(fullName), "/")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", "", fmt.Errorf("repository name must be in owner/repo format")
|
||||||
|
}
|
||||||
|
owner := strings.TrimSpace(parts[0])
|
||||||
|
repo := strings.TrimSpace(parts[1])
|
||||||
|
if owner == "" || repo == "" {
|
||||||
|
return "", "", fmt.Errorf("repository name must be in owner/repo format")
|
||||||
|
}
|
||||||
|
return owner, repo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) endpointURL(segments ...string) (string, error) {
|
||||||
|
u, err := url.Parse(c.baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid GitHub base URL: %w", err)
|
||||||
|
}
|
||||||
|
plainPath := strings.TrimRight(u.Path, "/")
|
||||||
|
escapedPath := strings.TrimRight(u.EscapedPath(), "/")
|
||||||
|
for _, segment := range segments {
|
||||||
|
if segment == "" {
|
||||||
|
return "", fmt.Errorf("empty GitHub API path segment")
|
||||||
|
}
|
||||||
|
plainPath += "/" + segment
|
||||||
|
escapedPath += "/" + url.PathEscape(segment)
|
||||||
|
}
|
||||||
|
u.Path = plainPath
|
||||||
|
u.RawPath = escapedPath
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyLimitedArchive(dst io.Writer, src io.Reader, maxBytes int64) (int64, error) {
|
||||||
|
limited := &io.LimitedReader{R: src, N: maxBytes + 1}
|
||||||
|
n, err := io.Copy(dst, limited)
|
||||||
|
if err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
if n > maxBytes {
|
||||||
|
return n, fmt.Errorf("archive exceeds %d bytes", maxBytes)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package githubclient
|
package githubclient
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -24,3 +26,50 @@ func TestReadLimitedGitHubBodyAllowsResponseAtLimit(t *testing.T) {
|
|||||||
t.Fatalf("body = %q, want abc", string(got))
|
t.Fatalf("body = %q, want abc", string(got))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEndpointURLEscapesRepoPathSegments(t *testing.T) {
|
||||||
|
client := New("https://ghe.example.com/api/v3", "")
|
||||||
|
|
||||||
|
got, err := client.endpointURL("repos", "owner", "repo", "commits", "feature/a b")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("endpointURL() error = %v", err)
|
||||||
|
}
|
||||||
|
want := "https://ghe.example.com/api/v3/repos/owner/repo/commits/feature%2Fa%20b"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("endpointURL() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitRepoFullNameRejectsExtraSegments(t *testing.T) {
|
||||||
|
if _, _, err := splitRepoFullName("owner/repo/../../user"); err == nil {
|
||||||
|
t.Fatal("expected malformed repository name to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyLimitedArchiveRejectsOversizedStream(t *testing.T) {
|
||||||
|
var dst bytes.Buffer
|
||||||
|
n, err := copyLimitedArchive(&dst, strings.NewReader("abcd"), 3)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected oversized archive stream to be rejected")
|
||||||
|
}
|
||||||
|
if n != 4 {
|
||||||
|
t.Fatalf("copied = %d, want 4", n)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "archive exceeds") {
|
||||||
|
t.Fatalf("error = %q, want archive exceeds", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyLimitedArchiveAllowsStreamAtLimit(t *testing.T) {
|
||||||
|
var dst bytes.Buffer
|
||||||
|
n, err := copyLimitedArchive(&dst, strings.NewReader("abc"), 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("copyLimitedArchive() error = %v", err)
|
||||||
|
}
|
||||||
|
if n != 3 {
|
||||||
|
t.Fatalf("copied = %d, want 3", n)
|
||||||
|
}
|
||||||
|
if got, err := io.ReadAll(&dst); err != nil || string(got) != "abc" {
|
||||||
|
t.Fatalf("dst = %q, err = %v; want abc", string(got), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,767 @@
|
|||||||
|
// Package ircclient wraps a full IRC client with Nexus-specific event and
|
||||||
|
// command handling, including optional SSH tunnelling.
|
||||||
|
package ircclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lrstanley/girc"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/crypto/ssh/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ServerTarget = "server"
|
||||||
|
|
||||||
|
const maxRoomListEntries = 2000
|
||||||
|
|
||||||
|
// Config describes an IRC connection.
|
||||||
|
type Config struct {
|
||||||
|
Server string
|
||||||
|
Port int
|
||||||
|
Nick string
|
||||||
|
User string
|
||||||
|
RealName string
|
||||||
|
UseTLS bool
|
||||||
|
ServerPass string
|
||||||
|
AutoJoinRooms []string
|
||||||
|
|
||||||
|
NickServIdentify bool
|
||||||
|
NickServAccount string
|
||||||
|
NickServPassword string
|
||||||
|
|
||||||
|
SSHTunnel bool
|
||||||
|
SSHHost string
|
||||||
|
SSHPort int
|
||||||
|
SSHUsername string
|
||||||
|
SSHAuthMethod string
|
||||||
|
SSHPassword string
|
||||||
|
SSHPrivateKey []byte
|
||||||
|
SSHPassphrase string
|
||||||
|
SSHAgentAuth bool
|
||||||
|
SSHAgentConn net.Conn
|
||||||
|
SSHHostKeyCallback ssh.HostKeyCallback
|
||||||
|
|
||||||
|
ConnectTimeout time.Duration
|
||||||
|
OnEvent func(Event)
|
||||||
|
OnError func(error)
|
||||||
|
OnClose func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event is a typed IRC event suitable for JSON delivery to the frontend.
|
||||||
|
type Event struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Target string `json:"target,omitempty"`
|
||||||
|
From string `json:"from,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
Rooms []RoomInfo `json:"rooms,omitempty"`
|
||||||
|
Users []string `json:"users,omitempty"`
|
||||||
|
Topic string `json:"topic,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomInfo describes a joined or listed IRC room.
|
||||||
|
type RoomInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Topic string `json:"topic,omitempty"`
|
||||||
|
Users int `json:"users,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client owns a girc client and optional SSH tunnel.
|
||||||
|
type Client struct {
|
||||||
|
cfg Config
|
||||||
|
client *girc.Client
|
||||||
|
sshClient *ssh.Client
|
||||||
|
agentConn net.Conn
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
connected bool
|
||||||
|
closing bool
|
||||||
|
listRooms []RoomInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates an IRC client wrapper.
|
||||||
|
func New(cfg Config) *Client {
|
||||||
|
if cfg.ConnectTimeout <= 0 {
|
||||||
|
cfg.ConnectTimeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
if cfg.User == "" {
|
||||||
|
cfg.User = cfg.Nick
|
||||||
|
}
|
||||||
|
if cfg.RealName == "" {
|
||||||
|
cfg.RealName = cfg.Nick
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Client{cfg: cfg}
|
||||||
|
c.client = girc.New(girc.Config{
|
||||||
|
Server: cfg.Server,
|
||||||
|
ServerPass: cfg.ServerPass,
|
||||||
|
Port: cfg.Port,
|
||||||
|
Nick: cfg.Nick,
|
||||||
|
User: cfg.User,
|
||||||
|
Name: cfg.RealName,
|
||||||
|
SSL: cfg.UseTLS && !cfg.SSHTunnel,
|
||||||
|
TLSConfig: &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
ServerName: cfg.Server,
|
||||||
|
},
|
||||||
|
AllowFlood: false,
|
||||||
|
})
|
||||||
|
c.registerHandlers()
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect starts the IRC connection and returns once registration succeeds.
|
||||||
|
func (c *Client) Connect() error {
|
||||||
|
connectedCh := make(chan struct{}, 1)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
var connectedOnce sync.Once
|
||||||
|
|
||||||
|
c.client.Handlers.Add(girc.CONNECTED, func(gc *girc.Client, e girc.Event) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.connected = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
c.emit(Event{
|
||||||
|
Type: "system",
|
||||||
|
Target: ServerTarget,
|
||||||
|
Text: fmt.Sprintf("Connected to %s", gc.Server()),
|
||||||
|
Timestamp: eventTime(e),
|
||||||
|
})
|
||||||
|
|
||||||
|
c.identifyWithNickServ(gc)
|
||||||
|
if len(c.cfg.AutoJoinRooms) > 0 {
|
||||||
|
gc.Cmd.Join(c.cfg.AutoJoinRooms...)
|
||||||
|
}
|
||||||
|
c.emitRooms()
|
||||||
|
|
||||||
|
connectedOnce.Do(func() {
|
||||||
|
select {
|
||||||
|
case connectedCh <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
var err error
|
||||||
|
if c.cfg.SSHTunnel {
|
||||||
|
dialer, dialerErr := c.newSSHDialer()
|
||||||
|
if dialerErr != nil {
|
||||||
|
select {
|
||||||
|
case errCh <- dialerErr:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = c.client.DialerConnect(dialer)
|
||||||
|
} else {
|
||||||
|
err = c.client.Connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
wasClosing := c.closing
|
||||||
|
wasConnected := c.connected
|
||||||
|
c.connected = false
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if err != nil && !wasClosing {
|
||||||
|
c.emit(Event{
|
||||||
|
Type: "error",
|
||||||
|
Target: ServerTarget,
|
||||||
|
Text: err.Error(),
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
})
|
||||||
|
if c.cfg.OnError != nil {
|
||||||
|
c.cfg.OnError(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case errCh <- err:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wasConnected && !wasClosing && c.cfg.OnClose != nil {
|
||||||
|
c.cfg.OnClose()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-connectedCh:
|
||||||
|
return nil
|
||||||
|
case err := <-errCh:
|
||||||
|
return err
|
||||||
|
case <-time.After(c.cfg.ConnectTimeout):
|
||||||
|
c.Close()
|
||||||
|
return fmt.Errorf("IRC connection timed out after %s", c.cfg.ConnectTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close disconnects from IRC and closes any SSH tunnel resources.
|
||||||
|
func (c *Client) Close() {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.closing {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.closing = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.client != nil {
|
||||||
|
c.client.Quit("Nexus Terminal disconnected")
|
||||||
|
c.client.Close()
|
||||||
|
}
|
||||||
|
if c.sshClient != nil {
|
||||||
|
_ = c.sshClient.Close()
|
||||||
|
c.sshClient = nil
|
||||||
|
}
|
||||||
|
if c.agentConn != nil {
|
||||||
|
_ = c.agentConn.Close()
|
||||||
|
c.agentConn = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleInput parses a user-entered IRC composer line.
|
||||||
|
func (c *Client) HandleInput(target string, input string) error {
|
||||||
|
text := strings.TrimSpace(input)
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if target == "" {
|
||||||
|
target = ServerTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(text, "/") || text == "/" {
|
||||||
|
if target == ServerTarget {
|
||||||
|
return fmt.Errorf("select a room or use /msg <nick> <message>")
|
||||||
|
}
|
||||||
|
c.client.Cmd.Message(target, text)
|
||||||
|
c.emitLocalMessage("message", target, text)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
commandLine := strings.TrimSpace(strings.TrimPrefix(text, "/"))
|
||||||
|
if commandLine == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fields := strings.Fields(commandLine)
|
||||||
|
cmd := strings.ToLower(fields[0])
|
||||||
|
args := strings.TrimSpace(commandLine[len(fields[0]):])
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "me", "action":
|
||||||
|
if target == ServerTarget {
|
||||||
|
return fmt.Errorf("/me requires an active room or private tab")
|
||||||
|
}
|
||||||
|
if args == "" {
|
||||||
|
return fmt.Errorf("/me requires an action")
|
||||||
|
}
|
||||||
|
c.client.Cmd.Action(target, args)
|
||||||
|
c.emitLocalMessage("action", target, args)
|
||||||
|
case "msg", "query":
|
||||||
|
recipient, body, ok := splitFirstArg(args)
|
||||||
|
if !ok || body == "" {
|
||||||
|
return fmt.Errorf("/msg requires a target and message")
|
||||||
|
}
|
||||||
|
c.client.Cmd.Message(recipient, body)
|
||||||
|
c.emitLocalMessage("message", recipient, body)
|
||||||
|
case "notice":
|
||||||
|
recipient, body, ok := splitFirstArg(args)
|
||||||
|
if !ok || body == "" {
|
||||||
|
return fmt.Errorf("/notice requires a target and message")
|
||||||
|
}
|
||||||
|
c.client.Cmd.Notice(recipient, body)
|
||||||
|
c.emitLocalMessage("notice", recipient, body)
|
||||||
|
case "join", "j":
|
||||||
|
room, key, ok := splitFirstArg(args)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("/join requires a room")
|
||||||
|
}
|
||||||
|
if key != "" {
|
||||||
|
c.client.Cmd.JoinKey(room, key)
|
||||||
|
} else {
|
||||||
|
c.client.Cmd.Join(room)
|
||||||
|
}
|
||||||
|
case "part", "leave":
|
||||||
|
room, msg, _ := splitFirstArg(args)
|
||||||
|
if room == "" {
|
||||||
|
room = target
|
||||||
|
}
|
||||||
|
if room == "" || room == ServerTarget {
|
||||||
|
return fmt.Errorf("/part requires a room")
|
||||||
|
}
|
||||||
|
if msg != "" {
|
||||||
|
c.client.Cmd.PartMessage(room, msg)
|
||||||
|
} else {
|
||||||
|
c.client.Cmd.Part(room)
|
||||||
|
}
|
||||||
|
case "quit":
|
||||||
|
if args == "" {
|
||||||
|
args = "Nexus Terminal disconnected"
|
||||||
|
}
|
||||||
|
c.client.Quit(args)
|
||||||
|
c.client.Close()
|
||||||
|
case "nick":
|
||||||
|
if args == "" {
|
||||||
|
return fmt.Errorf("/nick requires a nickname")
|
||||||
|
}
|
||||||
|
c.client.Cmd.Nick(strings.Fields(args)[0])
|
||||||
|
case "topic":
|
||||||
|
room, topic, _ := splitFirstArg(args)
|
||||||
|
if room == "" {
|
||||||
|
room = target
|
||||||
|
}
|
||||||
|
if room == "" || room == ServerTarget {
|
||||||
|
return fmt.Errorf("/topic requires a room")
|
||||||
|
}
|
||||||
|
c.client.Cmd.Topic(room, topic)
|
||||||
|
case "list":
|
||||||
|
if args == "" {
|
||||||
|
c.client.Cmd.List()
|
||||||
|
} else {
|
||||||
|
c.client.Cmd.List(strings.Fields(args)...)
|
||||||
|
}
|
||||||
|
case "names":
|
||||||
|
room := args
|
||||||
|
if room == "" && target != ServerTarget {
|
||||||
|
room = target
|
||||||
|
}
|
||||||
|
if room == "" {
|
||||||
|
return c.client.Cmd.SendRaw("NAMES")
|
||||||
|
}
|
||||||
|
return c.client.Cmd.SendRaw("NAMES " + room)
|
||||||
|
case "whois":
|
||||||
|
if args == "" {
|
||||||
|
return fmt.Errorf("/whois requires a nickname")
|
||||||
|
}
|
||||||
|
c.client.Cmd.Whois(strings.Fields(args)...)
|
||||||
|
case "who":
|
||||||
|
if args == "" {
|
||||||
|
if target == ServerTarget {
|
||||||
|
return fmt.Errorf("/who requires a target")
|
||||||
|
}
|
||||||
|
args = target
|
||||||
|
}
|
||||||
|
c.client.Cmd.Who(strings.Fields(args)...)
|
||||||
|
case "raw", "quote":
|
||||||
|
if args == "" {
|
||||||
|
return fmt.Errorf("/raw requires an IRC command")
|
||||||
|
}
|
||||||
|
return c.client.Cmd.SendRawNoSplit(args)
|
||||||
|
case "help":
|
||||||
|
c.emit(Event{
|
||||||
|
Type: "system",
|
||||||
|
Target: target,
|
||||||
|
Text: "Commands: /join, /part, /msg, /notice, /me, /nick, /topic, /list, /names, /whois, /who, /raw, /quit",
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
raw := strings.ToUpper(fields[0])
|
||||||
|
if args != "" {
|
||||||
|
raw += " " + args
|
||||||
|
}
|
||||||
|
return c.client.Cmd.SendRawNoSplit(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join requests a room join.
|
||||||
|
func (c *Client) Join(room string) error {
|
||||||
|
room = strings.TrimSpace(room)
|
||||||
|
if room == "" {
|
||||||
|
return fmt.Errorf("room is required")
|
||||||
|
}
|
||||||
|
c.client.Cmd.Join(room)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Part leaves a room.
|
||||||
|
func (c *Client) Part(room string) error {
|
||||||
|
room = strings.TrimSpace(room)
|
||||||
|
if room == "" {
|
||||||
|
return fmt.Errorf("room is required")
|
||||||
|
}
|
||||||
|
c.client.Cmd.Part(room)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRooms requests a server-side room list. Results arrive as events.
|
||||||
|
func (c *Client) ListRooms() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.listRooms = nil
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.client.Cmd.List()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rooms returns currently joined rooms.
|
||||||
|
func (c *Client) Rooms() []RoomInfo {
|
||||||
|
if c.client == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
channels := c.client.Channels()
|
||||||
|
rooms := make([]RoomInfo, 0, len(channels))
|
||||||
|
for _, ch := range channels {
|
||||||
|
if ch == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rooms = append(rooms, RoomInfo{
|
||||||
|
Name: ch.Name,
|
||||||
|
Topic: ch.Topic,
|
||||||
|
Users: len(ch.UserList),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(rooms, func(i, j int) bool {
|
||||||
|
return strings.ToLower(rooms[i].Name) < strings.ToLower(rooms[j].Name)
|
||||||
|
})
|
||||||
|
return rooms
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users returns known users in a room.
|
||||||
|
func (c *Client) Users(room string) []string {
|
||||||
|
if c.client == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ch := c.client.LookupChannel(room)
|
||||||
|
if ch == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
users := append([]string{}, ch.UserList...)
|
||||||
|
sort.Slice(users, func(i, j int) bool {
|
||||||
|
return strings.TrimLeft(users[i], "~&@%+") < strings.TrimLeft(users[j], "~&@%+")
|
||||||
|
})
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) registerHandlers() {
|
||||||
|
c.client.Handlers.Add(girc.PRIVMSG, c.handleMessage)
|
||||||
|
c.client.Handlers.Add(girc.NOTICE, c.handleNotice)
|
||||||
|
c.client.Handlers.Add(girc.JOIN, c.handleJoin)
|
||||||
|
c.client.Handlers.Add(girc.PART, c.handlePart)
|
||||||
|
c.client.Handlers.Add(girc.QUIT, c.handleQuit)
|
||||||
|
c.client.Handlers.Add(girc.NICK, c.handleNick)
|
||||||
|
c.client.Handlers.Add(girc.TOPIC, c.handleTopic)
|
||||||
|
c.client.Handlers.Add(girc.RPL_TOPIC, c.handleTopic)
|
||||||
|
c.client.Handlers.Add(girc.RPL_NAMREPLY, c.handleNames)
|
||||||
|
c.client.Handlers.Add(girc.RPL_ENDOFNAMES, c.handleEndOfNames)
|
||||||
|
c.client.Handlers.Add(girc.RPL_LIST, c.handleList)
|
||||||
|
c.client.Handlers.Add(girc.RPL_LISTEND, c.handleListEnd)
|
||||||
|
c.client.Handlers.Add(girc.ERROR, func(_ *girc.Client, e girc.Event) {
|
||||||
|
c.emit(Event{Type: "error", Target: ServerTarget, Text: e.Last(), Timestamp: eventTime(e)})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleMessage(gc *girc.Client, e girc.Event) {
|
||||||
|
if len(e.Params) < 2 || e.Source == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := e.Params[0]
|
||||||
|
if strings.EqualFold(target, gc.GetNick()) {
|
||||||
|
target = e.Source.Name
|
||||||
|
}
|
||||||
|
eventType := "message"
|
||||||
|
text := e.Last()
|
||||||
|
if e.IsAction() {
|
||||||
|
eventType = "action"
|
||||||
|
text = e.StripAction()
|
||||||
|
}
|
||||||
|
c.emit(Event{Type: eventType, Target: target, From: e.Source.Name, Text: text, Timestamp: eventTime(e)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleNotice(gc *girc.Client, e girc.Event) {
|
||||||
|
target := ServerTarget
|
||||||
|
if len(e.Params) > 0 {
|
||||||
|
target = e.Params[0]
|
||||||
|
if target == "*" {
|
||||||
|
target = ServerTarget
|
||||||
|
}
|
||||||
|
if strings.EqualFold(target, gc.GetNick()) && e.Source != nil {
|
||||||
|
target = e.Source.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
from := ""
|
||||||
|
if e.Source != nil {
|
||||||
|
from = e.Source.Name
|
||||||
|
}
|
||||||
|
c.emit(Event{Type: "notice", Target: target, From: from, Text: e.Last(), Timestamp: eventTime(e)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleJoin(gc *girc.Client, e girc.Event) {
|
||||||
|
room := eventRoom(e)
|
||||||
|
if room == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
from := sourceName(e)
|
||||||
|
c.emit(Event{Type: "join", Target: room, From: from, Text: fmt.Sprintf("%s joined %s", from, room), Timestamp: eventTime(e)})
|
||||||
|
c.emitRooms()
|
||||||
|
c.emitUsers(room)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handlePart(gc *girc.Client, e girc.Event) {
|
||||||
|
room := eventRoom(e)
|
||||||
|
if room == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
from := sourceName(e)
|
||||||
|
message := ""
|
||||||
|
if len(e.Params) > 1 {
|
||||||
|
message = strings.TrimSpace(e.Last())
|
||||||
|
}
|
||||||
|
c.emit(Event{Type: "part", Target: room, From: from, Text: message, Timestamp: eventTime(e)})
|
||||||
|
c.emitRooms()
|
||||||
|
c.emitUsers(room)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleQuit(gc *girc.Client, e girc.Event) {
|
||||||
|
from := sourceName(e)
|
||||||
|
c.emit(Event{Type: "quit", Target: ServerTarget, From: from, Text: strings.TrimSpace(e.Last()), Timestamp: eventTime(e)})
|
||||||
|
c.emitUsersForAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleNick(gc *girc.Client, e girc.Event) {
|
||||||
|
from := sourceName(e)
|
||||||
|
newNick := e.Last()
|
||||||
|
c.emit(Event{Type: "nick", Target: ServerTarget, From: from, Text: newNick, Timestamp: eventTime(e)})
|
||||||
|
c.emitUsersForAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleTopic(gc *girc.Client, e girc.Event) {
|
||||||
|
room := ""
|
||||||
|
topic := e.Last()
|
||||||
|
if e.Command == girc.RPL_TOPIC && len(e.Params) >= 3 {
|
||||||
|
room = e.Params[1]
|
||||||
|
topic = e.Params[2]
|
||||||
|
} else if len(e.Params) >= 2 {
|
||||||
|
room = e.Params[0]
|
||||||
|
topic = e.Params[1]
|
||||||
|
}
|
||||||
|
if room == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.emit(Event{Type: "topic", Target: room, From: sourceName(e), Text: topic, Topic: topic, Timestamp: eventTime(e)})
|
||||||
|
c.emitRooms()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleNames(gc *girc.Client, e girc.Event) {
|
||||||
|
if len(e.Params) >= 4 {
|
||||||
|
c.emit(Event{Type: "users", Target: e.Params[2], Users: splitNames(e.Params[3]), Timestamp: eventTime(e)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleEndOfNames(gc *girc.Client, e girc.Event) {
|
||||||
|
if len(e.Params) >= 2 {
|
||||||
|
c.emitUsers(e.Params[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleList(gc *girc.Client, e girc.Event) {
|
||||||
|
if len(e.Params) < 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, _ := strconv.Atoi(e.Params[2])
|
||||||
|
room := RoomInfo{Name: e.Params[1], Users: users, Topic: e.Params[3]}
|
||||||
|
c.mu.Lock()
|
||||||
|
if len(c.listRooms) < maxRoomListEntries {
|
||||||
|
c.listRooms = append(c.listRooms, room)
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleListEnd(gc *girc.Client, e girc.Event) {
|
||||||
|
c.mu.Lock()
|
||||||
|
rooms := append([]RoomInfo{}, c.listRooms...)
|
||||||
|
c.listRooms = nil
|
||||||
|
c.mu.Unlock()
|
||||||
|
sort.Slice(rooms, func(i, j int) bool {
|
||||||
|
if rooms[i].Users == rooms[j].Users {
|
||||||
|
return strings.ToLower(rooms[i].Name) < strings.ToLower(rooms[j].Name)
|
||||||
|
}
|
||||||
|
return rooms[i].Users > rooms[j].Users
|
||||||
|
})
|
||||||
|
c.emit(Event{Type: "room-list", Target: ServerTarget, Rooms: rooms, Timestamp: eventTime(e)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) identifyWithNickServ(gc *girc.Client) {
|
||||||
|
if !c.cfg.NickServIdentify || c.cfg.NickServPassword == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.cfg.NickServAccount != "" {
|
||||||
|
gc.Cmd.Message("NickServ", fmt.Sprintf("IDENTIFY %s %s", c.cfg.NickServAccount, c.cfg.NickServPassword))
|
||||||
|
} else {
|
||||||
|
gc.Cmd.Message("NickServ", "IDENTIFY "+c.cfg.NickServPassword)
|
||||||
|
}
|
||||||
|
c.emit(Event{Type: "system", Target: ServerTarget, Text: "Sent NickServ identify request", Timestamp: time.Now().UnixMilli()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) newSSHDialer() (*sshDialer, error) {
|
||||||
|
if c.cfg.SSHHostKeyCallback == nil {
|
||||||
|
return nil, fmt.Errorf("SSH host key callback is required")
|
||||||
|
}
|
||||||
|
authMethods, err := c.sshAuthMethods()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sshAddr := fmt.Sprintf("%s:%d", c.cfg.SSHHost, c.cfg.SSHPort)
|
||||||
|
sshConfig := &ssh.ClientConfig{
|
||||||
|
User: c.cfg.SSHUsername,
|
||||||
|
Auth: authMethods,
|
||||||
|
HostKeyCallback: c.cfg.SSHHostKeyCallback,
|
||||||
|
Timeout: c.cfg.ConnectTimeout,
|
||||||
|
}
|
||||||
|
sshClient, err := ssh.Dial("tcp", sshAddr, sshConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SSH connection failed: %w", err)
|
||||||
|
}
|
||||||
|
c.sshClient = sshClient
|
||||||
|
return &sshDialer{
|
||||||
|
client: sshClient,
|
||||||
|
useTLS: c.cfg.UseTLS,
|
||||||
|
serverName: c.cfg.Server,
|
||||||
|
timeout: c.cfg.ConnectTimeout,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) sshAuthMethods() ([]ssh.AuthMethod, error) {
|
||||||
|
var authMethods []ssh.AuthMethod
|
||||||
|
if c.cfg.SSHAgentAuth && c.cfg.SSHAgentConn != nil {
|
||||||
|
c.agentConn = c.cfg.SSHAgentConn
|
||||||
|
ag := agent.NewClient(c.cfg.SSHAgentConn)
|
||||||
|
signers, err := ag.Signers()
|
||||||
|
if err == nil && len(signers) > 0 {
|
||||||
|
authMethods = append(authMethods, ssh.PublicKeys(signers...))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(c.cfg.SSHPrivateKey) > 0 {
|
||||||
|
var signer ssh.Signer
|
||||||
|
var err error
|
||||||
|
if c.cfg.SSHPassphrase != "" {
|
||||||
|
signer, err = ssh.ParsePrivateKeyWithPassphrase(c.cfg.SSHPrivateKey, []byte(c.cfg.SSHPassphrase))
|
||||||
|
} else {
|
||||||
|
signer, err = ssh.ParsePrivateKey(c.cfg.SSHPrivateKey)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing SSH private key: %w", err)
|
||||||
|
}
|
||||||
|
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||||
|
}
|
||||||
|
if c.cfg.SSHPassword != "" {
|
||||||
|
authMethods = append(authMethods, ssh.Password(c.cfg.SSHPassword))
|
||||||
|
}
|
||||||
|
if len(authMethods) == 0 {
|
||||||
|
return nil, fmt.Errorf("no SSH authentication method available")
|
||||||
|
}
|
||||||
|
return authMethods, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) emitRooms() {
|
||||||
|
c.emit(Event{Type: "rooms", Target: ServerTarget, Rooms: c.Rooms(), Timestamp: time.Now().UnixMilli()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) emitUsers(room string) {
|
||||||
|
if room == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.emit(Event{Type: "users", Target: room, Users: c.Users(room), Timestamp: time.Now().UnixMilli()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) emitUsersForAll() {
|
||||||
|
for _, room := range c.Rooms() {
|
||||||
|
c.emitUsers(room.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) emitLocalMessage(eventType string, target string, text string) {
|
||||||
|
c.emit(Event{Type: eventType, Target: target, From: c.client.GetNick(), Text: text, Timestamp: time.Now().UnixMilli()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) emit(event Event) {
|
||||||
|
if event.Timestamp == 0 {
|
||||||
|
event.Timestamp = time.Now().UnixMilli()
|
||||||
|
}
|
||||||
|
if event.Target == "" {
|
||||||
|
event.Target = ServerTarget
|
||||||
|
}
|
||||||
|
if c.cfg.OnEvent != nil {
|
||||||
|
c.cfg.OnEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type sshDialer struct {
|
||||||
|
client *ssh.Client
|
||||||
|
useTLS bool
|
||||||
|
serverName string
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *sshDialer) Dial(network, address string) (net.Conn, error) {
|
||||||
|
conn, err := d.client.Dial(network, address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !d.useTLS {
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
tlsConn := tls.Client(conn, &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
ServerName: d.serverName,
|
||||||
|
})
|
||||||
|
if d.timeout > 0 {
|
||||||
|
_ = tlsConn.SetDeadline(time.Now().Add(d.timeout))
|
||||||
|
}
|
||||||
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("IRC TLS handshake failed: %w", err)
|
||||||
|
}
|
||||||
|
_ = tlsConn.SetDeadline(time.Time{})
|
||||||
|
return tlsConn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventTime(e girc.Event) int64 {
|
||||||
|
if e.Timestamp.IsZero() {
|
||||||
|
return time.Now().UnixMilli()
|
||||||
|
}
|
||||||
|
return e.Timestamp.UnixMilli()
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventRoom(e girc.Event) string {
|
||||||
|
if len(e.Params) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.Params[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceName(e girc.Event) string {
|
||||||
|
if e.Source == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.Source.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitFirstArg(input string) (first string, rest string, ok bool) {
|
||||||
|
fields := strings.Fields(input)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
first = fields[0]
|
||||||
|
rest = strings.TrimSpace(input[len(first):])
|
||||||
|
return first, rest, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitNames(input string) []string {
|
||||||
|
fields := strings.Fields(input)
|
||||||
|
names := make([]string, 0, len(fields))
|
||||||
|
for _, name := range fields {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name != "" {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ const (
|
|||||||
DefaultRAWPort = 23
|
DefaultRAWPort = 23
|
||||||
DefaultGitHubPort = 443
|
DefaultGitHubPort = 443
|
||||||
DefaultVNCPort = 5900
|
DefaultVNCPort = 5900
|
||||||
|
DefaultIRCPort = 6667
|
||||||
|
DefaultIRCTLSPort = 6697
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default timeout values (in seconds).
|
// Default timeout values (in seconds).
|
||||||
@@ -122,6 +124,19 @@ func NewProfile(protocol Protocol) Profile {
|
|||||||
SSHAuthMethod: "publickey",
|
SSHAuthMethod: "publickey",
|
||||||
SSHSecretMode: SecretModeSaved,
|
SSHSecretMode: SecretModeSaved,
|
||||||
}
|
}
|
||||||
|
case ProtocolIRC:
|
||||||
|
p.Port = DefaultIRCPort
|
||||||
|
p.IRC = &IRCConfig{
|
||||||
|
Nick: "nexus",
|
||||||
|
RealName: "Nexus Terminal",
|
||||||
|
ServerPasswordMode: SecretModeAskEveryTime,
|
||||||
|
NickServMode: SecretModeSaved,
|
||||||
|
AutoJoinRooms: []string{},
|
||||||
|
SSHPort: DefaultSSHPort,
|
||||||
|
SSHAuthMethod: "publickey",
|
||||||
|
SSHSecretMode: SecretModeSaved,
|
||||||
|
AutoReconnect: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return p
|
return p
|
||||||
@@ -186,6 +201,8 @@ func NormalizeProfileDefaults(p *Profile) {
|
|||||||
normalizeGitHubConfig(p, defaults.GitHub)
|
normalizeGitHubConfig(p, defaults.GitHub)
|
||||||
case ProtocolVNC:
|
case ProtocolVNC:
|
||||||
normalizeVNCConfig(p, defaults.VNC)
|
normalizeVNCConfig(p, defaults.VNC)
|
||||||
|
case ProtocolIRC:
|
||||||
|
normalizeIRCConfig(p, defaults.IRC)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +218,8 @@ func defaultProfileName(p *Profile) string {
|
|||||||
return "GitHub"
|
return "GitHub"
|
||||||
case ProtocolVNC:
|
case ProtocolVNC:
|
||||||
return "VNC"
|
return "VNC"
|
||||||
|
case ProtocolIRC:
|
||||||
|
return "IRC"
|
||||||
case ProtocolRAW:
|
case ProtocolRAW:
|
||||||
return "RAW TCP"
|
return "RAW TCP"
|
||||||
case ProtocolTelnet:
|
case ProtocolTelnet:
|
||||||
@@ -340,3 +359,31 @@ func normalizeVNCConfig(p *Profile, defaults *VNCConfig) {
|
|||||||
}
|
}
|
||||||
cfg.SSHSecretMode = normalizeSecretMode(cfg.SSHSecretMode, defaults.SSHSecretMode)
|
cfg.SSHSecretMode = normalizeSecretMode(cfg.SSHSecretMode, defaults.SSHSecretMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeIRCConfig(p *Profile, defaults *IRCConfig) {
|
||||||
|
if p.IRC == nil {
|
||||||
|
cfg := *defaults
|
||||||
|
cfg.AutoJoinRooms = append([]string{}, defaults.AutoJoinRooms...)
|
||||||
|
p.IRC = &cfg
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := p.IRC
|
||||||
|
if cfg.Nick == "" {
|
||||||
|
cfg.Nick = defaults.Nick
|
||||||
|
}
|
||||||
|
if cfg.RealName == "" {
|
||||||
|
cfg.RealName = defaults.RealName
|
||||||
|
}
|
||||||
|
cfg.ServerPasswordMode = normalizeSecretMode(cfg.ServerPasswordMode, defaults.ServerPasswordMode)
|
||||||
|
cfg.NickServMode = normalizeSecretMode(cfg.NickServMode, defaults.NickServMode)
|
||||||
|
if cfg.AutoJoinRooms == nil {
|
||||||
|
cfg.AutoJoinRooms = []string{}
|
||||||
|
}
|
||||||
|
if cfg.SSHPort == 0 {
|
||||||
|
cfg.SSHPort = defaults.SSHPort
|
||||||
|
}
|
||||||
|
if cfg.SSHAuthMethod == "" {
|
||||||
|
cfg.SSHAuthMethod = defaults.SSHAuthMethod
|
||||||
|
}
|
||||||
|
cfg.SSHSecretMode = normalizeSecretMode(cfg.SSHSecretMode, defaults.SSHSecretMode)
|
||||||
|
}
|
||||||
|
|||||||
+71
-29
@@ -13,6 +13,7 @@ const (
|
|||||||
ProtocolRAW Protocol = "raw"
|
ProtocolRAW Protocol = "raw"
|
||||||
ProtocolGitHub Protocol = "github"
|
ProtocolGitHub Protocol = "github"
|
||||||
ProtocolVNC Protocol = "vnc"
|
ProtocolVNC Protocol = "vnc"
|
||||||
|
ProtocolIRC Protocol = "irc"
|
||||||
ProtocolLocal Protocol = "local"
|
ProtocolLocal Protocol = "local"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,6 +86,9 @@ type Profile struct {
|
|||||||
|
|
||||||
// VNC-specific configuration
|
// VNC-specific configuration
|
||||||
VNC *VNCConfig `json:"vnc,omitempty"`
|
VNC *VNCConfig `json:"vnc,omitempty"`
|
||||||
|
|
||||||
|
// IRC-specific configuration
|
||||||
|
IRC *IRCConfig `json:"irc,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSHConfig holds SSH-specific settings for a connection profile.
|
// SSHConfig holds SSH-specific settings for a connection profile.
|
||||||
@@ -102,9 +106,9 @@ type SSHConfig struct {
|
|||||||
AgentForwarding bool `json:"agentForwarding"` // forward agent to remote host
|
AgentForwarding bool `json:"agentForwarding"` // forward agent to remote host
|
||||||
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
|
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
|
||||||
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
|
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
|
||||||
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
|
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
|
||||||
TOTPSecret string `json:"totpSecret"` // TOTP secret key for 2FA auto-fill (stored encrypted)
|
TOTPSecret string `json:"totpSecret"` // TOTP secret key for 2FA auto-fill (stored encrypted)
|
||||||
ProxyType string `json:"proxyType"` // "none", "socks5", "http"
|
ProxyType string `json:"proxyType"` // "none", "socks5", "http"
|
||||||
ProxyHost string `json:"proxyHost"`
|
ProxyHost string `json:"proxyHost"`
|
||||||
ProxyPort int `json:"proxyPort"`
|
ProxyPort int `json:"proxyPort"`
|
||||||
ProxyUsername string `json:"proxyUsername"`
|
ProxyUsername string `json:"proxyUsername"`
|
||||||
@@ -144,12 +148,12 @@ const (
|
|||||||
type PortForward struct {
|
type PortForward struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Direction PortForwardDirection `json:"direction"`
|
Direction PortForwardDirection `json:"direction"`
|
||||||
BindHost string `json:"bindHost"` // local address to listen on (local) or remote bind (remote)
|
BindHost string `json:"bindHost"` // local address to listen on (local) or remote bind (remote)
|
||||||
BindPort int `json:"bindPort"`
|
BindPort int `json:"bindPort"`
|
||||||
DestHost string `json:"destHost"` // target address to forward to
|
DestHost string `json:"destHost"` // target address to forward to
|
||||||
DestPort int `json:"destPort"`
|
DestPort int `json:"destPort"`
|
||||||
Enabled bool `json:"enabled"` // whether to activate on connect
|
Enabled bool `json:"enabled"` // whether to activate on connect
|
||||||
Label string `json:"label"` // optional descriptive label
|
Label string `json:"label"` // optional descriptive label
|
||||||
}
|
}
|
||||||
|
|
||||||
// TelnetConfig holds Telnet-specific settings for a connection profile.
|
// TelnetConfig holds Telnet-specific settings for a connection profile.
|
||||||
@@ -162,7 +166,7 @@ type TelnetConfig struct {
|
|||||||
LineMode bool `json:"lineMode"`
|
LineMode bool `json:"lineMode"`
|
||||||
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
|
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
|
||||||
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
|
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
|
||||||
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
|
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
|
||||||
}
|
}
|
||||||
|
|
||||||
// RAWConfig holds RAW TCP-specific settings for a connection profile.
|
// RAWConfig holds RAW TCP-specific settings for a connection profile.
|
||||||
@@ -221,33 +225,71 @@ type VNCConfig struct {
|
|||||||
SharedSession bool `json:"sharedSession"`
|
SharedSession bool `json:"sharedSession"`
|
||||||
|
|
||||||
// SSH tunnel settings — if enabled, VNC traffic is tunnelled through an SSH connection.
|
// SSH tunnel settings — if enabled, VNC traffic is tunnelled through an SSH connection.
|
||||||
SSHTunnel bool `json:"sshTunnel"`
|
SSHTunnel bool `json:"sshTunnel"`
|
||||||
SSHHost string `json:"sshHost"`
|
SSHHost string `json:"sshHost"`
|
||||||
SSHPort int `json:"sshPort"`
|
SSHPort int `json:"sshPort"`
|
||||||
SSHUsername string `json:"sshUsername"`
|
SSHUsername string `json:"sshUsername"`
|
||||||
SSHAuthMethod string `json:"sshAuthMethod"` // password, publickey
|
SSHAuthMethod string `json:"sshAuthMethod"` // password, publickey
|
||||||
SSHSecretMode SecretMode `json:"sshSecretMode"`
|
SSHSecretMode SecretMode `json:"sshSecretMode"`
|
||||||
SSHPrivateKey string `json:"sshPrivateKeyPath"`
|
SSHPrivateKey string `json:"sshPrivateKeyPath"`
|
||||||
SSHAgentAuth bool `json:"sshAgentAuth"`
|
SSHAgentAuth bool `json:"sshAgentAuth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCConfig holds IRC-specific settings for a connection profile.
|
||||||
|
type IRCConfig struct {
|
||||||
|
// Nick is the primary nickname used when connecting to the IRC network.
|
||||||
|
Nick string `json:"nick"`
|
||||||
|
|
||||||
|
// RealName is the user-facing realname/gecos sent during registration.
|
||||||
|
RealName string `json:"realName"`
|
||||||
|
|
||||||
|
// UseTLS enables TLS for the IRC server connection.
|
||||||
|
UseTLS bool `json:"useTLS"`
|
||||||
|
|
||||||
|
// ServerPasswordRequired controls whether to prompt for an IRC server PASS secret.
|
||||||
|
ServerPasswordRequired bool `json:"serverPasswordRequired"`
|
||||||
|
ServerPasswordMode SecretMode `json:"serverPasswordMode"`
|
||||||
|
|
||||||
|
// NickServ settings. The password is resolved through the secret store.
|
||||||
|
NickServIdentify bool `json:"nickServIdentify"`
|
||||||
|
NickServAccount string `json:"nickServAccount"`
|
||||||
|
NickServMode SecretMode `json:"nickServMode"`
|
||||||
|
|
||||||
|
// AutoJoinRooms are joined automatically after registration.
|
||||||
|
AutoJoinRooms []string `json:"autoJoinRooms"`
|
||||||
|
|
||||||
|
// SSH tunnel settings — if enabled, IRC traffic is tunnelled through SSH.
|
||||||
|
SSHTunnel bool `json:"sshTunnel"`
|
||||||
|
SSHHost string `json:"sshHost"`
|
||||||
|
SSHPort int `json:"sshPort"`
|
||||||
|
SSHUsername string `json:"sshUsername"`
|
||||||
|
SSHAuthMethod string `json:"sshAuthMethod"` // password, publickey
|
||||||
|
SSHSecretMode SecretMode `json:"sshSecretMode"`
|
||||||
|
SSHPrivateKey string `json:"sshPrivateKeyPath"`
|
||||||
|
SSHAgentAuth bool `json:"sshAgentAuth"`
|
||||||
|
|
||||||
|
// Reconnect policy for unexpected disconnects.
|
||||||
|
AutoReconnect bool `json:"autoReconnect"`
|
||||||
|
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GitHubRepo is a single repository returned to the frontend for display.
|
// GitHubRepo is a single repository returned to the frontend for display.
|
||||||
type GitHubRepo struct {
|
type GitHubRepo struct {
|
||||||
FullName string `json:"fullName"`
|
FullName string `json:"fullName"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
HTMLURL string `json:"htmlURL"`
|
HTMLURL string `json:"htmlURL"`
|
||||||
DefaultBranch string `json:"defaultBranch"`
|
DefaultBranch string `json:"defaultBranch"`
|
||||||
LatestCommitter string `json:"latestCommitter"`
|
LatestCommitter string `json:"latestCommitter"`
|
||||||
LatestCommitAt string `json:"latestCommitAt"`
|
LatestCommitAt string `json:"latestCommitAt"`
|
||||||
Private bool `json:"private"`
|
Private bool `json:"private"`
|
||||||
Fork bool `json:"fork"`
|
Fork bool `json:"fork"`
|
||||||
Archived bool `json:"archived"`
|
Archived bool `json:"archived"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
Stars int `json:"stars"`
|
Stars int `json:"stars"`
|
||||||
PushedAt string `json:"pushedAt"` // ISO 8601
|
PushedAt string `json:"pushedAt"` // ISO 8601
|
||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionState represents the lifecycle state of a connection session.
|
// SessionState represents the lifecycle state of a connection session.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
|
|
||||||
"github.com/andydixon/nexus/internal/audit"
|
"github.com/andydixon/nexus/internal/audit"
|
||||||
"github.com/andydixon/nexus/internal/githubclient"
|
"github.com/andydixon/nexus/internal/githubclient"
|
||||||
|
"github.com/andydixon/nexus/internal/ircclient"
|
||||||
"github.com/andydixon/nexus/internal/localshell"
|
"github.com/andydixon/nexus/internal/localshell"
|
||||||
"github.com/andydixon/nexus/internal/models"
|
"github.com/andydixon/nexus/internal/models"
|
||||||
"github.com/andydixon/nexus/internal/rawclient"
|
"github.com/andydixon/nexus/internal/rawclient"
|
||||||
@@ -32,7 +33,7 @@ import (
|
|||||||
"github.com/andydixon/nexus/internal/vncclient"
|
"github.com/andydixon/nexus/internal/vncclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxVNCTunnelPrivateKeyBytes = 1 << 20
|
const maxTunnelPrivateKeyBytes = 1 << 20
|
||||||
|
|
||||||
// Session wraps a protocol client with metadata and state.
|
// Session wraps a protocol client with metadata and state.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
@@ -43,6 +44,7 @@ type Session struct {
|
|||||||
githubClient *githubclient.Client
|
githubClient *githubclient.Client
|
||||||
githubCancel context.CancelFunc // stops the auto-refresh goroutine
|
githubCancel context.CancelFunc // stops the auto-refresh goroutine
|
||||||
vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions
|
vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions
|
||||||
|
ircClient *ircclient.Client // IRC client, nil for non-IRC sessions
|
||||||
localClient *localshell.Client // local shell PTY, nil for non-local sessions
|
localClient *localshell.Client // local shell PTY, nil for non-local sessions
|
||||||
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
|
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
|
||||||
profile *models.Profile
|
profile *models.Profile
|
||||||
@@ -139,6 +141,8 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string)
|
|||||||
err = m.connectGitHub(sess, getSecret)
|
err = m.connectGitHub(sess, getSecret)
|
||||||
case models.ProtocolVNC:
|
case models.ProtocolVNC:
|
||||||
err = m.connectVNC(sess, getSecret)
|
err = m.connectVNC(sess, getSecret)
|
||||||
|
case models.ProtocolIRC:
|
||||||
|
err = m.connectIRC(sess, getSecret)
|
||||||
default:
|
default:
|
||||||
err = fmt.Errorf("unsupported protocol %q", profile.Protocol)
|
err = fmt.Errorf("unsupported protocol %q", profile.Protocol)
|
||||||
}
|
}
|
||||||
@@ -539,6 +543,74 @@ func (m *Manager) SendBreak(sessionID string) error {
|
|||||||
return sess.telnetClient.SendBreak()
|
return sess.telnetClient.SendBreak()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IRCHandleInput sends a user-entered composer line to an IRC session.
|
||||||
|
func (m *Manager) IRCHandleInput(sessionID string, target string, input string) error {
|
||||||
|
client, err := m.getIRC(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return client.HandleInput(target, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCJoin joins an IRC room.
|
||||||
|
func (m *Manager) IRCJoin(sessionID string, room string) error {
|
||||||
|
client, err := m.getIRC(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return client.Join(room)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCPart leaves an IRC room.
|
||||||
|
func (m *Manager) IRCPart(sessionID string, room string) error {
|
||||||
|
client, err := m.getIRC(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return client.Part(room)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCListRooms requests a server-side IRC room list.
|
||||||
|
func (m *Manager) IRCListRooms(sessionID string) error {
|
||||||
|
client, err := m.getIRC(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return client.ListRooms()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCRooms returns currently joined IRC rooms.
|
||||||
|
func (m *Manager) IRCRooms(sessionID string) ([]ircclient.RoomInfo, error) {
|
||||||
|
client, err := m.getIRC(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return client.Rooms(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCUsers returns known users in an IRC room.
|
||||||
|
func (m *Manager) IRCUsers(sessionID string, room string) ([]string, error) {
|
||||||
|
client, err := m.getIRC(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return client.Users(room), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) getIRC(sessionID string) (*ircclient.Client, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
sess, ok := m.sessions[sessionID]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("sessions: session %q not found", sessionID)
|
||||||
|
}
|
||||||
|
if sess.ircClient == nil {
|
||||||
|
return nil, fmt.Errorf("sessions: session %q is not an IRC connection", sessionID)
|
||||||
|
}
|
||||||
|
return sess.ircClient, nil
|
||||||
|
}
|
||||||
|
|
||||||
// WriteSessionText sends text with line ending via the RAW client.
|
// WriteSessionText sends text with line ending via the RAW client.
|
||||||
func (m *Manager) WriteSessionText(sessionID string, text string) error {
|
func (m *Manager) WriteSessionText(sessionID string, text string) error {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
@@ -1040,8 +1112,8 @@ func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (strin
|
|||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
return fmt.Errorf("SSH key path is a directory")
|
return fmt.Errorf("SSH key path is a directory")
|
||||||
}
|
}
|
||||||
if info.Size() > maxVNCTunnelPrivateKeyBytes {
|
if info.Size() > maxTunnelPrivateKeyBytes {
|
||||||
return fmt.Errorf("SSH key is too large: %d bytes exceeds %d bytes", info.Size(), maxVNCTunnelPrivateKeyBytes)
|
return fmt.Errorf("SSH key is too large: %d bytes exceeds %d bytes", info.Size(), maxTunnelPrivateKeyBytes)
|
||||||
}
|
}
|
||||||
keyData, err := os.ReadFile(vncConf.SSHPrivateKey)
|
keyData, err := os.ReadFile(vncConf.SSHPrivateKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1091,6 +1163,139 @@ func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (strin
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// connectIRC establishes an IRC connection, optionally through an SSH tunnel,
|
||||||
|
// and forwards typed IRC events to the frontend.
|
||||||
|
func (m *Manager) connectIRC(sess *Session, getSecret func(prompt string) (string, error)) error {
|
||||||
|
profile := sess.profile
|
||||||
|
ircConf := profile.IRC
|
||||||
|
if ircConf == nil {
|
||||||
|
return fmt.Errorf("IRC configuration missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
serverPass := ""
|
||||||
|
if ircConf.ServerPasswordRequired {
|
||||||
|
getPass := m.buildSecretCallback(profile.ID, "irc-server-password", ircConf.ServerPasswordMode, getSecret)
|
||||||
|
password, err := getPass()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("IRC server password: %w", err)
|
||||||
|
}
|
||||||
|
serverPass = password
|
||||||
|
}
|
||||||
|
|
||||||
|
nickServPass := ""
|
||||||
|
if ircConf.NickServIdentify {
|
||||||
|
getPass := m.buildSecretCallback(profile.ID, "irc-nickserv-password", ircConf.NickServMode, getSecret)
|
||||||
|
password, err := getPass()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("NickServ password: %w", err)
|
||||||
|
}
|
||||||
|
nickServPass = password
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := ircclient.Config{
|
||||||
|
Server: profile.Host,
|
||||||
|
Port: profile.Port,
|
||||||
|
Nick: ircConf.Nick,
|
||||||
|
User: profile.Username,
|
||||||
|
RealName: ircConf.RealName,
|
||||||
|
UseTLS: ircConf.UseTLS,
|
||||||
|
ServerPass: serverPass,
|
||||||
|
AutoJoinRooms: append([]string{}, ircConf.AutoJoinRooms...),
|
||||||
|
NickServIdentify: ircConf.NickServIdentify,
|
||||||
|
NickServAccount: ircConf.NickServAccount,
|
||||||
|
NickServPassword: nickServPass,
|
||||||
|
ConnectTimeout: time.Duration(profile.ConnectTimeout) * time.Second,
|
||||||
|
OnEvent: func(event ircclient.Event) { m.emitIRCEvent(sess.Info.ID, event) },
|
||||||
|
OnError: func(err error) { m.markSessionError(sess, err) },
|
||||||
|
OnClose: func() { m.handleSessionClosed(sess.Info.ID) },
|
||||||
|
SSHTunnel: ircConf.SSHTunnel,
|
||||||
|
SSHHost: ircConf.SSHHost,
|
||||||
|
SSHPort: ircConf.SSHPort,
|
||||||
|
SSHUsername: ircConf.SSHUsername,
|
||||||
|
SSHAuthMethod: ircConf.SSHAuthMethod,
|
||||||
|
SSHAgentAuth: ircConf.SSHAgentAuth,
|
||||||
|
SSHHostKeyCallback: m.hostKeyCallback(sess.Info.ID, models.HostKeyAsk),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.User == "" {
|
||||||
|
cfg.User = ircConf.Nick
|
||||||
|
}
|
||||||
|
|
||||||
|
if ircConf.SSHTunnel {
|
||||||
|
switch ircConf.SSHAuthMethod {
|
||||||
|
case "password":
|
||||||
|
getPass := m.buildSecretCallback(profile.ID, "irc-ssh-password", ircConf.SSHSecretMode, getSecret)
|
||||||
|
password, err := getPass()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("IRC SSH password: %w", err)
|
||||||
|
}
|
||||||
|
cfg.SSHPassword = password
|
||||||
|
case "publickey":
|
||||||
|
if ircConf.SSHPrivateKey != "" {
|
||||||
|
info, err := os.Stat(ircConf.SSHPrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading IRC SSH key metadata: %w", err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return fmt.Errorf("IRC SSH key path is a directory")
|
||||||
|
}
|
||||||
|
if info.Size() > maxTunnelPrivateKeyBytes {
|
||||||
|
return fmt.Errorf("IRC SSH key is too large: %d bytes exceeds %d bytes", info.Size(), maxTunnelPrivateKeyBytes)
|
||||||
|
}
|
||||||
|
keyData, err := os.ReadFile(ircConf.SSHPrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading IRC SSH key: %w", err)
|
||||||
|
}
|
||||||
|
cfg.SSHPrivateKey = keyData
|
||||||
|
getPassphrase := m.buildSecretCallback(profile.ID, "irc-ssh-passphrase", ircConf.SSHSecretMode, getSecret)
|
||||||
|
passphrase, _ := getPassphrase()
|
||||||
|
cfg.SSHPassphrase = passphrase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ircConf.SSHAgentAuth {
|
||||||
|
socketPath := os.Getenv("SSH_AUTH_SOCK")
|
||||||
|
if socketPath != "" {
|
||||||
|
agentConn, err := net.Dial("unix", socketPath)
|
||||||
|
if err == nil {
|
||||||
|
cfg.SSHAgentConn = agentConn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := ircclient.New(cfg)
|
||||||
|
if err := client.Connect(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sess.ircClient = client
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) emitIRCEvent(sessionID string, event ircclient.Event) {
|
||||||
|
m.emitEvent("session:irc:event", map[string]interface{}{
|
||||||
|
"sessionID": sessionID,
|
||||||
|
"event": event,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) markSessionError(sess *Session, err error) {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
sess.Info.State = models.SessionError
|
||||||
|
sess.Info.Error = err.Error()
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
m.emitEvent("session:state", map[string]interface{}{
|
||||||
|
"sessionID": sess.Info.ID,
|
||||||
|
"state": string(models.SessionError),
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session.
|
// GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session.
|
||||||
func (m *Manager) GetVNCWebSocketURL(sessionID string) (string, error) {
|
func (m *Manager) GetVNCWebSocketURL(sessionID string) (string, error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
@@ -1728,6 +1933,10 @@ func (m *Manager) closeSession(sess *Session) {
|
|||||||
sess.vncClient.Close()
|
sess.vncClient.Close()
|
||||||
sess.vncClient = nil
|
sess.vncClient = nil
|
||||||
}
|
}
|
||||||
|
if sess.ircClient != nil {
|
||||||
|
sess.ircClient.Close()
|
||||||
|
sess.ircClient = nil
|
||||||
|
}
|
||||||
if sess.localClient != nil {
|
if sess.localClient != nil {
|
||||||
sess.localClient.Close()
|
sess.localClient.Close()
|
||||||
sess.localClient = nil
|
sess.localClient = nil
|
||||||
@@ -1879,6 +2088,10 @@ func (m *Manager) shouldAutoReconnect(sess *Session) bool {
|
|||||||
if sess.profile.RAW != nil {
|
if sess.profile.RAW != nil {
|
||||||
return sess.profile.RAW.AutoReconnect
|
return sess.profile.RAW.AutoReconnect
|
||||||
}
|
}
|
||||||
|
case models.ProtocolIRC:
|
||||||
|
if sess.profile.IRC != nil {
|
||||||
|
return sess.profile.IRC.AutoReconnect
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1895,6 +2108,14 @@ func (m *Manager) maxReconnectAttempts(sess *Session) int {
|
|||||||
if sess.profile.Telnet != nil && sess.profile.Telnet.MaxReconnectTries > 0 {
|
if sess.profile.Telnet != nil && sess.profile.Telnet.MaxReconnectTries > 0 {
|
||||||
maxTries = sess.profile.Telnet.MaxReconnectTries
|
maxTries = sess.profile.Telnet.MaxReconnectTries
|
||||||
}
|
}
|
||||||
|
case models.ProtocolRAW:
|
||||||
|
if sess.profile.RAW != nil {
|
||||||
|
maxTries = 5
|
||||||
|
}
|
||||||
|
case models.ProtocolIRC:
|
||||||
|
if sess.profile.IRC != nil && sess.profile.IRC.MaxReconnectTries > 0 {
|
||||||
|
maxTries = sess.profile.IRC.MaxReconnectTries
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return maxTries
|
return maxTries
|
||||||
}
|
}
|
||||||
@@ -1959,6 +2180,7 @@ func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
|
|||||||
sess.sshClient = nil
|
sess.sshClient = nil
|
||||||
sess.telnetClient = nil
|
sess.telnetClient = nil
|
||||||
sess.rawClient = nil
|
sess.rawClient = nil
|
||||||
|
sess.ircClient = nil
|
||||||
sess.sftpClient = nil
|
sess.sftpClient = nil
|
||||||
sess.Info.State = models.SessionConnecting
|
sess.Info.State = models.SessionConnecting
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
@@ -1976,6 +2198,8 @@ func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
|
|||||||
err = m.connectTelnet(sess)
|
err = m.connectTelnet(sess)
|
||||||
case models.ProtocolRAW:
|
case models.ProtocolRAW:
|
||||||
err = m.connectRAW(sess)
|
err = m.connectRAW(sess)
|
||||||
|
case models.ProtocolIRC:
|
||||||
|
err = m.connectIRC(sess, m.buildInteractiveSecretCallback(sessionID))
|
||||||
default:
|
default:
|
||||||
err = fmt.Errorf("unsupported protocol for reconnect: %s", sess.profile.Protocol)
|
err = fmt.Errorf("unsupported protocol for reconnect: %s", sess.profile.Protocol)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,16 +22,22 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxConfigFileBytes = 5 * 1024 * 1024
|
||||||
|
maxConfigLineBytes = 256 * 1024
|
||||||
|
maxHostBlocks = 5000
|
||||||
|
)
|
||||||
|
|
||||||
// HostBlock represents a parsed SSH config Host section.
|
// HostBlock represents a parsed SSH config Host section.
|
||||||
type HostBlock struct {
|
type HostBlock struct {
|
||||||
Aliases []string
|
Aliases []string
|
||||||
HostName string
|
HostName string
|
||||||
Port int
|
Port int
|
||||||
User string
|
User string
|
||||||
IdentityFile string
|
IdentityFile string
|
||||||
ProxyJump string
|
ProxyJump string
|
||||||
ForwardAgent bool
|
ForwardAgent bool
|
||||||
Compression bool
|
Compression bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseFile reads and parses an OpenSSH config file.
|
// ParseFile reads and parses an OpenSSH config file.
|
||||||
@@ -41,11 +47,33 @@ func ParseFile(path string) ([]HostBlock, error) {
|
|||||||
return nil, fmt.Errorf("sshconfig: failed to open %s: %w", path, err)
|
return nil, fmt.Errorf("sshconfig: failed to open %s: %w", path, err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sshconfig: failed to stat %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("sshconfig: %s is a directory", path)
|
||||||
|
}
|
||||||
|
if info.Size() > maxConfigFileBytes {
|
||||||
|
return nil, fmt.Errorf("sshconfig: config file is too large: %d bytes exceeds %d bytes", info.Size(), maxConfigFileBytes)
|
||||||
|
}
|
||||||
|
|
||||||
var blocks []HostBlock
|
var blocks []HostBlock
|
||||||
var current *HostBlock
|
var current *HostBlock
|
||||||
|
commitCurrent := func() error {
|
||||||
|
if current == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(blocks) >= maxHostBlocks {
|
||||||
|
return fmt.Errorf("sshconfig: too many Host blocks (max %d)", maxHostBlocks)
|
||||||
|
}
|
||||||
|
blocks = append(blocks, *current)
|
||||||
|
current = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
scanner := bufio.NewScanner(f)
|
scanner := bufio.NewScanner(f)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), maxConfigLineBytes)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := strings.TrimSpace(scanner.Text())
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
|
||||||
@@ -61,8 +89,8 @@ func ParseFile(path string) ([]HostBlock, error) {
|
|||||||
switch key {
|
switch key {
|
||||||
case "host":
|
case "host":
|
||||||
// Commit previous block.
|
// Commit previous block.
|
||||||
if current != nil {
|
if err := commitCurrent(); err != nil {
|
||||||
blocks = append(blocks, *current)
|
return nil, err
|
||||||
}
|
}
|
||||||
aliases := strings.Fields(value)
|
aliases := strings.Fields(value)
|
||||||
// Skip wildcard-only hosts (e.g. "Host *").
|
// Skip wildcard-only hosts (e.g. "Host *").
|
||||||
@@ -83,8 +111,8 @@ func ParseFile(path string) ([]HostBlock, error) {
|
|||||||
|
|
||||||
case "match":
|
case "match":
|
||||||
// Commit previous block and skip Match blocks.
|
// Commit previous block and skip Match blocks.
|
||||||
if current != nil {
|
if err := commitCurrent(); err != nil {
|
||||||
blocks = append(blocks, *current)
|
return nil, err
|
||||||
}
|
}
|
||||||
current = nil
|
current = nil
|
||||||
|
|
||||||
@@ -122,8 +150,8 @@ func ParseFile(path string) ([]HostBlock, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Commit final block.
|
// Commit final block.
|
||||||
if current != nil {
|
if err := commitCurrent(); err != nil {
|
||||||
blocks = append(blocks, *current)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
@@ -161,13 +189,13 @@ func ToProfiles(blocks []HostBlock, folder string) []models.Profile {
|
|||||||
username := b.User
|
username := b.User
|
||||||
|
|
||||||
sshConf := &models.SSHConfig{
|
sshConf := &models.SSHConfig{
|
||||||
AuthMethods: []string{"publickey", "password"},
|
AuthMethods: []string{"publickey", "password"},
|
||||||
SecretMode: models.SecretModeAskEveryTime,
|
SecretMode: models.SecretModeAskEveryTime,
|
||||||
HostKeyPolicy: models.HostKeyAsk,
|
HostKeyPolicy: models.HostKeyAsk,
|
||||||
TerminalType: "xterm-256color",
|
TerminalType: "xterm-256color",
|
||||||
AgentAuth: b.ForwardAgent,
|
AgentAuth: b.ForwardAgent,
|
||||||
AgentForwarding: b.ForwardAgent,
|
AgentForwarding: b.ForwardAgent,
|
||||||
Compression: b.Compression,
|
Compression: b.Compression,
|
||||||
}
|
}
|
||||||
|
|
||||||
if b.IdentityFile != "" {
|
if b.IdentityFile != "" {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package sshconfig
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -89,6 +91,50 @@ func TestParseFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseFileRejectsOversizedFile(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config")
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.Truncate(maxConfigFileBytes + 1); err != nil {
|
||||||
|
f.Close()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ParseFile(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected oversized config file to be rejected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "too large") {
|
||||||
|
t.Fatalf("error = %q, want too large", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFileRejectsTooManyHostBlocks(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config")
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < maxHostBlocks+1; i++ {
|
||||||
|
b.WriteString("Host host")
|
||||||
|
b.WriteString(strconv.Itoa(i))
|
||||||
|
b.WriteString("\n HostName example.com\n")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(b.String()), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ParseFile(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected excessive Host blocks to be rejected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "too many Host blocks") {
|
||||||
|
t.Fatalf("error = %q, want too many Host blocks", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestToProfiles(t *testing.T) {
|
func TestToProfiles(t *testing.T) {
|
||||||
blocks := []HostBlock{
|
blocks := []HostBlock{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
-- Migration 005: Add IRC protocol support
|
||||||
|
-- Adds the 'irc' value to the protocol CHECK constraint and an irc_config column.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS profiles_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
protocol TEXT NOT NULL CHECK(
|
||||||
|
protocol IN ('ssh', 'telnet', 'raw', 'github', 'vnc', 'irc')
|
||||||
|
),
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
username TEXT DEFAULT '',
|
||||||
|
tags TEXT DEFAULT '[]',
|
||||||
|
folder TEXT DEFAULT '',
|
||||||
|
environment TEXT DEFAULT '',
|
||||||
|
notes TEXT DEFAULT '',
|
||||||
|
favourite INTEGER DEFAULT 0,
|
||||||
|
last_connected TEXT,
|
||||||
|
connect_timeout INTEGER DEFAULT 30,
|
||||||
|
read_timeout INTEGER DEFAULT 0,
|
||||||
|
terminal_font_family TEXT DEFAULT '',
|
||||||
|
terminal_font_size INTEGER DEFAULT 14,
|
||||||
|
terminal_scrollback INTEGER DEFAULT 10000,
|
||||||
|
terminal_cursor_style TEXT DEFAULT 'block',
|
||||||
|
ssh_config TEXT,
|
||||||
|
telnet_config TEXT,
|
||||||
|
raw_config TEXT,
|
||||||
|
github_config TEXT,
|
||||||
|
vnc_config TEXT,
|
||||||
|
irc_config TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT
|
||||||
|
OR IGNORE INTO profiles_new (
|
||||||
|
id,
|
||||||
|
display_name,
|
||||||
|
description,
|
||||||
|
protocol,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
tags,
|
||||||
|
folder,
|
||||||
|
environment,
|
||||||
|
notes,
|
||||||
|
favourite,
|
||||||
|
last_connected,
|
||||||
|
connect_timeout,
|
||||||
|
read_timeout,
|
||||||
|
terminal_font_family,
|
||||||
|
terminal_font_size,
|
||||||
|
terminal_scrollback,
|
||||||
|
terminal_cursor_style,
|
||||||
|
ssh_config,
|
||||||
|
telnet_config,
|
||||||
|
raw_config,
|
||||||
|
github_config,
|
||||||
|
vnc_config,
|
||||||
|
irc_config,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
display_name,
|
||||||
|
description,
|
||||||
|
protocol,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
tags,
|
||||||
|
folder,
|
||||||
|
environment,
|
||||||
|
notes,
|
||||||
|
favourite,
|
||||||
|
last_connected,
|
||||||
|
connect_timeout,
|
||||||
|
read_timeout,
|
||||||
|
terminal_font_family,
|
||||||
|
terminal_font_size,
|
||||||
|
terminal_scrollback,
|
||||||
|
terminal_cursor_style,
|
||||||
|
ssh_config,
|
||||||
|
telnet_config,
|
||||||
|
raw_config,
|
||||||
|
github_config,
|
||||||
|
vnc_config,
|
||||||
|
NULL,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM
|
||||||
|
profiles;
|
||||||
|
|
||||||
|
DROP TABLE profiles;
|
||||||
|
|
||||||
|
ALTER TABLE
|
||||||
|
profiles_new RENAME TO profiles;
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
schema_version (version, applied_at)
|
||||||
|
VALUES
|
||||||
|
(5, datetime('now'));
|
||||||
+26
-14
@@ -111,15 +111,16 @@ func (s *Store) CreateProfile(p *models.Profile) error {
|
|||||||
rawJSON, _ := json.Marshal(p.RAW)
|
rawJSON, _ := json.Marshal(p.RAW)
|
||||||
githubJSON, _ := json.Marshal(p.GitHub)
|
githubJSON, _ := json.Marshal(p.GitHub)
|
||||||
vncJSON, _ := json.Marshal(p.VNC)
|
vncJSON, _ := json.Marshal(p.VNC)
|
||||||
|
ircJSON, _ := json.Marshal(p.IRC)
|
||||||
|
|
||||||
_, err := s.db.Exec(`INSERT INTO profiles (
|
_, err := s.db.Exec(`INSERT INTO profiles (
|
||||||
id, display_name, description, protocol, host, port, username,
|
id, display_name, description, protocol, host, port, username,
|
||||||
tags, folder, environment, notes, favourite, last_connected,
|
tags, folder, environment, notes, favourite, last_connected,
|
||||||
connect_timeout, read_timeout,
|
connect_timeout, read_timeout,
|
||||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
p.ID, p.DisplayName, p.Description, string(p.Protocol),
|
p.ID, p.DisplayName, p.Description, string(p.Protocol),
|
||||||
p.Host, p.Port, p.Username,
|
p.Host, p.Port, p.Username,
|
||||||
string(tagsJSON), p.Folder, p.Environment, p.Notes,
|
string(tagsJSON), p.Folder, p.Environment, p.Notes,
|
||||||
@@ -127,7 +128,7 @@ func (s *Store) CreateProfile(p *models.Profile) error {
|
|||||||
p.ConnectTimeout, p.ReadTimeout,
|
p.ConnectTimeout, p.ReadTimeout,
|
||||||
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
|
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
|
||||||
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
|
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
|
||||||
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON),
|
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON), nullableJSON(p.IRC, ircJSON),
|
||||||
p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339),
|
p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -143,7 +144,7 @@ func (s *Store) GetProfile(id string) (*models.Profile, error) {
|
|||||||
tags, folder, environment, notes, favourite, last_connected,
|
tags, folder, environment, notes, favourite, last_connected,
|
||||||
connect_timeout, read_timeout,
|
connect_timeout, read_timeout,
|
||||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM profiles WHERE id = ?`, id)
|
FROM profiles WHERE id = ?`, id)
|
||||||
|
|
||||||
@@ -165,13 +166,14 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
|
|||||||
rawJSON, _ := json.Marshal(p.RAW)
|
rawJSON, _ := json.Marshal(p.RAW)
|
||||||
githubJSON, _ := json.Marshal(p.GitHub)
|
githubJSON, _ := json.Marshal(p.GitHub)
|
||||||
vncJSON, _ := json.Marshal(p.VNC)
|
vncJSON, _ := json.Marshal(p.VNC)
|
||||||
|
ircJSON, _ := json.Marshal(p.IRC)
|
||||||
|
|
||||||
res, err := s.db.Exec(`UPDATE profiles SET
|
res, err := s.db.Exec(`UPDATE profiles SET
|
||||||
display_name=?, description=?, protocol=?, host=?, port=?, username=?,
|
display_name=?, description=?, protocol=?, host=?, port=?, username=?,
|
||||||
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
|
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
|
||||||
connect_timeout=?, read_timeout=?,
|
connect_timeout=?, read_timeout=?,
|
||||||
terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?,
|
terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?,
|
||||||
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?,
|
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?, irc_config=?,
|
||||||
updated_at=?
|
updated_at=?
|
||||||
WHERE id=?`,
|
WHERE id=?`,
|
||||||
p.DisplayName, p.Description, string(p.Protocol),
|
p.DisplayName, p.Description, string(p.Protocol),
|
||||||
@@ -181,7 +183,7 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
|
|||||||
p.ConnectTimeout, p.ReadTimeout,
|
p.ConnectTimeout, p.ReadTimeout,
|
||||||
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
|
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
|
||||||
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
|
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
|
||||||
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON),
|
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON), nullableJSON(p.IRC, ircJSON),
|
||||||
p.UpdatedAt.Format(time.RFC3339),
|
p.UpdatedAt.Format(time.RFC3339),
|
||||||
p.ID,
|
p.ID,
|
||||||
)
|
)
|
||||||
@@ -215,7 +217,7 @@ func (s *Store) ListProfiles() ([]*models.Profile, error) {
|
|||||||
tags, folder, environment, notes, favourite, last_connected,
|
tags, folder, environment, notes, favourite, last_connected,
|
||||||
connect_timeout, read_timeout,
|
connect_timeout, read_timeout,
|
||||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM profiles ORDER BY display_name`)
|
FROM profiles ORDER BY display_name`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -234,7 +236,7 @@ func (s *Store) SearchProfiles(query string) ([]*models.Profile, error) {
|
|||||||
tags, folder, environment, notes, favourite, last_connected,
|
tags, folder, environment, notes, favourite, last_connected,
|
||||||
connect_timeout, read_timeout,
|
connect_timeout, read_timeout,
|
||||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM profiles
|
FROM profiles
|
||||||
WHERE display_name LIKE ? OR host LIKE ? OR tags LIKE ?
|
WHERE display_name LIKE ? OR host LIKE ? OR tags LIKE ?
|
||||||
@@ -253,7 +255,7 @@ func (s *Store) ListProfilesByProtocol(protocol string) ([]*models.Profile, erro
|
|||||||
tags, folder, environment, notes, favourite, last_connected,
|
tags, folder, environment, notes, favourite, last_connected,
|
||||||
connect_timeout, read_timeout,
|
connect_timeout, read_timeout,
|
||||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol)
|
FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -271,7 +273,7 @@ func (s *Store) ListProfilesByTag(tag string) ([]*models.Profile, error) {
|
|||||||
tags, folder, environment, notes, favourite, last_connected,
|
tags, folder, environment, notes, favourite, last_connected,
|
||||||
connect_timeout, read_timeout,
|
connect_timeout, read_timeout,
|
||||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q)
|
FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -288,7 +290,7 @@ func (s *Store) ListProfilesByFolder(folder string) ([]*models.Profile, error) {
|
|||||||
tags, folder, environment, notes, favourite, last_connected,
|
tags, folder, environment, notes, favourite, last_connected,
|
||||||
connect_timeout, read_timeout,
|
connect_timeout, read_timeout,
|
||||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM profiles WHERE folder = ? ORDER BY display_name`, folder)
|
FROM profiles WHERE folder = ? ORDER BY display_name`, folder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -305,7 +307,7 @@ func (s *Store) ListFavourites() ([]*models.Profile, error) {
|
|||||||
tags, folder, environment, notes, favourite, last_connected,
|
tags, folder, environment, notes, favourite, last_connected,
|
||||||
connect_timeout, read_timeout,
|
connect_timeout, read_timeout,
|
||||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM profiles WHERE favourite = 1 ORDER BY display_name`)
|
FROM profiles WHERE favourite = 1 ORDER BY display_name`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -579,6 +581,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
|
|||||||
var (
|
var (
|
||||||
protocol, tagsJSON string
|
protocol, tagsJSON string
|
||||||
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
|
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
|
||||||
|
ircJSON sql.NullString
|
||||||
lastConn sql.NullString
|
lastConn sql.NullString
|
||||||
favourite int
|
favourite int
|
||||||
createdAt, updatedAt string
|
createdAt, updatedAt string
|
||||||
@@ -591,7 +594,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
|
|||||||
&favourite, &lastConn,
|
&favourite, &lastConn,
|
||||||
&p.ConnectTimeout, &p.ReadTimeout,
|
&p.ConnectTimeout, &p.ReadTimeout,
|
||||||
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
|
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
|
||||||
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON,
|
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON, &ircJSON,
|
||||||
&createdAt, &updatedAt,
|
&createdAt, &updatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -634,6 +637,10 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
|
|||||||
p.VNC = &models.VNCConfig{}
|
p.VNC = &models.VNCConfig{}
|
||||||
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
|
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
|
||||||
}
|
}
|
||||||
|
if ircJSON.Valid && ircJSON.String != "" {
|
||||||
|
p.IRC = &models.IRCConfig{}
|
||||||
|
_ = json.Unmarshal([]byte(ircJSON.String), p.IRC)
|
||||||
|
}
|
||||||
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
@@ -646,6 +653,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
|
|||||||
var (
|
var (
|
||||||
protocol, tagsJSON string
|
protocol, tagsJSON string
|
||||||
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
|
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
|
||||||
|
ircJSON sql.NullString
|
||||||
lastConn sql.NullString
|
lastConn sql.NullString
|
||||||
favourite int
|
favourite int
|
||||||
createdAt, updatedAt string
|
createdAt, updatedAt string
|
||||||
@@ -658,7 +666,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
|
|||||||
&favourite, &lastConn,
|
&favourite, &lastConn,
|
||||||
&p.ConnectTimeout, &p.ReadTimeout,
|
&p.ConnectTimeout, &p.ReadTimeout,
|
||||||
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
|
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
|
||||||
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON,
|
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON, &ircJSON,
|
||||||
&createdAt, &updatedAt,
|
&createdAt, &updatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -701,6 +709,10 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
|
|||||||
p.VNC = &models.VNCConfig{}
|
p.VNC = &models.VNCConfig{}
|
||||||
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
|
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
|
||||||
}
|
}
|
||||||
|
if ircJSON.Valid && ircJSON.String != "" {
|
||||||
|
p.IRC = &models.IRCConfig{}
|
||||||
|
_ = json.Unmarshal([]byte(ircJSON.String), p.IRC)
|
||||||
|
}
|
||||||
|
|
||||||
result = append(result, p)
|
result = append(result, p)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/andydixon/nexus/internal/models"
|
"github.com/andydixon/nexus/internal/models"
|
||||||
|
"github.com/lrstanley/girc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidationError describes a single field-level validation failure.
|
// ValidationError describes a single field-level validation failure.
|
||||||
@@ -111,6 +112,15 @@ func ValidateProfile(p *models.Profile) error {
|
|||||||
errs.Errors = append(errs.Errors, ve.Errors...)
|
errs.Errors = append(errs.Errors, ve.Errors...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case models.ProtocolIRC:
|
||||||
|
validateProfileHostPort(errs, p.Host, p.Port)
|
||||||
|
if p.IRC == nil {
|
||||||
|
errs.Add("irc", "IRC configuration is required for IRC protocol")
|
||||||
|
} else if err := ValidateIRCConfig(p.IRC); err != nil {
|
||||||
|
if ve, ok := err.(*ValidationErrors); ok {
|
||||||
|
errs.Errors = append(errs.Errors, ve.Errors...)
|
||||||
|
}
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol))
|
errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol))
|
||||||
}
|
}
|
||||||
@@ -403,6 +413,101 @@ func ValidateVNCConfig(profileHost string, cfg *models.VNCConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateIRCConfig validates IRC connection settings.
|
||||||
|
func ValidateIRCConfig(cfg *models.IRCConfig) error {
|
||||||
|
errs := &ValidationErrors{}
|
||||||
|
|
||||||
|
if cfg == nil {
|
||||||
|
return &ValidationError{Field: "irc", Message: "IRC configuration must not be nil"}
|
||||||
|
}
|
||||||
|
|
||||||
|
nick := strings.TrimSpace(cfg.Nick)
|
||||||
|
if nick == "" {
|
||||||
|
errs.Add("irc.nick", "nickname is required")
|
||||||
|
} else if !girc.IsValidNick(nick) {
|
||||||
|
errs.Add("irc.nick", "nickname is not valid for IRC")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.ContainsAny(cfg.RealName, "\r\n\x00") {
|
||||||
|
errs.Add("irc.realName", "real name must not contain control characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidSecretMode(cfg.ServerPasswordMode) {
|
||||||
|
errs.Add("irc.serverPasswordMode", fmt.Sprintf("unsupported secret mode %q", cfg.ServerPasswordMode))
|
||||||
|
}
|
||||||
|
if !isValidSecretMode(cfg.NickServMode) {
|
||||||
|
errs.Add("irc.nickServMode", fmt.Sprintf("unsupported secret mode %q", cfg.NickServMode))
|
||||||
|
}
|
||||||
|
if cfg.NickServIdentify && !isValidNickServAccount(cfg.NickServAccount) {
|
||||||
|
errs.Add("irc.nickServAccount", "NickServ account must not contain control characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
seenRooms := map[string]bool{}
|
||||||
|
for i, room := range cfg.AutoJoinRooms {
|
||||||
|
field := fmt.Sprintf("irc.autoJoinRooms[%d]", i)
|
||||||
|
name := strings.TrimSpace(room)
|
||||||
|
if name == "" {
|
||||||
|
errs.Add(field, "room name must not be empty")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !girc.IsValidChannel(name) {
|
||||||
|
errs.Add(field, "room name must start with a valid IRC channel prefix")
|
||||||
|
}
|
||||||
|
key := strings.ToLower(name)
|
||||||
|
if seenRooms[key] {
|
||||||
|
errs.Add(field, "room is listed more than once")
|
||||||
|
}
|
||||||
|
seenRooms[key] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.SSHTunnel {
|
||||||
|
if err := ValidateHost(cfg.SSHHost); err != nil {
|
||||||
|
errs.Add("irc.sshHost", err.(*ValidationError).Message)
|
||||||
|
}
|
||||||
|
if err := ValidatePort(cfg.SSHPort); err != nil {
|
||||||
|
errs.Add("irc.sshPort", err.(*ValidationError).Message)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.SSHUsername) == "" {
|
||||||
|
errs.Add("irc.sshUsername", "SSH username is required when tunnelling")
|
||||||
|
} else if err := ValidateUsername(cfg.SSHUsername); err != nil {
|
||||||
|
errs.Add("irc.sshUsername", err.(*ValidationError).Message)
|
||||||
|
}
|
||||||
|
if cfg.SSHAuthMethod != "password" && cfg.SSHAuthMethod != "publickey" {
|
||||||
|
errs.Add("irc.sshAuthMethod", fmt.Sprintf("unsupported SSH auth method %q", cfg.SSHAuthMethod))
|
||||||
|
}
|
||||||
|
if !isValidSecretMode(cfg.SSHSecretMode) {
|
||||||
|
errs.Add("irc.sshSecretMode", fmt.Sprintf("unsupported secret mode %q", cfg.SSHSecretMode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.MaxReconnectTries < 0 {
|
||||||
|
errs.Add("irc.maxReconnectTries", "max reconnect attempts must not be negative")
|
||||||
|
}
|
||||||
|
|
||||||
|
if errs.HasErrors() {
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidSecretMode(mode models.SecretMode) bool {
|
||||||
|
switch mode {
|
||||||
|
case models.SecretModeSaved, models.SecretModeSessionOnly, models.SecretModeAskEveryTime:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidNickServAccount(account string) bool {
|
||||||
|
for _, r := range account {
|
||||||
|
if r < 0x20 || r == 0x7f {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func isLoopbackHost(host string) bool {
|
func isLoopbackHost(host string) bool {
|
||||||
h := strings.ToLower(strings.TrimSpace(host))
|
h := strings.ToLower(strings.TrimSpace(host))
|
||||||
if h == "localhost" {
|
if h == "localhost" {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -53,11 +55,13 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
var upgrader = websocket.Upgrader{
|
||||||
CheckOrigin: func(r *http.Request) bool { return true },
|
CheckOrigin: allowProxyOrigin,
|
||||||
ReadBufferSize: 65536,
|
ReadBufferSize: 65536,
|
||||||
WriteBufferSize: 65536,
|
WriteBufferSize: 65536,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxWebSocketMessageBytes = 8 << 20
|
||||||
|
|
||||||
// NewClient creates a new VNC proxy client.
|
// NewClient creates a new VNC proxy client.
|
||||||
func NewClient(cfg Config) *Client {
|
func NewClient(cfg Config) *Client {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -186,6 +190,7 @@ func (c *Client) startProxy(vncConn net.Conn) (string, error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer ws.Close()
|
defer ws.Close()
|
||||||
|
ws.SetReadLimit(maxWebSocketMessageBytes)
|
||||||
|
|
||||||
// Bridge WebSocket ↔ VNC TCP in both directions
|
// Bridge WebSocket ↔ VNC TCP in both directions
|
||||||
done := make(chan struct{}, 2)
|
done := make(chan struct{}, 2)
|
||||||
@@ -252,6 +257,34 @@ func (c *Client) startProxy(vncConn net.Conn) (string, error) {
|
|||||||
return wsURL, nil
|
return wsURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func allowProxyOrigin(r *http.Request) bool {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if origin == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
u, err := url.Parse(origin)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(u.Scheme) {
|
||||||
|
case "wails":
|
||||||
|
return true
|
||||||
|
case "http", "https":
|
||||||
|
return isLoopbackHost(u.Hostname())
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLoopbackHost(host string) bool {
|
||||||
|
h := strings.ToLower(strings.TrimSpace(host))
|
||||||
|
if h == "localhost" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(h)
|
||||||
|
return ip != nil && ip.IsLoopback()
|
||||||
|
}
|
||||||
|
|
||||||
func newProxyToken() (string, error) {
|
func newProxyToken() (string, error) {
|
||||||
token := make([]byte, 16)
|
token := make([]byte, 16)
|
||||||
if _, err := rand.Read(token); err != nil {
|
if _, err := rand.Read(token); err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package vncclient
|
package vncclient
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestNewProxyTokenIsRandomHex(t *testing.T) {
|
func TestNewProxyTokenIsRandomHex(t *testing.T) {
|
||||||
first, err := newProxyToken()
|
first, err := newProxyToken()
|
||||||
@@ -24,3 +27,35 @@ func TestNewProxyTokenIsRandomHex(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAllowProxyOrigin(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
origin string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "missing origin", origin: "", want: true},
|
||||||
|
{name: "wails origin", origin: "wails://wails", want: true},
|
||||||
|
{name: "localhost http", origin: "http://localhost:34115", want: true},
|
||||||
|
{name: "loopback ipv4", origin: "http://127.0.0.1:34115", want: true},
|
||||||
|
{name: "loopback ipv6", origin: "http://[::1]:34115", want: true},
|
||||||
|
{name: "remote http", origin: "http://example.com", want: false},
|
||||||
|
{name: "lookalike host", origin: "http://127.0.0.1.example.com", want: false},
|
||||||
|
{name: "bad origin", origin: "://bad", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/ws", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tt.origin != "" {
|
||||||
|
req.Header.Set("Origin", tt.origin)
|
||||||
|
}
|
||||||
|
if got := allowProxyOrigin(req); got != tt.want {
|
||||||
|
t.Fatalf("allowProxyOrigin(%q) = %v, want %v", tt.origin, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user