# JustASRV DDNS updater for Windows (PowerShell 5.1+). https://docs.justasrv.com/windows # # Runs once per invocation. The installer registers a SYSTEM scheduled task that runs it every # 2 minutes, at startup, and after network changes are picked up on the next run. # - checks the public address with one small HTTPS request # - updates only on change, or every RefreshSeconds as a heartbeat (server answers nochg, no DNS write) # - exponential backoff with jitter after failures; state survives reboots # - the token is stored DPAPI-encrypted (LocalMachine) in an ACL-restricted folder # - config is re-read every run, so a rotated token takes effect on the next run $ErrorActionPreference = 'Stop' $Version = '1.0.0' $Dir = Join-Path $env:ProgramData 'JustASRV' $ConfigPath = Join-Path $Dir 'config.json' $StatePath = Join-Path $Dir 'state.json' $LogPath = Join-Path $Dir 'justasrv.log' function Write-Log([string]$Level, [string]$Message) { try { if ((Test-Path $LogPath) -and (Get-Item $LogPath).Length -gt 1MB) { Move-Item -Force $LogPath "$LogPath.1" } $line = '{0} {1,-5} {2}' -f (Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK'), $Level, $Message Add-Content -Path $LogPath -Value $line -Encoding ASCII } catch { } } function Save-State($State) { $State | ConvertTo-Json | Set-Content -Path $StatePath -Encoding ASCII } function Exit-Backoff($State, [int]$BaseSeconds, [string]$Reason) { $State.Fails = [int]$State.Fails + 1 $n = [Math]::Min([int]$State.Fails, 6) $wait = [Math]::Min($BaseSeconds * [Math]::Pow(2, $n - 1), 3600) $wait = [int]$wait + (Get-Random -Minimum 0 -Maximum 60) $State.NextTry = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $wait Write-Log 'WARN' ('{0}; retry in {1}s (failure {2})' -f $Reason, $wait, $State.Fails) Save-State $State exit 0 } if (-not (Test-Path $ConfigPath)) { Write-Log 'ERROR' "missing $ConfigPath"; exit 1 } $cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json Add-Type -AssemblyName System.Security try { $bytes = [Convert]::FromBase64String($cfg.TokenProtected) $Token = [Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, 'LocalMachine')) } catch { Write-Log 'ERROR' 'cannot decrypt the stored token; re-run Install-JustASRV.ps1' exit 1 } $Server = if ($cfg.Server) { $cfg.Server } else { 'https://ddns.justasrv.com' } $Refresh = if ($cfg.RefreshSeconds) { [int]$cfg.RefreshSeconds } else { 600 } $Fqdn = [string]$cfg.Hostname $state = [pscustomobject]@{ LastIp = ''; LastOk = 0; Fails = 0; NextTry = 0 } if (Test-Path $StatePath) { try { $state = Get-Content $StatePath -Raw | ConvertFrom-Json } catch { } } $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() if ($now -lt [int64]$state.NextTry) { exit 0 } [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $ua = "JustASRV-Windows/$Version" try { $ip = (Invoke-WebRequest -UseBasicParsing -Uri "$Server/ip" -UserAgent $ua -TimeoutSec 20).Content.Trim() } catch { Exit-Backoff $state 60 "cannot reach $Server (network down?)" } if ($ip -notmatch '^\d{1,3}(\.\d{1,3}){3}$') { Exit-Backoff $state 60 "unexpected /ip answer" } if ($ip -eq $state.LastIp -and ($now - [int64]$state.LastOk) -lt $Refresh) { exit 0 } $pair = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("$($Fqdn):$Token")) $uri = "$Server/nic/update?hostname=$([Uri]::EscapeDataString($Fqdn))" $body = '' try { $resp = Invoke-WebRequest -UseBasicParsing -Uri $uri -UserAgent $ua -TimeoutSec 30 -Headers @{ Authorization = "Basic $pair" } $body = [string]$resp.Content } catch [System.Net.WebException] { if ($_.Exception.Response) { $reader = New-Object IO.StreamReader($_.Exception.Response.GetResponseStream()) $body = $reader.ReadToEnd() } else { Exit-Backoff $state 60 "update request failed: $($_.Exception.Message)" } } $result = ($body.Trim() -split '\s+')[0] switch ($result) { { $_ -in 'good', 'nochg' } { if ($result -eq 'good') { Write-Log 'INFO' "$Fqdn updated to $ip" } elseif ($ip -ne $state.LastIp) { Write-Log 'INFO' "$Fqdn already $ip" } $state.LastIp = $ip; $state.LastOk = $now; $state.Fails = 0; $state.NextTry = 0 Save-State $state exit 0 } 'badauth' { Exit-Backoff $state 1800 'token rejected (revoked or rotated?) - re-run Install-JustASRV.ps1 with the new token' } 'nohost' { Exit-Backoff $state 1800 "server says $Fqdn is not valid for this token or is disabled" } 'abuse' { Exit-Backoff $state 900 'rate limited by server' } 'badip' { Exit-Backoff $state 1800 "server rejected the address $ip" } { $_ -in 'dnserr', '911' } { Exit-Backoff $state 120 'server-side DNS error (change is queued server-side)' } default { Exit-Backoff $state 60 ("unexpected response: " + $body.Substring(0, [Math]::Min(80, $body.Length))) } }