diff --git a/build.cmd b/build.cmd index 3d7e50a..fefbb0d 100644 --- a/build.cmd +++ b/build.cmd @@ -1,6 +1,8 @@ @echo off :: SimpleRemoter Quick Build Script -:: Usage: build.cmd [release|debug] [x64|x86|all] [server|clean|publish] +:: Usage: build.cmd [release|debug] [x64|x86|all] [server|clean|publish|go-server] +:: go-server Build Go fallback server only -> Bin\YamaGo_x64.exe +:: go-server publish Same, plus UPX --best compression setlocal enabledelayedexpansion @@ -18,6 +20,7 @@ if /i "%~1"=="all" (set PLATFORM=all& shift& goto :parse_args) if /i "%~1"=="server" (set EXTRA_ARGS=!EXTRA_ARGS! -ServerOnly& shift& goto :parse_args) if /i "%~1"=="clean" (set EXTRA_ARGS=!EXTRA_ARGS! -Clean& shift& goto :parse_args) if /i "%~1"=="publish" (set EXTRA_ARGS=!EXTRA_ARGS! -Publish& shift& goto :parse_args) +if /i "%~1"=="go-server" (set EXTRA_ARGS=!EXTRA_ARGS! -GoServer& shift& goto :parse_args) echo Unknown argument: %~1 shift goto :parse_args diff --git a/build.ps1 b/build.ps1 index 711768c..179b440 100644 --- a/build.ps1 +++ b/build.ps1 @@ -15,11 +15,110 @@ param( [switch]$ServerOnly, # Only build main server (Yama), skip client projects [switch]$Clean, # Clean before build - [switch]$Publish # Publish mode: rebuild all deps + x64 Release + UPX compress + [switch]$Publish, # Publish mode: rebuild all deps + x64 Release + UPX compress + [switch]$GoServer # Build Go fallback server (server/go) -> Bin/YamaGo_x64.exe ) $ErrorActionPreference = "Stop" +$rootDir = $PSScriptRoot +$binDir = Join-Path $rootDir "Bin" +$upxPath = Join-Path $rootDir "server\2015Remote\res\3rd\upx.exe" + +# Build Go fallback server. No-op (with warning) if Go compiler is not installed. +# When -Compress is set, run UPX --best on the output (mirrors C++ publish flow). +function Build-GoServer { + param( + [string]$Configuration, + [switch]$Compress + ) + + Write-Host "" + Write-Host "Building Go server (server/go)..." -ForegroundColor Magenta + + $goCmd = Get-Command go -ErrorAction SilentlyContinue + if (-not $goCmd) { + Write-Host "WARNING: Go compiler not found in PATH. Skipping Go server build." -ForegroundColor Yellow + Write-Host " Install from https://go.dev/dl/ and ensure 'go' is in PATH." -ForegroundColor DarkGray + return $false + } + + Write-Host "Using Go: $($goCmd.Source)" -ForegroundColor Cyan + + $goDir = Join-Path $rootDir "server\go" + if (-not (Test-Path $goDir)) { + Write-Host "ERROR: Go source directory not found at $goDir" -ForegroundColor Red + return $false + } + + # Sync web assets (mirrors Makefile `sync` target — single source is server/web/index.html) + $webSrc = Join-Path $rootDir "server\web\index.html" + $webDstDir = Join-Path $goDir "web\assets" + if (Test-Path $webSrc) { + if (-not (Test-Path $webDstDir)) { New-Item -ItemType Directory -Path $webDstDir -Force | Out-Null } + Copy-Item -Path $webSrc -Destination (Join-Path $webDstDir "index.html") -Force + } + + if (-not (Test-Path $binDir)) { New-Item -ItemType Directory -Path $binDir -Force | Out-Null } + + $outFile = Join-Path $binDir "YamaGo_x64.exe" + # Release strips debug info for smaller binary; Debug keeps symbols. + $ldflags = if ($Configuration -eq "Debug") { "" } else { "-s -w" } + + Push-Location $goDir + try { + $env:GOOS = "windows" + $env:GOARCH = "amd64" + if ($ldflags) { + & go build -ldflags $ldflags -o $outFile ./cmd + } else { + & go build -o $outFile ./cmd + } + $code = $LASTEXITCODE + } finally { + Pop-Location + } + + if ($code -ne 0) { + Write-Host "ERROR: Go build failed (exit $code)" -ForegroundColor Red + return $false + } + + $size = (Get-Item $outFile).Length / 1MB + Write-Host "OK: $outFile ($($size.ToString('F2')) MB)" -ForegroundColor Green + + # In-place UPX compression. Failure is a warning, not an error — the + # uncompressed binary is still usable, and UPX occasionally refuses on + # certain PE sections. + if ($Compress) { + Write-Host "" + Write-Host "UPX compressing Go server..." -ForegroundColor Magenta + if (-not (Test-Path $upxPath)) { + Write-Host "WARNING: UPX not found at $upxPath — skipping compression" -ForegroundColor Yellow + } else { + $sizeBefore = (Get-Item $outFile).Length / 1MB + Write-Host " Before: $($sizeBefore.ToString('F2')) MB" -ForegroundColor DarkGray + & $upxPath --best $outFile + if ($LASTEXITCODE -ne 0) { + Write-Host "WARNING: UPX compression failed, uncompressed binary kept" -ForegroundColor Yellow + } else { + $sizeAfter = (Get-Item $outFile).Length / 1MB + $ratio = (1 - $sizeAfter / $sizeBefore) * 100 + Write-Host " After: $($sizeAfter.ToString('F2')) MB (-$($ratio.ToString('F1'))%)" -ForegroundColor Green + } + } + } + return $true +} + +# Go-only fast path: skip MSBuild entirely. -Publish here means "compress the +# Go binary too" (not the full C++ publish flow). +if ($GoServer) { + $ok = Build-GoServer -Configuration $Config -Compress:$Publish + if (-not $ok) { exit 1 } + exit 0 +} + # Find MSBuild (VS2019 or VS2022, including Insiders/Preview) # Order: Prefer installations with v142 toolset (VS2019) over VS2022 BuildTools $msBuildPaths = @( @@ -72,9 +171,7 @@ elseif ($msBuild -match "\\18\\") { $vsYear = "2019 Insiders" } Write-Host "Using MSBuild: $msBuild" -ForegroundColor Cyan -$rootDir = $PSScriptRoot $slnFile = Join-Path $rootDir "YAMA.sln" -$upxPath = Join-Path $rootDir "server\2015Remote\res\3rd\upx.exe" # Publish mode overrides if ($Publish) { diff --git a/server/go/cmd/main.go b/server/go/cmd/main.go index aba47d6..a9a4017 100644 --- a/server/go/cmd/main.go +++ b/server/go/cmd/main.go @@ -668,6 +668,15 @@ func main() { log := logger.New(logCfg) + // Track env vars where we fell back to a built-in default. Printed once + // at the end of startup so the operator sees what's in effect — vars the + // operator explicitly set are NOT listed (they already know their value). + type defaultedEnv struct{ name, value string } + var defaultsUsed []defaultedEnv + rememberDefault := func(name, value string) { + defaultsUsed = append(defaultsUsed, defaultedEnv{name, value}) + } + // Create auth config authCfg := auth.DefaultConfig() // PwdHash can be set from environment or config file @@ -675,6 +684,7 @@ func main() { if authCfg.PwdHash == "" { // Default placeholder - should be configured in production authCfg.PwdHash = "61f04dd637a74ee34493fc1025de2c131022536da751c29e3ff4e9024d8eec43" + rememberDefault("YAMA_PWDHASH", authCfg.PwdHash) } authCfg.SuperPass = os.Getenv("YAMA_PWD") @@ -721,19 +731,23 @@ func main() { // Web user authenticator. Bootstrap admin from env var YAMA_WEB_ADMIN_PASS; // if unset, fall back to YAMA_PWD (same secret the TCP authorization uses) // so a single password env var is enough to bring up the whole stack. - // If neither is set, no admin is registered and login will always fail — - // the user must define a password before browsers can log in. + // If neither is set we use a hard-coded "admin" default so the web UI is + // usable out of the box — the startup banner surfaces this so operators + // know to override it in any non-dev deployment. + const defaultWebAdminPass = "admin" webAuth := wsauth.New() adminPass := os.Getenv("YAMA_WEB_ADMIN_PASS") if adminPass == "" { adminPass = os.Getenv("YAMA_PWD") } - if adminPass != "" { - webAuth.AddAdminFromPlainPassword("admin", adminPass) - log.Info("Web admin user configured") - } else { - log.Warn("Neither YAMA_WEB_ADMIN_PASS nor YAMA_PWD is set; web login will be unavailable") + usingDefaultWebPass := false + if adminPass == "" { + adminPass = defaultWebAdminPass + rememberDefault("YAMA_WEB_ADMIN_PASS", defaultWebAdminPass) + usingDefaultWebPass = true } + webAuth.AddAdminFromPlainPassword("admin", adminPass) + log.Info("Web admin user configured") // Persistent users live in users.json next to the binary's working dir // — same default the C++ WebService uses (m_ConfigDir + "users.json"). @@ -741,6 +755,7 @@ func main() { usersFile := os.Getenv("YAMA_USERS_FILE") if usersFile == "" { usersFile = "users.json" + rememberDefault("YAMA_USERS_FILE", usersFile) } if err := webAuth.SetUsersFile(usersFile); err != nil { log.Warn("Failed to load %s: %v (continuing with admin only)", usersFile, err) @@ -849,10 +864,21 @@ func main() { fmt.Printf("Server started on port(s): %v\n", ports) if *httpPort != 0 { fmt.Printf("Web UI on http://localhost:%d/\n", *httpPort) + if usingDefaultWebPass { + fmt.Printf(" Default login: admin / %s (set YAMA_WEB_ADMIN_PASS to override)\n", + defaultWebAdminPass) + } } if licenseHTTP != nil { fmt.Printf("License Server on http://%s/license/{sign,heartbeat}\n", licAddr) } + if len(defaultsUsed) > 0 { + fmt.Println() + fmt.Println("[!] Using built-in defaults (set the env var to override):") + for _, d := range defaultsUsed { + fmt.Printf(" %s = %s\n", d.name, d.value) + } + } fmt.Println("Logs are written to: logs/server.log") fmt.Println("Press Ctrl+C to stop...") diff --git a/server/go/web/ws_handlers.go b/server/go/web/ws_handlers.go index 8d69349..44087dc 100644 --- a/server/go/web/ws_handlers.go +++ b/server/go/web/ws_handlers.go @@ -7,8 +7,25 @@ import ( "github.com/yuanyuanxiang/SimpleRemoter/server/go/hub" "github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol" + "github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth" ) +// rotateChallenge replaces the client's nonce with a fresh one and pushes a +// {cmd:"challenge"} message so the browser updates its stored nonce. Called +// after every login failure so a retry on the SAME WebSocket works without +// the user having to refresh — the old nonce is still burned for replay +// protection, but the client now has a usable new one. +func (h *wsHub) rotateChallenge(c *wsClient) { + n, err := wsauth.NewNonce() + if err != nil { + h.log.Error("nonce regen failed: %v", err) + c.nonce = "" + return + } + c.nonce = n + c.queue([]byte(`{"cmd":"challenge","nonce":"` + n + `"}`)) +} + // dispatch routes one inbound message to its handler. The `raw` payload is // passed through so handlers can re-parse to their own shape. // @@ -125,10 +142,12 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) { // with a uniform "credentials" error so the limit is not detectable. if !h.allowLoginByIP(c) || !h.allowLoginByUsername(in.Username) { h.log.Warn("ws login throttled: user=%s addr=%s", in.Username, c.addr) - // Burn the challenge so the attacker can't immediately replay. - c.nonce = "" time.Sleep(500 * time.Millisecond) c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid credentials"})) + // Rotate the challenge: burns the previous nonce (replay protection) + // AND hands the client a fresh one so the next attempt does not + // require a page refresh. + h.rotateChallenge(c) return } @@ -136,18 +155,18 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) { // replays from a different connection can't reuse a captured response. if in.Nonce == "" || in.Nonce != c.nonce { c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid challenge"})) + h.rotateChallenge(c) return } token, role, err := h.auth.VerifyLogin(in.Username, in.Response, in.Nonce) if err != nil { - // Burn the challenge on failure too — forces a new round on retry. - c.nonce = "" // Fixed delay on failure: makes online brute force impractical // even within the rate-limit budget, and erases the timing // difference between "wrong password" and "wrong nonce". time.Sleep(250 * time.Millisecond) c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid credentials"})) + h.rotateChallenge(c) return } // Successful login: clear the per-IP/per-user budgets so a legitimate