Загрузка данных


#Requires -RunAsAdministrator
<#
.SYNOPSIS
  Domain allow-list ONLY for chosen local account(s): local filtering proxy + per-user firewall rules.
  Covers every program of that user (Edge, git, npm, pip, VS Code, Python...). Admin is not affected.

.DESCRIPTION
  How it works
   1. A tiny allow-list proxy (Python) runs as SYSTEM on 127.0.0.1:8899 (scheduled task at startup).
      Files: C:\ProgramData\DevProxy  (only SYSTEM and Administrators have access).
   2. Windows Firewall: the restricted user may not connect anywhere except loopback, so the only
      way out is the proxy. Administrators and system services are not affected.
   3. The user's profile gets proxy settings: system proxy (Edge, VS Code) and environment
      variables (git, npm, pip, Python, curl). The user must sign out and in once.

  Actions
   Install    (default)  -UserName german[,other]
   Status                health check + test requests
   Blocked               hosts blocked most often (what you may want to add)
   Allow      -Domains a.com,b.org     adds domains to allowlist.txt (reloaded automatically)
                         a leading = means that exact host only, e.g.  -Domains '=www.example.com'
   Uninstall  -UserName german [-RemoveFiles]

  Needs a system-wide Python (run install-dev-tools.ps1 first) or -PythonPath.
  The restricted account must exist, have signed in at least once, and must NOT be an administrator.

.EXAMPLE
  .\setup-user-proxy.ps1 -UserName german
  .\setup-user-proxy.ps1 -UserName german,Admin2      # several restricted accounts
  .\setup-user-proxy.ps1 -Action Blocked
  .\setup-user-proxy.ps1 -Action Allow -Domains vuejs.org,nuxt.com
  .\setup-user-proxy.ps1 -Action Status

  Run in Windows PowerShell as Administrator:
  powershell -ExecutionPolicy Bypass -File .\setup-user-proxy.ps1 -UserName german
#>
[CmdletBinding()]
param(
    [ValidateSet('Install', 'Status', 'Blocked', 'Allow', 'Uninstall')]
    [string]$Action = 'Install',
    [string[]]$UserName,
    [string[]]$Domains,
    [int]$Port = 8899,
    [string]$PythonPath,
    [switch]$RemoveFiles
)

$ErrorActionPreference = 'Stop'

$Dir = Join-Path $env:ProgramData 'DevProxy'
$ProxyFile = Join-Path $Dir 'proxy.py'
$AllowFile = Join-Path $Dir 'allowlist.txt'
$LogFile = Join-Path $Dir 'proxy.log'
$TaskName = 'DevProxy'
$FwGroup = 'DevProxy'

# ---------------------------------------------------------------------
#  Embedded files
# ---------------------------------------------------------------------
$ProxyPy = @'
"""DevProxy: tiny allow-list forward proxy (HTTP + HTTPS CONNECT).

Listens on 127.0.0.1 only. A request is served only if its destination host is in
allowlist.txt. Everything else gets 403.
Allow-list syntax (one entry per line, # starts a comment):
    example.com        the domain AND all its subdomains
    =www.example.com   exactly this host only (no subdomains)
Fail-closed: if the allowlist is missing or empty, everything is blocked.
Log format (one event per line):  time|ALLOW|host|port   time|BLOCK|host|port
"""
import asyncio
import ipaddress
import logging
import logging.handlers
import os
import socket
from urllib.parse import urlsplit

BASE = os.path.dirname(os.path.abspath(__file__))
ALLOWLIST = os.path.join(BASE, 'allowlist.txt')
LOGFILE = os.path.join(BASE, 'proxy.log')
HOST = '127.0.0.1'
PORT = 8899
CONNECT_PORTS = {443}      # HTTPS only
HTTP_PORTS = {80}          # plain HTTP only
CONNECT_TIMEOUT = 15
HEADER_TIMEOUT = 30

log = logging.getLogger('devproxy')
log.setLevel(logging.INFO)
_h = logging.handlers.RotatingFileHandler(LOGFILE, maxBytes=5_000_000, backupCount=3, encoding='utf-8')
_h.setFormatter(logging.Formatter('%(asctime)s|%(message)s', '%Y-%m-%d %H:%M:%S'))
log.addHandler(_h)

_allow = set()   # domain + subdomains
_exact = set()   # exact hosts (entries starting with '=')
_mtime = None


def load_allow():
    """(Re)load the allowlist when the file changed."""
    global _allow, _exact, _mtime
    try:
        m = os.path.getmtime(ALLOWLIST)
    except OSError:
        return
    if m == _mtime:
        return
    new = set()
    new_exact = set()
    try:
        with open(ALLOWLIST, encoding='utf-8-sig') as f:
            for line in f:
                line = line.split('#', 1)[0].strip().lower()
                if not line:
                    continue
                if line.startswith('='):
                    h = line[1:].strip().strip('.')
                    if h:
                        new_exact.add(h)
                    continue
                if line.startswith('*.'):
                    line = line[2:]
                line = line.strip('.')
                if line:
                    new.add(line)
    except OSError:
        return
    _allow, _exact, _mtime = new, new_exact, m
    log.info('INFO|allowlist loaded|%d entries', len(new) + len(new_exact))


def allowed(host):
    load_allow()
    host = host.lower().strip('.')
    if not host:
        return False
    try:
        ipaddress.ip_address(host.strip('[]'))
        return False            # IP literals are never allowed
    except ValueError:
        pass
    if host in _exact:
        return True
    labels = host.split('.')
    return any('.'.join(labels[i:]) in _allow for i in range(len(labels)))


async def open_upstream(host, port):
    """Connect to a PUBLIC address of host (never loopback/private/link-local)."""
    loop = asyncio.get_running_loop()
    infos = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM)
    last = None
    for _fam, _typ, _proto, _canon, sa in infos:
        try:
            ip = ipaddress.ip_address(sa[0])
        except ValueError:
            continue
        if not ip.is_global:
            continue
        try:
            return await asyncio.wait_for(asyncio.open_connection(sa[0], port), CONNECT_TIMEOUT)
        except Exception as e:  # try next address
            last = e
    raise last or OSError('no public address for host')


def reply(code, text, body=''):
    data = body.encode()
    head = ('HTTP/1.1 %d %s\r\nContent-Type: text/plain; charset=utf-8\r\n'
            'Content-Length: %d\r\nConnection: close\r\n\r\n' % (code, text, len(data)))
    return head.encode() + data


async def pipe(r, w):
    try:
        while True:
            data = await r.read(65536)
            if not data:
                break
            w.write(data)
            await w.drain()
    except Exception:
        pass
    finally:
        try:
            w.close()
        except Exception:
            pass


async def tunnel(cr, cw, ur, uw):
    t1 = asyncio.ensure_future(pipe(cr, uw))
    t2 = asyncio.ensure_future(pipe(ur, cw))
    _done, pending = await asyncio.wait({t1, t2}, return_when=asyncio.FIRST_COMPLETED)
    if pending:
        _done, pending = await asyncio.wait(pending, timeout=5)
        for t in pending:
            t.cancel()
    for w in (cw, uw):
        try:
            w.close()
        except Exception:
            pass


async def handle(reader, writer):
    try:
        try:
            head = await asyncio.wait_for(reader.readuntil(b'\r\n\r\n'), HEADER_TIMEOUT)
        except Exception:
            return
        lines = head.decode('latin-1').split('\r\n')
        try:
            method, target, version = lines[0].split(' ', 2)
        except ValueError:
            writer.write(reply(400, 'Bad Request'))
            await writer.drain()
            return

        if method.upper() == 'CONNECT':
            host, sep, port_s = target.rpartition(':')
            if not sep or not port_s.isdigit():
                writer.write(reply(400, 'Bad Request'))
                await writer.drain()
                return
            host = host.strip('[]')
            port = int(port_s)
            if not (allowed(host) and port in CONNECT_PORTS):
                log.info('BLOCK|%s|%d', host, port)
                writer.write(reply(403, 'Forbidden', 'Blocked by allowlist: %s\n' % host))
                await writer.drain()
                return
            try:
                ur, uw = await open_upstream(host, port)
            except Exception as e:
                log.info('FAIL|%s|%d|%s', host, port, e)
                writer.write(reply(502, 'Bad Gateway'))
                await writer.drain()
                return
            log.info('ALLOW|%s|%d', host, port)
            writer.write(b'HTTP/1.1 200 Connection Established\r\n\r\n')
            await writer.drain()
            await tunnel(reader, writer, ur, uw)
            return

        # plain HTTP request in absolute form: GET http://host/path HTTP/1.1
        u = urlsplit(target)
        if u.scheme != 'http' or not u.hostname:
            writer.write(reply(400, 'Bad Request', 'This is a filtering proxy.\n'))
            await writer.drain()
            return
        host = u.hostname
        port = u.port or 80
        if not (allowed(host) and port in HTTP_PORTS):
            log.info('BLOCK|%s|%d', host, port)
            writer.write(reply(403, 'Forbidden', 'Blocked by allowlist: %s\n' % host))
            await writer.drain()
            return
        path = (u.path or '/') + ('?' + u.query if u.query else '')
        kept = []
        for line in lines[1:]:
            if not line:
                continue
            name = line.split(':', 1)[0].strip().lower()
            if name in ('proxy-connection', 'proxy-authorization', 'connection', 'keep-alive'):
                continue
            kept.append(line)
        new_head = ('%s %s %s\r\n%s\r\nConnection: close\r\n\r\n'
                    % (method, path, version, '\r\n'.join(kept))).encode('latin-1')
        try:
            ur, uw = await open_upstream(host, port)
        except Exception as e:
            log.info('FAIL|%s|%d|%s', host, port, e)
            writer.write(reply(502, 'Bad Gateway'))
            await writer.drain()
            return
        log.info('ALLOW|%s|%d', host, port)
        uw.write(new_head)
        await uw.drain()
        await tunnel(reader, writer, ur, uw)
    except Exception as e:
        log.info('ERR|%s', e)
    finally:
        try:
            writer.close()
        except Exception:
            pass


async def main():
    server = await asyncio.start_server(handle, HOST, PORT, limit=65536)
    log.info('INFO|listening on %s:%d', HOST, PORT)
    async with server:
        await server.serve_forever()


if __name__ == '__main__':
    load_allow()
    try:
        asyncio.run(main())
    except Exception as exc:
        log.exception('FATAL|%s', exc)
        raise
'@

$DefaultAllowlist = @'
# DevProxy allow-list. One domain per line. A domain also allows ALL of its subdomains.
# Lines starting with # are comments. The file is re-read automatically (no restart needed).
# Tip: run  setup-user-proxy.ps1 -Action Blocked  to see what the user tried to open.

# --- docs / references ---
developer.mozilla.org
mozilla.org
mozilla.net
learn.microsoft.com
devdocs.io
w3schools.com
web.dev
caniuse.com
json.org
regex101.com

# --- languages, runtimes, package registries ---
python.org
pypi.org
pythonhosted.org
nodejs.org
npmjs.com
npmjs.org
yarnpkg.com
typescriptlang.org
go.dev
rust-lang.org
php.net

# --- frameworks and libraries ---
react.dev
vuejs.org
angular.dev
svelte.dev
nextjs.org
vite.dev
vitejs.dev
expressjs.com
tailwindcss.com
getbootstrap.com
djangoproject.com
palletsprojects.com
fastapi.tiangolo.com

# --- git, code hosting, Q&A ---
github.com
githubusercontent.com
githubassets.com
git-scm.com
stackoverflow.com
stackexchange.com
sstatic.net

# --- VS Code (extension marketplace; remove these 3 lines to freeze extensions) ---
code.visualstudio.com
marketplace.visualstudio.com
vsassets.io

# --- search ---
duckduckgo.com
# Google: EXACT hosts only (=host), so translate./drive./docs./mail.google.com etc. stay blocked.
# google.com has no path filtering: translate.google.com would otherwise work as a proxy to any site.
=google.com
=www.google.com
=google.ru
=www.google.ru
gstatic.com

# --- messengers: MAX and VK (CDN names are from memory: if pictures/video are missing, check -Action Blocked) ---
max.ru
oneme.ru
vk.com
vk.ru
userapi.com
vkuser.net
vk-cdn.net
# vkvideo.ru
# mycdn.me

# --- learning ---
freecodecamp.org
javascript.info
learn.javascript.ru
metanit.com

# --- certificate revocation checks (OCSP/CRL); curl.exe and other Windows-TLS programs need them ---
digicert.com
lencr.org
pki.goog
sectigo.com
usertrust.com
comodoca.com
globalsign.com
amazontrust.com
godaddy.com
entrust.net

# --- optional (remove the # to enable) ---
# youtube.com
# ytimg.com
# googlevideo.com
# claude.ai
# chatgpt.com
# github.io
# cdn.jsdelivr.net
# unpkg.com
# cdnjs.cloudflare.com
# fonts.googleapis.com
# fonts.gstatic.com
'@

# ---------------------------------------------------------------------
#  Helpers
# ---------------------------------------------------------------------
function Write-Text {
    param([string]$Path, [string]$Text)
    [IO.File]::WriteAllText($Path, $Text, (New-Object System.Text.UTF8Encoding($false)))
}

function Step-Header { param([string]$Text) Write-Host "`n== $Text ==" -ForegroundColor Cyan }

function Get-PythonExe {
    if ($PythonPath) {
        if (Test-Path -LiteralPath $PythonPath) { return $PythonPath }
        throw "Python not found: $PythonPath"
    }
    $found = @(Get-ChildItem "$env:ProgramFiles\Python3*\python.exe" -ErrorAction SilentlyContinue |
            Sort-Object { [int]($_.Directory.Name -replace '\D', '') } -Descending)
    if ($found.Count -gt 0) { return $found[0].FullName }
    if (Get-Command py -ErrorAction SilentlyContinue) {
        $p = & py -3 -c "import sys;print(sys.executable)" 2>$null
        if ($p -and (Test-Path -LiteralPath $p)) { return $p }
    }
    throw 'System-wide Python not found. Run install-dev-tools.ps1 first or pass -PythonPath.'
}

function Get-InstalledPort {
    if (Test-Path -LiteralPath $ProxyFile) {
        $m = Select-String -LiteralPath $ProxyFile -Pattern '^PORT = (\d+)' | Select-Object -First 1
        if ($m) { return [int]$m.Matches[0].Groups[1].Value }
    }
    return $Port
}

function Get-TargetUsers {
    if (-not $UserName -or $UserName.Count -eq 0) {
        throw 'Specify -UserName (the restricted account), e.g. -UserName german'
    }
    $me = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
    $admins = @(Get-LocalGroupMember -SID 'S-1-5-32-544' -ErrorAction SilentlyContinue | ForEach-Object { $_.SID.Value })
    foreach ($n in $UserName) {
        $u = Get-LocalUser -Name $n -ErrorAction SilentlyContinue
        if (-not $u) { throw "Local user '$n' not found (check Get-LocalUser)" }
        $sid = $u.SID.Value
        if ($Action -eq 'Install') {
            if ($sid -eq $me) { throw "'$n' is the account you are running as - you would lock yourself out" }
            if ($admins -contains $sid) { throw "'$n' is an administrator - demote it first, an admin could remove the rules" }
        }
        $prof = Get-CimInstance Win32_UserProfile -Filter "SID='$sid'" -ErrorAction SilentlyContinue
        $path = $null
        if ($prof) { $path = $prof.LocalPath }
        [pscustomobject]@{ Name = $u.Name; Sid = $sid; Path = $path }
    }
}

function Use-UserHive {
    # runs $Body with the registry root of the user's hive (loads NTUSER.DAT if the user is not signed in)
    param($U, [scriptblock]$Body)
    $tmp = $null
    if (Test-Path -LiteralPath "Registry::HKEY_USERS\$($U.Sid)") {
        $root = "Registry::HKEY_USERS\$($U.Sid)"
    }
    else {
        $nt = Join-Path $U.Path 'NTUSER.DAT'
        if (-not (Test-Path -LiteralPath $nt)) { Write-Warning "No NTUSER.DAT for $($U.Name)"; return }
        $tmp = 'DevProxyTmp_' + $U.Sid.Replace('-', '_')
        & reg.exe load "HKU\$tmp" $nt | Out-Null
        if ($LASTEXITCODE -ne 0) { Write-Warning "Could not load registry hive of $($U.Name)"; return }
        $root = "Registry::HKEY_USERS\$tmp"
    }
    try { & $Body $root }
    finally {
        if ($tmp) {
            [gc]::Collect(); [gc]::WaitForPendingFinalizers()
            & reg.exe unload "HKU\$tmp" | Out-Null
        }
    }
}

function Set-UserProxy {
    param($U, [int]$P)
    if (-not $U.Path) {
        Write-Warning "'$($U.Name)' has no profile yet (never signed in). Sign in once, sign out, then rerun Install. Until then he has no internet at all."
        return
    }
    Use-UserHive -U $U -Body {
        param($root)
        $proxy = "http://127.0.0.1:$P"
        $envKey = "$root\Environment"
        if (-not (Test-Path -LiteralPath $envKey)) { New-Item -Path $envKey -Force | Out-Null }
        $vals = @{
            HTTP_PROXY = $proxy; HTTPS_PROXY = $proxy; ALL_PROXY = $proxy
            NO_PROXY = 'localhost,127.0.0.1,::1'; NODE_USE_ENV_PROXY = '1'
        }
        foreach ($k in $vals.Keys) {
            New-ItemProperty -LiteralPath $envKey -Name $k -Value $vals[$k] -PropertyType String -Force | Out-Null
        }
        $is = "$root\Software\Microsoft\Windows\CurrentVersion\Internet Settings"
        if (-not (Test-Path -LiteralPath $is)) { New-Item -Path $is -Force | Out-Null }
        New-ItemProperty -LiteralPath $is -Name 'ProxyEnable' -Value 1 -PropertyType DWord -Force | Out-Null
        New-ItemProperty -LiteralPath $is -Name 'ProxyServer' -Value "127.0.0.1:$P" -PropertyType String -Force | Out-Null
        New-ItemProperty -LiteralPath $is -Name 'ProxyOverride' -Value 'localhost;127.*;[::1];<local>' -PropertyType String -Force | Out-Null
    }
    Write-Host "  proxy settings written to profile of '$($U.Name)'"
}

function Remove-UserProxy {
    param($U)
    if (-not $U.Path) { return }
    Use-UserHive -U $U -Body {
        param($root)
        $envKey = "$root\Environment"
        foreach ($k in 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', 'NODE_USE_ENV_PROXY') {
            Remove-ItemProperty -LiteralPath $envKey -Name $k -ErrorAction SilentlyContinue
        }
        $is = "$root\Software\Microsoft\Windows\CurrentVersion\Internet Settings"
        if (Test-Path -LiteralPath $is) {
            New-ItemProperty -LiteralPath $is -Name 'ProxyEnable' -Value 0 -PropertyType DWord -Force | Out-Null
            Remove-ItemProperty -LiteralPath $is -Name 'ProxyServer' -ErrorAction SilentlyContinue
            Remove-ItemProperty -LiteralPath $is -Name 'ProxyOverride' -ErrorAction SilentlyContinue
        }
    }
    Write-Host "  proxy settings removed from profile of '$($U.Name)'"
}

function Set-FirewallRules {
    param([string[]]$Sids)
    Get-NetFirewallRule -Group $FwGroup -ErrorAction SilentlyContinue | Remove-NetFirewallRule
    $sddl = 'D:' + (($Sids | ForEach-Object { "(A;;CC;;;$_)" }) -join '')
    # everything except loopback (127.0.0.0/8 and ::1)
    $v4 = @('0.0.0.0-126.255.255.255', '128.0.0.0-255.255.255.255')
    $v6 = @('::2-ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff')
    New-NetFirewallRule -DisplayName 'DevProxy: block direct internet (IPv4) for restricted users' -Group $FwGroup `
        -Direction Outbound -Action Block -Profile Any -Protocol Any -LocalUser $sddl -RemoteAddress $v4 -Enabled True | Out-Null
    try {
        New-NetFirewallRule -DisplayName 'DevProxy: block direct internet (IPv6) for restricted users' -Group $FwGroup `
            -Direction Outbound -Action Block -Profile Any -Protocol Any -LocalUser $sddl -RemoteAddress $v6 -Enabled True | Out-Null
    }
    catch {
        Write-Warning "IPv6 rule was not created ($($_.Exception.Message)). IPv6 traffic of the user is NOT blocked - disable IPv6 or add the rule manually."
    }
    foreach ($prof in (Get-NetFirewallProfile)) {
        if (-not $prof.Enabled) { Write-Warning "Firewall profile '$($prof.Name)' is OFF - the rules do nothing there. Turn it on." }
    }
}

function Test-Proxy {
    param([int]$P)
    $curl = Join-Path $env:windir 'System32\curl.exe'
    $tests = @(
        @('https://developer.mozilla.org', '200 expected'),
        @('https://example.com', '403 expected (blocked)')
    )
    foreach ($t in $tests) {
        $code = & $curl -s -o NUL -m 20 -x "http://127.0.0.1:$P" -w '%{http_connect}' $t[0] 2>$null
        Write-Host ("  {0,-34} CONNECT -> {1}   ({2})" -f $t[0], $code, $t[1])
    }
}

# ---------------------------------------------------------------------
#  Actions
# ---------------------------------------------------------------------
function Install-DevProxy {
    $users = @(Get-TargetUsers)
    $py = Get-PythonExe
    Step-Header 'Files'
    New-Item -ItemType Directory -Path $Dir -Force | Out-Null
    & icacls $Dir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
    if ($LASTEXITCODE -ne 0) { throw "icacls failed on $Dir" }
    Write-Text $ProxyFile ($ProxyPy.Replace('PORT = 8899', "PORT = $Port"))
    if (Test-Path -LiteralPath $AllowFile) {
        Write-Host "  allowlist.txt kept as is (your edits are preserved)"
    }
    else {
        Write-Text $AllowFile $DefaultAllowlist
        Write-Host "  allowlist.txt created: $AllowFile"
    }
    Write-Host "  proxy.py written: $ProxyFile   (Python: $py)"
    if (-not $py.StartsWith($env:ProgramFiles, [StringComparison]::OrdinalIgnoreCase)) {
        Write-Warning 'Python is not under Program Files. Make sure standard users cannot modify that folder.'
    }

    Step-Header 'Proxy service (scheduled task, SYSTEM)'
    $pyw = Join-Path (Split-Path $py) 'pythonw.exe'
    $exe = $py
    if (Test-Path -LiteralPath $pyw) { $exe = $pyw }
    $act = New-ScheduledTaskAction -Execute $exe -Argument "`"$ProxyFile`"" -WorkingDirectory $Dir
    $trg = New-ScheduledTaskTrigger -AtStartup
    $pri = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
    $set = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 99 `
        -RestartInterval (New-TimeSpan -Minutes 1) -MultipleInstances IgnoreNew -StartWhenAvailable `
        -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
    Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
    Register-ScheduledTask -TaskName $TaskName -Action $act -Trigger $trg -Principal $pri -Settings $set -Force | Out-Null
    Start-ScheduledTask -TaskName $TaskName
    Start-Sleep -Seconds 3
    if (Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) {
        Write-Host "  proxy is listening on 127.0.0.1:$Port"
    }
    else {
        Write-Warning "Proxy is NOT listening. Check $LogFile (or run:  & '$py' '$ProxyFile'  in a console)."
    }

    Step-Header 'Firewall rules'
    Set-FirewallRules -Sids ($users | ForEach-Object { $_.Sid })
    Write-Host ("  direct internet blocked for: " + (($users | ForEach-Object { $_.Name }) -join ', '))

    Step-Header 'User profiles'
    foreach ($u in $users) { Set-UserProxy -U $u -P $Port }

    Step-Header 'Self-test (as administrator, through the proxy)'
    Test-Proxy -P $Port

    Write-Host @"

Done. Next:
  1. The restricted user signs out and in (settings are read at sign-in).
  2. As that user check (cmd/PowerShell):
       curl.exe -I https://developer.mozilla.org           -> works (via the proxy)
       curl.exe -I https://example.com                      -> 403 (blocked)
       curl.exe --noproxy "*" -I https://developer.mozilla.org   -> must FAIL (firewall)
  3. Browse as that user, then on your side:   .\setup-user-proxy.ps1 -Action Blocked
     Add missing helper domains with:           .\setup-user-proxy.ps1 -Action Allow -Domains x.com,y.org
  4. Do NOT also use the old machine-wide Edge policy (setup-dev-pc.ps1 -Steps Web): not needed any more.
"@
}

function Show-Status {
    $p = Get-InstalledPort
    Step-Header 'DevProxy status'
    $t = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
    $tState = 'NOT INSTALLED'
    if ($t) { $tState = "$($t.State)" }
    Write-Host "  Task:      $tState"
    $listening = 'NOT LISTENING'
    if (Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction SilentlyContinue) { $listening = "127.0.0.1:$p OK" }
    Write-Host "  Listening: $listening"
    $rules = @(Get-NetFirewallRule -Group $FwGroup -ErrorAction SilentlyContinue)
    Write-Host "  Firewall rules: $($rules.Count)"
    foreach ($r in $rules) { Write-Host ("    [{0}] {1}" -f $r.Enabled, $r.DisplayName) }
    if (Test-Path -LiteralPath $AllowFile) {
        $n = @(Get-Content -LiteralPath $AllowFile | Where-Object { $_ -and ($_ -notmatch '^\s*#') }).Count
        Write-Host "  Allow-list: $n entries ($AllowFile)"
    }
    Write-Host '  Test requests:'
    Test-Proxy -P $p
    if (Test-Path -LiteralPath $LogFile) {
        Write-Host '  Last log lines:'
        Get-Content -LiteralPath $LogFile -Tail 8 | ForEach-Object { Write-Host "    $_" }
    }
}

function Show-Blocked {
    if (-not (Test-Path -LiteralPath $LogFile)) { throw "No log yet: $LogFile" }
    $rows = Get-Content -LiteralPath $LogFile | Where-Object { $_ -match '\|BLOCK\|' } | ForEach-Object {
        $f = $_ -split '\|'
        [pscustomobject]@{ Time = $f[0]; Host = $f[2] }
    }
    if (-not $rows) { Write-Host 'Nothing blocked in the log.'; return }
    $rows | Group-Object Host | ForEach-Object {
        [pscustomobject]@{ Count = $_.Count; Host = $_.Name; Last = ($_.Group | Select-Object -Last 1).Time }
    } | Sort-Object Count -Descending | Select-Object -First 40 | Format-Table -AutoSize
    Write-Host 'Add what is legitimate:  .\setup-user-proxy.ps1 -Action Allow -Domains host1,host2'
    Write-Host '(allowing a domain also allows all its subdomains)'
}

function Add-Domains {
    if (-not $Domains -or $Domains.Count -eq 0) { throw 'Specify -Domains a.com,b.org' }
    if (-not (Test-Path -LiteralPath $AllowFile)) { throw "Not installed yet: $AllowFile" }
    $have = @(Get-Content -LiteralPath $AllowFile | ForEach-Object { ($_ -split '#')[0].Trim().ToLower() } | Where-Object { $_ })
    $text = [IO.File]::ReadAllText($AllowFile)
    $add = ''
    foreach ($d in $Domains) {
        $raw = $d.Trim().ToLower()
        $prefix = ''
        if ($raw.StartsWith('=')) { $prefix = '='; $raw = $raw.Substring(1).Trim() }
        $x = $raw -replace '^https?://', '' -replace '/.*$', '' -replace '^\*\.', ''
        if (-not $x) { continue }
        $entry = "$prefix$x"
        if ($have -contains $entry) { Write-Host "  already allowed: $entry"; continue }
        $add += "$entry`n"
        Write-Host "  added: $entry"
    }
    if ($add) {
        if ($text.Length -gt 0 -and -not $text.EndsWith("`n")) { $text += "`n" }
        Write-Text $AllowFile ($text + $add)
        Write-Host 'The proxy picks the change up automatically (no restart needed).'
    }
}

function Uninstall-DevProxy {
    $users = @(Get-TargetUsers)
    Step-Header 'Uninstall'
    Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
    Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
    Write-Host '  scheduled task removed'
    Get-NetFirewallRule -Group $FwGroup -ErrorAction SilentlyContinue | Remove-NetFirewallRule
    Write-Host '  firewall rules removed'
    foreach ($u in $users) { Remove-UserProxy -U $u }
    if ($RemoveFiles -and (Test-Path -LiteralPath $Dir)) {
        Remove-Item -LiteralPath $Dir -Recurse -Force
        Write-Host "  files removed: $Dir"
    }
    elseif (Test-Path -LiteralPath $Dir) {
        Write-Host "  files kept: $Dir (allow-list and log); add -RemoveFiles to delete them"
    }
    Write-Host 'The user must sign out and in to pick up the change.'
}

switch ($Action) {
    'Install' { Install-DevProxy }
    'Status' { Show-Status }
    'Blocked' { Show-Blocked }
    'Allow' { Add-Domains }
    'Uninstall' { Uninstall-DevProxy }
}