Copy-SshId.psm1

# ===== askpass 密码复用(单次密码)=====
# 原理:OpenSSH 从 TTY 读密码,不接受 stdin 管道;但设置了 SSH_ASKPASS(且 DISPLAY、
# SSH_ASKPASS_REQUIRE=force)后会改从该程序读密码。这里收集一次密码存入进程环境变量,
# SSH_ASKPASS 指向模块内固定的辅助脚本(Read-SshPassword.ps1),脚本从环境变量读密码——
# 之后每次 ssh 连接都自动复用同一密码。不落盘、无后台进程、无临时文件。

# 收集一次密码(SecureString),返回明文供环境变量传递(用完即清)
function 请求-Ssh密码 {
    param([string]$目标描述)
    $sec = Read-Host "输入 $目标描述 的密码" -AsSecureString
    return [Runtime.InteropServices.Marshal]::PtrToStringBSTR(
        [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
}

# 密钥缺失时自动生成密钥对(无口令),返回公钥路径;失败返回 $null。
# $公钥文件 为目标公钥路径(*.pub);未指定时按 $密钥类型 生成在 ~\.ssh\id_<密钥类型>。$密钥类型 可取 ed25519(默认)/rsa/dsa/ecdsa:过老的服务器(如 OpenSSH 5.x)不支持 ed25519,此时调用方应传 rsa。
function 新建-Ssh密钥 {
    param([string]$密钥类型 = 'ed25519', [string]$公钥文件)
    if(-not $公钥文件){ $公钥文件 = Join-Path $env:USERPROFILE (".ssh\id_$密钥类型.pub") }
    if(-not (Get-Command ssh-keygen -ErrorAction SilentlyContinue)) {
        Write-Warning "本机未找到 ssh-keygen,无法自动生成 SSH 密钥。请安装 OpenSSH 客户端,或手动执行 ssh-keygen -t $密钥类型 后重试。"
        return $null
    }
    # ssh-keygen -f 需要不带 .pub 的私钥路径
    $密钥路径 = $公钥文件 -replace '\.pub$', ''
    if(Test-Path -LiteralPath $密钥路径 -PathType Leaf) {
        Write-Warning "私钥已存在但缺少公钥($公钥文件),无法自动补全,请手动处理。"
        return $null
    }
    $密钥目录 = Split-Path -Parent $密钥路径
    if($密钥目录 -and -not (Test-Path -LiteralPath $密钥目录)) {
        $null = New-Item -Path $密钥目录 -ItemType Directory -ErrorAction SilentlyContinue
    }
    Write-Host "本机尚未生成 SSH 密钥,正在自动生成($密钥类型,无口令):$密钥路径"
    $null = & ssh-keygen -t $密钥类型 -f $密钥路径 -N '""' 2>&1
    if($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $公钥文件 -PathType Leaf)) {
        Write-Warning "自动生成 SSH 密钥失败。可手动执行 ssh-keygen -t $密钥类型 后重试。"
        return $null
    }
    Write-Host "SSH 密钥已生成:$公钥文件"
    return $公钥文件
}

# 启动 askpass:密码写入进程环境变量,SSH_ASKPASS 指向模块内固定的辅助脚本
function 启动-Askpass服务 {
    param([string]$密码)
    $helper = Join-Path $PSScriptRoot 'Read-SshPassword.ps1'
    $env:密码 = $密码
    $env:SSH_ASKPASS = "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"$helper`""
    $env:DISPLAY = 'localhost:0'
    $env:SSH_ASKPASS_REQUIRE = 'force'
}

# 停止 askpass:清除环境变量(含密码)
function 停止-Askpass服务 {
    $env:密码 = $null
    $env:SSH_ASKPASS = $null
    $env:SSH_ASKPASS_REQUIRE = $null
    $env:DISPLAY = $null
}

# 在远程 Windows 主机执行 PowerShell 脚本:scp 上传 + EncodedCommand 引导执行(不用 stdin 管道、不用超长命令行)。
# 方案演进:
# 1) stdin 管道(远端 [Console]::In.ReadToEnd()):经 cpolar 等 NAT 隧道时 EOF 有时送达不了 → 永久挂起;
# 2) 整脚本 base64 内嵌进 ssh 命令参数:脚 base64 后约 7KB,超 cmd 命令行 8191 上限
# → 'The command line is too long.';
# 3) 现行方案:脚本写本地临时文件 → scp -O 上传到远端用户目录(文件传输走数据通道,
# 隧道下同样稳定)→ 一条短命令经 -EncodedCommand 引导执行(纯 base64,远端默认 shell
# 无论是 cmd 还是 PowerShell 都不会拆解它),引导脚本执行完毕自行删除远端临时文件。
function 通过scp执行远程Windows脚本 {
    param([string]$脚本内容, [string[]]$SSH参数, [string]$远程用户, [string]$远程端口, [string]$远程主机, [string[]]$加密附加参数 = @())

    $随机名 = 'sshcopyid_' + [guid]::NewGuid().ToString('N').Substring(0, 12) + '.ps1'
    $本地临时文件 = Join-Path $env:TEMP $随机名
    Set-Content -LiteralPath $本地临时文件 -Value $脚本内容 -Encoding UTF8

    # 上传:-O 强制传统 SCP 协议(不依赖远端 sftp 子系统),目标路径相对远端用户目录;加密附加参数让 scp 走与 ssh 相同的 AEAD 加密,避开同样的 MAC 不兼容问题
    # 端口为空时绝不能传 -P:scp 的 -P 与 ssh 的 -p 一样会覆盖 ~/.ssh/config 里该 Host 的 Port 配置,导致连错端口
    $scp参数 = @('-O')
    if($远程端口){ $scp参数 += @('-P', $远程端口) }
    $scp参数 += $加密附加参数
    if($远程用户){ $远程目标 = "${远程用户}@${远程主机}:$随机名" } else { $远程目标 = "${远程主机}:$随机名" }

    try {
        Write-Verbose "通过 scp 上传临时脚本到远程主机:$随机名"
        $null = scp @scp参数 $本地临时文件 $远程目标 2>&1
        if($LASTEXITCODE -ne 0){
            Write-Warning 'scp 上传脚本文件失败。公钥未写入。'
            return 1
        }

        # 引导脚本:执行已上传的文件 → 记录退出码 → 删除远端临时文件 → 原样返回退出码。
        # 模板存于模块目录 Invoke-Bootstrap.ps1;整体 -EncodedCommand 编码后只有纯 base64,远端任何 shell 都不会拆解;-ExecutionPolicy Bypass 保证文件执行不受远端执行策略限制。
        $引导脚本模板 = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'Invoke-Bootstrap.ps1') -Raw
        $引导脚本 = $引导脚本模板.Replace('__REMOTE_SCRIPT_NAME__', $随机名)
        $编码引导 = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($引导脚本))
        Write-Verbose '执行引导:调用远程临时脚本并自清理'
        ssh @SSH参数 "cmd /c powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand $编码引导"
        return [int]$LASTEXITCODE
    } finally {
        Remove-Item -LiteralPath $本地临时文件 -Force -ErrorAction SilentlyContinue
    }
}

# 通过 base64 参数在远程执行 sh 脚本:脚本整体 base64 编码后作为 ssh 参数,远端解码后经管道交给 sh。base64 只含 A-Za-z0-9+/=,无空格/引号,不受 PS 5.1 传参吞引号影响,也避免多行脚本经 PowerShell 管道被逐行拆分的问题。
# 两个必须遵守的细节(均在老系统 GNU coreutils 6.7 上实测踩过坑):
# 1) 必须用 printf '%s' 而不是 echo 输出 base64:echo 会追加末尾换行,老版本 base64 -d 会报 "base64: invalid input" 且返回非零,导致整个执行失败;
# 2) 编码前必须把 CRLF 规范成 LF:Windows 上 here-string 的行尾是 CRLF,\r 会随 base64 原样进入远端 sh,引发 "command not found" 或 heredoc/关键字(如 do\r)语法错误;
# 3) 不要用 $(...) 命令替换包裹整条命令:脚本 stdout 会被替换结果二次执行,产生莫名错误且丢失原始输出。
function 通过base64执行远程sh脚本 {
    param([string]$脚本内容, [string[]]$SSH参数)
    $规范脚本 = $脚本内容.Replace("`r`n", "`n").Replace("`r", "`n")
    $编码脚本 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($规范脚本))
    Write-Verbose '以 base64 参数方式在远程执行 sh 脚本'
    ssh @SSH参数 "printf '%s' $编码脚本 | base64 -d | sh"
    return [int]$LASTEXITCODE
}

# 把有实现缺陷的 umac 系列 MAC 从默认候选中剔除,写进用户级 ~/.ssh/config 的 Host * 块,使终端里手敲的裸 ssh/scp/sftp/git 命令也一并避开该缺陷。
# 背景:Windows 自带 OpenSSH 客户端对 umac-*-etm 的 MAC 计算实现有缺陷(LibreSSL 侧问题,官方 PowerShell/Win32-OpenSSH#2078 标记为外部依赖且至今未修),一旦与服务器协商到 umac 必然 Corrupted MAC 断连;把它剔除后连接会落到 hmac-sha2-*-etm 或 AEAD 等实现正常的强算法,所以这既不是削弱安全也不是禁用算法家族,仅仅是在这台机器上避开用不了的实现。
# 选用户级而非系统级(ProgramData\ssh\ssh_config):用户级无需管理员权限、不改动系统组件、且不影响本机其它用户的既有配置;选 Host * 全局降权而非单主机块:缺陷在客户端而非某台服务器,任何不提供 AEAD/chacha20 的服务器都会以同样方式触发,逐台添加只会反复踩坑。
# 幂等:以固定标记块识别,已写过则跳过;首次修改前对既有 config 做时间戳备份;按字节读写并用 UTF-8 无 BOM 追加,既保留原文件中的中文主机名也不会写入 BOM 干扰 ssh 解析。
# 返回 $true 表示本次新写入,$false 表示已存在或写入失败(失败原因已通过 Warning 报出)。
function 写入-Umac降权配置 {
    $SSH目录 = Join-Path $env:USERPROFILE '.ssh'
    $config路径 = Join-Path $SSH目录 'config'
    $起始标记 = '# BEGIN Copy-SshId UMAC-FIX'
    $结束标记 = '# END Copy-SshId UMAC-FIX'
    $配置块行 = @(
        $起始标记,
        '# 由 Copy-SshId 模块自动写入:Windows 自带 OpenSSH 客户端对 umac 系列 MAC 的计算实现有缺陷,一旦与服务器协商到就会 Corrupted MAC 断连(官方 issue PowerShell/Win32-OpenSSH#2078,至今未修)。',
        '# 下面把 umac 从默认候选中剔除(MACs 减号前缀是降权剔除语义),连接会改走 hmac-sha2-*-etm 或 AEAD 等实现正常的强算法,安全性不受影响;删掉本块即恢复默认行为。',
        'Host *',
        ' MACs -umac-128-etm@openssh.com,umac-64-etm@openssh.com,umac-128@openssh.com,umac-64@openssh.com',
        $结束标记
    )

    if(-not (Test-Path -LiteralPath $SSH目录)){
        $null = New-Item -Path $SSH目录 -ItemType Directory -ErrorAction SilentlyContinue
    }

    $现有内容 = ''
    if(Test-Path -LiteralPath $config路径){
        $现有内容 = [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($config路径))
        if($现有内容 -match [regex]::Escape($起始标记)){
            Write-Verbose '用户级 ssh config 中已存在 umac 降权配置块,跳过写入。'
            return $false
        }
    }

    # ssh_config 每个关键字首匹配生效:若文件中已有 MACs 指令,则我们追加的块不会生效,必须如实告知而不是假装修好了
    if($现有内容 -match '(?m)^\s*MACs'){
        Write-Warning "检测到 $config路径 中已存在 MACs 指令:ssh 配置首个匹配生效,自动追加的 umac 降权块可能被它覆盖而不生效。请人工检查该指令,未进行自动写入。"
        return $false
    }

    try{
        if($现有内容){
            Copy-Item -LiteralPath $config路径 -Destination "$config路径.Copy-SshId备份_$(Get-Date -Format 'yyyyMMdd_HHmmss')" -ErrorAction Stop
            Write-Verbose "已备份原 ssh config 后再修改。"
        }
        # 保证既有内容与新块之间有空行分隔,且原文件不以换行结尾时先补一个换行,避免把最后一行粘连进新块
        $前置分隔 = if($现有内容){ if($现有内容.EndsWith("`n")){ "`r`n" } else { "`r`n`r`n" } } else { '' }
        $待追加文本 = $前置分隔 + ($配置块行 -join "`r`n") + "`r`n"
        $追加字节 = [Text.Encoding]::UTF8.GetBytes($待追加文本)
        $现有字节 = if($现有内容){ [IO.File]::ReadAllBytes($config路径) } else { [byte[]]@() }
        [IO.File]::WriteAllBytes($config路径, $现有字节 + $追加字节)
        Write-Host "已自动写入 umac 降权配置到用户级 ssh config($config路径),终端里手敲的裸 ssh/scp/git 命令也将自动避开该加密缺陷。"
        return $true
    }catch{
        Write-Warning "自动写入 ssh config 失败:$($_.Exception.Message)"
        return $false
    }
}

# 为指定主机在用户级 ~/.ssh/config 写入旧算法兼容块:老服务器(典型如 OpenSSH 5.x)只提供 ssh-rsa/ssh-dss 旧版主机密钥/公钥签名算法,新版客户端默认不再接受,报 "Unable to negotiate ... no matching host key type found" 协商失败。启用旧算法属于在该主机上重新接受 SHA-1 时代签名,因此按主机限定写入而不是 Host *,不影响其他主机的安全默认值。幂等:标记含主机名,已写过则跳过;首次修改前备份;按字节 UTF-8 无 BOM 追加(保留中文主机名、不写 BOM)。返回 $true=本次新写入,$false=已存在或写入失败(原因已 Warning)。
function 写入-主机密钥兼容配置 {
    param([string]$主机名)
    if([string]::IsNullOrWhiteSpace($主机名)){ return $false }
    $SSH目录 = Join-Path $env:USERPROFILE '.ssh'
    $config路径 = Join-Path $SSH目录 'config'
    $起始标记 = "# BEGIN Copy-SshId HOSTKEY-FIX $主机名"
    $结束标记 = "# END Copy-SshId HOSTKEY-FIX $主机名"
    $配置块行 = @(
        $起始标记,
        '# 由 Copy-SshId 模块自动写入:该主机的 SSH 服务器过老,只提供 ssh-rsa/ssh-dss 等旧版算法,新版客户端默认不再接受,会报 no matching host key type found 协商失败。下面仅为该主机启用旧算法(HostKeyAlgorithms 管主机密钥验证,PubkeyAcceptedAlgorithms 管用户公钥签名),其他主机不受影响;删掉本块即恢复默认行为。',
        "Host $主机名",
        ' HostKeyAlgorithms +ssh-rsa,ssh-dss',
        ' PubkeyAcceptedAlgorithms +ssh-rsa,ssh-dss',
        $结束标记
    )

    if(-not (Test-Path -LiteralPath $SSH目录)){
        $null = New-Item -Path $SSH目录 -ItemType Directory -ErrorAction SilentlyContinue
    }

    $现有内容 = ''
    if(Test-Path -LiteralPath $config路径){
        $现有内容 = [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($config路径))
        if($现有内容.Contains($起始标记)){
            Write-Verbose "用户级 ssh config 中已存在 $主机名 的旧算法兼容块,跳过写入。"
            return $false
        }
    }

    # ssh_config 每个关键字首匹配生效:若已有本模块块之外的算法指令,追加块可能被盖住,必须如实告知而不是假装修好了
    $剔除本模块块 = [regex]::Replace($现有内容, '(?ms)^# BEGIN Copy-SshId HOSTKEY-FIX .*?^# END Copy-SshId HOSTKEY-FIX .*\r?\n?', '')
    if($剔除本模块块 -match '(?m)^\s*(HostKeyAlgorithms|PubkeyAcceptedAlgorithms|PubkeyAcceptedKeyTypes)'){
        Write-Warning "检测到 $config路径 中已存在主机密钥/公钥算法指令:ssh 配置首个匹配生效,自动追加的兼容块可能被它覆盖而不生效。请人工检查该指令,未进行自动写入。"
        return $false
    }

    try{
        if($现有内容){
            Copy-Item -LiteralPath $config路径 -Destination "$config路径.Copy-SshId备份_$(Get-Date -Format 'yyyyMMdd_HHmmss')" -ErrorAction Stop
            Write-Verbose "已备份原 ssh config 后再修改。"
        }
        # 保证既有内容与新块之间有空行分隔,且原文件不以换行结尾时先补一个换行,避免把最后一行粘连进新块
        $前置分隔 = if($现有内容){ if($现有内容.EndsWith("`n")){ "`r`n" } else { "`r`n`r`n" } } else { '' }
        $待追加文本 = $前置分隔 + ($配置块行 -join "`r`n") + "`r`n"
        $追加字节 = [Text.Encoding]::UTF8.GetBytes($待追加文本)
        $现有字节 = if($现有内容){ [IO.File]::ReadAllBytes($config路径) } else { [byte[]]@() }
        [IO.File]::WriteAllBytes($config路径, $现有字节 + $追加字节)
        Write-Host "已自动为 $主机名 启用旧版密钥算法兼容(用户级 ssh config),终端里手敲的裸 ssh/scp/git 命令也将一并生效。"
        return $true
    }catch{
        Write-Warning "自动写入 ssh config 失败:$($_.Exception.Message)"
        return $false
    }
}

# 移除指定主机的旧算法兼容块(当检测到该主机实际协商的是现代主机密钥算法、根本不需旧算法时调用,自动清理过期残留)。
# 场景:Host 别名被改指向新机器(如学校集群从 OpenSSH 5.3 的 .109 改到 7.4 的 .110),上一轮为旧机器写的兼容块成了过期脏数据,会让模块误判“旧服务器”、多此一举换 RSA。检测到主机协商的是现代算法就自动清掉,无需人工干预。
# 幂等:没有该块则什么都不做;有则先备份再删。返回 $true=本次删除,$false=无需删或失败。
function 移除-主机密钥兼容配置 {
    param([string]$主机名)
    if([string]::IsNullOrWhiteSpace($主机名)){ return $false }
    $config路径 = Join-Path (Join-Path $env:USERPROFILE '.ssh') 'config'
    if(-not (Test-Path -LiteralPath $config路径)){ return $false }
    $现有内容 = [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($config路径))
    $起始标记 = "# BEGIN Copy-SshId HOSTKEY-FIX $主机名"
    $结束标记 = "# END Copy-SshId HOSTKEY-FIX $主机名"
    if(-not $现有内容.Contains($起始标记)){ return $false }
    try{
        Copy-Item -LiteralPath $config路径 -Destination "$config路径.Copy-SshId备份_$(Get-Date -Format 'yyyyMMdd_HHmmss')" -ErrorAction Stop
        # 删除整个标记块(含 BEGIN/END 行及其间内容及尾部换行);用 regex.Escape 确保中文主机名也能精确匹配
        $块正则 = [regex]::Escape($起始标记) + '.*?' + [regex]::Escape($结束标记) + '\r?\n?'
        $新内容 = [regex]::Replace($现有内容, "(?ms)$块正则", '')
        # 顺手把删块后可能残留的连续空行收成一个空行,避免 config 变稀疏
        $新内容 = $新内容 -replace '(\r?\n){3,}', "`r`n`r`n"
        [IO.File]::WriteAllBytes($config路径, [Text.Encoding]::UTF8.GetBytes($新内容))
        Write-Host "检测到 $主机名 实际协商的是现代主机密钥算法,已自动清理过期的旧算法兼容配置块(无需人工操作)。"
        return $true
    }catch{
        Write-Warning "自动清理过期 ssh config 块失败:$($_.Exception.Message)"
        return $false
    }
}

# 探测免密登录的同时自动检测并修复两类兼容性问题,全程自动(含自动写/清理用户级 ssh config),不把修复步骤丢给用户:
# 1) 主机密钥类型协商失败:老服务器(如 OpenSSH 5.x)只提供 ssh-rsa/ssh-dss,新版客户端默认不再接受,报 "Unable to negotiate ... no matching host key type found"。自动改用旧算法参数重试并写 config(见 写入-主机密钥兼容配置);这类服务器也不支持 ed25519 用户公钥,故同时置 旧服务器 标志,调用方应改用 RSA 密钥。
# 2) umac MAC 计算缺陷:协商到 umac-*-etm 时报 "Corrupted MAC on input"。自动改用 AEAD 加密重试并全局降权 umac 写 config(见 写入-Umac降权配置)。
# 返回 [PSCustomObject]:免密输出、免密退出码(生效参数下的最终探测结果,调用方直接复用避免重复连接)、附加参数(调用方应将其前置到 SSH 参数数组开头,即主机名之前)、旧服务器($true=服务器过老,用户公钥须用 RSA/DSA 而非 ed25519)。
function 探测免密登录并修复加密 {
    param([string[]]$SSH参数)
    $主机名 = $SSH参数[-1]
    $AEAD参数 = @('-o', 'Ciphers=aes128-gcm@openssh.com,aes256-gcm@openssh.com,aes128-ctr,aes192-ctr,aes256-ctr')
    $旧算法参数 = @('-o', 'HostKeyAlgorithms=+ssh-rsa,ssh-dss', '-o', 'PubkeyAcceptedAlgorithms=+ssh-rsa,ssh-dss')
    $附加参数 = @()

    # -Width 65535 必须加:Out-String 默认按控制台宽度(约120)折行,会在长错误消息中间插 CR+LF,把 "no matching host key type" 拆成 "ty\r\npe" 导致下方正则永远匹配不上。
    # 探测时加 -v 以便解析实际协商到的主机密钥算法:这是判断服务器新旧的可靠依据(现代=ed25519/rsa-sha2/ecdsa-sha2/sk-,旧=ssh-rsa/ssh-dss)。不用 config 标记判断新旧——Host 别名被改指向新机器后旧标记会误导模块。
    $输出 = ssh -v -o BatchMode=yes @SSH参数 'echo __SSH_COPY_ID_LOGIN_OK__' 2>&1 | Out-String -Width 65535
    $退出码 = [int]$LASTEXITCODE

    $协商算法 = ''
    $算法匹配 = [regex]::Match($输出, 'kex: host key algorithm:\s*(\S+)')
    if($算法匹配.Success){ $协商算法 = $算法匹配.Groups[1].Value }

    $旧服务器 = $false
    if($输出 -match 'no matching host key type'){
        # 协商失败:服务器只提供 ssh-rsa/ssh-dss 旧算法,自动改用旧算法参数重试并写 config
        $旧服务器 = $true
        Write-Verbose '检测到主机密钥类型协商失败:服务器只提供 ssh-rsa/ssh-dss 等旧算法,自动启用兼容参数并重试…'
        $null = 写入-主机密钥兼容配置 -主机名 $主机名
        $附加参数 += $旧算法参数
        $输出 = ssh -o BatchMode=yes @附加参数 @SSH参数 'echo __SSH_COPY_ID_LOGIN_OK__' 2>&1 | Out-String -Width 65535
        $退出码 = [int]$LASTEXITCODE
        if($输出 -match 'no matching host key type'){
            return [PSCustomObject]@{ 免密输出 = $输出; 免密退出码 = $退出码; 附加参数 = $附加参数; 旧服务器 = $旧服务器 }
        }
        Write-Host '已自动修复主机密钥类型协商问题(已为该主机启用 ssh-rsa/ssh-dss 旧算法兼容),无需修改任何配置文件。'
    } elseif($协商算法 -match '^(ssh-rsa|ssh-dss)$'){
        # 协商到旧算法(即便连接成功):服务器偏老,确保兼容块存在以支持后续免密
        $旧服务器 = $true
        $null = 写入-主机密钥兼容配置 -主机名 $主机名
    } elseif($协商算法 -match 'ed25519|rsa-sha2|ecdsa-sha2|sk-'){
        # 协商到现代算法:服务器现代、根本不需旧算法块 → 自动清理可能残留的过期兼容块(如别名改指向新机器后遗留的脏数据)
        $null = 移除-主机密钥兼容配置 -主机名 $主机名
    }
    # else:无法确定协商算法(网络抖动等),保持现状不动,避免误删

    if($输出 -match 'Corrupted MAC|message authentication code incorrect'){
        Write-Verbose '检测到加密层 MAC 校验失败:本机 OpenSSH 的 umac 实现缺陷被触发,自动降权 umac 并改用 AEAD 加密优先重试…'
        # 会话内附加参数只能修复模块自身发起的连接,用户在终端手敲的裸命令拿不到这些参数,因此必须同时把降权配置持久化进用户级 ssh config,两边一起生效
        $null = 写入-Umac降权配置
        $附加参数 += $AEAD参数
        $输出 = ssh -o BatchMode=yes @附加参数 @SSH参数 'echo __SSH_COPY_ID_LOGIN_OK__' 2>&1 | Out-String -Width 65535
        $退出码 = [int]$LASTEXITCODE
        if($输出 -notmatch 'Corrupted MAC|message authentication code incorrect'){
            Write-Host '已自动修复与服务器的加密算法不兼容(本会话强制 AEAD 加密)。'
        }
    }

    return [PSCustomObject]@{ 免密输出 = $输出; 免密退出码 = $退出码; 附加参数 = $附加参数; 旧服务器 = $旧服务器 }
}

function Copy-SshId
{
<#
.SYNOPSIS
    Appends a public key to a machines ~/.ssh/authorized_keys file.
    Existing keys are skipped instead of being appended again.

.DESCRIPTION
    Copy-SshId is a PowerShell script that uses ssh to log into a remote machine and append the
    indicated identity file to that machine's ~/.ssh/authorized_keys file. By default, it installs the key(s) stored in "$env:USERPROFILE\.ssh\id_rsa.pub", or the first common public key found in .ssh if that file does not exist.
    Keys already present in authorized_keys are skipped.

.PARAMETER RemoteHost
    Specifies the IP or DNS name of the machine to install the public key on.

.PARAMETER RemoteUser
    Specifies which user's authorized_keys file that the key will be installed under.

.PARAMETER KeyFile
    A path of the keyfile to be installed.

.PARAMETER RemotePort
    SSH will attempt to connect to this port on the remote host. Defaults to 22

.INPUTS

    None at the moment.

.OUTPUTS

    None at the moment.

.EXAMPLE

    PS> Copy-SshId root@172.16.1.10

.EXAMPLE

    PS> Copy-SshId 172.16.1.10 -l root

.EXAMPLE

    PS> Copy-SshId 172.16.1.10 -p 2222

.EXAMPLE

    PS> Copy-SshId root@172.16.1.10 -i C:\users\n8tg\SpecialKeyDir\key.pub

.EXAMPLE

    PS> Copy-SshId -RemoteHost 172.16.1.10 -RemoteUser root

.EXAMPLE

    PS> Copy-SshId -RemoteHost 172.16.1.10 -RemoteUser root -KeyFile C:\users\n8tg\SpecialKeyDir\key.pub

.NOTES

    If no username is supplied using -RemoteUser or the User@RemoteHost syntax, the user running the command's username will be used.

.LINK

https://github.com/Ebola-Chan-bot/Copy-SshId
#>



    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true,
        ValueFromPipeline=$false)]
        [string]
        $RemoteHost,

        [Alias('l')]
        [string]
        $RemoteUser,

        [Alias('p')]
        [string]
        $RemotePort,

        [Alias('i')]
        [string]
        $KeyFile = "$env:USERPROFILE\.ssh\id_rsa.pub"
    )

    PROCESS {

        if($RemoteHost.Contains("@")){
            $RemoteHostParts = $RemoteHost -split "@", 2
            $RemoteUser = $RemoteHostParts[0]
            $RemoteHost = $RemoteHostParts[1]
        }

        # Check key file is there
        if(!(Test-Path -LiteralPath $KeyFile -PathType Leaf)) {
            if(-not $PSBoundParameters.ContainsKey('KeyFile')) {
                $sshDirectory = Join-Path $env:USERPROFILE '.ssh'
                $commonKeyFileNames = @(
                    'id_ed25519.pub',
                    'id_ecdsa.pub',
                    'id_ecdsa_sk.pub',
                    'id_ed25519_sk.pub',
                    'id_rsa.pub',
                    'id_xmss.pub',
                    'id_dsa.pub'
                )

                $fallbackKeyFile = $commonKeyFileNames |
                    ForEach-Object { Join-Path $sshDirectory $_ } |
                    Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } |
                    Select-Object -First 1

                if($fallbackKeyFile) {
                    $KeyFile = $fallbackKeyFile
                    Write-Verbose "Using public key file '$KeyFile'."
                } else {
                    # 本机还没有任何 SSH 密钥:自动生成 ed25519 密钥对,再继续安装流程
                    Write-Verbose '本机尚未生成任何 SSH 密钥,尝试自动生成……'
                    $KeyFile = 新建-Ssh密钥
                    if(-not $KeyFile) { return }
                }
            } else {
                Write-Warning '未找到密钥文件,尝试在当前路径自动生成……'
                $KeyFile = 新建-Ssh密钥 -公钥文件 $KeyFile
                if(-not $KeyFile) { return }
            }
        }

        if(!(Test-Path -LiteralPath $KeyFile -PathType Leaf)) { Write-Warning "Specified key file not found"; return }

        # RemotePort 无默认值:只有用户显式传了 -p/-RemotePort 才向 ssh 加 -p,否则不传端口参数,由 ssh 自己从 ~/.ssh/config 的对应 Host 块读取 Port(缺省时才是 22)。
        # 这一点必须严格遵守:ssh 命令行的 -p 优先级高于 config 的 Port,只要硬编码传一个 -p 22,就会把 config 里配好的非标准端口(例如 Port 22112)盖掉,导致连接超时,而且超时会被后面的误判逻辑报成“密码错误”,极难排查。
        # StrictHostKeyChecking=accept-new:首次连接的新主机密钥自动信任(等同用户在提示里敲 yes,已知主机密钥被篡改时仍会拒绝,不削弱防中间人)。必须显式加,否则 ssh 首次连会问 yes/no,而该提示被本模块 2>&1 捕获藏进输出流,ssh 却仍在等终端输入,造成隐式卡死。
        $sshArguments = @('-o', 'StrictHostKeyChecking=accept-new')
        if($PSBoundParameters.ContainsKey('RemotePort')){
            $sshArguments += @('-p', $RemotePort)
        }
        if($RemoteUser){
            $sshArguments += @('-l', $RemoteUser)
        }
        $sshArguments += $RemoteHost

        # 安装脚本构建(Unix 脚本存于模块目录 Install-SshKey.sh、Windows 脚本存于 Install-SshKey.ps1,公钥经占位符内嵌)。做成函数是因为老服务器会中途换用 RSA 密钥,换完必须按新公钥重新构建脚本。
        # Unix 脚本本身在安装公钥的同时会清理 authorized_keys 中的异常行(空行、不以合法公钥算法前缀开头的噪声行),保留合法公钥行并整体去重后原子写回。
        function 生成-安装脚本 {
            param([string]$公钥路径)
            $密钥文本 = Get-Content -LiteralPath $公钥路径 -Raw
            $密钥行 = @($密钥文本 -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
            if($密钥行.Count -eq 0){ return $null }
            # 把公钥内容填进 heredoc 占位符(避免在脚本文件里做复杂转义)
            $unix脚本 = (Get-Content -LiteralPath (Join-Path $PSScriptRoot 'Install-SshKey.sh') -Raw).Replace('__KEYS__', ($密钥行 -join "`n"))
            $编码密钥文本 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($密钥文本))
            $win脚本 = (Get-Content -LiteralPath (Join-Path $PSScriptRoot 'Install-SshKey.ps1') -Raw).Replace('__ENCODED_KEY_TEXT__', $编码密钥文本)
            return [PSCustomObject]@{ Unix脚本 = $unix脚本; Windows脚本 = $win脚本 }
        }
        $安装脚本 = 生成-安装脚本 $KeyFile
        if(-not $安装脚本){ Write-Warning "公钥文件为空"; return }
        $unixScript = $安装脚本.Unix脚本
        $windowsScript = $安装脚本.Windows脚本

        # 平台检测:只在【登录已成功】的前提下调用(此时连接必然已认证)。
        # cmd 执行成功(0 且含标记) → Windows;否则(非 0) → Unix。绝不再返回 Unknown。
        # Windows 时用 reg.exe 探测 OpenSSH 默认 shell:cmd/powershell 均可安全执行 reg query,
        # 不受远端默认 shell 差异影响(命令中无 $、无引号转义陷阱)。
        # 返回 [PSCustomObject]:平台 ('Windows'/'Unix')、默认Shell ('PowerShell'/'cmd'/'')。
        function Get-RemotePlatform {
            $detectionOutput = ssh @sshArguments 'cmd /c echo __SSH_COPY_ID_WINDOWS__' 2>&1
            $detectionExitCode = [int]$LASTEXITCODE
            $detectionText = ($detectionOutput | Out-String -Width 65535)
            if($detectionExitCode -ne 0 -or $detectionText -notlike '*__SSH_COPY_ID_WINDOWS__*') {
                return [PSCustomObject]@{ 平台 = 'Unix'; 默认Shell = '' }
            }
            $regOutput = ssh @sshArguments 'reg query HKLM\SOFTWARE\OpenSSH /v DefaultShell' 2>&1
            $regText = ($regOutput | Out-String -Width 65535)
            $默认Shell = 'cmd'
            if($regText -match 'powershell\.exe' -or $regText -match 'pwsh\.exe') {
                $默认Shell = 'PowerShell'
            }
            return [PSCustomObject]@{ 平台 = 'Windows'; 默认Shell = $默认Shell }
        }

        $使用密码 = $false
        $密码 = $null
        try{
            # 第一步:先试免密登录(BatchMode 不弹密码),同时自动检测并修复加密算法不兼容(MAC 损坏时改用 AEAD 参数重探,附加参数前置到主机名之前,后续所有 ssh 连接沿用)。免密成功说明公钥已生效,无需安装。
            Write-Verbose '尝试免密登录…'
            $探测结果 = 探测免密登录并修复加密 $sshArguments
            $加密附加参数 = $探测结果.附加参数
            if($加密附加参数.Count -gt 0){ $sshArguments = $加密附加参数 + $sshArguments }
            $免密退出码 = $探测结果.免密退出码

            if($免密退出码 -eq 0){
                Write-Host '目标主机已可免密登录(公钥已存在),无需安装。'
                return
            }

            # 老服务器(如 OpenSSH 5.x)不支持 ed25519/ecdsa 用户公钥(6.5 才引入),装上去也永远无法免密;自动改用 RSA 密钥(本机没有就生成),否则整个安装毫无意义
            if($探测结果.旧服务器){
                $公钥类型 = ((Get-Content -LiteralPath $KeyFile -Raw).Trim() -split '\s+')[0]
                if($公钥类型 -notin @('ssh-rsa','ssh-dss')){
                    Write-Host "检测到目标服务器过老,不支持 $公钥类型 公钥,自动改用 RSA 密钥……"
                    $rsa公钥 = Join-Path $env:USERPROFILE '.ssh\id_rsa.pub'
                    if(-not (Test-Path -LiteralPath $rsa公钥 -PathType Leaf)){
                        $rsa公钥 = 新建-Ssh密钥 -密钥类型 rsa -公钥文件 $rsa公钥
                        if(-not $rsa公钥){ return }
                    }
                    $KeyFile = $rsa公钥
                    $安装脚本 = 生成-安装脚本 $KeyFile
                    if(-not $安装脚本){ Write-Warning "公钥文件为空"; return }
                    $unixScript = $安装脚本.Unix脚本
                    $windowsScript = $安装脚本.Windows脚本
                    Write-Host "已改用 RSA 密钥($KeyFile)进行安装。"
                }
            }

            # 免密失败:收集一次密码,启动 askpass 服务,之后所有连接复用该密码
            Write-Verbose '免密不可用,改用密码(将只询问一次,后续连接复用)。'
            # 密码提示显示真实的登录用户:用户没显式传 -l 时 ssh 会用 ~/.ssh/config 里该 Host 的 User,用 ssh -G 解析出来,避免提示显示成 "@主机名"
            $显示用户 = $RemoteUser
            if(-not $显示用户){
                $用户行 = ssh -G @sshArguments 2>$null | Select-String -Pattern '^user\s+(\S+)' | Select-Object -First 1
                if($用户行){ $显示用户 = $用户行.Matches[0].Groups[1].Value }
            }
            if(-not $显示用户){ $显示用户 = $RemoteHost }
            $密码 = 请求-Ssh密码 -目标描述 "$显示用户@$RemoteHost"
            启动-Askpass服务 -密码 $密码
            $密码 = $null   # 明文只留在 pipe 服务 runspace 里,主作用域立即清除
            $使用密码 = $true

            # 用密码登录验证(askpass 自动供密码,不弹提示)
            Write-Verbose '正在用密码登录远程主机…'
            $loginOutput = ssh @sshArguments 'echo __SSH_COPY_ID_LOGIN_OK__' 2>&1
            $loginExit = [int]$LASTEXITCODE
            $loginText = ($loginOutput | Out-String -Width 65535)
            if($loginExit -ne 0){
                if($loginText -match 'Corrupted MAC|message authentication code incorrect'){
                    # 走到这里说明已由探测阶段自动切换 AEAD 重试后仍然 MAC 损坏,属于更严重的加密层不兼容。这不是密码错误,必须如实报出,绝不能误报成密码错误让用户反复试密码。
                    Write-Warning '加密通道 MAC 校验失败(非密码错误):自动切换 AEAD 加密后仍无法避开,属于本机 OpenSSH 与该服务器的严重不兼容。请升级本机 OpenSSH 客户端,或联系服务器管理员检查 sshd 的加密配置。'
                }elseif($loginText -match 'Unable to negotiate|no matching host key type|no matching cipher|no matching MAC|no matching key exchange'){
                    # 算法协商失败:连接在加密协商阶段就断了,根本没到认证,密码对错无从检验;自动修复未能奏效时必须如实报出,绝不能误报成密码错误。
                    Write-Warning "SSH 算法协商失败(非密码错误):本机客户端与服务器没有共同可用的算法,自动修复未能奏效。未进行任何平台操作。`n实际错误:$loginText"
                }elseif($loginText -match 'Connection timed out|Connection refused|Could not resolve hostname|No route to host|Network is unreachable'){
                    # 连接层失败:根本没走到认证,密码是对是错都还无从判断。这类错误绝不能归为“密码错误”,否则用户会反复去改正确的密码,而真正的问题(最常见是端口不对,其次才是防火墙/主机不可达)永远被掩盖。
                    Write-Warning "无法建立 SSH 连接(端口不通或主机不可达),根本没到认证阶段,因此无法判断密码对错。未进行任何平台操作。请确认端口:若该主机在 ~/.ssh/config 里配了非标准 Port,请用 -p 指定(如 -p 22112),或检查目标主机与网络。`n实际错误:$loginText"
                }elseif($loginText -match 'Not allowed|Connection closed|Connection reset|kex_exchange_identification'){
                    # 这类"连接被断开"的文案只作辅助判断,绝不能作为唯一结论——真实原因可能是网络抖动、fail2ban、账号锁定、或密码认证期的服务器侧行为。必须把 ssh 原始报错一并透出,否则用户只会看到一句猜测而无从判断(这是本模块反复踩过的"上层文案掩盖底层错误"教训)。
                    Write-Warning "SSH 连接在认证阶段被断开(原因待查,不一定是 fail2ban)。未进行任何平台操作。`n实际错误:$loginText"
                }else{
                    Write-Warning "密码认证失败(密码错误)。未进行任何平台操作。`n实际错误:$loginText"
                }
                return
            }
            Write-Verbose '登录成功。'

            # 第二步:登录成功后做平台检测(askpass 复用密码,不再弹密码)
            $检测结果 = Get-RemotePlatform
            $remotePlatform = $检测结果.平台
            Write-Verbose "远程平台检测结果: $remotePlatform(Windows 时默认 shell: $($检测结果.默认Shell))"

            # 第三步:按真实平台执行安装
            if($remotePlatform -eq 'Windows'){
                # Windows 且 OpenSSH 默认 shell 是 cmd 时,把它改成 PowerShell。
                # 仅 Copy-SshId 负责设置;Remove-SshId 不会还原该配置(用户要求:一次改好,不再变回去)。
                # best-effort:写 HKLM 需要管理员权限,无权限时只警告,不阻断密钥安装。
                if($检测结果.默认Shell -eq 'cmd'){
                    Write-Verbose '远程 OpenSSH 默认 shell 为 cmd,尝试切换为 PowerShell……'
                    $切换脚本 = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'Set-DefaultShell.ps1') -Raw
                    $切换结果 = 通过scp执行远程Windows脚本 $切换脚本 $sshArguments -远程用户 $RemoteUser -远程端口 $RemotePort -远程主机 $RemoteHost -加密附加参数 $加密附加参数 2>&1
                    $切换文本 = ($切换结果 | Out-String -Width 65535)
                    if($切换文本 -like '*SHELL_SET*'){
                        Write-Host '远程 OpenSSH 默认 shell 已切换为 PowerShell(Remove-SshId 不会还原此设置)。'
                    }elseif($切换文本 -like '*ALREADY_POWERSHELL*'){
                        Write-Verbose '远程默认 shell 已经是 PowerShell,无需切换。'
                    }else{
                        Write-Warning "默认 shell 切换为 PowerShell 失败($切换文本)。不影响密钥安装,继续。"
                    }
                }
                $exitCode = 通过scp执行远程Windows脚本 $windowsScript $sshArguments -远程用户 $RemoteUser -远程端口 $RemotePort -远程主机 $RemoteHost -加密附加参数 $加密附加参数
            }else{
                $exitCode = 通过base64执行远程sh脚本 $unixScript $sshArguments
            }

            Write-Verbose "安装结束,退出码: $exitCode"
            if($exitCode -eq 255){
                Write-Warning '安装过程中 SSH 连接被断开,公钥可能未写入。'
            }elseif($exitCode -ne 0){
                Write-Warning 'An error occurred when installing the key'
            }
        } catch {
            Write-Warning "An error occurred when installing the key"
            Write-Host $_
        } finally {
            if($使用密码){ 停止-Askpass服务 }
        }
    }
}

# 从目标主机上移除本机公钥,恢复密码登录

function Remove-SshId
{
<#
.SYNOPSIS
    从目标主机的 ~/.ssh/authorized_keys 中移除本机公钥,使该主机恢复需要密码登录的状态。

.DESCRIPTION
    Remove-SshId 通过 SSH 登录远程主机,从指定用户的 authorized_keys 文件中删除本机公钥。
    删除后,该远程主机将不再接受本机的免密登录,恢复为需要密码认证。

.PARAMETER RemoteHost
    目标主机的 IP 地址或 DNS 名称。

.PARAMETER RemoteUser
    目标主机上要移除免密登录许可的用户名。

.PARAMETER KeyFile
    要移除的密钥文件路径。未指定时,将移除本机 ~\\.ssh 目录下所有公钥在远程主机上的对应条目。

.PARAMETER RemotePort
    SSH 连接端口,默认为 22。

.EXAMPLE

    PS> Remove-SshId root@172.16.1.10

.EXAMPLE

    PS> Remove-SshId 172.16.1.10 -l root

.EXAMPLE

    PS> Remove-SshId root@172.16.1.10 -p 2222

.EXAMPLE

    PS> Remove-SshId -RemoteHost 172.16.1.10 -RemoteUser root

.NOTES

    如果未通过 -RemoteUser 或 User@RemoteHost 语法提供用户名,将使用当前 Windows 用户名。
    未指定 -KeyFile 时,会自动搜索 ~\\.ssh 下所有 .pub 文件并全部移除。

.LINK

https://github.com/Ebola-Chan-bot/Copy-SshId
#>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true,
        ValueFromPipeline=$false)]
        [string]
        $RemoteHost,

        [Alias('l')]
        [string]
        $RemoteUser,

        [Alias('p')]
        [string]
        $RemotePort,

        [Alias('i')]
        [string]
        $KeyFile

    )

    PROCESS {

        # 解析 User@Host 语法
        if($RemoteHost.Contains("@")){
            $主机部分 = $RemoteHost -split "@", 2
            $RemoteUser = $主机部分[0]
            $RemoteHost = $主机部分[1]
        }

        # 如果未指定 -KeyFile,则收集本机 ~\.ssh 下所有公钥
        if(-not $PSBoundParameters.ContainsKey('KeyFile')){
            $SSH目录 = Join-Path $env:USERPROFILE '.ssh'
            $常见密钥模式 = @('*.pub')
            $所有公钥文件 = Get-ChildItem -LiteralPath $SSH目录 -Filter '*.pub' -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer }
            if($所有公钥文件){
                Write-Verbose "将移除 $($所有公钥文件.Count) 个公钥:$($所有公钥文件.Name -join ', ')"
            } else {
                Write-Warning "未找到任何公钥文件($SSH目录 下无 .pub 文件)"
                return
            }
            $密钥文本 = ($所有公钥文件 | ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw }) -join "`n"
        } else {
            if(!(Test-Path -LiteralPath $KeyFile -PathType Leaf)){
                Write-Warning "指定的密钥文件未找到"
                return
            }
            $密钥文本 = Get-Content -LiteralPath $KeyFile -Raw
        }

        # 同 Copy-SshId:不传默认的 -p 22,让 ssh 从 ~/.ssh/config 的对应 Host 块读取 Port,避免硬编码端口盖掉用户配置导致连接超时;accept-new 同理自动信任首次连接的新主机密钥,避免 yes/no 提示被 2>&1 藏起来卡住
        $SSH参数 = @('-o', 'StrictHostKeyChecking=accept-new')
        if($PSBoundParameters.ContainsKey('RemotePort')){
            $SSH参数 += @('-p', $RemotePort)
        }
        if($RemoteUser){
            $SSH参数 += @('-l', $RemoteUser)
        }
        $SSH参数 += $RemoteHost

        # Unix 端移除脚本存于模块目录 Uninstall-SshKey.sh:公钥经 heredoc 内嵌(占位符 __KEYS__),整体 base64 后作为 ssh 参数在远端解码执行
        $密钥行列表 = @($密钥文本 -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
        if($密钥行列表.Count -eq 0){ Write-Warning "没有可移除的公钥内容"; return }
        $转义密钥行 = $密钥行列表 -join "`n"
        $Unix脚本 = (Get-Content -LiteralPath (Join-Path $PSScriptRoot 'Uninstall-SshKey.sh') -Raw).Replace('__KEYS__', $转义密钥行)

        # Windows 端移除:脚本经 scp 上传到远端用户目录,再由短引导命令
        # (-EncodedCommand)调用执行;不依赖 stdin 管道、不受命令行长度限制
        $编码密钥文本 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($密钥文本))
        $Windows脚本 = (Get-Content -LiteralPath (Join-Path $PSScriptRoot 'Uninstall-SshKey.ps1') -Raw).Replace('__ENCODED_KEY_TEXT__', $编码密钥文本)

        # 平台检测:只在【免密登录已成功】后调用,此连接必然已认证
        function 获取远程平台 {
            $检测输出 = ssh @SSH参数 'cmd /c echo __SSH_COPY_ID_WINDOWS__' 2>&1
            $检测退出码 = [int]$LASTEXITCODE
            $检测文本 = ($检测输出 | Out-String -Width 65535)
            if($检测退出码 -eq 0 -and $检测文本 -like '*__SSH_COPY_ID_WINDOWS__*') { return 'Windows' }
            return 'Unix'
        }

        try{
            # Remove-SshId 的目的是移除公钥;只有在【已能免密登录】时才有公钥可移除。
            # 因此只用免密(BatchMode 不弹密码):免密失败说明公钥已不存在,无需移除。
            Write-Verbose '检测免密登录(仅免密有效时才需要移除公钥)…'
            # 探测的同时自动检测并修复加密算法不兼容(MAC 损坏时改用 AEAD 参数重探),附加参数前置到主机名之前,后续所有 ssh/scp 连接沿用,无需用户修改任何配置
            $探测结果 = 探测免密登录并修复加密 $SSH参数
            $加密附加参数 = $探测结果.附加参数
            if($加密附加参数.Count -gt 0){ $SSH参数 = $加密附加参数 + $SSH参数 }
            $免密退出码 = $探测结果.免密退出码

            if($免密退出码 -ne 0){
                # 自动修复后仍失败的两类情况必须如实报出,不能误报成“公钥未生效”
                if($探测结果.免密输出 -match 'Corrupted MAC|message authentication code incorrect'){
                    Write-Warning '加密通道 MAC 校验失败:自动切换 AEAD 加密后仍无法避开,属于本机 OpenSSH 与该服务器的严重不兼容,无法判断公钥是否存在。请升级本机 OpenSSH 客户端,或联系服务器管理员检查 sshd 的加密配置。未进行任何移除操作。'
                    return
                }
                if($探测结果.免密输出 -match 'Unable to negotiate|no matching host key type'){
                    Write-Warning "SSH 算法协商失败:自动修复未能奏效,无法判断公钥是否存在。未进行任何移除操作。实际错误:$($探测结果.免密输出)"
                    return
                }
                Write-Host '目标主机上本机公钥未生效(无法免密登录),无需移除。'
                return
            }
            Write-Verbose '免密登录成功,存在可移除的公钥。'

            # 免密登录已成功,检测平台后按真实平台移除(全程免密,不弹密码)
            $远程平台 = 获取远程平台
            Write-Verbose "远程平台检测结果: $远程平台"
            if($远程平台 -eq 'Windows'){
                $退出码 = 通过scp执行远程Windows脚本 $Windows脚本 $SSH参数 -远程用户 $RemoteUser -远程端口 $RemotePort -远程主机 $RemoteHost -加密附加参数 $加密附加参数
            }else{
                $退出码 = 通过base64执行远程sh脚本 $Unix脚本 $SSH参数
            }
            Write-Verbose "移除结束,退出码: $退出码"
            if($退出码 -eq 255){
                Write-Warning "SSH 连接失败(认证未通过或被服务器主动断开),密钥未移除。"
            }elseif($退出码 -ne 0){ Write-Warning "移除密钥时发生错误" }
        } catch {
            Write-Warning "移除密钥时发生错误"
            Write-Host $_
        }
    }
}

Export-ModuleMember -Function Copy-SshId, Remove-SshId