Security Fixes
This commit is contained in:
@@ -6,6 +6,7 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
source "$SCRIPT_DIR/scripts/build-common.sh"
|
||||
|
||||
# Colours for output
|
||||
RED='\033[0;31m'
|
||||
@@ -18,7 +19,7 @@ warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
|
||||
err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
|
||||
|
||||
MIN_GO_VERSION="1.25.0"
|
||||
WAILS_VERSION="$(awk '$1 == "github.com/wailsapp/wails/v2" { print $2; exit }' go.mod)"
|
||||
WAILS_VERSION="$(nexus_wails_version go.mod)"
|
||||
WAILS_BIN=""
|
||||
WAILS_USE_GO_RUN=0
|
||||
|
||||
@@ -118,28 +119,15 @@ check_linux_deps() {
|
||||
}
|
||||
|
||||
build_tags() {
|
||||
if command -v pkg-config >/dev/null 2>&1 \
|
||||
&& pkg-config --exists webkit2gtk-4.1 \
|
||||
&& ! pkg-config --exists webkit2gtk-4.0; then
|
||||
echo "webkit2_41"
|
||||
fi
|
||||
nexus_build_tags
|
||||
}
|
||||
|
||||
build_version() {
|
||||
local tag
|
||||
local commit
|
||||
tag="$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")"
|
||||
commit="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
|
||||
echo "${tag}-${commit}"
|
||||
nexus_build_version
|
||||
}
|
||||
|
||||
build_ldflags() {
|
||||
local version="$1"
|
||||
local flags="-s -w -X github.com/andydixon/nexus/internal/api.appVersion=${version}"
|
||||
if [ -n "${NEXUS_UPDATE_URL:-}" ]; then
|
||||
flags="${flags} -X github.com/andydixon/nexus/internal/api.appUpdateURL=${NEXUS_UPDATE_URL}"
|
||||
fi
|
||||
echo "${flags}"
|
||||
nexus_build_ldflags "$1"
|
||||
}
|
||||
|
||||
# Check prerequisites
|
||||
@@ -213,6 +201,7 @@ run_tests() {
|
||||
check_go
|
||||
log "Running tests…"
|
||||
go test ./... -v -count=1
|
||||
(cd frontend && npm test)
|
||||
}
|
||||
|
||||
# Clean build artefacts
|
||||
|
||||
+250
-14
@@ -1,26 +1,262 @@
|
||||
# Nexus Terminal Overview
|
||||
|
||||
Nexus Terminal is a desktop client for managing SSH, Telnet, RAW TCP, and GitHub sessions in one place. It is designed for people who work across multiple servers, environments, or repositories and want a fast, focused workflow with strong credential handling.
|
||||
Nexus Terminal is a desktop connection workspace for people who manage servers, network services, remote desktops, and repositories every day. It brings SSH, Telnet, RAW TCP, VNC, GitHub, and local shell workflows into one focused app with secure credential handling, multi-session organisation, file transfer, automation, and recording built in.
|
||||
|
||||
## What it does
|
||||
## Product positioning
|
||||
|
||||
- Connect to SSH, Telnet, and RAW TCP endpoints with saved profiles or quick connect.
|
||||
- Manage multiple live sessions with tabs, split panes, and broadcast input.
|
||||
- Browse and move files on SSH hosts with a built-in SFTP browser.
|
||||
- Record sessions for later playback.
|
||||
- Store credentials securely with OS keychain support and an encrypted vault fallback.
|
||||
Nexus is more than an SSH client. It is a single command centre for remote access, terminal operations, file movement, raw socket inspection, GitHub repository visibility, and repeatable operational workflows.
|
||||
|
||||
## Highlights
|
||||
Use Nexus when you need to:
|
||||
|
||||
- Multi-session tab bar with pinning, reordering, and split panes.
|
||||
- SFTP browser with drag-and-drop uploads and a directory picker.
|
||||
- Clipboard-aware terminal with search, snippets, and right-click actions.
|
||||
- Auto-reconnect, login scripts, and SSH extras such as jump hosts and port forwarding.
|
||||
- GitHub view for browsing repositories by organisation.
|
||||
- Connect quickly to many systems without re-entering credentials.
|
||||
- Keep multiple live sessions organised with tabs, pinned tabs, split panes, and status indicators.
|
||||
- Move files over SSH without leaving the terminal workspace.
|
||||
- Work safely with host key verification, encrypted secrets, TOTP support, and audit logging.
|
||||
- Reuse commands, automate login flows, record sessions, and react to terminal output.
|
||||
- Support mixed estates that include SSH servers, Telnet devices, raw TCP services, VNC desktops, GitHub repositories, and local shells.
|
||||
|
||||
## Main feature groups
|
||||
|
||||
### Multi-protocol connections
|
||||
|
||||
- **SSH** for secure remote terminal access.
|
||||
- **Telnet** for legacy devices and services.
|
||||
- **RAW TCP** for direct socket interaction, protocol testing, and device troubleshooting.
|
||||
- **VNC** for remote desktop access.
|
||||
- **GitHub** for repository browsing and download workflows.
|
||||
- **Local terminal** for opening a shell on the local machine.
|
||||
- **Quick connect** for one-off sessions without saving a profile.
|
||||
- **Saved profiles** for repeat connections.
|
||||
|
||||
### Profile management
|
||||
|
||||
- Create, edit, duplicate, and delete connection profiles.
|
||||
- Store display names, descriptions, notes, groups, tags, environment labels, and favourites.
|
||||
- Search profiles by name, host, or tag.
|
||||
- Filter profiles by protocol, group, tag, and favourite status.
|
||||
- Test basic host and port reachability before connecting.
|
||||
- Import existing OpenSSH config entries from `~/.ssh/config`, including hostnames, usernames, ports, identity files, and jump hosts.
|
||||
- Export and import profiles using encrypted `.nexus` bundles.
|
||||
- Choose import behaviour for duplicates: skip, overwrite, or duplicate.
|
||||
|
||||
### Multi-session workspace
|
||||
|
||||
- Tabbed sessions with connection state indicators.
|
||||
- Pin tabs to keep important systems visible.
|
||||
- Rename sessions.
|
||||
- Reorder tabs.
|
||||
- Close one session or all sessions.
|
||||
- Split panes vertically or horizontally.
|
||||
- Resize split panes.
|
||||
- Switch tabs by keyboard shortcut.
|
||||
- Broadcast input to multiple connected sessions.
|
||||
- Show disconnected overlays with a one-click reconnect action.
|
||||
- Auto-reconnect support for SSH, Telnet, and RAW TCP profiles.
|
||||
- Command palette for fast access to connections, settings, terminal actions, security tools, import/export, snippets, triggers, and local terminal.
|
||||
|
||||
### Terminal experience
|
||||
|
||||
- xterm.js-powered terminal rendering.
|
||||
- Configurable terminal font family, font size, scrollback, cursor style, and colour theme.
|
||||
- Search within terminal output.
|
||||
- Search options for case sensitivity, regular expressions, and whole-word matching.
|
||||
- Copy, paste, select all, clear, and reset from the terminal context menu.
|
||||
- Clipboard-aware paste behaviour.
|
||||
- Clickable web links where supported.
|
||||
- Alternate-screen scroll handling for tools such as `tmux`, `screen`, and `byobu`.
|
||||
- Export terminal scrollback as plain text.
|
||||
- Send break signals where supported.
|
||||
- Ping active sessions to check responsiveness.
|
||||
|
||||
### SSH capabilities
|
||||
|
||||
- Password authentication.
|
||||
- Public key authentication.
|
||||
- Keyboard-interactive authentication.
|
||||
- SSH agent authentication.
|
||||
- SSH agent forwarding.
|
||||
- Private key path discovery from `~/.ssh`.
|
||||
- SSH key manager for listing, generating, copying, and deleting key pairs.
|
||||
- Ed25519, RSA, and ECDSA key generation.
|
||||
- Optional private key passphrase handling.
|
||||
- TOTP / 2FA secret storage and code generation for keyboard-interactive login flows.
|
||||
- Host key verification policies per profile: Strict, Ask, Accept new, and Insecure.
|
||||
- Known hosts storage and management.
|
||||
- Jump host support for bastion and multi-hop access.
|
||||
- SOCKS5 proxy support.
|
||||
- HTTP CONNECT proxy support.
|
||||
- Local and remote port forwarding.
|
||||
- Runtime port forward management for active SSH sessions.
|
||||
- Startup commands.
|
||||
- Custom environment variables.
|
||||
- Terminal type configuration.
|
||||
- Keepalive interval and missed-keepalive settings.
|
||||
- Compression option.
|
||||
- Expect-style login scripts with wait and send steps.
|
||||
- Built-in SFTP browser for file transfer.
|
||||
|
||||
### SFTP file browser
|
||||
|
||||
- Open a side panel for SSH file browsing.
|
||||
- Navigate remote folders with breadcrumbs.
|
||||
- Go directly to a remote path.
|
||||
- Bookmark remote locations.
|
||||
- Refresh remote listings.
|
||||
- Create folders.
|
||||
- Upload files.
|
||||
- Download single files or multiple selected files.
|
||||
- Rename files and folders.
|
||||
- Delete files and folders.
|
||||
- Multi-select support for batch actions.
|
||||
- Resizable SFTP panel alongside the terminal.
|
||||
- Drag and drop files into an SSH terminal.
|
||||
- Choose whether dropped files are uploaded or pasted into the terminal as text.
|
||||
- Upload directory picker with folder filtering, breadcrumbs, keyboard navigation, and permission badges.
|
||||
- Permission labels for read-only and inaccessible folders.
|
||||
|
||||
### Telnet capabilities
|
||||
|
||||
- Telnet option negotiation.
|
||||
- Terminal type negotiation.
|
||||
- NAWS window-size updates.
|
||||
- Local echo option.
|
||||
- Line mode option.
|
||||
- Configurable line endings: LF, CR, CRLF, or none.
|
||||
- Configurable encoding: UTF-8, ASCII, and ISO 8859-1.
|
||||
- Negotiation debug mode.
|
||||
- Expect-style login scripts.
|
||||
- Auto-reconnect with configurable retry count.
|
||||
- Break signal support.
|
||||
|
||||
### RAW TCP capabilities
|
||||
|
||||
- Direct TCP socket sessions.
|
||||
- Text, hex, and split data views.
|
||||
- Send data as text, hex, or escaped bytes.
|
||||
- Quick insert controls for common bytes such as NUL, CR, LF, ETX, and EOT.
|
||||
- Payload presets for reusable test messages.
|
||||
- Optional timestamps.
|
||||
- Optional send/receive direction labels.
|
||||
- Pause and resume display updates.
|
||||
- Clear the capture buffer.
|
||||
- Save RAW session captures.
|
||||
- Configurable encoding and line endings.
|
||||
- Connect and read timeouts.
|
||||
- Auto-reconnect option.
|
||||
|
||||
### VNC capabilities
|
||||
|
||||
- VNC remote desktop sessions.
|
||||
- noVNC-based viewer inside the Nexus workspace.
|
||||
- Password prompt and password storage modes.
|
||||
- View-only mode.
|
||||
- Scale-to-fit display.
|
||||
- Shared-session option.
|
||||
- Quality setting.
|
||||
- SSH tunnel option for VNC traffic.
|
||||
- SSH tunnel authentication by password, public key, or SSH agent.
|
||||
|
||||
### GitHub capabilities
|
||||
|
||||
- GitHub profile type using a personal access token.
|
||||
- GitHub.com and GitHub Enterprise API base URL support.
|
||||
- Repository viewer grouped by owner or organisation.
|
||||
- Repository filtering by name, description, or language.
|
||||
- Repositories sorted by latest commit activity.
|
||||
- Shows repository metadata such as language, stars, private/fork/archived state, latest committer, and latest commit time.
|
||||
- Manual refresh.
|
||||
- Configurable refresh interval.
|
||||
- Open repositories in the browser.
|
||||
- Download repository ZIP archives.
|
||||
|
||||
### Automation and reusable workflows
|
||||
|
||||
- Snippet library for saved commands.
|
||||
- Snippet folders, descriptions, and tags.
|
||||
- Send snippets to active sessions.
|
||||
- Snippet variables before sending commands.
|
||||
- Broadcast input mode for running the same command across connected sessions.
|
||||
- Trigger rules that watch terminal output.
|
||||
- Trigger matching by text or regular expression.
|
||||
- Trigger actions for notifications, tab highlighting, sound, or combined notify/highlight behaviour.
|
||||
- Expect-style login scripts for SSH and Telnet.
|
||||
- Startup commands for SSH sessions.
|
||||
|
||||
### Session recording
|
||||
|
||||
- Start and stop terminal recording per session.
|
||||
- Recording indicator on active tabs.
|
||||
- Recordings saved in asciicast v2-compatible format.
|
||||
- Recording browser for listing saved recordings.
|
||||
- Playback controls.
|
||||
- Delete saved recordings.
|
||||
- Recording metadata includes title, dimensions, duration, size, creation time, and file path.
|
||||
|
||||
### Security and credential handling
|
||||
|
||||
- OS keychain first where available, including macOS Keychain, Linux Secret Service, and Windows Credential Manager.
|
||||
- Encrypted vault fallback.
|
||||
- Vault encryption with AES-256-GCM and Argon2id-derived keys.
|
||||
- Secret store lock and unlock workflow.
|
||||
- Per-profile secret modes: Saved, Session only, and Ask every time.
|
||||
- Encrypted storage for TOTP secrets.
|
||||
- Known host verification and known host management.
|
||||
- Audit log for security-relevant operations.
|
||||
- Audit entries for connections, disconnections, profile changes, secret access, imports, and exports.
|
||||
- Secrets are not written into audit entries.
|
||||
- Encrypted profile exports with password-based AES-256-GCM encryption and Argon2id key derivation.
|
||||
- Plain profile export can redact inline secrets.
|
||||
|
||||
### Settings and maintenance
|
||||
|
||||
- Application theme setting.
|
||||
- Terminal theme selection.
|
||||
- Default protocol preference.
|
||||
- Default terminal font, size, scrollback, and cursor settings.
|
||||
- Confirm-on-close and confirm-on-disconnect preferences.
|
||||
- Status bar visibility setting.
|
||||
- Sidebar width preference.
|
||||
- Native paste preference.
|
||||
- Local shell selection.
|
||||
- Secret store settings.
|
||||
- Known hosts management.
|
||||
- Help and troubleshooting content inside settings.
|
||||
- Background and manual update checks.
|
||||
- Update checks report availability, current version, latest version, release notes, release URL, and publish date.
|
||||
|
||||
## Website-ready summary
|
||||
|
||||
### Suggested headline
|
||||
|
||||
Nexus Terminal: one desktop workspace for SSH, files, sockets, remote desktops, and repositories.
|
||||
|
||||
### Suggested subheadline
|
||||
|
||||
Manage SSH, Telnet, RAW TCP, VNC, GitHub, and local shell sessions from a fast tabbed interface with secure credentials, SFTP file transfer, split panes, snippets, recordings, and automation built in.
|
||||
|
||||
### Short product description
|
||||
|
||||
Nexus Terminal helps developers, operators, support engineers, and homelab users manage many connections without juggling separate tools. Save profiles, organise them with groups and tags, connect in tabs or split panes, move files over SFTP, inspect raw TCP traffic, open VNC desktops, browse GitHub repositories, and keep credentials protected with keychain and encrypted-vault storage.
|
||||
|
||||
### Product page feature bullets
|
||||
|
||||
- All-in-one connection workspace for SSH, Telnet, RAW TCP, VNC, GitHub, and local shells.
|
||||
- Fast multi-session interface with tabs, pinned sessions, split panes, reconnect, and broadcast input.
|
||||
- Rich SSH support including agent auth, TOTP, jump hosts, proxies, port forwarding, startup commands, login scripts, and host key policies.
|
||||
- Built-in SFTP browser with upload, download, rename, delete, folders, bookmarks, drag-and-drop upload, and permission-aware directory picking.
|
||||
- RAW TCP tooling with text, hex, split views, payload presets, captures, timestamps, and byte-level sending.
|
||||
- VNC viewer with scale-to-fit, view-only mode, quality controls, shared sessions, and optional SSH tunnelling.
|
||||
- GitHub repository browser with organisation grouping, filtering, activity sorting, metadata, ZIP downloads, and GitHub Enterprise support.
|
||||
- Secure credential model using OS keychain first, encrypted vault fallback, per-profile secret modes, known hosts, encrypted exports, and audit logging.
|
||||
- Productivity tools including command palette, snippets, trigger rules, session recording, terminal search, scrollback export, and local terminal.
|
||||
|
||||
## Who it is for
|
||||
|
||||
Nexus Terminal suits operators, developers, and support teams who need to connect to many endpoints quickly, keep sessions organised, and avoid re-entering credentials.
|
||||
- Developers who connect to many application servers, staging environments, and production hosts.
|
||||
- Platform and DevOps teams who need organised, secure access across infrastructure.
|
||||
- Support teams who troubleshoot customer systems, network appliances, and legacy Telnet devices.
|
||||
- Network and hardware engineers who need RAW TCP and Telnet tools beside normal SSH workflows.
|
||||
- Homelab users who want SSH, VNC, file transfer, and repo access in one desktop app.
|
||||
|
||||
## Learn more
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"bundle:novnc": "node scripts/bundle-novnc.js",
|
||||
"build": "node scripts/build.js",
|
||||
"dev": "node scripts/build.js --watch",
|
||||
"test": "node scripts/test-regressions.js",
|
||||
"postinstall": "npm run install:assets && npm run bundle:novnc"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const repo = path.resolve(root, "..");
|
||||
|
||||
function read(rel) {
|
||||
return fs.readFileSync(path.join(repo, rel), "utf8");
|
||||
}
|
||||
|
||||
function assertContains(file, needle, message) {
|
||||
const text = read(file);
|
||||
if (!text.includes(needle)) {
|
||||
throw new Error(`${message}\nMissing ${JSON.stringify(needle)} in ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotContains(file, needle, message) {
|
||||
const text = read(file);
|
||||
if (text.includes(needle)) {
|
||||
throw new Error(`${message}\nUnexpected ${JSON.stringify(needle)} in ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
assertContains(
|
||||
"frontend/src/js/terminal.js",
|
||||
"escapeHtml(title)",
|
||||
"file-drop prompt titles must escape filenames containing HTML characters",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/terminal.js",
|
||||
"SFTPUploadStart",
|
||||
"drag-drop upload must use chunked SFTP streaming",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/terminal.js",
|
||||
"SFTPUploadChunk",
|
||||
"drag-drop upload must send bounded chunks",
|
||||
);
|
||||
assertNotContains(
|
||||
"frontend/src/js/terminal.js",
|
||||
"Use SFTP Upload for files over 25 MB",
|
||||
"drag-drop upload must not reject files only because they exceed the old inline limit",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/app.js",
|
||||
"confirmDirectVNCProfile",
|
||||
"direct VNC connections must show an explicit warning",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/profiles.js",
|
||||
"skipDirectVNCWarning",
|
||||
"save-and-connect should not show duplicate direct VNC prompts",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/recordings.js",
|
||||
"import-status",
|
||||
"profile import failures must be visible inside the import modal",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/recordings.js",
|
||||
"_formatImportError",
|
||||
"profile import failures must be formatted without using HTML injection",
|
||||
);
|
||||
assertContains(
|
||||
"internal/api/api_test.go",
|
||||
"TestCreateProfileRejectsUnsafeGitHubBaseURL",
|
||||
"GitHub HTTP/HTTPS URL validation must stay covered",
|
||||
);
|
||||
|
||||
console.log("frontend regression checks passed");
|
||||
@@ -116,6 +116,19 @@
|
||||
border-color: #e53935;
|
||||
}
|
||||
|
||||
/* Warning */
|
||||
.btn-warning {
|
||||
background: var(--warning);
|
||||
color: #1a1a1e;
|
||||
border-color: var(--warning);
|
||||
}
|
||||
.btn-warning:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
.btn-warning:active {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
/* Size variants */
|
||||
.btn-sm {
|
||||
height: 26px;
|
||||
|
||||
@@ -18,6 +18,34 @@
|
||||
}
|
||||
|
||||
/* Recordings list in browser modal */
|
||||
.recording-storage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 0 12px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border, #3f3f46);
|
||||
}
|
||||
|
||||
.recording-storage-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #a1a1aa);
|
||||
}
|
||||
|
||||
.recording-cleanup-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recording-cleanup-status {
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.recordings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -66,6 +94,12 @@
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.recording-cleanup-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Playback modal — larger to fit a terminal */
|
||||
.modal-playback {
|
||||
width: 90vw;
|
||||
|
||||
+80
-2
@@ -744,7 +744,7 @@
|
||||
* Opens a new session for the given profile ID.
|
||||
* @param {string} profileId
|
||||
*/
|
||||
async connectProfile(profileId) {
|
||||
async connectProfile(profileId, options = {}) {
|
||||
const profile = this.state.getProfile(profileId);
|
||||
if (!profile) {
|
||||
this.notifications.error("Profile not found");
|
||||
@@ -774,6 +774,14 @@
|
||||
if (decision !== "new") return;
|
||||
}
|
||||
|
||||
if (
|
||||
!options.skipDirectVNCWarning &&
|
||||
this._requiresDirectVNCWarning(profile)
|
||||
) {
|
||||
const ok = await this.confirmDirectVNCProfile(profile, "connect");
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionInfo =
|
||||
await window.go.api.Service.ConnectSession(profileId);
|
||||
@@ -977,7 +985,9 @@
|
||||
*/
|
||||
_confirmReuseSession(profile, session) {
|
||||
const createElement = window.NexusTerminal.createElement;
|
||||
const label = (profile.displayName || profile.host || "Untitled").trim();
|
||||
const label = window.NexusTerminal.escapeHtml(
|
||||
(profile.displayName || profile.host || "Untitled").trim(),
|
||||
);
|
||||
const stateLabel =
|
||||
session.state === "connected" ? "connected" : "connecting";
|
||||
|
||||
@@ -1033,6 +1043,74 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether a profile connects directly to VNC without SSH tunnelling.
|
||||
* @param {Object} profile
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
_requiresDirectVNCWarning(profile) {
|
||||
return !!(
|
||||
profile &&
|
||||
profile.protocol === "vnc" &&
|
||||
profile.vnc &&
|
||||
!profile.vnc.sshTunnel
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warns before saving or connecting direct VNC profiles.
|
||||
* @param {Object} profile
|
||||
* @param {"save"|"connect"} action
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
confirmDirectVNCProfile(profile, action) {
|
||||
const { createElement, createModalShell } = window.NexusTerminal;
|
||||
const label = (
|
||||
profile.displayName ||
|
||||
profile.vnc?.vncHost ||
|
||||
profile.host ||
|
||||
"VNC"
|
||||
).trim();
|
||||
const actionLabel = action === "connect" ? "Connect" : "Save";
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const shell = createModalShell("Direct VNC Connection", "modal-sm");
|
||||
const bodyText = createElement("p", "", {
|
||||
textContent:
|
||||
label +
|
||||
" is configured without an SSH tunnel. VNC traffic may be visible to the network unless the server is otherwise protected.",
|
||||
});
|
||||
bodyText.style.fontSize = "var(--text-sm)";
|
||||
bodyText.style.color = "var(--text-secondary)";
|
||||
shell.body.appendChild(bodyText);
|
||||
|
||||
const cancelBtn = createElement("button", "btn btn-sm", {
|
||||
textContent: "Cancel",
|
||||
});
|
||||
const okBtn = createElement(
|
||||
"button",
|
||||
"btn btn-sm btn-warning direct-vnc-confirm",
|
||||
{ textContent: actionLabel },
|
||||
);
|
||||
shell.footer.appendChild(cancelBtn);
|
||||
shell.footer.appendChild(okBtn);
|
||||
|
||||
const close = (value) => {
|
||||
shell.remove();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
shell.closeButton.addEventListener("click", () => close(false));
|
||||
cancelBtn.addEventListener("click", () => close(false));
|
||||
okBtn.addEventListener("click", () => close(true));
|
||||
shell.backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === shell.backdrop) close(false);
|
||||
});
|
||||
shell.show();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Import / Export ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -2314,6 +2314,17 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.app._requiresDirectVNCWarning &&
|
||||
this.app._requiresDirectVNCWarning(this._profile)
|
||||
) {
|
||||
const ok = await this.app.confirmDirectVNCProfile(
|
||||
this._profile,
|
||||
andConnect ? "connect" : "save",
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
try {
|
||||
let saved;
|
||||
if (this._isNew) {
|
||||
@@ -2329,7 +2340,7 @@
|
||||
this.close();
|
||||
|
||||
if (andConnect && saved && saved.id) {
|
||||
this.app.connectProfile(saved.id);
|
||||
this.app.connectProfile(saved.id, { skipDirectVNCWarning: true });
|
||||
}
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Save failed: " + err);
|
||||
|
||||
@@ -70,8 +70,11 @@ class RecordingManager {
|
||||
*/
|
||||
async openBrowser() {
|
||||
try {
|
||||
const recordings = await window.go.api.Service.ListRecordings();
|
||||
this._showBrowserModal(recordings || []);
|
||||
const [recordings, storageInfo] = await Promise.all([
|
||||
window.go.api.Service.ListRecordings(),
|
||||
window.go.api.Service.GetRecordingStorageInfo(),
|
||||
]);
|
||||
this._showBrowserModal(recordings || [], storageInfo || {});
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to list recordings: ' + err);
|
||||
}
|
||||
@@ -133,9 +136,10 @@ class RecordingManager {
|
||||
/**
|
||||
* Renders a modal listing all saved recordings.
|
||||
* @param {Array} recordings
|
||||
* @param {Object} storageInfo
|
||||
* @private
|
||||
*/
|
||||
_showBrowserModal(recordings) {
|
||||
_showBrowserModal(recordings, storageInfo = {}) {
|
||||
const backdrop = createElement('div', 'modal-backdrop');
|
||||
const modal = createElement('div', 'modal modal-lg');
|
||||
|
||||
@@ -164,12 +168,36 @@ class RecordingManager {
|
||||
listHTML += '</div>';
|
||||
}
|
||||
|
||||
const totalSize = _formatBytes(storageInfo.totalBytes || 0);
|
||||
const count = storageInfo.count ?? recordings.length;
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Session Recordings</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="max-height:60vh;overflow-y:auto;">
|
||||
<div class="recording-storage">
|
||||
<div class="recording-storage-summary">
|
||||
<span>${count} recording${count === 1 ? '' : 's'}</span>
|
||||
<span>${totalSize}</span>
|
||||
</div>
|
||||
<div class="recording-cleanup-controls">
|
||||
<select class="form-input recording-cleanup-age">
|
||||
<option value="30">Older than 30 days</option>
|
||||
<option value="7">Older than 7 days</option>
|
||||
<option value="90">Older than 90 days</option>
|
||||
<option value="0">Ignore age</option>
|
||||
</select>
|
||||
<select class="form-input recording-cleanup-size">
|
||||
<option value="1073741824">Keep under 1 GB</option>
|
||||
<option value="5368709120">Keep under 5 GB</option>
|
||||
<option value="0">Ignore size</option>
|
||||
</select>
|
||||
<button class="btn btn-sm recording-cleanup">Clean up</button>
|
||||
</div>
|
||||
<div class="form-help recording-cleanup-status"></div>
|
||||
</div>
|
||||
${listHTML}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -189,6 +217,26 @@ class RecordingManager {
|
||||
modal.querySelector('.modal-close').addEventListener('click', close);
|
||||
modal.querySelector('.modal-close-btn').addEventListener('click', close);
|
||||
|
||||
modal.querySelector('.recording-cleanup').addEventListener('click', async () => {
|
||||
const ageDays = parseInt(modal.querySelector('.recording-cleanup-age').value, 10) || 0;
|
||||
const maxBytes = parseInt(modal.querySelector('.recording-cleanup-size').value, 10) || 0;
|
||||
const status = modal.querySelector('.recording-cleanup-status');
|
||||
const btn = modal.querySelector('.recording-cleanup');
|
||||
status.textContent = '';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const result = await window.go.api.Service.CleanupRecordings(ageDays, maxBytes);
|
||||
status.textContent = `Deleted ${result.deleted || 0}; freed ${_formatBytes(result.freedBytes || 0)}.`;
|
||||
const fresh = await window.go.api.Service.ListRecordings();
|
||||
close();
|
||||
this._showBrowserModal(fresh || [], await window.go.api.Service.GetRecordingStorageInfo());
|
||||
} catch (err) {
|
||||
status.textContent = 'Cleanup failed: ' + err;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Play buttons
|
||||
modal.querySelectorAll('.rec-play').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
@@ -545,6 +593,7 @@ class ExportImportUI {
|
||||
<option value="duplicate">Create duplicates</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-error import-status" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm import-cancel">Cancel</button>
|
||||
@@ -567,13 +616,18 @@ class ExportImportUI {
|
||||
modal.querySelector('.import-confirm').addEventListener('click', async () => {
|
||||
const password = modal.querySelector('.import-password').value;
|
||||
const strategy = modal.querySelector('.import-strategy').value;
|
||||
const status = modal.querySelector('.import-status');
|
||||
const confirmBtn = modal.querySelector('.import-confirm');
|
||||
status.textContent = '';
|
||||
|
||||
if (!password) {
|
||||
this.app.notifications.error('Please enter the decryption password.');
|
||||
status.textContent = 'Please enter the decryption password.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = 'Importing...';
|
||||
const count = await window.go.api.Service.ImportProfilesEncrypted(password, strategy);
|
||||
this.app.notifications.success(`Imported ${count} profile(s)`);
|
||||
await this.app.loadProfiles();
|
||||
@@ -582,10 +636,19 @@ class ExportImportUI {
|
||||
if (String(err).includes('cancelled')) {
|
||||
return;
|
||||
}
|
||||
this.app.notifications.error('Import failed: ' + err);
|
||||
status.textContent = this._formatImportError(err);
|
||||
this.app.notifications.error('Import failed');
|
||||
} finally {
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = 'Import';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_formatImportError(err) {
|
||||
const msg = String(err || 'Unknown import error');
|
||||
return msg.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+42
-25
@@ -7,7 +7,7 @@
|
||||
|
||||
/* global Terminal, FitAddon, SearchAddon, WebLinksAddon, Unicode11Addon */
|
||||
|
||||
const MAX_INLINE_UPLOAD_BYTES = 25 * 1024 * 1024;
|
||||
const UPLOAD_CHUNK_BYTES = 768 * 1024;
|
||||
|
||||
/**
|
||||
* Encode a string as UTF-8 bytes then base64-encode them.
|
||||
@@ -828,31 +828,8 @@
|
||||
|
||||
for (let i = 0; i < files.length; i += 1) {
|
||||
const file = files[i];
|
||||
if (typeof file.size === "number" && file.size > MAX_INLINE_UPLOAD_BYTES) {
|
||||
this.app.notifications.error(
|
||||
"Upload failed for " +
|
||||
file.name +
|
||||
": file is too large for drag-and-drop upload. Use SFTP Upload for files over 25 MB.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const buffer = await this._readFileBuffer(file);
|
||||
if (buffer && buffer.byteLength > MAX_INLINE_UPLOAD_BYTES) {
|
||||
this.app.notifications.error(
|
||||
"Upload failed for " +
|
||||
file.name +
|
||||
": file is too large for drag-and-drop upload. Use SFTP Upload for files over 25 MB.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const base64Data = this._arrayBufferToBase64(buffer);
|
||||
await window.go.api.Service.SFTPUploadData(
|
||||
sessionId,
|
||||
remoteDir,
|
||||
file.name,
|
||||
base64Data,
|
||||
);
|
||||
await this._uploadDroppedFileStream(sessionId, remoteDir, file);
|
||||
} catch (err) {
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
this.app.notifications.error(
|
||||
@@ -875,6 +852,46 @@
|
||||
});
|
||||
}
|
||||
|
||||
async _uploadDroppedFileStream(sessionId, remoteDir, file) {
|
||||
const totalBytes = typeof file.size === "number" ? file.size : 0;
|
||||
let uploadId = "";
|
||||
try {
|
||||
uploadId = await window.go.api.Service.SFTPUploadStart(
|
||||
sessionId,
|
||||
remoteDir,
|
||||
file.name,
|
||||
totalBytes,
|
||||
);
|
||||
|
||||
for (let offset = 0; offset < totalBytes; offset += UPLOAD_CHUNK_BYTES) {
|
||||
const end = Math.min(offset + UPLOAD_CHUNK_BYTES, totalBytes);
|
||||
const buffer = await this._readFileSlice(file, offset, end);
|
||||
const base64Data = this._arrayBufferToBase64(buffer);
|
||||
await window.go.api.Service.SFTPUploadChunk(uploadId, base64Data);
|
||||
}
|
||||
|
||||
await window.go.api.Service.SFTPUploadFinish(uploadId);
|
||||
} catch (err) {
|
||||
if (uploadId) {
|
||||
try {
|
||||
await window.go.api.Service.SFTPUploadCancel(uploadId);
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
_readFileSlice(file, start, end) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = () => reject(new Error("read failed"));
|
||||
reader.readAsArrayBuffer(file.slice(start, end));
|
||||
});
|
||||
}
|
||||
|
||||
_readFileBuffer(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
@@ -52,6 +52,52 @@
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standard modal skeleton without HTML string interpolation.
|
||||
* @param {string} title
|
||||
* @param {string} [sizeClass]
|
||||
* @returns {{backdrop: HTMLElement, modal: HTMLElement, body: HTMLElement, footer: HTMLElement, closeButton: HTMLElement, show: Function, remove: Function}}
|
||||
*/
|
||||
function createModalShell(title, sizeClass = "modal-sm") {
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", `modal ${sizeClass}`);
|
||||
const header = createElement("div", "modal-header");
|
||||
const titleEl = createElement("h3", "modal-title", { textContent: title });
|
||||
const closeButton = createElement("button", "btn btn-icon btn-xs modal-close", {
|
||||
textContent: "x",
|
||||
"aria-label": "Close",
|
||||
});
|
||||
const body = createElement("div", "modal-body");
|
||||
const footer = createElement("div", "modal-footer");
|
||||
|
||||
header.appendChild(titleEl);
|
||||
header.appendChild(closeButton);
|
||||
modal.appendChild(header);
|
||||
modal.appendChild(body);
|
||||
modal.appendChild(footer);
|
||||
backdrop.appendChild(modal);
|
||||
|
||||
return {
|
||||
backdrop,
|
||||
modal,
|
||||
body,
|
||||
footer,
|
||||
closeButton,
|
||||
show() {
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
});
|
||||
},
|
||||
remove() {
|
||||
backdrop.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes HTML special characters to prevent XSS.
|
||||
* @param {string} str - Raw string.
|
||||
@@ -305,6 +351,7 @@
|
||||
$,
|
||||
$$,
|
||||
createElement,
|
||||
createModalShell,
|
||||
escapeHtml,
|
||||
formatTimestamp,
|
||||
debounce,
|
||||
|
||||
+199
-19
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -43,8 +44,10 @@ var appVersion = "dev"
|
||||
var appUpdateURL = ""
|
||||
|
||||
const (
|
||||
maxProfileImportJSONBytes = 5 * 1024 * 1024
|
||||
maxInlineSFTPUploadBytes = 25 * 1024 * 1024
|
||||
maxProfileImportJSONBytes = 5 * 1024 * 1024
|
||||
maxInlineSFTPUploadBytes = 25 * 1024 * 1024
|
||||
maxSFTPUploadChunkBytes = 1024 * 1024
|
||||
maxStreamingSFTPUploadSize = int64(2 * 1024 * 1024 * 1024)
|
||||
)
|
||||
|
||||
// Service is the main API surface exposed to the frontend via Wails bindings.
|
||||
@@ -60,6 +63,17 @@ type Service struct {
|
||||
// secretChans maps request IDs to channels awaiting user secret input.
|
||||
secretMu sync.Mutex
|
||||
secretChans map[string]chan secretResponse
|
||||
|
||||
uploadMu sync.Mutex
|
||||
uploads map[string]*sftpUpload
|
||||
}
|
||||
|
||||
type sftpUpload struct {
|
||||
mu sync.Mutex
|
||||
writer io.WriteCloser
|
||||
sessionID string
|
||||
remotePath string
|
||||
written int64
|
||||
}
|
||||
|
||||
// NewService creates a Service wired to the given backing components.
|
||||
@@ -78,6 +92,7 @@ func NewService(
|
||||
sessions: sess,
|
||||
auditLog: auditLog,
|
||||
prefs: prefs,
|
||||
uploads: make(map[string]*sftpUpload),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1088,6 +1103,39 @@ func (s *Service) DeleteRecording(id string) error {
|
||||
return s.sessions.DeleteRecording(id)
|
||||
}
|
||||
|
||||
// GetRecordingStorageInfo returns aggregate recording storage usage and limits.
|
||||
func (s *Service) GetRecordingStorageInfo() (map[string]interface{}, error) {
|
||||
info, err := s.sessions.RecordingStorageInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := map[string]interface{}{
|
||||
"count": info.Count,
|
||||
"totalBytes": info.TotalBytes,
|
||||
"maxRecordingBytes": info.MaxRecordingBytes,
|
||||
"maxPlaybackBytes": info.MaxPlaybackBytes,
|
||||
}
|
||||
if info.OldestCreatedAt != nil {
|
||||
result["oldestCreatedAt"] = info.OldestCreatedAt.Format(time.RFC3339)
|
||||
}
|
||||
if info.NewestCreatedAt != nil {
|
||||
result["newestCreatedAt"] = info.NewestCreatedAt.Format(time.RFC3339)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CleanupRecordings removes recordings by age and total storage limit.
|
||||
func (s *Service) CleanupRecordings(maxAgeDays int, maxTotalBytes int64) (map[string]interface{}, error) {
|
||||
result, err := s.sessions.CleanupRecordings(maxAgeDays, maxTotalBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"deleted": result.Deleted,
|
||||
"freedBytes": result.FreedBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encrypted export / import
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1349,15 +1397,120 @@ func (s *Service) SFTPUploadData(sessionID string, remoteDir string, fileName st
|
||||
return fmt.Errorf("upload payload is too large (max %d bytes)", maxInlineSFTPUploadBytes)
|
||||
}
|
||||
|
||||
safeName := filepath.Base(fileName)
|
||||
if safeName == "" || safeName == "." || safeName == "/" {
|
||||
return fmt.Errorf("invalid file name")
|
||||
safeName, err := safeSFTPUploadName(fileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
destPath := path.Join(remoteDir, safeName)
|
||||
return s.sessions.SFTPUploadBytes(sessionID, destPath, data)
|
||||
}
|
||||
|
||||
// SFTPUploadStart opens a remote SFTP file for chunked browser uploads.
|
||||
func (s *Service) SFTPUploadStart(sessionID string, remoteDir string, fileName string, totalBytes int64) (string, error) {
|
||||
if s.sessions == nil {
|
||||
return "", fmt.Errorf("sessions service is not configured")
|
||||
}
|
||||
if totalBytes < 0 {
|
||||
return "", fmt.Errorf("upload size must not be negative")
|
||||
}
|
||||
if totalBytes > maxStreamingSFTPUploadSize {
|
||||
return "", fmt.Errorf("upload is too large (max %d bytes)", maxStreamingSFTPUploadSize)
|
||||
}
|
||||
|
||||
safeName, err := safeSFTPUploadName(fileName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
remotePath := path.Join(remoteDir, safeName)
|
||||
writer, err := s.sessions.SFTPOpenUpload(sessionID, remotePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
uploadID := uuid.New().String()
|
||||
s.uploadMu.Lock()
|
||||
s.uploads[uploadID] = &sftpUpload{
|
||||
writer: writer,
|
||||
sessionID: sessionID,
|
||||
remotePath: remotePath,
|
||||
}
|
||||
s.uploadMu.Unlock()
|
||||
return uploadID, nil
|
||||
}
|
||||
|
||||
// SFTPUploadChunk writes a base64-encoded chunk to an active browser upload.
|
||||
func (s *Service) SFTPUploadChunk(uploadID string, base64Chunk string) error {
|
||||
if base64Chunk == "" {
|
||||
return fmt.Errorf("empty upload chunk")
|
||||
}
|
||||
if base64.StdEncoding.DecodedLen(len(base64Chunk)) > maxSFTPUploadChunkBytes {
|
||||
return fmt.Errorf("upload chunk is too large (max %d bytes)", maxSFTPUploadChunkBytes)
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(base64Chunk)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode upload chunk: %w", err)
|
||||
}
|
||||
if len(data) > maxSFTPUploadChunkBytes {
|
||||
return fmt.Errorf("upload chunk is too large (max %d bytes)", maxSFTPUploadChunkBytes)
|
||||
}
|
||||
|
||||
upload, err := s.lookupSFTPUpload(uploadID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
upload.mu.Lock()
|
||||
defer upload.mu.Unlock()
|
||||
if upload.written+int64(len(data)) > maxStreamingSFTPUploadSize {
|
||||
return fmt.Errorf("upload is too large (max %d bytes)", maxStreamingSFTPUploadSize)
|
||||
}
|
||||
n, err := upload.writer.Write(data)
|
||||
upload.written += int64(n)
|
||||
if err != nil {
|
||||
s.removeSFTPUpload(uploadID)
|
||||
_ = upload.writer.Close()
|
||||
if s.sessions != nil {
|
||||
_ = s.sessions.SFTPRemove(upload.sessionID, upload.remotePath)
|
||||
}
|
||||
return fmt.Errorf("failed to write upload chunk: %w", err)
|
||||
}
|
||||
if n != len(data) {
|
||||
s.removeSFTPUpload(uploadID)
|
||||
_ = upload.writer.Close()
|
||||
if s.sessions != nil {
|
||||
_ = s.sessions.SFTPRemove(upload.sessionID, upload.remotePath)
|
||||
}
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SFTPUploadFinish closes an active browser upload.
|
||||
func (s *Service) SFTPUploadFinish(uploadID string) error {
|
||||
upload, err := s.takeSFTPUpload(uploadID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
upload.mu.Lock()
|
||||
defer upload.mu.Unlock()
|
||||
return upload.writer.Close()
|
||||
}
|
||||
|
||||
// SFTPUploadCancel closes and removes a partially uploaded remote file.
|
||||
func (s *Service) SFTPUploadCancel(uploadID string) error {
|
||||
upload, err := s.takeSFTPUpload(uploadID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
upload.mu.Lock()
|
||||
closeErr := upload.writer.Close()
|
||||
upload.mu.Unlock()
|
||||
if s.sessions != nil {
|
||||
_ = s.sessions.SFTPRemove(upload.sessionID, upload.remotePath)
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
// SFTPPwd returns the current working directory on the remote host.
|
||||
func (s *Service) SFTPPwd(sessionID string) (string, error) {
|
||||
return s.sessions.SFTPPwd(sessionID)
|
||||
@@ -1751,6 +1904,46 @@ func validateSSHKeyName(name string) (string, error) {
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func safeSFTPUploadName(fileName string) (string, error) {
|
||||
name := strings.TrimSpace(fileName)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("invalid file name")
|
||||
}
|
||||
name = strings.ReplaceAll(name, "\\", "/")
|
||||
safeName := path.Base(name)
|
||||
if safeName == "" || safeName == "." || safeName == "/" || strings.ContainsRune(safeName, 0) {
|
||||
return "", fmt.Errorf("invalid file name")
|
||||
}
|
||||
return safeName, nil
|
||||
}
|
||||
|
||||
func (s *Service) lookupSFTPUpload(uploadID string) (*sftpUpload, error) {
|
||||
s.uploadMu.Lock()
|
||||
defer s.uploadMu.Unlock()
|
||||
upload := s.uploads[uploadID]
|
||||
if upload == nil {
|
||||
return nil, fmt.Errorf("upload session not found")
|
||||
}
|
||||
return upload, nil
|
||||
}
|
||||
|
||||
func (s *Service) takeSFTPUpload(uploadID string) (*sftpUpload, error) {
|
||||
s.uploadMu.Lock()
|
||||
defer s.uploadMu.Unlock()
|
||||
upload := s.uploads[uploadID]
|
||||
if upload == nil {
|
||||
return nil, fmt.Errorf("upload session not found")
|
||||
}
|
||||
delete(s.uploads, uploadID)
|
||||
return upload, nil
|
||||
}
|
||||
|
||||
func (s *Service) removeSFTPUpload(uploadID string) {
|
||||
s.uploadMu.Lock()
|
||||
defer s.uploadMu.Unlock()
|
||||
delete(s.uploads, uploadID)
|
||||
}
|
||||
|
||||
func validateImportStrategy(strategy string) error {
|
||||
switch strategy {
|
||||
case "skip", "overwrite", "duplicate":
|
||||
@@ -1774,20 +1967,7 @@ func validateImportProfiles(profiles []*models.Profile) error {
|
||||
}
|
||||
|
||||
func normaliseProfileForSave(profile *models.Profile) {
|
||||
if profile == nil {
|
||||
return
|
||||
}
|
||||
if profile.Tags == nil {
|
||||
profile.Tags = []string{}
|
||||
}
|
||||
if profile.VNC != nil {
|
||||
if profile.VNC.SecretMode == "prompt" {
|
||||
profile.VNC.SecretMode = models.SecretModeAskEveryTime
|
||||
}
|
||||
if profile.VNC.SSHSecretMode == "prompt" {
|
||||
profile.VNC.SSHSecretMode = models.SecretModeAskEveryTime
|
||||
}
|
||||
}
|
||||
models.NormalizeProfileDefaults(profile)
|
||||
}
|
||||
|
||||
func redactProfileSecrets(profiles []*models.Profile) []*models.Profile {
|
||||
|
||||
@@ -113,6 +113,82 @@ func TestSFTPUploadDataRejectsOversizedPayloadBeforeSessionLookup(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSFTPUploadChunkRejectsOversizedChunkBeforeUploadLookup(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
payload := base64.StdEncoding.EncodeToString(make([]byte, maxSFTPUploadChunkBytes+1))
|
||||
|
||||
err := svc.SFTPUploadChunk("missing-upload", payload)
|
||||
if err == nil {
|
||||
t.Fatal("expected oversized upload chunk to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "chunk is too large") {
|
||||
t.Fatalf("error = %q, want chunk is too large", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeSFTPUploadNamePreservesHTMLCharacters(t *testing.T) {
|
||||
got, err := safeSFTPUploadName(`../report <final>.txt`)
|
||||
if err != nil {
|
||||
t.Fatalf("safeSFTPUploadName() error = %v", err)
|
||||
}
|
||||
if got != "report <final>.txt" {
|
||||
t.Fatalf("safe name = %q, want HTML characters preserved in base name", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportProfilesNormalizesSparseRAWProfile(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
data := `[
|
||||
{
|
||||
"displayName": "Raw Device",
|
||||
"protocol": "raw",
|
||||
"host": "device.local",
|
||||
"raw": {}
|
||||
}
|
||||
]`
|
||||
|
||||
count, err := svc.ImportProfiles(data, "skip")
|
||||
if err != nil {
|
||||
t.Fatalf("ImportProfiles() error = %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("imported = %d, want 1", count)
|
||||
}
|
||||
profiles, err := svc.ListProfiles()
|
||||
if err != nil {
|
||||
t.Fatalf("ListProfiles() error = %v", err)
|
||||
}
|
||||
if len(profiles) != 1 {
|
||||
t.Fatalf("profiles = %d, want 1", len(profiles))
|
||||
}
|
||||
if profiles[0].Port != models.DefaultRAWPort {
|
||||
t.Fatalf("port = %d, want %d", profiles[0].Port, models.DefaultRAWPort)
|
||||
}
|
||||
if profiles[0].RAW.Encoding != "utf-8" {
|
||||
t.Fatalf("raw encoding = %q, want utf-8", profiles[0].RAW.Encoding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProfileNormalizesLegacyVNCSecretMode(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
profile := models.NewProfile(models.ProtocolVNC)
|
||||
profile.DisplayName = "VNC"
|
||||
profile.Host = "vnc.local"
|
||||
profile.VNC.SecretMode = "prompt"
|
||||
profile.VNC.SSHSecretMode = "prompt"
|
||||
|
||||
got, err := svc.CreateProfile(profile)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProfile() error = %v", err)
|
||||
}
|
||||
if got.VNC.SecretMode != models.SecretModeAskEveryTime {
|
||||
t.Fatalf("secret mode = %q, want %q", got.VNC.SecretMode, models.SecretModeAskEveryTime)
|
||||
}
|
||||
if got.VNC.SSHSecretMode != models.SecretModeAskEveryTime {
|
||||
t.Fatalf("ssh secret mode = %q, want %q", got.VNC.SSHSecretMode, models.SecretModeAskEveryTime)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
store, err := storage.NewStore(filepath.Join(t.TempDir(), "test.db"))
|
||||
|
||||
@@ -126,3 +126,217 @@ func NewProfile(protocol Protocol) Profile {
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// NormalizeProfileDefaults fills missing default values on profiles loaded
|
||||
// from older exports, third-party imports, or sparse frontend payloads.
|
||||
func NormalizeProfileDefaults(p *Profile) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if p.Protocol == "" {
|
||||
p.Protocol = ProtocolSSH
|
||||
}
|
||||
|
||||
defaults := NewProfile(p.Protocol)
|
||||
if p.ID == "" {
|
||||
p.ID = defaults.ID
|
||||
}
|
||||
if p.Tags == nil {
|
||||
p.Tags = []string{}
|
||||
}
|
||||
if p.ConnectTimeout == 0 {
|
||||
p.ConnectTimeout = DefaultConnectTimeout
|
||||
}
|
||||
if p.TerminalFontFamily == "" {
|
||||
p.TerminalFontFamily = DefaultFontFamily
|
||||
}
|
||||
if p.TerminalFontSize == 0 {
|
||||
p.TerminalFontSize = DefaultFontSize
|
||||
}
|
||||
if p.TerminalScrollback == 0 {
|
||||
p.TerminalScrollback = DefaultScrollback
|
||||
}
|
||||
if p.TerminalCursorStyle == "" {
|
||||
p.TerminalCursorStyle = DefaultCursorStyle
|
||||
}
|
||||
if p.CreatedAt.IsZero() {
|
||||
p.CreatedAt = defaults.CreatedAt
|
||||
}
|
||||
if p.UpdatedAt.IsZero() {
|
||||
p.UpdatedAt = defaults.UpdatedAt
|
||||
}
|
||||
if p.Port == 0 {
|
||||
p.Port = defaults.Port
|
||||
}
|
||||
if p.DisplayName == "" {
|
||||
p.DisplayName = defaultProfileName(p)
|
||||
}
|
||||
|
||||
switch p.Protocol {
|
||||
case ProtocolSSH:
|
||||
normalizeSSHConfig(p, defaults.SSH)
|
||||
case ProtocolTelnet:
|
||||
normalizeTelnetConfig(p, defaults.Telnet)
|
||||
case ProtocolRAW:
|
||||
normalizeRAWConfig(p, defaults.RAW)
|
||||
case ProtocolGitHub:
|
||||
if p.Host == "" {
|
||||
p.Host = defaults.Host
|
||||
}
|
||||
normalizeGitHubConfig(p, defaults.GitHub)
|
||||
case ProtocolVNC:
|
||||
normalizeVNCConfig(p, defaults.VNC)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultProfileName(p *Profile) string {
|
||||
if p.Host != "" {
|
||||
return p.Host
|
||||
}
|
||||
if p.VNC != nil && p.VNC.VNCHost != "" {
|
||||
return p.VNC.VNCHost
|
||||
}
|
||||
switch p.Protocol {
|
||||
case ProtocolGitHub:
|
||||
return "GitHub"
|
||||
case ProtocolVNC:
|
||||
return "VNC"
|
||||
case ProtocolRAW:
|
||||
return "RAW TCP"
|
||||
case ProtocolTelnet:
|
||||
return "Telnet"
|
||||
default:
|
||||
return "SSH"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSecretMode(mode SecretMode, fallback SecretMode) SecretMode {
|
||||
switch mode {
|
||||
case SecretModeSaved, SecretModeSessionOnly, SecretModeAskEveryTime:
|
||||
return mode
|
||||
case "prompt":
|
||||
return SecretModeAskEveryTime
|
||||
case "":
|
||||
return fallback
|
||||
default:
|
||||
return mode
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSSHConfig(p *Profile, defaults *SSHConfig) {
|
||||
if p.SSH == nil {
|
||||
cfg := *defaults
|
||||
p.SSH = &cfg
|
||||
return
|
||||
}
|
||||
cfg := p.SSH
|
||||
if len(cfg.AuthMethods) == 0 {
|
||||
cfg.AuthMethods = append([]string{}, defaults.AuthMethods...)
|
||||
}
|
||||
cfg.SecretMode = normalizeSecretMode(cfg.SecretMode, defaults.SecretMode)
|
||||
cfg.KeySecretMode = normalizeSecretMode(cfg.KeySecretMode, defaults.KeySecretMode)
|
||||
if cfg.HostKeyPolicy == "" {
|
||||
cfg.HostKeyPolicy = defaults.HostKeyPolicy
|
||||
}
|
||||
if cfg.TerminalType == "" {
|
||||
cfg.TerminalType = defaults.TerminalType
|
||||
}
|
||||
if cfg.KeepaliveInterval == 0 {
|
||||
cfg.KeepaliveInterval = defaults.KeepaliveInterval
|
||||
}
|
||||
if cfg.KeepaliveCountMax == 0 {
|
||||
cfg.KeepaliveCountMax = defaults.KeepaliveCountMax
|
||||
}
|
||||
if cfg.JumpHosts == nil {
|
||||
cfg.JumpHosts = []JumpHost{}
|
||||
}
|
||||
if cfg.PortForwards == nil {
|
||||
cfg.PortForwards = []PortForward{}
|
||||
}
|
||||
if cfg.EnvVars == nil {
|
||||
cfg.EnvVars = map[string]string{}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTelnetConfig(p *Profile, defaults *TelnetConfig) {
|
||||
if p.Telnet == nil {
|
||||
cfg := *defaults
|
||||
p.Telnet = &cfg
|
||||
return
|
||||
}
|
||||
cfg := p.Telnet
|
||||
if cfg.LineEnding == "" {
|
||||
cfg.LineEnding = defaults.LineEnding
|
||||
}
|
||||
if cfg.TerminalType == "" {
|
||||
cfg.TerminalType = defaults.TerminalType
|
||||
}
|
||||
if cfg.Encoding == "" {
|
||||
cfg.Encoding = defaults.Encoding
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRAWConfig(p *Profile, defaults *RAWConfig) {
|
||||
if p.RAW == nil {
|
||||
cfg := *defaults
|
||||
p.RAW = &cfg
|
||||
return
|
||||
}
|
||||
cfg := p.RAW
|
||||
if cfg.LineEnding == "" {
|
||||
cfg.LineEnding = defaults.LineEnding
|
||||
}
|
||||
if cfg.Encoding == "" {
|
||||
cfg.Encoding = defaults.Encoding
|
||||
}
|
||||
if cfg.PayloadPresets == nil {
|
||||
cfg.PayloadPresets = []PayloadPreset{}
|
||||
}
|
||||
for i := range cfg.PayloadPresets {
|
||||
if cfg.PayloadPresets[i].ID == "" {
|
||||
cfg.PayloadPresets[i].ID = uuid.New().String()
|
||||
}
|
||||
if cfg.PayloadPresets[i].Format == "" {
|
||||
cfg.PayloadPresets[i].Format = "text"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeGitHubConfig(p *Profile, defaults *GitHubConfig) {
|
||||
if p.GitHub == nil {
|
||||
cfg := *defaults
|
||||
p.GitHub = &cfg
|
||||
return
|
||||
}
|
||||
cfg := p.GitHub
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = defaults.BaseURL
|
||||
}
|
||||
cfg.SecretMode = normalizeSecretMode(cfg.SecretMode, defaults.SecretMode)
|
||||
if cfg.RefreshIntervalSecs == 0 {
|
||||
cfg.RefreshIntervalSecs = defaults.RefreshIntervalSecs
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeVNCConfig(p *Profile, defaults *VNCConfig) {
|
||||
if p.VNC == nil {
|
||||
cfg := *defaults
|
||||
p.VNC = &cfg
|
||||
return
|
||||
}
|
||||
cfg := p.VNC
|
||||
if cfg.VNCPort == 0 {
|
||||
cfg.VNCPort = defaults.VNCPort
|
||||
}
|
||||
cfg.SecretMode = normalizeSecretMode(cfg.SecretMode, defaults.SecretMode)
|
||||
if cfg.Quality < 0 {
|
||||
cfg.Quality = defaults.Quality
|
||||
}
|
||||
if cfg.SSHPort == 0 {
|
||||
cfg.SSHPort = defaults.SSHPort
|
||||
}
|
||||
if cfg.SSHAuthMethod == "" {
|
||||
cfg.SSHAuthMethod = defaults.SSHAuthMethod
|
||||
}
|
||||
cfg.SSHSecretMode = normalizeSecretMode(cfg.SSHSecretMode, defaults.SSHSecretMode)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package rawclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
)
|
||||
|
||||
func TestHistoryIsCappedByEntryCount(t *testing.T) {
|
||||
client := NewClient(&models.Profile{})
|
||||
|
||||
for i := 0; i < maxHistoryEntries+25; i++ {
|
||||
client.appendHistory(HistoryEntry{Direction: "recv", Data: []byte{byte(i)}})
|
||||
}
|
||||
|
||||
history := client.GetHistory()
|
||||
if len(history) != maxHistoryEntries {
|
||||
t.Fatalf("history length = %d, want %d", len(history), maxHistoryEntries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryIsCappedByBytes(t *testing.T) {
|
||||
client := NewClient(&models.Profile{})
|
||||
chunk := bytes.Repeat([]byte("a"), 1024)
|
||||
|
||||
for i := 0; i < (maxHistoryBytes/len(chunk))+10; i++ {
|
||||
client.appendHistory(HistoryEntry{Direction: "recv", Data: chunk})
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, entry := range client.GetHistory() {
|
||||
total += len(entry.Data)
|
||||
}
|
||||
if total > maxHistoryBytes {
|
||||
t.Fatalf("history bytes = %d, want <= %d", total, maxHistoryBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHistoryReturnsDeepCopy(t *testing.T) {
|
||||
client := NewClient(&models.Profile{})
|
||||
client.appendHistory(HistoryEntry{Direction: "recv", Data: []byte("secret")})
|
||||
|
||||
history := client.GetHistory()
|
||||
history[0].Data[0] = 'X'
|
||||
|
||||
again := client.GetHistory()
|
||||
if string(again[0].Data) != "secret" {
|
||||
t.Fatalf("history was mutated through returned slice: %q", string(again[0].Data))
|
||||
}
|
||||
}
|
||||
@@ -13,21 +13,27 @@ import (
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
maxHistoryEntries = 10000
|
||||
maxHistoryBytes = 10 * 1024 * 1024
|
||||
)
|
||||
|
||||
// Client manages a raw TCP connection.
|
||||
// It provides binary-safe data handling with support for text and hex views,
|
||||
// timestamps, direction markers, and payload presets.
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
config *models.Profile
|
||||
conn net.Conn
|
||||
connected bool
|
||||
onData func(data []byte, direction string, timestamp time.Time)
|
||||
onError func(err error)
|
||||
onClose func()
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
history []HistoryEntry
|
||||
historyMu sync.RWMutex
|
||||
mu sync.Mutex
|
||||
config *models.Profile
|
||||
conn net.Conn
|
||||
connected bool
|
||||
onData func(data []byte, direction string, timestamp time.Time)
|
||||
onError func(err error)
|
||||
onClose func()
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
history []HistoryEntry
|
||||
historyBytes int
|
||||
historyMu sync.RWMutex
|
||||
}
|
||||
|
||||
// HistoryEntry records a single sent or received data frame with its timestamp.
|
||||
@@ -193,7 +199,10 @@ func (c *Client) GetHistory() []HistoryEntry {
|
||||
defer c.historyMu.RUnlock()
|
||||
|
||||
out := make([]HistoryEntry, len(c.history))
|
||||
copy(out, c.history)
|
||||
for i, entry := range c.history {
|
||||
out[i] = entry
|
||||
out[i].Data = copyBytes(entry.Data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -202,6 +211,7 @@ func (c *Client) ClearHistory() {
|
||||
c.historyMu.Lock()
|
||||
defer c.historyMu.Unlock()
|
||||
c.history = nil
|
||||
c.historyBytes = 0
|
||||
}
|
||||
|
||||
// SaveCapture writes the session history to a file in JSON format.
|
||||
@@ -271,7 +281,21 @@ func (c *Client) readLoop() {
|
||||
func (c *Client) appendHistory(entry HistoryEntry) {
|
||||
c.historyMu.Lock()
|
||||
defer c.historyMu.Unlock()
|
||||
|
||||
if len(entry.Data) > maxHistoryBytes {
|
||||
entry.Data = copyBytes(entry.Data[len(entry.Data)-maxHistoryBytes:])
|
||||
}
|
||||
c.history = append(c.history, entry)
|
||||
c.historyBytes += len(entry.Data)
|
||||
for len(c.history) > maxHistoryEntries || c.historyBytes > maxHistoryBytes {
|
||||
if len(c.history) == 0 {
|
||||
c.historyBytes = 0
|
||||
return
|
||||
}
|
||||
c.historyBytes -= len(c.history[0].Data)
|
||||
c.history[0].Data = nil
|
||||
c.history = c.history[1:]
|
||||
}
|
||||
}
|
||||
|
||||
// applyLineEnding appends the configured line ending to text.
|
||||
|
||||
+181
-22
@@ -9,6 +9,7 @@
|
||||
package recording
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -27,6 +28,11 @@ const (
|
||||
EventOutput EventType = "o"
|
||||
// EventInput represents data sent from the user to the remote host.
|
||||
EventInput EventType = "i"
|
||||
|
||||
// MaxRecordingBytes bounds a single active recording file.
|
||||
MaxRecordingBytes = int64(250 * 1024 * 1024)
|
||||
// MaxPlaybackBytes bounds the file size loaded for playback.
|
||||
MaxPlaybackBytes = int64(100 * 1024 * 1024)
|
||||
)
|
||||
|
||||
// Header is the asciicast v2 file header (first line).
|
||||
@@ -66,18 +72,23 @@ type RecordingMeta struct {
|
||||
|
||||
// Recorder captures terminal I/O for a single session.
|
||||
type Recorder struct {
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
encoder *json.Encoder
|
||||
startTime time.Time
|
||||
header Header
|
||||
eventCount int
|
||||
closed bool
|
||||
filePath string
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
startTime time.Time
|
||||
header Header
|
||||
eventCount int
|
||||
closed bool
|
||||
filePath string
|
||||
bytesWritten int64
|
||||
maxBytes int64
|
||||
}
|
||||
|
||||
// NewRecorder creates a new recording file and writes the header.
|
||||
func NewRecorder(dir, id, title string, cols, rows int) (*Recorder, error) {
|
||||
return newRecorderWithLimit(dir, id, title, cols, rows, MaxRecordingBytes)
|
||||
}
|
||||
|
||||
func newRecorderWithLimit(dir, id, title string, cols, rows int, maxBytes int64) (*Recorder, error) {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("recording: failed to create directory: %w", err)
|
||||
}
|
||||
@@ -102,20 +113,26 @@ func NewRecorder(dir, id, title string, cols, rows int) (*Recorder, error) {
|
||||
},
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(hdr); err != nil {
|
||||
headerLine, err := marshalJSONLine(hdr)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
os.Remove(path)
|
||||
return nil, fmt.Errorf("recording: failed to encode header: %w", err)
|
||||
}
|
||||
n, err := f.Write(headerLine)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
os.Remove(path)
|
||||
return nil, fmt.Errorf("recording: failed to write header: %w", err)
|
||||
}
|
||||
|
||||
return &Recorder{
|
||||
file: f,
|
||||
encoder: enc,
|
||||
startTime: now,
|
||||
header: hdr,
|
||||
filePath: path,
|
||||
file: f,
|
||||
startTime: now,
|
||||
header: hdr,
|
||||
filePath: path,
|
||||
bytesWritten: int64(n),
|
||||
maxBytes: maxBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -145,11 +162,23 @@ func (r *Recorder) writeEvent(eventType EventType, data []byte) {
|
||||
Data: string(data),
|
||||
}
|
||||
|
||||
// Write as a JSON array line: [elapsed, "o"/"i", "data"]
|
||||
if err := r.encoder.Encode(evt); err != nil {
|
||||
// Non-fatal — continue recording even if one event fails.
|
||||
line, err := marshalJSONLine(evt)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if r.maxBytes > 0 && r.bytesWritten+int64(len(line)) > r.maxBytes {
|
||||
r.closed = true
|
||||
_ = r.file.Sync()
|
||||
_ = r.file.Close()
|
||||
r.file = nil
|
||||
return
|
||||
}
|
||||
|
||||
n, err := r.file.Write(line)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r.bytesWritten += int64(n)
|
||||
r.eventCount++
|
||||
}
|
||||
|
||||
@@ -175,7 +204,9 @@ func (r *Recorder) Close() error {
|
||||
r.file.Close()
|
||||
return fmt.Errorf("recording: sync failed: %w", err)
|
||||
}
|
||||
return r.file.Close()
|
||||
err := r.file.Close()
|
||||
r.file = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -252,7 +283,21 @@ func (s *Store) List() ([]RecordingMeta, error) {
|
||||
|
||||
// Get reads the full recording (header + events) from a file.
|
||||
func (s *Store) Get(id string) (*Header, []Event, error) {
|
||||
path := filepath.Join(s.dir, id+".cast")
|
||||
return s.getWithLimit(id, MaxPlaybackBytes)
|
||||
}
|
||||
|
||||
func (s *Store) getWithLimit(id string, maxBytes int64) (*Header, []Event, error) {
|
||||
path, err := s.recordingPath(id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("recording: failed to stat file: %w", err)
|
||||
}
|
||||
if maxBytes > 0 && info.Size() > maxBytes {
|
||||
return nil, nil, fmt.Errorf("recording: file is too large for playback (max %d bytes)", maxBytes)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("recording: failed to read file: %w", err)
|
||||
@@ -308,13 +353,127 @@ func (s *Store) Get(id string) (*Header, []Event, error) {
|
||||
|
||||
// Delete removes a recording file.
|
||||
func (s *Store) Delete(id string) error {
|
||||
path := filepath.Join(s.dir, id+".cast")
|
||||
path, err := s.recordingPath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
return fmt.Errorf("recording: failed to delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StorageInfo summarizes recording storage usage.
|
||||
type StorageInfo struct {
|
||||
Count int `json:"count"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
OldestCreatedAt *time.Time `json:"oldestCreatedAt,omitempty"`
|
||||
NewestCreatedAt *time.Time `json:"newestCreatedAt,omitempty"`
|
||||
MaxRecordingBytes int64 `json:"maxRecordingBytes"`
|
||||
MaxPlaybackBytes int64 `json:"maxPlaybackBytes"`
|
||||
}
|
||||
|
||||
// CleanupResult describes files removed by retention cleanup.
|
||||
type CleanupResult struct {
|
||||
Deleted int `json:"deleted"`
|
||||
FreedBytes int64 `json:"freedBytes"`
|
||||
}
|
||||
|
||||
// StorageInfo returns aggregate metadata for recordings.
|
||||
func (s *Store) StorageInfo() (StorageInfo, error) {
|
||||
metas, err := s.List()
|
||||
if err != nil {
|
||||
return StorageInfo{}, err
|
||||
}
|
||||
info := StorageInfo{
|
||||
Count: len(metas),
|
||||
MaxRecordingBytes: MaxRecordingBytes,
|
||||
MaxPlaybackBytes: MaxPlaybackBytes,
|
||||
}
|
||||
for i := range metas {
|
||||
meta := metas[i]
|
||||
info.TotalBytes += meta.Size
|
||||
if info.OldestCreatedAt == nil || meta.CreatedAt.Before(*info.OldestCreatedAt) {
|
||||
t := meta.CreatedAt
|
||||
info.OldestCreatedAt = &t
|
||||
}
|
||||
if info.NewestCreatedAt == nil || meta.CreatedAt.After(*info.NewestCreatedAt) {
|
||||
t := meta.CreatedAt
|
||||
info.NewestCreatedAt = &t
|
||||
}
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// Cleanup removes recordings older than maxAgeDays and/or enough oldest files
|
||||
// to bring total storage below maxTotalBytes. Non-positive limits are ignored.
|
||||
func (s *Store) Cleanup(maxAgeDays int, maxTotalBytes int64) (CleanupResult, error) {
|
||||
metas, err := s.List()
|
||||
if err != nil {
|
||||
return CleanupResult{}, err
|
||||
}
|
||||
|
||||
result := CleanupResult{}
|
||||
deleted := map[string]bool{}
|
||||
total := int64(0)
|
||||
for _, meta := range metas {
|
||||
total += meta.Size
|
||||
}
|
||||
|
||||
deleteMeta := func(meta RecordingMeta) {
|
||||
if deleted[meta.ID] {
|
||||
return
|
||||
}
|
||||
if err := s.Delete(meta.ID); err == nil {
|
||||
deleted[meta.ID] = true
|
||||
result.Deleted++
|
||||
result.FreedBytes += meta.Size
|
||||
total -= meta.Size
|
||||
}
|
||||
}
|
||||
|
||||
if maxAgeDays > 0 {
|
||||
cutoff := time.Now().Add(-time.Duration(maxAgeDays) * 24 * time.Hour)
|
||||
for _, meta := range metas {
|
||||
if meta.CreatedAt.Before(cutoff) {
|
||||
deleteMeta(meta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if maxTotalBytes > 0 && total > maxTotalBytes {
|
||||
sort.Slice(metas, func(i, j int) bool {
|
||||
return metas[i].CreatedAt.Before(metas[j].CreatedAt)
|
||||
})
|
||||
for _, meta := range metas {
|
||||
if total <= maxTotalBytes {
|
||||
break
|
||||
}
|
||||
deleteMeta(meta)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) recordingPath(id string) (string, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || id == "." || id == ".." || strings.ContainsAny(id, `/\`) {
|
||||
return "", fmt.Errorf("recording: invalid recording ID")
|
||||
}
|
||||
return filepath.Join(s.dir, id+".cast"), nil
|
||||
}
|
||||
|
||||
func marshalJSONLine(v interface{}) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// readMeta reads just the header from a .cast file to produce metadata.
|
||||
func (s *Store) readMeta(filename string) (*RecordingMeta, error) {
|
||||
path := filepath.Join(s.dir, filename)
|
||||
|
||||
@@ -163,3 +163,121 @@ func TestDoubleClose(t *testing.T) {
|
||||
t.Fatalf("second Close failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecorderStopsAtSizeLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rec, err := newRecorderWithLimit(dir, "limited", "Limited", 80, 24, 512)
|
||||
if err != nil {
|
||||
t.Fatalf("newRecorderWithLimit failed: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
rec.WriteOutput([]byte(strings.Repeat("x", 80)))
|
||||
}
|
||||
if rec.EventCount() >= 50 {
|
||||
t.Fatalf("expected recording to stop before all events were written")
|
||||
}
|
||||
if err := rec.Close(); err != nil {
|
||||
t.Fatalf("Close failed: %v", err)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(dir, "limited.cast"))
|
||||
if err != nil {
|
||||
t.Fatalf("stat failed: %v", err)
|
||||
}
|
||||
if info.Size() > 512 {
|
||||
t.Fatalf("recording size = %d, want <= 512", info.Size())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreGetRejectsOversizedPlayback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "huge.cast")
|
||||
header := `{"version":2,"width":80,"height":24,"timestamp":1}` + "\n"
|
||||
if err := os.WriteFile(path, []byte(header+strings.Repeat("x", 256)), 0600); err != nil {
|
||||
t.Fatalf("write failed: %v", err)
|
||||
}
|
||||
|
||||
store := NewStore(dir)
|
||||
_, _, err := store.getWithLimit("huge", 128)
|
||||
if err == nil {
|
||||
t.Fatal("expected oversized playback to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too large") {
|
||||
t.Fatalf("error = %q, want too large", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreCleanupByAgeAndSize(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
makeRecording := func(id string, created time.Time, payload string) {
|
||||
rec, err := NewRecorder(dir, id, id, 80, 24)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRecorder(%s) failed: %v", id, err)
|
||||
}
|
||||
rec.WriteOutput([]byte(payload))
|
||||
if err := rec.Close(); err != nil {
|
||||
t.Fatalf("Close(%s) failed: %v", id, err)
|
||||
}
|
||||
path := filepath.Join(dir, id+".cast")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) failed: %v", id, err)
|
||||
}
|
||||
parts := strings.SplitN(string(data), "\n", 2)
|
||||
var hdr Header
|
||||
if err := json.Unmarshal([]byte(parts[0]), &hdr); err != nil {
|
||||
t.Fatalf("header parse(%s) failed: %v", id, err)
|
||||
}
|
||||
hdr.Timestamp = created.Unix()
|
||||
line, err := marshalJSONLine(hdr)
|
||||
if err != nil {
|
||||
t.Fatalf("header encode(%s) failed: %v", id, err)
|
||||
}
|
||||
rest := ""
|
||||
if len(parts) == 2 {
|
||||
rest = parts[1]
|
||||
}
|
||||
if err := os.WriteFile(path, append(line, []byte(rest)...), 0600); err != nil {
|
||||
t.Fatalf("WriteFile(%s) failed: %v", id, err)
|
||||
}
|
||||
if err := os.Chtimes(path, created, created); err != nil {
|
||||
t.Fatalf("Chtimes(%s) failed: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
old := time.Now().Add(-45 * 24 * time.Hour)
|
||||
recent := time.Now().Add(-2 * time.Hour)
|
||||
makeRecording("old", old, "old")
|
||||
makeRecording("recent", recent, strings.Repeat("recent", 50))
|
||||
|
||||
store := NewStore(dir)
|
||||
result, err := store.Cleanup(30, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Cleanup by age failed: %v", err)
|
||||
}
|
||||
if result.Deleted != 1 {
|
||||
t.Fatalf("deleted = %d, want 1", result.Deleted)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "old.cast")); !os.IsNotExist(err) {
|
||||
t.Fatalf("old recording still exists, stat err=%v", err)
|
||||
}
|
||||
|
||||
info, err := store.StorageInfo()
|
||||
if err != nil {
|
||||
t.Fatalf("StorageInfo failed: %v", err)
|
||||
}
|
||||
result, err = store.Cleanup(0, info.TotalBytes-1)
|
||||
if err != nil {
|
||||
t.Fatalf("Cleanup by size failed: %v", err)
|
||||
}
|
||||
if result.Deleted != 1 {
|
||||
t.Fatalf("deleted = %d, want 1", result.Deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsTraversalRecordingID(t *testing.T) {
|
||||
store := NewStore(t.TempDir())
|
||||
if err := store.Delete("../outside"); err == nil {
|
||||
t.Fatal("expected traversal recording ID to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -1198,6 +1199,22 @@ func (m *Manager) DeleteRecording(id string) error {
|
||||
return m.recordStore.Delete(id)
|
||||
}
|
||||
|
||||
// RecordingStorageInfo returns aggregate recording storage usage.
|
||||
func (m *Manager) RecordingStorageInfo() (recording.StorageInfo, error) {
|
||||
if m.recordStore == nil {
|
||||
return recording.StorageInfo{}, fmt.Errorf("sessions: recording store not configured")
|
||||
}
|
||||
return m.recordStore.StorageInfo()
|
||||
}
|
||||
|
||||
// CleanupRecordings applies age and total-size retention limits.
|
||||
func (m *Manager) CleanupRecordings(maxAgeDays int, maxTotalBytes int64) (recording.CleanupResult, error) {
|
||||
if m.recordStore == nil {
|
||||
return recording.CleanupResult{}, fmt.Errorf("sessions: recording store not configured")
|
||||
}
|
||||
return m.recordStore.Cleanup(maxAgeDays, maxTotalBytes)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SFTP file browser
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1340,6 +1357,15 @@ func (m *Manager) SFTPUploadBytes(sessionID, remotePath string, data []byte) err
|
||||
})
|
||||
}
|
||||
|
||||
// SFTPOpenUpload opens a remote file for a caller-managed streaming upload.
|
||||
func (m *Manager) SFTPOpenUpload(sessionID, remotePath string) (io.WriteCloser, error) {
|
||||
sc, err := m.getSFTP(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sc.CreateWriter(remotePath)
|
||||
}
|
||||
|
||||
// SFTPPwd returns the current working directory on the remote host.
|
||||
func (m *Manager) SFTPPwd(sessionID string) (string, error) {
|
||||
sc, err := m.getSFTP(sessionID)
|
||||
|
||||
@@ -267,6 +267,21 @@ func (c *Client) UploadBytes(remotePath string, data []byte, onProgress func(Tra
|
||||
return c.copyWithProgress(reader, remote, int64(len(data)), remotePath, onProgress)
|
||||
}
|
||||
|
||||
// CreateWriter creates or truncates a remote file and returns a streaming
|
||||
// writer for callers that already manage progress and chunking.
|
||||
func (c *Client) CreateWriter(remotePath string) (io.WriteCloser, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.closed || c.sftp == nil {
|
||||
return nil, fmt.Errorf("sftpclient: client is closed")
|
||||
}
|
||||
remote, err := c.sftp.Create(remotePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sftpclient: failed to create remote file: %w", err)
|
||||
}
|
||||
return remote, nil
|
||||
}
|
||||
|
||||
// Pwd returns the current working directory on the remote host.
|
||||
func (c *Client) Pwd() (string, error) {
|
||||
c.mu.Lock()
|
||||
|
||||
+5
-17
@@ -5,6 +5,7 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
source "$SCRIPT_DIR/scripts/build-common.sh"
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
@@ -13,7 +14,7 @@ NC='\033[0m'
|
||||
|
||||
DEFAULT_UPDATE_URL="https://dixon.cx/downloads/nexus/manifest-{platform}-{arch}.json"
|
||||
DEFAULT_RELEASE_BASE_URL="https://dixon.cx/downloads/nexus"
|
||||
WAILS_VERSION="$(awk '$1 == "github.com/wailsapp/wails/v2" { print $2; exit }' go.mod)"
|
||||
WAILS_VERSION="$(nexus_wails_version go.mod)"
|
||||
|
||||
log() { echo -e "${GREEN}[nexus]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
|
||||
@@ -36,11 +37,7 @@ host_arch() {
|
||||
}
|
||||
|
||||
build_tags() {
|
||||
if command -v pkg-config >/dev/null 2>&1 \
|
||||
&& pkg-config --exists webkit2gtk-4.1 \
|
||||
&& ! pkg-config --exists webkit2gtk-4.0; then
|
||||
echo "webkit2_41"
|
||||
fi
|
||||
nexus_build_tags
|
||||
}
|
||||
|
||||
default_targets() {
|
||||
@@ -101,20 +98,11 @@ install_deps() {
|
||||
}
|
||||
|
||||
build_version() {
|
||||
local tag
|
||||
local commit
|
||||
tag="$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")"
|
||||
commit="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
|
||||
echo "${tag}-${commit}"
|
||||
nexus_build_version
|
||||
}
|
||||
|
||||
build_ldflags() {
|
||||
local version="$1"
|
||||
local flags="-s -w -X github.com/andydixon/nexus/internal/api.appVersion=${version}"
|
||||
if [ -n "${NEXUS_UPDATE_URL:-}" ]; then
|
||||
flags="${flags} -X github.com/andydixon/nexus/internal/api.appUpdateURL=${NEXUS_UPDATE_URL}"
|
||||
fi
|
||||
echo "${flags}"
|
||||
nexus_build_ldflags "$1"
|
||||
}
|
||||
|
||||
pick_cc() {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared build helpers for local builds, release packaging, and CI snippets.
|
||||
|
||||
nexus_wails_version() {
|
||||
local go_mod="${1:-go.mod}"
|
||||
awk '$1 == "github.com/wailsapp/wails/v2" { print $2; exit }' "$go_mod"
|
||||
}
|
||||
|
||||
nexus_build_tags() {
|
||||
if command -v pkg-config >/dev/null 2>&1 \
|
||||
&& pkg-config --exists webkit2gtk-4.1 \
|
||||
&& ! pkg-config --exists webkit2gtk-4.0; then
|
||||
echo "webkit2_41"
|
||||
fi
|
||||
}
|
||||
|
||||
nexus_build_version() {
|
||||
local tag
|
||||
local commit
|
||||
tag="$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")"
|
||||
commit="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
|
||||
echo "${tag}-${commit}"
|
||||
}
|
||||
|
||||
nexus_build_ldflags() {
|
||||
local version="$1"
|
||||
local flags="-s -w -X github.com/andydixon/nexus/internal/api.appVersion=${version}"
|
||||
if [ -n "${NEXUS_UPDATE_URL:-}" ]; then
|
||||
flags="${flags} -X github.com/andydixon/nexus/internal/api.appUpdateURL=${NEXUS_UPDATE_URL}"
|
||||
fi
|
||||
echo "${flags}"
|
||||
}
|
||||
Reference in New Issue
Block a user