Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f3f3601d2 | ||
|
|
b022722d39 | ||
|
|
bf96dba50e | ||
|
|
2b4611f7ac | ||
|
|
fdbd591c73 | ||
|
|
1989d6cd98 | ||
|
|
763eafa4f8 | ||
|
|
3dff45350b |
+90
-12
@@ -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
|
||||
}
|
||||
|
||||
@@ -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="ALL""
|
||||
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="ALL"" />
|
||||
</InstallExecuteSequence>
|
||||
</Package>
|
||||
</Wix>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -113,6 +113,7 @@ export interface NewServerResponse {
|
||||
server_id: string;
|
||||
pre_reg_token: string;
|
||||
install_command: string;
|
||||
install_command_ps: string;
|
||||
}
|
||||
|
||||
export interface GenerateKeyOptions {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -18,6 +18,10 @@ const nextConfig: NextConfig = {
|
||||
source: "/install",
|
||||
destination: `${apiUrl}/install`,
|
||||
},
|
||||
{
|
||||
source: "/install.ps1",
|
||||
destination: `${apiUrl}/install.ps1`,
|
||||
},
|
||||
{
|
||||
source: "/update",
|
||||
destination: `${apiUrl}/update`,
|
||||
|
||||
Reference in New Issue
Block a user