Compare commits

..
Author SHA1 Message Date
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
mrhid6 40dee57688 fix: Fixed agent ci install
Server Deploy / deploy (push) Has been cancelled
Agent Release / build (push) Successful in 10m34s
Agent Release / msi (push) Successful in 1m31s
2026-07-17 13:16:50 +01:00
12 changed files with 230 additions and 35 deletions
+8
View File
@@ -75,7 +75,15 @@ jobs:
$env:GOOS = "windows"; $env:GOARCH = "amd64"
go build -o ../installer/vantage-agent-windows-amd64.exe ./cmd
- name: Cache nssm
id: cache-nssm
uses: actions/cache@v4
with:
path: installer/nssm.exe
key: nssm-2.24-win64
- name: Fetch nssm
if: steps.cache-nssm.outputs.cache-hit != 'true'
working-directory: installer
shell: pwsh
run: |
+90 -12
View File
@@ -2,11 +2,72 @@ 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
$cfg = @"
server_url: "$ServerUrl"
server_id: "$ServerId"
pre_reg_token: "$Token"
@@ -14,12 +75,29 @@ 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
$cfgPath = Join-Path $cfgDir "config.yaml"
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" }
Invoke-Native -File $nssm -Arguments @("install", "VantageAgent", $exe)
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "Start", "SERVICE_AUTO_START")
Invoke-Native -File $nssm -Arguments @("start", "VantageAgent")
Write-Log "=== setup ok ==="
exit 0
}
catch {
Write-Log ("ERROR: {0}" -f $_.Exception.Message)
Write-Log ($_.ScriptStackTrace)
exit 1
}
+13 -1
View File
@@ -54,13 +54,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 {
+9 -3
View File
@@ -124,10 +124,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,
})
}
+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;
+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}
+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 {
+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());
+4
View File
@@ -18,6 +18,10 @@ const nextConfig: NextConfig = {
source: "/install",
destination: `${apiUrl}/install`,
},
{
source: "/install.ps1",
destination: `${apiUrl}/install.ps1`,
},
{
source: "/update",
destination: `${apiUrl}/update`,