Compare commits

..
Author SHA1 Message Date
mrhid6 2657780e7a fix: agent msi agent version upgrade
Server Deploy / deploy (push) Successful in 11s
Agent Release / build (push) Successful in 10m38s
Agent Release / msi (push) Successful in 58s
2026-07-17 16:19:42 +01:00
mrhid6 129be23a9d fix: Fixed msi version
Server Deploy / deploy (push) Successful in 13s
Agent Release / build (push) Successful in 33s
Agent Release / msi (push) Successful in 33s
2026-07-17 16:10:19 +01:00
mrhid6 d31486ae1b fix: Fixed agent windows version
Server Deploy / deploy (push) Successful in 1m28s
Agent Release / build (push) Successful in 10m34s
Agent Release / msi (push) Successful in 57s
2026-07-17 15:38:00 +01:00
mrhid6 4f3f3601d2 fix: Fixes to setup scripts
Agent Release / build (push) Successful in 35s
Server Deploy / deploy (push) Successful in 48s
Agent Release / msi (push) Successful in 1m8s
2026-07-17 15:18:49 +01:00
mrhid6 b022722d39 fix: More agent install debugging
Server Deploy / deploy (push) Successful in 12s
Agent Release / build (push) Successful in 33s
Agent Release / msi (push) Successful in 44s
2026-07-17 15:05:34 +01:00
mrhid6 bf96dba50e fix: Fixed install ps1 script handler
Server Deploy / deploy (push) Successful in 1m28s
2026-07-17 14:45:19 +01:00
mrhid6 2b4611f7ac feat: added windows install script to ui
Server Deploy / deploy (push) Successful in 1m20s
2026-07-17 14:40:00 +01:00
mrhid6 fdbd591c73 fix: Fixed console height
Server Deploy / deploy (push) Successful in 1m31s
2026-07-17 13:49:47 +01:00
mrhid6 1989d6cd98 fix: Fixed console width
Server Deploy / deploy (push) Successful in 1m17s
2026-07-17 13:46:12 +01:00
mrhid6 763eafa4f8 fix: Fixed guac connection
Server Deploy / deploy (push) Successful in 1m22s
2026-07-17 13:40:54 +01:00
mrhid6 3dff45350b fix: Fixed server backfill existing servers
Server Deploy / deploy (push) Successful in 1m14s
2026-07-17 13:35:10 +01:00
15 changed files with 367 additions and 42 deletions
+13 -2
View File
@@ -68,12 +68,23 @@ jobs:
cache: true
cache-dependency-path: agent/go.sum
- name: Extract version
id: version
shell: pwsh
run: |
$v = "${{ github.ref_name }}" -replace '^agent/v', ''
"VERSION=$v" | Out-File -Append $env:GITHUB_OUTPUT
# MSI ProductVersion must be numeric x.x.x.x
"MSIVERSION=$v.0" | Out-File -Append $env:GITHUB_OUTPUT
- name: Build agent exe
working-directory: agent
shell: pwsh
env:
VERSION: ${{ steps.version.outputs.VERSION }}
run: |
$env:GOOS = "windows"; $env:GOARCH = "amd64"
go build -o ../installer/vantage-agent-windows-amd64.exe ./cmd
go build -ldflags="-s -w -X main.Version=$env:VERSION" -o ../installer/vantage-agent-windows-amd64.exe ./cmd
- name: Cache nssm
id: cache-nssm
@@ -100,7 +111,7 @@ jobs:
shell: pwsh
run: |
$env:PATH = "$env:PATH;$env:USERPROFILE\.dotnet\tools"
wix build vantage-agent.wxs -o vantage-agent.msi
wix build vantage-agent.wxs -d Version=${{ steps.version.outputs.MSIVERSION }} -o vantage-agent.msi
(Get-FileHash vantage-agent.msi -Algorithm SHA256).Hash.ToLower() + " vantage-agent.msi" | Out-File -Encoding ascii checksums-msi.txt
- name: Attach MSI to release
+47
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
@@ -265,6 +266,11 @@ func handleDeleteKey(cmd *pb.ServerCommand) {
}
func handleUpdateAgent(cmd *pb.ServerCommand) {
if runtime.GOOS == "windows" {
handleUpdateAgentWindows(cmd)
return
}
u := cmd.UpdateAgent
arch := runtime.GOARCH // "amd64" or "arm64"
tag := "agent%2Fv" + u.Version
@@ -305,6 +311,47 @@ func handleUpdateAgent(cmd *pb.ServerCommand) {
exec.Command("systemctl", "restart", "vantage-agent").Run()
}
// handleUpdateAgentWindows downloads the latest MSI and launches msiexec to
// perform a MajorUpgrade. msiexec is started DETACHED (via "cmd /c start") so
// that when the upgrade stops the VantageAgent service, nssm's process-tree
// kill of this agent does not also kill the installer mid-flight. Config
// (server_id, agent_token) is preserved by setup.ps1 on upgrade.
func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
u := cmd.UpdateAgent
tag := "agent%2Fv" + u.Version
msiURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent.msi", u.GiteaBaseURL, tag)
checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums-msi.txt", u.GiteaBaseURL, tag)
log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId)
msiPath := filepath.Join(os.TempDir(), "vantage-agent-update.msi")
if err := downloadFile(msiURL, msiPath); err != nil {
log.Printf("update download failed (cmd=%s): %v", cmd.CommandId, err)
return
}
checksumData, err := httpGetBytes(checksumURL)
if err != nil {
log.Printf("update checksum fetch failed (cmd=%s): %v", cmd.CommandId, err)
return
}
if err := verifyChecksum(msiPath, "vantage-agent.msi", checksumData); err != nil {
log.Printf("update checksum mismatch (cmd=%s): %v", cmd.CommandId, err)
os.Remove(msiPath)
return
}
logPath := filepath.Join(os.TempDir(), "vantage-agent-msi.log")
log.Printf("launching msiexec for upgrade to v%s (cmd=%s)", u.Version, cmd.CommandId)
// "start" detaches msiexec from this process tree so the service stop
// during the upgrade does not terminate the installer.
up := exec.Command("cmd", "/c", "start", "", "/wait", "msiexec", "/i", msiPath, "/qn", "/norestart", "/l*v", logPath)
if err := up.Start(); err != nil {
log.Printf("failed to launch msiexec (cmd=%s): %v", cmd.CommandId, err)
return
}
}
func downloadFile(url, dest string) error {
resp, err := http.Get(url) //nolint:gosec
if err != nil {
+121 -12
View File
@@ -2,11 +2,81 @@ param(
[string]$ServerId,
[string]$Token,
[string]$ServerUrl,
[string]$InstallDir
[string]$InstallDir,
[switch]$Uninstall
)
$cfgDir = Join-Path $env:ProgramData "vantage"
New-Item -ItemType Directory -Force -Path $cfgDir | Out-Null
$cfg = @"
$ErrorActionPreference = "Stop"
$logDir = Join-Path $env:ProgramData "vantage"
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
$log = Join-Path $logDir "install.log"
function Write-Log($msg) {
$line = "{0} {1}" -f (Get-Date -Format "s"), $msg
Add-Content -Path $log -Value $line
}
# Fail native-exe (nssm) calls loudly: check $LASTEXITCODE after each call
function Invoke-Native {
param([string]$File, [string[]]$Arguments)
Write-Log ("RUN: {0} {1}" -f $File, ($Arguments -join " "))
$out = & $File @Arguments 2>&1
if ($out) { Write-Log ("OUT: {0}" -f ($out -join "`n")) }
if ($LASTEXITCODE -ne 0) {
throw ("{0} exited {1}" -f $File, $LASTEXITCODE)
}
}
# Like Invoke-Native but never throws — for teardown, where a missing/stopped
# service must not abort the uninstall.
function Invoke-NativeSoft {
param([string]$File, [string[]]$Arguments)
Write-Log ("RUN(soft): {0} {1}" -f $File, ($Arguments -join " "))
$out = & $File @Arguments 2>&1
if ($out) { Write-Log ("OUT: {0}" -f ($out -join "`n")) }
Write-Log ("EXIT: {0}" -f $LASTEXITCODE)
}
if ($Uninstall) {
try {
Write-Log "=== teardown start ==="
if (-not $InstallDir) { $InstallDir = $PSScriptRoot }
$nssm = Join-Path $InstallDir "nssm.exe"
if (Test-Path $nssm) {
Invoke-NativeSoft -File $nssm -Arguments @("stop", "VantageAgent")
Invoke-NativeSoft -File $nssm -Arguments @("remove", "VantageAgent", "confirm")
} else {
Write-Log "nssm.exe not found at $nssm - using sc.exe fallback"
Invoke-NativeSoft -File "sc.exe" -Arguments @("stop", "VantageAgent")
Invoke-NativeSoft -File "sc.exe" -Arguments @("delete", "VantageAgent")
}
Write-Log "=== teardown ok ==="
exit 0
}
catch {
Write-Log ("TEARDOWN ERROR: {0}" -f $_.Exception.Message)
# Never block uninstall
exit 0
}
}
try {
Write-Log "=== setup start ==="
Write-Log ("ServerId={0} ServerUrl={1} InstallDir={2}" -f $ServerId, $ServerUrl, $InstallDir)
$cfgDir = Join-Path $env:ProgramData "vantage"
New-Item -ItemType Directory -Force -Path $cfgDir | Out-Null
$cfgPath = Join-Path $cfgDir "config.yaml"
# Preserve existing config on upgrade. A MajorUpgrade re-runs this script with
# no SERVERID/TOKEN, so blindly rewriting would wipe the agent_token the agent
# persisted after Register(). Only (re)write when a ServerId is supplied
# (fresh install / explicit re-register).
if ((Test-Path $cfgPath) -and (-not $ServerId)) {
Write-Log "config.yaml exists and no ServerId supplied - preserving existing config (upgrade)"
}
else {
$cfg = @"
server_url: "$ServerUrl"
server_id: "$ServerId"
pre_reg_token: "$Token"
@@ -14,12 +84,51 @@ agent_token: ""
poll_interval: 30s
tls: true
"@
Set-Content -Path (Join-Path $cfgDir "config.yaml") -Value $cfg -Encoding utf8
# Lock down ACL: SYSTEM + Administrators only
icacls (Join-Path $cfgDir "config.yaml") /inheritance:r /grant:r "SYSTEM:F" "Administrators:F" | Out-Null
Set-Content -Path $cfgPath -Value $cfg -Encoding utf8
Write-Log "wrote $cfgPath"
$nssm = Join-Path $InstallDir "nssm.exe"
$exe = Join-Path $InstallDir "vantage-agent.exe"
& $nssm install VantageAgent $exe
& $nssm set VantageAgent Start SERVICE_AUTO_START
& $nssm start VantageAgent
# Lock down ACL: SYSTEM + Administrators only
Invoke-Native -File "icacls" -Arguments @($cfgPath, "/inheritance:r", "/grant:r", "SYSTEM:F", "Administrators:F")
}
if (-not $InstallDir) { $InstallDir = $PSScriptRoot }
$nssm = Join-Path $InstallDir "nssm.exe"
$exe = Join-Path $InstallDir "vantage-agent.exe"
if (-not (Test-Path $nssm)) { throw "nssm.exe not found at $nssm" }
if (-not (Test-Path $exe)) { throw "vantage-agent.exe not found at $exe" }
# Install only if the service isn't already registered (an upgrade may leave
# it in place). "nssm install" on an existing service errors otherwise.
$exists = Get-Service -Name "VantageAgent" -ErrorAction SilentlyContinue
if (-not $exists) {
Invoke-Native -File $nssm -Arguments @("install", "VantageAgent", $exe)
} else {
Write-Log "VantageAgent service already exists - updating binary path"
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "Application", $exe)
}
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "Start", "SERVICE_AUTO_START")
# Redirect service stdout/stderr to log files (nssm discards them otherwise)
# with online rotation at ~1MB.
$outLog = Join-Path $logDir "agent-stdout.log"
$errLog = Join-Path $logDir "agent-stderr.log"
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStdout", $outLog)
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStderr", $errLog)
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStdoutCreationDisposition", "4")
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStderrCreationDisposition", "4")
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateFiles", "1")
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateOnline", "1")
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateBytes", "1048576")
# restart (not just start) so an upgrade picks up the new binary
Invoke-NativeSoft -File $nssm -Arguments @("restart", "VantageAgent")
Write-Log "=== setup ok ==="
exit 0
}
catch {
Write-Log ("ERROR: {0}" -f $_.Exception.Message)
Write-Log ($_.ScriptStackTrace)
exit 1
}
+19 -3
View File
@@ -1,9 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<?ifndef Version ?>
<?define Version = "0.0.0.0" ?>
<?endif?>
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package Name="Vantage Agent" Manufacturer="Vantage"
Version="1.0.0.0" UpgradeCode="7d1e6d2c-2a5f-4b3e-9c3a-8a1b2c3d4e5f"
Version="$(var.Version)" UpgradeCode="7d1e6d2c-2a5f-4b3e-9c3a-8a1b2c3d4e5f"
Scope="perMachine">
<MajorUpgrade DowngradeErrorMessage="A newer version is already installed." />
<MajorUpgrade DowngradeErrorMessage="A newer version is already installed."
Schedule="afterInstallInitialize" />
<MediaTemplate EmbedCab="yes" />
<!-- Public properties settable via msiexec: SERVERID, TOKEN, SERVERURL -->
@@ -54,13 +58,25 @@
actual Windows machine before this is trusted in production. -->
<SetProperty Id="WriteConfig"
Before="WriteConfig" Sequence="execute" Condition="NOT Installed"
Value='cmd.exe /c powershell -ExecutionPolicy Bypass -File "[INSTALLDIR]setup.ps1" -ServerId "[SERVERID]" -Token "[TOKEN]" -ServerUrl "[SERVERURL]" -InstallDir "[INSTALLDIR]"' />
Value='cmd.exe /c powershell -ExecutionPolicy Bypass -File "[INSTALLDIR]setup.ps1" -ServerId "[SERVERID]" -Token "[TOKEN]" -ServerUrl "[SERVERURL]"' />
<CustomAction Id="WriteConfig" Directory="INSTALLDIR" ExeCommand="[WriteConfig]"
Execute="deferred" Impersonate="no" Return="check" />
<!-- Teardown on uninstall: stop + remove the service BEFORE RemoveFiles
deletes nssm.exe/setup.ps1. Same CustomActionData marshaling pattern
as WriteConfig. REMOVE="ALL" = full uninstall (not a component-level
repair/modify). -->
<SetProperty Id="RemoveService"
Before="RemoveService" Sequence="execute" Condition="REMOVE=&quot;ALL&quot;"
Value='cmd.exe /c powershell -ExecutionPolicy Bypass -File "[INSTALLDIR]setup.ps1" -Uninstall' />
<CustomAction Id="RemoveService" Directory="INSTALLDIR" ExeCommand="[RemoveService]"
Execute="deferred" Impersonate="no" Return="ignore" />
<InstallExecuteSequence>
<Custom Action="WriteConfig" After="InstallFiles" Condition="NOT Installed" />
<Custom Action="RemoveService" Before="RemoveFiles" Condition="REMOVE=&quot;ALL&quot;" />
</InstallExecuteSequence>
</Package>
</Wix>
+14 -3
View File
@@ -4,6 +4,7 @@ import (
"net"
"net/http"
"os"
"strconv"
"time"
"github.com/gin-gonic/gin"
@@ -69,6 +70,16 @@ func consoleConnect(c *gin.Context) {
})
}
// queryIntDefault reads a positive integer query param, falling back to def
// when absent, unparseable, or non-positive.
func queryIntDefault(r *http.Request, key string, def int) int {
v, err := strconv.Atoi(r.URL.Query().Get(key))
if err != nil || v <= 0 {
return def
}
return v
}
// GET /api/console/tunnel?token=... (WebSocket upgrade)
func consoleTunnel(c *gin.Context) {
token := c.Query("token")
@@ -139,9 +150,9 @@ func consoleTunnel(c *gin.Context) {
for k, v := range gp.Params {
config.Parameters[k] = v
}
config.OptimalScreenWidth = 1024
config.OptimalScreenHeight = 768
config.OptimalResolution = 96
config.OptimalScreenWidth = queryIntDefault(r, "width", 1024)
config.OptimalScreenHeight = queryIntDefault(r, "height", 768)
config.OptimalResolution = queryIntDefault(r, "dpi", 96)
addr, err := net.ResolveTCPAddr("tcp", guacdAddr)
if err != nil {
+10 -3
View File
@@ -23,6 +23,7 @@ func RegisterRoutes(r *gin.Engine) {
r.GET("/install", handleInstallScript)
r.GET("/install.ps1", handleInstallScriptWindows)
r.GET("/update", handleUpdateScript)
r.GET("/update.ps1", handleUpdateScriptWindows)
// ESO read endpoint — bearer-token auth, not session auth, so Kubernetes
// External Secrets Operator can call it. Lives under /api (so the reverse
@@ -124,10 +125,16 @@ func newServer(c *gin.Context) {
host, s.ServerID, token,
)
installCmdPS := fmt.Sprintf(
`irm "%s/install.ps1?server_id=%s&token=%s" | iex`,
host, s.ServerID, token,
)
c.JSON(http.StatusOK, gin.H{
"server_id": s.ServerID,
"pre_reg_token": token,
"install_command": installCmd,
"server_id": s.ServerID,
"pre_reg_token": token,
"install_command": installCmd,
"install_command_ps": installCmdPS,
})
}
+37
View File
@@ -54,3 +54,40 @@ func handleInstallScriptWindows(c *gin.Context) {
c.Header("Content-Type", "text/plain; charset=utf-8")
c.String(http.StatusOK, script)
}
// handleUpdateScriptWindows serves a PowerShell one-liner that upgrades an
// already-installed Windows agent. No server_id/token needed: the MSI is a
// MajorUpgrade and setup.ps1 preserves the existing config on upgrade.
func handleUpdateScriptWindows(c *gin.Context) {
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
script := fmt.Sprintf(
"#Requires -RunAsAdministrator\n"+
"$ErrorActionPreference = \"Stop\"\n"+
"\n"+
"$GiteaHost = \"%s\"\n"+
"\n"+
"$rel = Invoke-RestMethod -Uri \"https://$GiteaHost/api/v1/repos/mrhid6/vantage/releases?limit=10\"\n"+
"$tag = ($rel | Where-Object { $_.tag_name -like 'agent/v*' } | Select-Object -First 1).tag_name\n"+
"if (-not $tag) { throw \"Could not determine latest agent version\" }\n"+
"$enc = $tag -replace '/','%%2F'\n"+
"$base = \"https://$GiteaHost/mrhid6/vantage/releases/download/$enc\"\n"+
"\n"+
"$tmp = Join-Path $env:TEMP \"vantage-agent.msi\"\n"+
"Invoke-WebRequest -Uri \"$base/vantage-agent.msi\" -OutFile $tmp\n"+
"Invoke-WebRequest -Uri \"$base/checksums-msi.txt\" -OutFile \"$env:TEMP\\checksums-msi.txt\"\n"+
"\n"+
"$expected = (Get-Content \"$env:TEMP\\checksums-msi.txt\" | Select-String 'vantage-agent.msi').ToString().Split()[0]\n"+
"$actual = (Get-FileHash $tmp -Algorithm SHA256).Hash.ToLower()\n"+
"if ($expected -ne $actual) { throw \"Checksum mismatch\" }\n"+
"\n"+
"Start-Process msiexec.exe -Wait -ArgumentList \"/i `\"$tmp`\" /qn /norestart\"\n"+
"Write-Host \"Vantage agent updated to $tag.\"\n",
giteaHost)
c.Header("Content-Type", "text/plain; charset=utf-8")
c.String(http.StatusOK, script)
}
+4
View File
@@ -43,6 +43,10 @@ func (s *vantageServer) SyncKeys(ctx context.Context, req *pb.SyncRequest) (*pb.
log.Printf("failed to update last seen for %s: %v", srv.ServerID, err)
}
if err := services.BackfillConsoleConfig(srv); err != nil {
log.Printf("failed to backfill console config for %s: %v", srv.ServerID, err)
}
keys, err := services.BuildAuthorizedKeys(req.ServerId)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to build authorized keys: %v", err)
+31
View File
@@ -167,6 +167,37 @@ func ValidateAgentToken(serverID, agentToken string) (*models.Server, error) {
return &s, nil
}
// BackfillConsoleConfig sets default console_protocols/ports for a server that
// predates the console feature (or was updated without re-registering). Servers
// register only once via a single-use pre_reg_token, so Register() never runs
// again to populate these fields — this runs on every sync as a cheap no-op
// once the fields are present.
func BackfillConsoleConfig(srv *models.Server) error {
if srv == nil || len(srv.ConsoleProtocols) > 0 {
return nil
}
osType := srv.OSType
if osType == "" {
osType = OSTypeFromInfo(srv.OSInfo)
}
protocols, sshPort, rdpPort := defaultConsoleFields(osType)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("servers").UpdateOne(ctx,
bson.M{"server_id": srv.ServerID, "console_protocols": bson.M{"$in": []interface{}{nil, bson.A{}}}},
bson.M{"$set": bson.M{
"os_type": osType,
"console_protocols": protocols,
"ssh_port": sshPort,
"rdp_port": rdpPort,
}},
)
return err
}
func UpdateServerLastSeen(serverID, agentVersion string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
+24 -9
View File
@@ -26,6 +26,7 @@ export default function ServerConsolePage() {
const [connecting, setConnecting] = useState(false);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState<{ token: string; wsPath: string } | null>(null);
// Inject the vendored Guacamole client script once.
useEffect(() => {
@@ -83,15 +84,10 @@ export default function ServerConsolePage() {
}
const { token, ws_path } = await api.connectConsole(body);
const wsProto = location.protocol === "https:" ? "wss" : "ws";
const wsUrl = `${wsProto}://${location.host}${ws_path}?token=${encodeURIComponent(token)}`;
if (containerRef.current) {
const conn = openConsole(containerRef.current, wsUrl);
connectionRef.current = conn;
setConnected(true);
}
// Defer the actual openConsole until after the form is unmounted so the
// container measures at full height (see effect below).
setPending({ token, wsPath: ws_path });
setConnected(true);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to connect");
} finally {
@@ -99,6 +95,25 @@ export default function ServerConsolePage() {
}
}
// Runs after `connected` flips and the connection form is gone, so the
// container now occupies its full flex height.
useEffect(() => {
if (!connected || !pending || !containerRef.current) return;
const wsProto = location.protocol === "https:" ? "wss" : "ws";
const wsUrl = `${wsProto}://${location.host}${pending.wsPath}`;
const rect = containerRef.current.getBoundingClientRect();
const dpi = Math.round(96 * (window.devicePixelRatio || 1));
const connectData =
`token=${encodeURIComponent(pending.token)}` +
`&width=${Math.floor(rect.width)}` +
`&height=${Math.floor(rect.height)}` +
`&dpi=${dpi}`;
connectionRef.current = openConsole(containerRef.current, wsUrl, connectData);
setPending(null);
}, [connected, pending]);
function handleDisconnect() {
connectionRef.current?.disconnect();
connectionRef.current = null;
+2 -2
View File
@@ -416,10 +416,10 @@ export default function ServerDetailPage() {
{updateSuccess ? "Update Sent!" : "Update Agent"}
</Button>
<div className="relative flex-1 min-w-64 rounded-lg border border-border bg-[#0a0c14] px-4 py-2.5 font-mono text-sm">
<span className="text-accent">$</span> <span className="text-text-primary">{api.getUpdateCommand()}</span>
<span className="text-accent">{server.os_info?.toLowerCase().includes("windows") ? "PS>" : "$"}</span> <span className="text-text-primary">{api.getUpdateCommand(server.os_info)}</span>
<button
onClick={async () => {
await navigator.clipboard.writeText(api.getUpdateCommand());
await navigator.clipboard.writeText(api.getUpdateCommand(server.os_info));
setCopiedUpdate(true);
setTimeout(() => setCopiedUpdate(false), 2000);
}}
+28 -5
View File
@@ -8,15 +8,18 @@ import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
export default function NewServerPage() {
const [result, setResult] = useState<NewServerResponse | null>(null);
const [copied, setCopied] = useState(false);
const [os, setOs] = useState<"linux" | "windows">("linux");
const { mutate: createServer, isPending, error } = useMutation({
mutationFn: api.createServer,
onSuccess: (data) => setResult(data),
});
const command = os === "windows" ? result?.install_command_ps : result?.install_command;
const handleCopy = async () => {
if (!result?.install_command) return;
await navigator.clipboard.writeText(result.install_command);
if (!command) return;
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
@@ -65,14 +68,34 @@ export default function NewServerPage() {
Valid for 1 hour
</span>
</CardHeader>
<div className="mb-4 flex gap-2">
{(["linux", "windows"] as const).map((o) => (
<button
key={o}
onClick={() => { setOs(o); setCopied(false); }}
className={`rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors ${
os === o
? "border-accent bg-accent/10 text-accent"
: "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
}`}
>
{o === "linux" ? "Linux (bash)" : "Windows (PowerShell)"}
</button>
))}
</div>
<p className="mb-4 text-sm text-text-secondary">
Run this command on the target server as <code className="rounded bg-surface-2 px-1 py-0.5 text-xs font-mono text-text-primary">root</code>:
{os === "windows" ? (
<>Run this in an <strong className="text-text-primary">elevated PowerShell</strong> (Run as Administrator):</>
) : (
<>Run this command on the target server as <code className="rounded bg-surface-2 px-1 py-0.5 text-xs font-mono text-text-primary">root</code>:</>
)}
</p>
<div className="relative rounded-lg border border-border bg-[#0a0c14] p-4 font-mono text-sm">
<pre className="overflow-x-auto whitespace-pre-wrap break-all text-text-secondary leading-relaxed">
<span className="text-accent">$</span>{" "}
<span className="text-text-primary">{result.install_command}</span>
<span className="text-accent">{os === "windows" ? "PS>" : "$"}</span>{" "}
<span className="text-text-primary">{command}</span>
</pre>
<button
onClick={handleCopy}
+5 -1
View File
@@ -113,6 +113,7 @@ export interface NewServerResponse {
server_id: string;
pre_reg_token: string;
install_command: string;
install_command_ps: string;
}
export interface GenerateKeyOptions {
@@ -188,7 +189,10 @@ export const api = {
});
},
getUpdateCommand(): string {
getUpdateCommand(osInfo?: string): string {
if (osInfo && osInfo.toLowerCase().includes("windows")) {
return `irm "${window.location.origin}/update.ps1" | iex`;
}
return `curl -fsSL "${window.location.origin}/update" | bash`;
},
+4 -2
View File
@@ -2,14 +2,16 @@
// The library attaches a global `Guacamole` object when loaded.
declare const Guacamole: any;
export function openConsole(container: HTMLElement, wsUrl: string): { disconnect: () => void } {
export function openConsole(container: HTMLElement, wsUrl: string, connectData = ""): { disconnect: () => void } {
// Guacamole's WebSocketTunnel builds the socket URL as `wsUrl + "?" + data`,
// so wsUrl must NOT already contain a query string — pass params via connectData.
const tunnel = new Guacamole.WebSocketTunnel(wsUrl);
const client = new Guacamole.Client(tunnel);
container.innerHTML = "";
container.appendChild(client.getDisplay().getElement());
client.connect("");
client.connect(connectData);
// Wire keyboard + mouse.
const mouse = new Guacamole.Mouse(client.getDisplay().getElement());
+8
View File
@@ -18,10 +18,18 @@ const nextConfig: NextConfig = {
source: "/install",
destination: `${apiUrl}/install`,
},
{
source: "/install.ps1",
destination: `${apiUrl}/install.ps1`,
},
{
source: "/update",
destination: `${apiUrl}/update`,
},
{
source: "/update.ps1",
destination: `${apiUrl}/update.ps1`,
},
];
},
};