2026-05-14 19:34:33 +08:00
|
|
|
$ErrorActionPreference = 'Stop'
|
2026-09-04 03:45:52 +08:00
|
|
|
Add-Type -AssemblyName System.Net.Http
|
2026-05-14 19:34:33 +08:00
|
|
|
|
2026-05-20 01:02:01 +08:00
|
|
|
$Repo = if ($env:AETHER_TUNNEL_RELEASE_REPO) { $env:AETHER_TUNNEL_RELEASE_REPO } else { 'fawney19/Aether' }
|
|
|
|
|
$ReleaseTag = $env:AETHER_TUNNEL_RELEASE_TAG
|
|
|
|
|
$InstallDir = $env:AETHER_TUNNEL_INSTALL_DIR
|
|
|
|
|
$ConfigPath = $env:AETHER_TUNNEL_CONFIG
|
2026-09-04 03:45:52 +08:00
|
|
|
$TunnelReleaseTagPattern = '^tunnel-v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$'
|
2026-05-14 19:34:33 +08:00
|
|
|
|
2026-05-20 01:02:01 +08:00
|
|
|
function Say([string]$Message) { Write-Host "[Aether Tunnel] $Message" }
|
|
|
|
|
function Fail([string]$Message) { throw "[Aether Tunnel] $Message" }
|
2026-05-14 19:34:33 +08:00
|
|
|
|
|
|
|
|
function Prompt-IfEmpty([string]$Name, [string]$Value, [string]$Prompt) {
|
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($Value)) { return $Value }
|
|
|
|
|
$Read = Read-Host $Prompt
|
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Read)) { Fail "$Name cannot be empty" }
|
|
|
|
|
return $Read
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
function Assert-SafeReleaseRepo([string]$Value) {
|
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Value) -or
|
|
|
|
|
$Value.Length -gt 200 -or
|
|
|
|
|
$Value -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$') {
|
|
|
|
|
Fail 'Release repository must be a safe GitHub OWNER/REPO identifier'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Assert-SafeTunnelReleaseTag([string]$Value) {
|
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Value) -or
|
|
|
|
|
$Value.Length -gt 128 -or
|
|
|
|
|
$Value -cnotmatch $TunnelReleaseTagPattern) {
|
|
|
|
|
Fail 'Release tag must use tunnel-v followed by a valid semantic version'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Assert-TrustedGithubUri([Uri]$Uri) {
|
|
|
|
|
if (-not $Uri.IsAbsoluteUri -or
|
|
|
|
|
$Uri.Scheme -cne 'https' -or
|
|
|
|
|
-not [string]::IsNullOrEmpty($Uri.UserInfo) -or
|
|
|
|
|
-not [string]::IsNullOrEmpty($Uri.Fragment) -or
|
|
|
|
|
$Uri.Port -ne 443) {
|
|
|
|
|
Fail "GitHub downloads must use credential-free HTTPS on port 443: $Uri"
|
|
|
|
|
}
|
|
|
|
|
$HostName = $Uri.IdnHost.ToLowerInvariant()
|
|
|
|
|
$TrustedHost = $HostName -in @('api.github.com', 'github.com', 'objects.githubusercontent.com', 'release-assets.githubusercontent.com') -or
|
|
|
|
|
$HostName.EndsWith('.objects.githubusercontent.com', [StringComparison]::Ordinal) -or
|
|
|
|
|
$HostName.EndsWith('.release-assets.githubusercontent.com', [StringComparison]::Ordinal)
|
|
|
|
|
if (-not $TrustedHost) { Fail "GitHub download redirected to an untrusted host: $HostName" }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Get-TrustedGithubBytes([string]$UriText) {
|
|
|
|
|
$CurrentUri = [Uri]::new($UriText, [UriKind]::Absolute)
|
|
|
|
|
Assert-TrustedGithubUri $CurrentUri
|
|
|
|
|
$Handler = [System.Net.Http.HttpClientHandler]::new()
|
|
|
|
|
$Handler.AllowAutoRedirect = $false
|
|
|
|
|
$Client = [System.Net.Http.HttpClient]::new($Handler, $true)
|
|
|
|
|
$Client.DefaultRequestHeaders.UserAgent.ParseAdd('aether-tunnel-installer')
|
|
|
|
|
try {
|
|
|
|
|
for ($RedirectCount = 0; $RedirectCount -le 10; $RedirectCount++) {
|
|
|
|
|
$Response = $null
|
|
|
|
|
try {
|
|
|
|
|
$Response = $Client.GetAsync(
|
|
|
|
|
$CurrentUri,
|
|
|
|
|
[System.Net.Http.HttpCompletionOption]::ResponseHeadersRead
|
|
|
|
|
).GetAwaiter().GetResult()
|
|
|
|
|
$StatusCode = [int]$Response.StatusCode
|
|
|
|
|
if ($StatusCode -in @(301, 302, 303, 307, 308)) {
|
|
|
|
|
if ($RedirectCount -eq 10) { Fail 'GitHub download redirected too many times' }
|
|
|
|
|
$Location = $Response.Headers.Location
|
|
|
|
|
if (-not $Location) { Fail 'GitHub redirect is missing the Location header' }
|
|
|
|
|
$CurrentUri = if ($Location.IsAbsoluteUri) {
|
|
|
|
|
$Location
|
|
|
|
|
} else {
|
|
|
|
|
[Uri]::new($CurrentUri, $Location)
|
|
|
|
|
}
|
|
|
|
|
Assert-TrustedGithubUri $CurrentUri
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if (-not $Response.IsSuccessStatusCode) {
|
|
|
|
|
Fail "GitHub download returned HTTP $StatusCode for $CurrentUri"
|
|
|
|
|
}
|
|
|
|
|
$Bytes = $Response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
|
|
|
|
|
return ,$Bytes
|
|
|
|
|
} finally {
|
|
|
|
|
if ($Response) { $Response.Dispose() }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Fail 'GitHub download redirected too many times'
|
|
|
|
|
} finally {
|
|
|
|
|
$Client.Dispose()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Read-TrustedGithubJson([string]$Uri) {
|
|
|
|
|
$Bytes = Get-TrustedGithubBytes $Uri
|
|
|
|
|
$Text = [Text.UTF8Encoding]::new($false, $true).GetString($Bytes)
|
|
|
|
|
return ($Text | ConvertFrom-Json)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Save-TrustedGithubFile([string]$Uri, [string]$Path) {
|
|
|
|
|
$Bytes = Get-TrustedGithubBytes $Uri
|
|
|
|
|
$Stream = [IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
|
|
|
|
try {
|
|
|
|
|
$Stream.Write($Bytes, 0, $Bytes.Length)
|
|
|
|
|
$Stream.Flush($true)
|
|
|
|
|
} finally {
|
|
|
|
|
$Stream.Dispose()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Assert-SafeNodeName([string]$Value) {
|
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Value) -or
|
|
|
|
|
$Value.Length -gt 255 -or
|
|
|
|
|
$Value -ne $Value.Trim() -or
|
|
|
|
|
$Value -match '[\x00-\x1F\x7F]') {
|
|
|
|
|
Fail 'Node name must be 1 to 255 characters without surrounding whitespace or control characters'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 19:34:33 +08:00
|
|
|
function ConvertTo-TomlQuotedString([string]$Value) {
|
|
|
|
|
return ($Value | ConvertTo-Json -Compress)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 01:02:01 +08:00
|
|
|
function Resolve-LatestTunnelTag {
|
2026-09-04 03:45:52 +08:00
|
|
|
Assert-SafeReleaseRepo $Repo
|
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($ReleaseTag)) {
|
|
|
|
|
Assert-SafeTunnelReleaseTag $ReleaseTag
|
|
|
|
|
$RequestedUri = "https://api.github.com/repos/$Repo/releases/tags/$ReleaseTag"
|
|
|
|
|
$RequestedRelease = Read-TrustedGithubJson $RequestedUri
|
|
|
|
|
if ($RequestedRelease.draft -or ([string]$RequestedRelease.tag_name -cne $ReleaseTag)) {
|
|
|
|
|
Fail 'GitHub returned a draft or mismatched tunnel release'
|
|
|
|
|
}
|
|
|
|
|
return $ReleaseTag
|
|
|
|
|
}
|
2026-05-14 19:34:33 +08:00
|
|
|
$Uri = "https://api.github.com/repos/$Repo/releases?per_page=100"
|
2026-09-04 03:45:52 +08:00
|
|
|
$Releases = Read-TrustedGithubJson $Uri
|
|
|
|
|
$TunnelReleases = @($Releases | Where-Object {
|
|
|
|
|
-not $_.draft -and -not $_.prerelease -and ([string]$_.tag_name -cmatch $TunnelReleaseTagPattern)
|
|
|
|
|
} | Sort-Object published_at -Descending)
|
2026-05-20 01:02:01 +08:00
|
|
|
if ($TunnelReleases.Count -eq 0) { Fail "No tunnel-v* release found in $Repo" }
|
|
|
|
|
return $TunnelReleases[0].tag_name
|
2026-05-14 19:34:33 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Test-IsAdministrator {
|
|
|
|
|
$Identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
|
|
|
$Principal = [Security.Principal.WindowsPrincipal]::new($Identity)
|
|
|
|
|
return $Principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
function Protect-SensitiveConfigFile([string]$Path) {
|
|
|
|
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
|
|
|
|
Fail "Sensitive config file does not exist: $Path"
|
|
|
|
|
}
|
|
|
|
|
Assert-NotReparsePoint $Path 'Sensitive config file'
|
|
|
|
|
|
|
|
|
|
$Identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
|
|
|
$CurrentSid = $Identity.User
|
|
|
|
|
if (-not $CurrentSid) { Fail 'Unable to resolve the current Windows account SID' }
|
|
|
|
|
|
|
|
|
|
$Acl = [System.Security.AccessControl.FileSecurity]::new()
|
|
|
|
|
$Acl.SetAccessRuleProtection($true, $false)
|
|
|
|
|
$Acl.SetOwner($CurrentSid)
|
|
|
|
|
|
|
|
|
|
$AllowedSids = @(
|
|
|
|
|
$CurrentSid.Value,
|
|
|
|
|
'S-1-5-18',
|
|
|
|
|
'S-1-5-32-544'
|
|
|
|
|
) | Select-Object -Unique
|
|
|
|
|
foreach ($SidValue in $AllowedSids) {
|
|
|
|
|
$Sid = [Security.Principal.SecurityIdentifier]::new($SidValue)
|
|
|
|
|
$Rule = [System.Security.AccessControl.FileSystemAccessRule]::new(
|
|
|
|
|
$Sid,
|
|
|
|
|
[System.Security.AccessControl.FileSystemRights]::FullControl,
|
|
|
|
|
[System.Security.AccessControl.AccessControlType]::Allow
|
|
|
|
|
)
|
|
|
|
|
$Acl.AddAccessRule($Rule) | Out-Null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Set-Acl -LiteralPath $Path -AclObject $Acl
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Protect-SensitiveConfigDirectory([string]$Path) {
|
|
|
|
|
if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
|
|
|
|
|
Fail "Sensitive config directory does not exist: $Path"
|
|
|
|
|
}
|
|
|
|
|
Assert-NotReparsePoint $Path 'Sensitive config directory'
|
|
|
|
|
|
|
|
|
|
$Identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
|
|
|
$CurrentSid = $Identity.User
|
|
|
|
|
if (-not $CurrentSid) { Fail 'Unable to resolve the current Windows account SID' }
|
|
|
|
|
|
|
|
|
|
$Acl = [System.Security.AccessControl.DirectorySecurity]::new()
|
|
|
|
|
$Acl.SetAccessRuleProtection($true, $false)
|
|
|
|
|
$Acl.SetOwner($CurrentSid)
|
|
|
|
|
$AllowedSids = @($CurrentSid.Value, 'S-1-5-18', 'S-1-5-32-544') | Select-Object -Unique
|
|
|
|
|
foreach ($SidValue in $AllowedSids) {
|
|
|
|
|
$Sid = [Security.Principal.SecurityIdentifier]::new($SidValue)
|
|
|
|
|
$Rule = [System.Security.AccessControl.FileSystemAccessRule]::new(
|
|
|
|
|
$Sid,
|
|
|
|
|
[System.Security.AccessControl.FileSystemRights]::FullControl,
|
|
|
|
|
[System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit',
|
|
|
|
|
[System.Security.AccessControl.PropagationFlags]::None,
|
|
|
|
|
[System.Security.AccessControl.AccessControlType]::Allow
|
|
|
|
|
)
|
|
|
|
|
$Acl.AddAccessRule($Rule) | Out-Null
|
|
|
|
|
}
|
|
|
|
|
Set-Acl -LiteralPath $Path -AclObject $Acl
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Protect-SensitiveConfigArtifacts([string]$Path) {
|
|
|
|
|
if (Test-Path -LiteralPath $Path -PathType Leaf) {
|
|
|
|
|
Protect-SensitiveConfigFile $Path
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$Directory = Split-Path -Parent $Path
|
|
|
|
|
$Leaf = Split-Path -Leaf $Path
|
|
|
|
|
if (-not (Test-Path -LiteralPath $Directory -PathType Container)) { return }
|
|
|
|
|
foreach ($Backup in Get-ChildItem -LiteralPath $Directory -File) {
|
|
|
|
|
if ($Backup.Name.StartsWith("$Leaf.bak.", [StringComparison]::OrdinalIgnoreCase)) {
|
|
|
|
|
Protect-SensitiveConfigFile $Backup.FullName
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Assert-NotReparsePoint([string]$Path, [string]$Description) {
|
|
|
|
|
if (-not (Test-Path -LiteralPath $Path)) { return }
|
|
|
|
|
$Item = Get-Item -LiteralPath $Path -Force
|
|
|
|
|
if (($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
|
|
|
|
Fail "$Description must not be a reparse point or symbolic link: $Path"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Assert-NoReparsePointAncestors([string]$Path, [string]$Description) {
|
|
|
|
|
$Current = [IO.Path]::GetFullPath($Path)
|
|
|
|
|
while (-not [string]::IsNullOrEmpty($Current)) {
|
|
|
|
|
if (Test-Path -LiteralPath $Current) {
|
|
|
|
|
Assert-NotReparsePoint $Current "$Description ancestor"
|
|
|
|
|
}
|
|
|
|
|
$Parent = Split-Path -Parent $Current
|
|
|
|
|
if ([string]::IsNullOrEmpty($Parent) -or $Parent -eq $Current) { break }
|
|
|
|
|
$Current = $Parent
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Assert-NotHardLink([string]$Path, [string]$Description) {
|
|
|
|
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return }
|
|
|
|
|
$Item = Get-Item -LiteralPath $Path -Force
|
|
|
|
|
$LinkTypeProperty = $Item.PSObject.Properties['LinkType']
|
|
|
|
|
if (-not $LinkTypeProperty) {
|
|
|
|
|
Fail "Unable to verify hard-link safety for $Description: $Path"
|
|
|
|
|
}
|
|
|
|
|
if ([string]$Item.LinkType -eq 'HardLink') {
|
|
|
|
|
Fail "$Description must not be a hard link: $Path"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Initialize-SecureConfigPath([string]$Path) {
|
|
|
|
|
$Directory = Split-Path -Parent $Path
|
|
|
|
|
Assert-NoReparsePointAncestors $Directory 'Config directory'
|
|
|
|
|
[IO.Directory]::CreateDirectory($Directory) | Out-Null
|
|
|
|
|
Assert-NotReparsePoint $Directory 'Config directory'
|
|
|
|
|
Protect-SensitiveConfigDirectory $Directory
|
|
|
|
|
Assert-NotReparsePoint $Path 'Config file'
|
|
|
|
|
if (Test-Path -LiteralPath $Path) {
|
|
|
|
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
|
|
|
|
Fail "Config path is not a regular file: $Path"
|
|
|
|
|
}
|
|
|
|
|
Assert-NotHardLink $Path 'Config file'
|
|
|
|
|
Protect-SensitiveConfigFile $Path
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Write-SensitiveUtf8File([string]$Path, [string]$Content) {
|
|
|
|
|
$CreateStream = [IO.File]::Open(
|
|
|
|
|
$Path,
|
|
|
|
|
[IO.FileMode]::CreateNew,
|
|
|
|
|
[IO.FileAccess]::Write,
|
|
|
|
|
[IO.FileShare]::None
|
|
|
|
|
)
|
|
|
|
|
$CreateStream.Dispose()
|
|
|
|
|
Protect-SensitiveConfigFile $Path
|
|
|
|
|
|
|
|
|
|
$Stream = [IO.File]::Open(
|
|
|
|
|
$Path,
|
|
|
|
|
[IO.FileMode]::Open,
|
|
|
|
|
[IO.FileAccess]::Write,
|
|
|
|
|
[IO.FileShare]::None
|
|
|
|
|
)
|
|
|
|
|
try {
|
|
|
|
|
$Encoding = [Text.UTF8Encoding]::new($false)
|
|
|
|
|
$Writer = [IO.StreamWriter]::new($Stream, $Encoding)
|
|
|
|
|
try {
|
|
|
|
|
$Writer.Write($Content)
|
|
|
|
|
$Writer.Flush()
|
|
|
|
|
} finally {
|
|
|
|
|
$Writer.Dispose()
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
$Stream.Dispose()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 19:34:33 +08:00
|
|
|
function Initialize-Paths {
|
|
|
|
|
if ([string]::IsNullOrWhiteSpace($script:InstallDir)) {
|
|
|
|
|
if (Test-IsAdministrator) {
|
2026-05-20 01:02:01 +08:00
|
|
|
$script:InstallDir = Join-Path $env:ProgramFiles 'AetherTunnel'
|
2026-05-14 19:34:33 +08:00
|
|
|
} else {
|
2026-05-20 01:02:01 +08:00
|
|
|
$script:InstallDir = Join-Path $env:LOCALAPPDATA 'AetherTunnel'
|
2026-05-14 19:34:33 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if ([string]::IsNullOrWhiteSpace($script:ConfigPath)) {
|
|
|
|
|
if (Test-IsAdministrator) {
|
2026-05-20 01:02:01 +08:00
|
|
|
$script:ConfigPath = Join-Path $env:ProgramData 'AetherTunnel\aether-tunnel.toml'
|
2026-05-14 19:34:33 +08:00
|
|
|
} else {
|
2026-05-20 01:02:01 +08:00
|
|
|
$script:ConfigPath = Join-Path $env:APPDATA 'AetherTunnel\aether-tunnel.toml'
|
2026-05-14 19:34:33 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-09-04 03:45:52 +08:00
|
|
|
$script:InstallDir = [IO.Path]::GetFullPath($script:InstallDir)
|
|
|
|
|
$script:ConfigPath = [IO.Path]::GetFullPath($script:ConfigPath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Install-VerifiedTunnelBinary([string]$SourceBinary) {
|
|
|
|
|
Assert-NoReparsePointAncestors $script:InstallDir 'Install directory'
|
|
|
|
|
[IO.Directory]::CreateDirectory($script:InstallDir) | Out-Null
|
|
|
|
|
Assert-NotReparsePoint $script:InstallDir 'Install directory'
|
|
|
|
|
|
|
|
|
|
$TargetBinary = Join-Path $script:InstallDir 'aether-tunnel.exe'
|
|
|
|
|
Assert-NotReparsePoint $TargetBinary 'Install target'
|
|
|
|
|
if ((Test-Path -LiteralPath $TargetBinary) -and
|
|
|
|
|
-not (Test-Path -LiteralPath $TargetBinary -PathType Leaf)) {
|
|
|
|
|
Fail "Install target is not a regular file: $TargetBinary"
|
|
|
|
|
}
|
|
|
|
|
Assert-NotHardLink $TargetBinary 'Install target'
|
|
|
|
|
|
|
|
|
|
$TempBinary = Join-Path $script:InstallDir ('.aether-tunnel.tmp.' + [Guid]::NewGuid().ToString('N'))
|
|
|
|
|
try {
|
|
|
|
|
[IO.File]::Copy($SourceBinary, $TempBinary, $false)
|
|
|
|
|
Assert-NotReparsePoint $TargetBinary 'Install target'
|
|
|
|
|
Assert-NotHardLink $TargetBinary 'Install target'
|
|
|
|
|
if (Test-Path -LiteralPath $TargetBinary -PathType Leaf) {
|
|
|
|
|
[IO.File]::Replace($TempBinary, $TargetBinary, $null, $true)
|
|
|
|
|
} elseif (Test-Path -LiteralPath $TargetBinary) {
|
|
|
|
|
Fail "Install target changed to a non-file during installation: $TargetBinary"
|
|
|
|
|
} else {
|
|
|
|
|
[IO.File]::Move($TempBinary, $TargetBinary)
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
if (Test-Path -LiteralPath $TempBinary) {
|
|
|
|
|
Remove-Item -LiteralPath $TempBinary -Force
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Expand-VerifiedTunnelArchive([string]$Archive, [string]$Destination) {
|
|
|
|
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
|
|
|
$Zip = [System.IO.Compression.ZipFile]::OpenRead($Archive)
|
|
|
|
|
try {
|
|
|
|
|
$Entries = @($Zip.Entries)
|
|
|
|
|
if ($Entries.Count -ne 1) { Fail 'Release archive must contain exactly one file' }
|
|
|
|
|
|
|
|
|
|
$Entry = $Entries[0]
|
|
|
|
|
if (($Entry.FullName -ne 'aether-tunnel.exe') -or ($Entry.Name -ne 'aether-tunnel.exe')) {
|
|
|
|
|
Fail 'Release archive must contain only aether-tunnel.exe at its root'
|
|
|
|
|
}
|
|
|
|
|
$UnixType = (($Entry.ExternalAttributes -shr 16) -band 0xF000)
|
|
|
|
|
if (($UnixType -ne 0) -and ($UnixType -ne 0x8000)) {
|
|
|
|
|
Fail 'aether-tunnel.exe in release archive is not a regular file'
|
|
|
|
|
}
|
|
|
|
|
if ($Entry.Length -le 0) { Fail 'aether-tunnel.exe in release archive is empty' }
|
|
|
|
|
|
|
|
|
|
$InputStream = $Entry.Open()
|
|
|
|
|
try {
|
|
|
|
|
$OutputStream = [System.IO.File]::Open(
|
|
|
|
|
$Destination,
|
|
|
|
|
[System.IO.FileMode]::CreateNew,
|
|
|
|
|
[System.IO.FileAccess]::Write,
|
|
|
|
|
[System.IO.FileShare]::None
|
|
|
|
|
)
|
|
|
|
|
try {
|
|
|
|
|
$InputStream.CopyTo($OutputStream)
|
|
|
|
|
} finally {
|
|
|
|
|
$OutputStream.Dispose()
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
$InputStream.Dispose()
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
$Zip.Dispose()
|
|
|
|
|
}
|
2026-05-14 19:34:33 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 01:02:01 +08:00
|
|
|
function Install-AetherTunnelBinary([string]$Tag, [string]$TempDir) {
|
2026-09-04 03:45:52 +08:00
|
|
|
Assert-SafeReleaseRepo $Repo
|
|
|
|
|
Assert-SafeTunnelReleaseTag $Tag
|
2026-05-14 19:34:33 +08:00
|
|
|
if (-not [Environment]::Is64BitOperatingSystem) { Fail 'Windows release currently supports amd64 only' }
|
2026-05-20 01:02:01 +08:00
|
|
|
$Asset = 'aether-tunnel-windows-amd64.zip'
|
2026-05-14 19:34:33 +08:00
|
|
|
$Base = "https://github.com/$Repo/releases/download/$Tag"
|
|
|
|
|
$Archive = Join-Path $TempDir $Asset
|
|
|
|
|
$Sums = Join-Path $TempDir 'SHA256SUMS.txt'
|
|
|
|
|
|
|
|
|
|
Say "Downloading $Tag / $Asset"
|
2026-09-04 03:45:52 +08:00
|
|
|
Save-TrustedGithubFile "$Base/$Asset" $Archive
|
|
|
|
|
Save-TrustedGithubFile "$Base/SHA256SUMS.txt" $Sums
|
|
|
|
|
if (-not (Test-Path -LiteralPath $Sums -PathType Leaf)) { Fail 'SHA256SUMS.txt download is missing' }
|
2026-05-14 19:34:33 +08:00
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
$EscapedAsset = [regex]::Escape($Asset)
|
|
|
|
|
$ChecksumTargetPattern = '(?:^|\s)\*?' + $EscapedAsset + '(?:\s|$)'
|
|
|
|
|
$ExpectedLines = @(Get-Content -LiteralPath $Sums | Where-Object { $_ -match $ChecksumTargetPattern })
|
|
|
|
|
if ($ExpectedLines.Count -ne 1) {
|
|
|
|
|
Fail "SHA256SUMS.txt must contain exactly one entry for $Asset"
|
2026-05-14 19:34:33 +08:00
|
|
|
}
|
2026-09-04 03:45:52 +08:00
|
|
|
$ChecksumPattern = '^\s*(?<hash>\S+)\s+\*?' + $EscapedAsset + '\s*$'
|
|
|
|
|
$ExpectedMatch = [regex]::Match([string]$ExpectedLines[0], $ChecksumPattern)
|
|
|
|
|
if (-not $ExpectedMatch.Success) { Fail "SHA256SUMS.txt has an invalid entry for $Asset" }
|
|
|
|
|
$Expected = $ExpectedMatch.Groups['hash'].Value
|
|
|
|
|
if ($Expected -notmatch '^[0-9A-Fa-f]{64}$') { Fail "SHA256SUMS.txt has an invalid hash for $Asset" }
|
|
|
|
|
|
|
|
|
|
$Actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $Archive).Hash.ToLowerInvariant()
|
|
|
|
|
if ($Actual -ne $Expected.ToLowerInvariant()) { Fail "SHA256 verification failed for $Asset" }
|
2026-05-14 19:34:33 +08:00
|
|
|
|
|
|
|
|
$ExtractDir = Join-Path $TempDir 'extract'
|
2026-09-04 03:45:52 +08:00
|
|
|
New-Item -ItemType Directory -Force -Path $ExtractDir | Out-Null
|
2026-05-20 01:02:01 +08:00
|
|
|
$Binary = Join-Path $ExtractDir 'aether-tunnel.exe'
|
2026-09-04 03:45:52 +08:00
|
|
|
Expand-VerifiedTunnelArchive $Archive $Binary
|
|
|
|
|
Install-VerifiedTunnelBinary $Binary
|
2026-05-20 01:02:01 +08:00
|
|
|
Say "Installed binary: $(Join-Path $script:InstallDir 'aether-tunnel.exe')"
|
2026-05-14 19:34:33 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Test-LegacySingleServerConfig([string]$Path) {
|
2026-09-04 03:45:52 +08:00
|
|
|
if (-not (Test-Path -LiteralPath $Path)) { return $false }
|
|
|
|
|
foreach ($Line in Get-Content -LiteralPath $Path) {
|
2026-05-14 19:34:33 +08:00
|
|
|
if ($Line -match '^\s*\[') { return $false }
|
|
|
|
|
if ($Line -match '^\s*(aether_url|management_token)\s*=') { return $true }
|
|
|
|
|
}
|
|
|
|
|
return $false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Test-ServerExists([string]$Path, [string]$QuotedUrl, [string]$QuotedName) {
|
2026-09-04 03:45:52 +08:00
|
|
|
if (-not (Test-Path -LiteralPath $Path)) { return $false }
|
2026-05-14 19:34:33 +08:00
|
|
|
$FoundUrl = $false
|
|
|
|
|
$FoundName = $false
|
2026-09-04 03:45:52 +08:00
|
|
|
foreach ($Line in Get-Content -LiteralPath $Path) {
|
2026-05-14 19:34:33 +08:00
|
|
|
if ($Line -match '^\s*\[\[servers\]\]\s*$') {
|
|
|
|
|
if ($FoundUrl -and $FoundName) { return $true }
|
|
|
|
|
$FoundUrl = $false
|
|
|
|
|
$FoundName = $false
|
|
|
|
|
}
|
|
|
|
|
if ($Line.Trim() -eq "aether_url = $QuotedUrl") { $FoundUrl = $true }
|
|
|
|
|
if ($Line.Trim() -eq "node_name = $QuotedName") { $FoundName = $true }
|
|
|
|
|
}
|
|
|
|
|
return ($FoundUrl -and $FoundName)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 16:08:14 +08:00
|
|
|
function Add-ServerConfig([string]$AetherUrl, [string]$ManagementToken, [string]$NodeName, [string]$TunnelSecurity, [string]$TunnelEncryptionKey) {
|
2026-09-04 03:45:52 +08:00
|
|
|
Assert-SafeNodeName $NodeName
|
|
|
|
|
Initialize-SecureConfigPath $script:ConfigPath
|
2026-05-14 19:34:33 +08:00
|
|
|
|
|
|
|
|
if (Test-LegacySingleServerConfig $script:ConfigPath) {
|
2026-05-20 01:02:01 +08:00
|
|
|
Fail "Existing config uses removed top-level aether_url/management_token. Run aether-tunnel setup to migrate to [[servers]] first: $script:ConfigPath"
|
2026-05-14 19:34:33 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$QuotedUrl = ConvertTo-TomlQuotedString $AetherUrl
|
|
|
|
|
$QuotedToken = ConvertTo-TomlQuotedString $ManagementToken
|
|
|
|
|
$QuotedName = ConvertTo-TomlQuotedString $NodeName
|
2026-05-21 16:08:14 +08:00
|
|
|
$QuotedTunnelEncryptionKey = ConvertTo-TomlQuotedString $TunnelEncryptionKey
|
2026-05-14 19:34:33 +08:00
|
|
|
|
|
|
|
|
if (Test-ServerExists $script:ConfigPath $QuotedUrl $QuotedName) {
|
|
|
|
|
Say "Same aether_url + node_name already exists, skipping config append: $script:ConfigPath"
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
$ConfigExists = Test-Path -LiteralPath $script:ConfigPath -PathType Leaf
|
|
|
|
|
$ExistingContent = if ($ConfigExists) {
|
|
|
|
|
[IO.File]::ReadAllText($script:ConfigPath)
|
|
|
|
|
} else {
|
|
|
|
|
''
|
|
|
|
|
}
|
|
|
|
|
if ($ConfigExists) {
|
|
|
|
|
$BackupPath = "$script:ConfigPath.bak.$(Get-Date -Format yyyyMMddHHmmss).$([Guid]::NewGuid().ToString('N'))"
|
|
|
|
|
Write-SensitiveUtf8File $BackupPath $ExistingContent
|
2026-05-14 19:34:33 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
$Prefix = if ($ExistingContent.Length -gt 0) { "`n" } else { '' }
|
2026-05-14 19:34:33 +08:00
|
|
|
$Block = @(
|
2026-05-20 01:02:01 +08:00
|
|
|
"$Prefix# Added by Aether Tunnel one-click installer. Existing config is preserved.",
|
2026-05-14 19:34:33 +08:00
|
|
|
'[[servers]]',
|
|
|
|
|
"aether_url = $QuotedUrl",
|
|
|
|
|
"management_token = $QuotedToken",
|
2026-05-22 15:20:36 +08:00
|
|
|
"node_name = $QuotedName"
|
2026-05-14 19:34:33 +08:00
|
|
|
) -join "`n"
|
2026-05-22 15:20:36 +08:00
|
|
|
if ($TunnelSecurity) {
|
|
|
|
|
$QuotedTunnelSecurity = ConvertTo-TomlQuotedString $TunnelSecurity
|
|
|
|
|
$Block += "`ntunnel_security = $QuotedTunnelSecurity"
|
|
|
|
|
}
|
2026-05-21 16:08:14 +08:00
|
|
|
if ($TunnelEncryptionKey) {
|
|
|
|
|
$Block += "`ntunnel_encryption_key = $QuotedTunnelEncryptionKey"
|
|
|
|
|
}
|
2026-09-04 03:45:52 +08:00
|
|
|
$ConfigDir = Split-Path -Parent $script:ConfigPath
|
|
|
|
|
$TempPath = Join-Path $ConfigDir ("." + (Split-Path -Leaf $script:ConfigPath) + ".tmp." + [Guid]::NewGuid().ToString('N'))
|
|
|
|
|
try {
|
|
|
|
|
Write-SensitiveUtf8File $TempPath ($ExistingContent + $Block + "`n")
|
|
|
|
|
Assert-NotReparsePoint $script:ConfigPath 'Config file'
|
|
|
|
|
if ($ConfigExists) {
|
|
|
|
|
if (-not (Test-Path -LiteralPath $script:ConfigPath -PathType Leaf)) {
|
|
|
|
|
Fail "Config file changed while it was being updated: $script:ConfigPath"
|
|
|
|
|
}
|
|
|
|
|
[IO.File]::Replace($TempPath, $script:ConfigPath, $null, $true)
|
|
|
|
|
} else {
|
|
|
|
|
[IO.File]::Move($TempPath, $script:ConfigPath)
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
if (Test-Path -LiteralPath $TempPath) {
|
|
|
|
|
Remove-Item -LiteralPath $TempPath -Force
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Protect-SensitiveConfigArtifacts $script:ConfigPath
|
2026-05-14 19:34:33 +08:00
|
|
|
Say "Appended [[servers]] to: $script:ConfigPath"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Main {
|
2026-09-04 03:45:52 +08:00
|
|
|
Assert-SafeReleaseRepo $Repo
|
2026-05-14 19:34:33 +08:00
|
|
|
Initialize-Paths
|
2026-05-20 01:02:01 +08:00
|
|
|
$AetherUrl = Prompt-IfEmpty 'AETHER_TUNNEL_AETHER_URL' $env:AETHER_TUNNEL_AETHER_URL 'Aether URL'
|
|
|
|
|
$ManagementToken = Prompt-IfEmpty 'AETHER_TUNNEL_MANAGEMENT_TOKEN' $env:AETHER_TUNNEL_MANAGEMENT_TOKEN 'Management token (ae_xxx)'
|
|
|
|
|
$NodeName = Prompt-IfEmpty 'AETHER_TUNNEL_NODE_NAME' $env:AETHER_TUNNEL_NODE_NAME 'Node name'
|
2026-09-04 03:45:52 +08:00
|
|
|
Assert-SafeNodeName $NodeName
|
2026-05-22 15:20:36 +08:00
|
|
|
$TunnelSecurity = if ($env:AETHER_TUNNEL_SECURITY) { $env:AETHER_TUNNEL_SECURITY } else { '' }
|
2026-05-21 16:08:14 +08:00
|
|
|
$TunnelEncryptionKey = if ($env:AETHER_TUNNEL_ENCRYPTION_KEY) { $env:AETHER_TUNNEL_ENCRYPTION_KEY } else { '' }
|
2026-05-22 15:20:36 +08:00
|
|
|
if ($TunnelSecurity -and ($TunnelSecurity -notin @('off', 'non_tls_required'))) {
|
2026-05-21 16:08:14 +08:00
|
|
|
Fail 'AETHER_TUNNEL_SECURITY must be off or non_tls_required'
|
|
|
|
|
}
|
|
|
|
|
if (($TunnelSecurity -eq 'non_tls_required') -and -not $TunnelEncryptionKey) {
|
|
|
|
|
Fail 'AETHER_TUNNEL_ENCRYPTION_KEY is required when AETHER_TUNNEL_SECURITY=non_tls_required'
|
|
|
|
|
}
|
2026-05-14 19:34:33 +08:00
|
|
|
|
2026-05-20 01:02:01 +08:00
|
|
|
$TempDir = Join-Path ([IO.Path]::GetTempPath()) ("aether-tunnel-" + [Guid]::NewGuid().ToString('N'))
|
2026-09-04 03:45:52 +08:00
|
|
|
Assert-NoReparsePointAncestors (Split-Path -Parent $TempDir) 'Temporary directory'
|
|
|
|
|
if (Test-Path -LiteralPath $TempDir) { Fail "Secure temporary path already exists: $TempDir" }
|
|
|
|
|
[IO.Directory]::CreateDirectory($TempDir) | Out-Null
|
|
|
|
|
Assert-NotReparsePoint $TempDir 'Temporary directory'
|
|
|
|
|
Protect-SensitiveConfigDirectory $TempDir
|
2026-05-14 19:34:33 +08:00
|
|
|
try {
|
2026-05-20 01:02:01 +08:00
|
|
|
$Tag = Resolve-LatestTunnelTag
|
2026-09-04 03:45:52 +08:00
|
|
|
Assert-SafeTunnelReleaseTag $Tag
|
2026-05-20 01:02:01 +08:00
|
|
|
Install-AetherTunnelBinary $Tag $TempDir
|
2026-05-21 16:08:14 +08:00
|
|
|
Add-ServerConfig $AetherUrl $ManagementToken $NodeName $TunnelSecurity $TunnelEncryptionKey
|
2026-05-14 19:34:33 +08:00
|
|
|
} finally {
|
2026-09-04 03:45:52 +08:00
|
|
|
if (Test-Path -LiteralPath $TempDir) {
|
|
|
|
|
Assert-NotReparsePoint $TempDir 'Temporary directory'
|
|
|
|
|
Remove-Item -Recurse -Force -LiteralPath $TempDir -ErrorAction SilentlyContinue
|
|
|
|
|
}
|
2026-05-14 19:34:33 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Say 'Complete. Start or configure the node with:'
|
2026-05-20 01:02:01 +08:00
|
|
|
Say " & '$(Join-Path $script:InstallDir 'aether-tunnel.exe')' setup '$script:ConfigPath'"
|
2026-05-14 19:34:33 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Main
|