Skip to content

Instantly share code, notes, and snippets.

@superyngo
Last active January 11, 2026 16:17
Show Gist options
  • Select an option

  • Save superyngo/f74f4749882df654dfdf286b7f718a9e to your computer and use it in GitHub Desktop.

Select an option

Save superyngo/f74f4749882df654dfdf286b7f718a9e to your computer and use it in GitHub Desktop.
Manage rclone mount through ssh config.
<#
.SYNOPSIS
rclone Mount 管理工具 (PowerShell 版本)
.DESCRIPTION
功能: mount, unmount, status, show, help
使用 SSH config 設定掛載 rclone SFTP remote 到本地目錄
.PARAMETER Operation
操作類型: mount/mnt, unmount/umount/umnt, status, show, help
.PARAMETER RemoteSpec
SSH config 中的 Host 名稱,格式: <remote_name>[:<remote_path>]
或使用 --all/--a 進行批量操作
.PARAMETER CustomMountPoint
自定義本地掛載點 (選填)
.EXAMPLE
.\rclonemm.ps1 mount mom
掛載 mom 根目錄到預設位置
.EXAMPLE
.\rclonemm.ps1 mount mom:/WEB/logs
掛載 mom 指定目錄到預設位置
.EXAMPLE
.\rclonemm.ps1 mount mom D:\custom\path
掛載 mom 根目錄到自定義位置
.EXAMPLE
.\rclonemm.ps1 mount --all
批量掛載所有 SSH Host
.EXAMPLE
.\rclonemm.ps1 unmount mom
卸載 mom
.EXAMPLE
.\rclonemm.ps1 status mom
檢查 mom 狀態
.NOTES
版本: 1.0.0
日期: 2026-01-02
作者: Translated from Bash version by GitHub Copilot
原始 Bash 版本更新: 2025-12-19
#>
[CmdletBinding()]
param(
[Parameter(Position = 0)]
[string]$Operation,
[Parameter(Position = 1)]
[string]$RemoteSpec,
[Parameter(Position = 2)]
[string]$CustomMountPoint
)
# ==================== 錯誤處理設定 ====================
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
# ==================== 全域設定 ====================
$script:Config = @{
SSHConfig = "$env:USERPROFILE\.ssh\config"
MountBaseDir = "C:\mnt"
LogDir = "$env:LOCALAPPDATA\rclonemm\logs"
LogRetentionDays = 7
MountTimeout = 10
AccessTestTimeout = 10
}
# 支援自定義 PREFIX 環境變數
if ($env:RCLONEMM_PREFIX) {
Write-Verbose "使用自定義 PREFIX: $env:RCLONEMM_PREFIX"
$script:Config.MountBaseDir = "$env:RCLONEMM_PREFIX\mnt"
$script:Config.LogDir = "$env:RCLONEMM_PREFIX\var\log"
}
# 日誌檔案路徑
$script:LogFile = Join-Path $script:Config.LogDir "rclone_mount_$(Get-Date -Format 'yyyyMMdd').log"
# ==================== 輔助函數: 日誌與訊息 ====================
<#
.SYNOPSIS
輸出帶顏色的訊息並記錄到日誌
#>
function Write-ColorMessage {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('INFO', 'WARN', 'ERROR', 'DEBUG')]
[string]$Level,
[Parameter(Mandatory)]
[string]$Message
)
$color = switch ($Level) {
'INFO' { 'Green' }
'WARN' { 'Yellow' }
'ERROR' { 'Red' }
'DEBUG' { 'Cyan' }
}
Write-Host "[$Level] $Message" -ForegroundColor $color
# 記錄到日誌檔案
Write-Log -Level $Level -Message $Message
}
<#
.SYNOPSIS
寫入日誌檔案
#>
function Write-Log {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Level,
[Parameter(Mandatory)]
[string]$Message
)
try {
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$logEntry = "[$timestamp] [$Level] $Message"
# 確保日誌目錄存在
$logDir = Split-Path $script:LogFile -Parent
if (-not (Test-Path $logDir)) {
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}
# 寫入日誌
Add-Content -Path $script:LogFile -Value $logEntry -ErrorAction SilentlyContinue
} catch {
# 忽略日誌寫入錯誤
}
}
<#
.SYNOPSIS
清理過期日誌檔案
#>
function Clear-OldLogs {
[CmdletBinding()]
param()
try {
$logDir = $script:Config.LogDir
if (-not (Test-Path $logDir)) {
return
}
$cutoffDate = (Get-Date).AddDays(-$script:Config.LogRetentionDays)
Get-ChildItem -Path $logDir -Filter "rclone_mount_*.log" |
Where-Object { $_.LastWriteTime -lt $cutoffDate } |
Remove-Item -Force -ErrorAction SilentlyContinue
} catch {
# 忽略清理錯誤
}
}
# ==================== 輔助函數: 依賴檢查 ====================
<#
.SYNOPSIS
檢查必要的依賴套件
#>
function Test-Dependencies {
[CmdletBinding()]
param()
$missingDeps = @()
# 檢查 rclone
if (-not (Get-Command rclone -ErrorAction SilentlyContinue)) {
$missingDeps += "rclone"
}
# 檢查 WinFsp (透過註冊表或檔案系統)
$winfspPaths = @(
"$env:ProgramFiles\WinFsp",
"${env:ProgramFiles(x86)}\WinFsp"
)
$winfspInstalled = $winfspPaths | Where-Object { Test-Path $_ }
if (-not $winfspInstalled) {
$missingDeps += "WinFsp"
}
# 如果有缺失的依賴,顯示錯誤並退出
if ($missingDeps.Count -gt 0) {
Write-ColorMessage -Level ERROR -Message "缺少必要的依賴套件:"
foreach ($dep in $missingDeps) {
Write-Host " ✗ $dep" -ForegroundColor Red
}
Write-Host ""
Write-Host "請安裝缺少的套件:" -ForegroundColor Yellow
Write-Host " rclone: https://rclone.org/downloads/" -ForegroundColor Yellow
Write-Host " WinFsp: https://winfsp.dev/" -ForegroundColor Yellow
exit 1
}
}
<#
.SYNOPSIS
檢查是否具有管理員權限
#>
function Test-AdminPrivileges {
[CmdletBinding()]
param()
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]$identity
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
# ==================== 輔助函數: SSH Config 解析 ====================
<#
.SYNOPSIS
解析 SSH config 獲取指定 Host 的指定屬性
#>
function Get-SSHConfigValue {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias,
[Parameter(Mandatory)]
[string]$Key
)
$sshConfigPath = $script:Config.SSHConfig
if (-not (Test-Path $sshConfigPath)) {
return $null
}
$content = Get-Content $sshConfigPath -Raw
$lines = $content -split "`r?`n"
$inHostBlock = $false
foreach ($line in $lines) {
# 檢查是否進入目標 Host 區塊
if ($line -match '^\s*Host\s+(.+)$') {
$hostPatterns = $Matches[1] -split '\s+'
$inHostBlock = $hostPatterns -contains $HostAlias
continue
}
# 如果在目標 Host 區塊內,查找屬性
if ($inHostBlock) {
if ($line -match "^\s*$Key\s+(.+)$") {
return $Matches[1].Trim()
}
# 遇到下一個 Host 區塊,停止搜尋
if ($line -match '^\s*Host\s+') {
break
}
}
}
return $null
}
<#
.SYNOPSIS
獲取 SSH Host 的完整設定
#>
function Get-SSHHostConfig {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias
)
$hostname = Get-SSHConfigValue -HostAlias $HostAlias -Key 'HostName'
$user = Get-SSHConfigValue -HostAlias $HostAlias -Key 'User'
$port = Get-SSHConfigValue -HostAlias $HostAlias -Key 'Port'
$identityFile = Get-SSHConfigValue -HostAlias $HostAlias -Key 'IdentityFile'
$proxyJump = Get-SSHConfigValue -HostAlias $HostAlias -Key 'ProxyJump'
# 如果沒有明確的 HostName,使用 Host alias
if (-not $hostname) {
$hostname = $HostAlias
}
# 展開 ~ 為實際路徑
if ($identityFile -and $identityFile.StartsWith('~')) {
$identityFile = $identityFile -replace '^~', $env:USERPROFILE
}
return [PSCustomObject]@{
HostAlias = $HostAlias
HostName = $hostname
User = $user
Port = $port
IdentityFile = $identityFile
ProxyJump = $proxyJump
}
}
<#
.SYNOPSIS
組建 --sftp-ssh 選項
#>
function Get-SftpSshOption {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[PSCustomObject]$Config
)
$sshCmd = "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=2"
# 添加 Port
if ($Config.Port) {
$sshCmd += " -p $($Config.Port)"
}
# 添加 IdentityFile
if ($Config.IdentityFile) {
$sshCmd += " -i `"$($Config.IdentityFile)`""
}
# 添加 ProxyJump
if ($Config.ProxyJump) {
$sshCmd += " -J $($Config.ProxyJump)"
}
# 組建完整的 SSH 連線字串 user@hostname
$sshTarget = if ($Config.User) {
"$($Config.User)@$($Config.HostName)"
} else {
$Config.HostName
}
# 返回完整的 SSH 命令(不含外層引號,因為會作為參數傳遞)
return "$sshCmd $sshTarget"
}
# ==================== 輔助函數: 掛載點管理 ====================
<#
.SYNOPSIS
檢查指定路徑是否已掛載
#>
function Test-Mounted {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$MountPoint
)
# 透過 Get-Process 查找 rclone 進程並檢查命令列
$rcloneProcesses = Get-Process -Name rclone -ErrorAction SilentlyContinue
if (-not $rcloneProcesses) {
return $false
}
foreach ($process in $rcloneProcesses) {
try {
$cmdLine = (Get-CimInstance Win32_Process -Filter "ProcessId = $($process.Id)").CommandLine
if ($cmdLine -like "*mount*" -and $cmdLine -like "*$MountPoint*") {
return $true
}
} catch {
# 忽略權限錯誤
}
}
return $false
}
<#
.SYNOPSIS
確保掛載點的父目錄存在,但掛載點本身必須不存在
Windows 下 rclone 要求掛載點不能是已存在的目錄
#>
function Ensure-MountPoint {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$MountPoint
)
# 檢查掛載點是否已存在
if (Test-Path $MountPoint) {
Write-ColorMessage -Level ERROR -Message "掛載點已存在: $MountPoint (Windows 下 rclone 要求掛載點必須不存在)"
return $false
}
# 檢查父目錄是否存在
$parentPath = Split-Path $MountPoint -Parent
if (-not $parentPath) {
Write-ColorMessage -Level ERROR -Message "無效的掛載點路徑: $MountPoint"
return $false
}
if (-not (Test-Path $parentPath)) {
Write-ColorMessage -Level INFO -Message "建立父目錄: $parentPath"
try {
New-Item -Path $parentPath -ItemType Directory -Force | Out-Null
} catch {
Write-ColorMessage -Level ERROR -Message "無法建立父目錄: $parentPath - $_"
return $false
}
}
Write-ColorMessage -Level INFO -Message "掛載點路徑驗證通過: $MountPoint"
return $true
}
<#
.SYNOPSIS
查找指定 host alias 的所有掛載點
返回格式: @{RemotePath=''; MountPoint=''}
#>
function Find-MountPoints {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias
)
$mountPoints = @()
$rcloneProcesses = Get-Process -Name rclone -ErrorAction SilentlyContinue
if (-not $rcloneProcesses) {
return ,$mountPoints
}
foreach ($process in $rcloneProcesses) {
try {
$cmdLine = (Get-CimInstance Win32_Process -Filter "ProcessId = $($process.Id)").CommandLine
# 檢查是否為 mount 操作
if ($cmdLine -notlike "*mount*") {
continue
}
# 解析命令列,提取 remote source 和 mount point
# 格式: rclone mount "remote:path" "mount_point" [options]
# 或: rclone mount remote:path mount_point [options]
$pattern = 'mount\s+"?([^":]+):([^"]*)"?\s+"?([^"]+)"?'
if ($cmdLine -match $pattern) {
$remote = $Matches[1]
$remotePath = $Matches[2]
$mountPoint = $Matches[3]
if ($remote -eq $HostAlias) {
$mountPoints += [PSCustomObject]@{
RemotePath = $remotePath
MountPoint = $mountPoint
ProcessId = $process.Id
}
}
}
} catch {
# 忽略錯誤
}
}
return ,$mountPoints
}
<#
.SYNOPSIS
查找匹配 host alias 和掛載點的 rclone 進程 PID
#>
function Find-RclonePids {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias,
[Parameter()]
[string]$MountPoint
)
$pids = @()
$rcloneProcesses = Get-Process -Name rclone -ErrorAction SilentlyContinue
if (-not $rcloneProcesses) {
return ,$pids
}
foreach ($process in $rcloneProcesses) {
try {
$cmdLine = (Get-CimInstance Win32_Process -Filter "ProcessId = $($process.Id)").CommandLine
# 檢查是否匹配 host alias
if ($cmdLine -notlike "*$HostAlias*") {
continue
}
# 如果指定了掛載點,還要匹配掛載點
if ($MountPoint -and $cmdLine -notlike "*$MountPoint*") {
continue
}
$pids += $process.Id
} catch {
# 忽略錯誤
}
}
return ,$pids
}
# ==================== 核心函數: 掛載操作 ====================
<#
.SYNOPSIS
核心掛載函數(單一 host 的掛載邏輯)
#>
function Invoke-MountSingle {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias,
[Parameter()]
[string]$RemotePath = '',
[Parameter(Mandatory)]
[string]$MountPoint
)
# 取得 SSH Host 設定
$sshConfig = Get-SSHHostConfig -HostAlias $HostAlias
# 組建 --sftp-ssh 選項
$sftpSshOption = Get-SftpSshOption -Config $sshConfig
# 組合遠端來源路徑
$remoteSource = if ($RemotePath) {
"${HostAlias}:$RemotePath"
} else {
"${HostAlias}:"
}
# 檢查目標掛載點是否被占用
if (Test-Mounted -MountPoint $MountPoint) {
Write-ColorMessage -Level ERROR -Message "掛載點 $MountPoint 已被其他檔案系統占用"
return $false
}
# 確保掛載點路徑有效(父目錄存在,但掛載點本身不存在)
if (-not (Ensure-MountPoint -MountPoint $MountPoint)) {
return $false
}
# 在掛載前先建立 rclone config
Write-ColorMessage -Level INFO -Message "建立 rclone config: $HostAlias"
try {
$configResult = & rclone config create $HostAlias sftp 2>&1
if ($LASTEXITCODE -ne 0) {
Write-ColorMessage -Level ERROR -Message "建立 rclone config 失敗: $configResult"
return $false
}
} catch {
Write-ColorMessage -Level ERROR -Message "建立 rclone config 失敗: $_"
return $false
}
# 建構 rclone 參數
$argString = "mount `"$remoteSource`" `"$MountPoint`" --vfs-cache-mode full --default-permissions --multi-thread-streams 4 --multi-thread-cutoff 100M --low-level-retries 2 --sftp-disable-hashcheck --sftp-ssh `"$sftpSshOption`""
Write-ColorMessage -Level INFO -Message "掛載指令: rclone $argString"
Write-ColorMessage -Level INFO -Message "提示: 如果 SSH 需要密碼,請在提示時輸入"
# 執行掛載命令(直接在背景執行)
try {
# 使用 Start-Process 並直接傳遞參數
# 將整個命令字串傳遞,讓 PowerShell 自動處理引號
$process = Start-Process -FilePath "rclone" -ArgumentList $argString -NoNewWindow -PassThru
# 等待掛載完成
$count = 0
while ($count -lt $script:Config.MountTimeout) {
Write-ColorMessage -Level INFO -Message "等待掛載中... ($($count + 1)/$($script:Config.MountTimeout))"
Start-Sleep -Seconds 1
if (Test-Mounted -MountPoint $MountPoint) {
Write-ColorMessage -Level INFO -Message "掛載進程已啟動: ${HostAlias}:$RemotePath -> $MountPoint"
# 等待 rclone 服務完全啟動
# 檢測掛載點是否可訪問,重試幾次
Write-ColorMessage -Level INFO -Message "等待 rclone 服務啟動完成..."
$accessTestCount = 0
$maxAccessTests = 5
$accessSuccess = $false
while ($accessTestCount -lt $maxAccessTests) {
Start-Sleep -Seconds 2
$accessTestCount++
Write-ColorMessage -Level INFO -Message "測試掛載點訪問... (嘗試 $accessTestCount/$maxAccessTests)"
try {
# 嘗試訪問掛載點
if (Test-Path $MountPoint) {
$testResult = Get-ChildItem -Path $MountPoint -ErrorAction Stop | Select-Object -First 1
Write-ColorMessage -Level INFO -Message "✓ 掛載點測試通過,日誌: $($script:LogFile)"
$accessSuccess = $true
break
}
} catch {
Write-ColorMessage -Level DEBUG -Message "訪問測試失敗 (嘗試 $accessTestCount/$maxAccessTests): $_"
# 繼續重試
}
}
if ($accessSuccess) {
# 掛載成功後清除 rclone config
Write-ColorMessage -Level INFO -Message "清除 rclone config: $HostAlias"
& rclone config delete $HostAlias 2>&1 | Out-Null
return $true
} else {
Write-ColorMessage -Level ERROR -Message "✗ 掛載點在 $maxAccessTests 次嘗試後仍無法訪問,掛載失敗"
Write-ColorMessage -Level WARN -Message "執行清理操作..."
# 執行清理
$rclonePids = Find-RclonePids -HostAlias $HostAlias -MountPoint $MountPoint
foreach ($procId in $rclonePids) {
Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
}
# 清除 rclone config
Write-ColorMessage -Level INFO -Message "清除 rclone config: $HostAlias"
& rclone config delete $HostAlias 2>&1 | Out-Null
return $false
}
}
$count++
}
Write-ColorMessage -Level ERROR -Message "掛載超時,請檢查日誌: $($script:LogFile)"
Write-ColorMessage -Level WARN -Message "執行清理操作..."
# 執行清理
$rclonePids = Find-RclonePids -HostAlias $HostAlias -MountPoint $MountPoint
foreach ($procId in $rclonePids) {
Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
}
# 清除 rclone config
Write-ColorMessage -Level INFO -Message "清除 rclone config: $HostAlias"
& rclone config delete $HostAlias 2>&1 | Out-Null
return $false
} catch {
Write-ColorMessage -Level ERROR -Message "rclone mount 命令執行失敗: $_"
Write-ColorMessage -Level WARN -Message "執行清理操作..."
# 清除 rclone config
Write-ColorMessage -Level INFO -Message "清除 rclone config: $HostAlias"
& rclone config delete $HostAlias 2>&1 | Out-Null
return $false
}
}
<#
.SYNOPSIS
掛載 remote(用戶接口函數)
#>
function Mount-RcloneRemote {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias,
[Parameter()]
[string]$RemotePath = '',
[Parameter()]
[string]$CustomMountPoint = ''
)
# 決定掛載點:自定義 > 預設
$mountPoint = if ($CustomMountPoint) {
$CustomMountPoint
} else {
Join-Path $script:Config.MountBaseDir $HostAlias
}
Write-ColorMessage -Level INFO -Message "使用 --sftp-ssh 選項進行掛載"
Write-ColorMessage -Level INFO -Message "掛載 ${HostAlias}:$RemotePath 到 $mountPoint"
if ($CustomMountPoint) {
Write-ColorMessage -Level INFO -Message "使用自定義掛載點: $CustomMountPoint"
}
# 調用核心掛載函數
return Invoke-MountSingle -HostAlias $HostAlias -RemotePath $RemotePath -MountPoint $mountPoint
}
# ==================== 核心函數: 卸載操作 ====================
<#
.SYNOPSIS
執行實際的卸載操作
#>
function Invoke-Unmount {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$MountPoint,
[Parameter(Mandatory)]
[string]$HostAlias
)
Write-ColorMessage -Level INFO -Message "卸載 $MountPoint"
# 查找相關的 rclone 進程
$rclonePids = Find-RclonePids -HostAlias $HostAlias -MountPoint $MountPoint
if ($rclonePids.Count -eq 0) {
Write-ColorMessage -Level WARN -Message "找不到相關的 rclone 進程"
return $false
}
Write-ColorMessage -Level INFO -Message "發現相關的 rclone 進程: $($rclonePids -join ', ')"
Write-ColorMessage -Level INFO -Message "正在終止進程..."
# 先嘗試正常終止
foreach ($procId in $rclonePids) {
try {
Stop-Process -Id $procId -ErrorAction Stop
} catch {
Write-ColorMessage -Level WARN -Message "無法終止進程 $procId,嘗試強制終止"
Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
}
}
Start-Sleep -Seconds 2
Write-ColorMessage -Level INFO -Message "✓ rclone 進程已終止"
# Windows 下卸載後不刪除掛載點目錄
# 因為 rclone 在掛載前就要求路徑不存在,卸載後也不該主動刪除
# 如果目錄存在,表示可能有其他用途或是遺留檔案
if (Test-Path $MountPoint) {
Write-ColorMessage -Level INFO -Message "掛載點目錄仍存在: $MountPoint (未自動刪除)"
} else {
Write-ColorMessage -Level INFO -Message "掛載點已清除: $MountPoint"
}
return $true
}
<#
.SYNOPSIS
卸載 remote
#>
function Dismount-RcloneRemote {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias,
[Parameter()]
[string]$CustomMountPoint = ''
)
# 查找所有掛載點
$mountInfo = Find-MountPoints -HostAlias $HostAlias
if ($mountInfo.Count -eq 0) {
Write-ColorMessage -Level WARN -Message "$HostAlias 未掛載"
# 檢查是否還有相關的 rclone 進程在運行
$rclonePids = Find-RclonePids -HostAlias $HostAlias
if ($rclonePids.Count -gt 0) {
Write-ColorMessage -Level INFO -Message "發現殘留的 rclone 進程: $($rclonePids -join ', ')"
Write-ColorMessage -Level INFO -Message "正在終止進程..."
foreach ($procId in $rclonePids) {
Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
}
Start-Sleep -Seconds 2
Write-ColorMessage -Level INFO -Message "✓ rclone 進程已終止"
}
return $true
}
# 如果指定了掛載點,只卸載匹配的
if ($CustomMountPoint) {
$found = $false
foreach ($mount in $mountInfo) {
if ($mount.MountPoint -eq $CustomMountPoint) {
$found = $true
Invoke-Unmount -MountPoint $mount.MountPoint -HostAlias $HostAlias
break
}
}
if (-not $found) {
Write-ColorMessage -Level ERROR -Message "找不到掛載的路徑: $HostAlias $CustomMountPoint"
Write-ColorMessage -Level INFO -Message "當前 $HostAlias 的掛載:"
foreach ($mount in $mountInfo) {
$pathDisplay = if ($mount.RemotePath) { $mount.RemotePath } else { "~" }
Write-ColorMessage -Level INFO -Message " [$pathDisplay] → $($mount.MountPoint)"
}
return $false
}
} else {
# 未指定掛載點,逐筆詢問確認
Write-ColorMessage -Level INFO -Message "找到 $HostAlias 的 $($mountInfo.Count) 個掛載:"
foreach ($mount in $mountInfo) {
$pathDisplay = if ($mount.RemotePath) { $mount.RemotePath } else { "~" }
Write-Host ""
Write-ColorMessage -Level INFO -Message "掛載: [$pathDisplay] on $($mount.MountPoint)"
$answer = Read-Host "是否要卸載此掛載? [y/N]"
if ($answer -match '^[Yy]$') {
Invoke-Unmount -MountPoint $mount.MountPoint -HostAlias $HostAlias
} else {
Write-ColorMessage -Level INFO -Message "跳過 $($mount.MountPoint)"
}
}
}
return $true
}
# ==================== 核心函數: 狀態檢查 ====================
<#
.SYNOPSIS
檢查掛載狀態
#>
function Get-RcloneStatus {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias,
[Parameter()]
[string]$RemotePath = ''
)
Write-ColorMessage -Level INFO -Message "檢查 $HostAlias 的掛載狀態"
# 查找所有掛載點
$mountInfo = Find-MountPoints -HostAlias $HostAlias
if ($mountInfo.Count -eq 0) {
Write-ColorMessage -Level INFO -Message "✗ 未掛載"
return
}
# 如果指定了 RemotePath,只顯示匹配的
if ($RemotePath) {
$found = $false
foreach ($mount in $mountInfo) {
if ($mount.RemotePath -eq $RemotePath) {
$found = $true
Write-ColorMessage -Level INFO -Message "✓ 已掛載: $($mount.MountPoint)"
# 檢查掛載點是否可訪問
try {
$null = Get-ChildItem -Path $mount.MountPoint -ErrorAction Stop
Write-ColorMessage -Level INFO -Message "✓ 掛載點可正常訪問"
} catch {
Write-ColorMessage -Level WARN -Message "⚠ 掛載點無法訪問,可能需要重新掛載"
}
}
}
if (-not $found) {
Write-ColorMessage -Level INFO -Message "✗ 指定路徑 $RemotePath 未掛載"
}
} else {
# 顯示所有掛載
Write-ColorMessage -Level INFO -Message "找到 $($mountInfo.Count) 個掛載:"
foreach ($mount in $mountInfo) {
$pathDisplay = if ($mount.RemotePath) { $mount.RemotePath } else { "~" }
Write-ColorMessage -Level INFO -Message " [$pathDisplay] → $($mount.MountPoint)"
# 檢查掛載點是否可訪問
try {
$null = Get-ChildItem -Path $mount.MountPoint -ErrorAction Stop
Write-ColorMessage -Level INFO -Message " ✓ 可訪問"
} catch {
Write-ColorMessage -Level WARN -Message " ✗ 無法訪問"
}
}
}
}
# ==================== 輔助函數: 批量操作 ====================
<#
.SYNOPSIS
批量掛載所有可用的 SSH hosts
#>
function Mount-AllRemotes {
[CmdletBinding()]
param()
$successCount = 0
$failCount = 0
$failedHosts = @()
Write-ColorMessage -Level INFO -Message "獲取所有可用的 SSH Host..."
# 獲取所有可用的 hosts
$sshConfigPath = $script:Config.SSHConfig
if (-not (Test-Path $sshConfigPath)) {
Write-ColorMessage -Level WARN -Message "SSH config 不存在: $sshConfigPath"
return
}
$content = Get-Content $sshConfigPath
$allHosts = $content | Where-Object { $_ -match '^\s*Host\s+(.+)$' } | ForEach-Object {
$Matches[1] -split '\s+' | Where-Object { $_ -notmatch '\*' -and $_ -ne 'github.com' }
} | Select-Object -Unique | Sort-Object
if ($allHosts.Count -eq 0) {
Write-ColorMessage -Level WARN -Message "沒有可掛載的 SSH Host"
return
}
$totalHosts = $allHosts.Count
Write-ColorMessage -Level INFO -Message "找到 $totalHosts 個 SSH Host,開始批量掛載..."
Write-Host ""
$current = 0
foreach ($sshHost in $allHosts) {
$current++
$mountPoint = Join-Path $script:Config.MountBaseDir $sshHost
# 檢查是否已掛載
if (Test-Mounted -MountPoint $mountPoint) {
Write-ColorMessage -Level INFO -Message "[$current/$totalHosts] $sshHost 已經掛載在 $mountPoint,跳過"
$successCount++
continue
}
Write-ColorMessage -Level INFO -Message "==== [$current/$totalHosts] 掛載 $sshHost ===="
# 使用核心掛載函數
if (Invoke-MountSingle -HostAlias $sshHost -RemotePath '' -MountPoint $mountPoint) {
$successCount++
Write-ColorMessage -Level INFO -Message "✓ $sshHost 掛載成功"
} else {
$failCount++
$failedHosts += $sshHost
Write-ColorMessage -Level WARN -Message "✗ $sshHost 掛載失敗,繼續下一個"
}
Write-Host ""
}
# 顯示統計
Write-Host ""
Write-ColorMessage -Level INFO -Message "==== 批量掛載完成 ===="
Write-ColorMessage -Level INFO -Message "成功: $successCount, 失敗: $failCount, 總計: $totalHosts"
if ($failCount -gt 0) {
Write-ColorMessage -Level WARN -Message "失敗的 hosts: $($failedHosts -join ', ')"
}
}
<#
.SYNOPSIS
批量卸載所有 rclone 掛載
#>
function Dismount-AllRemotes {
[CmdletBinding()]
param()
$successCount = 0
$failCount = 0
$failedMounts = @()
Write-ColorMessage -Level INFO -Message "檢查所有 rclone 掛載..."
# 獲取所有 rclone 進程
$rcloneProcesses = Get-Process -Name rclone -ErrorAction SilentlyContinue
if (-not $rcloneProcesses) {
Write-ColorMessage -Level INFO -Message "目前沒有 rclone 掛載"
return
}
$allMounts = @()
foreach ($process in $rcloneProcesses) {
try {
$cmdLine = (Get-CimInstance Win32_Process -Filter "ProcessId = $($process.Id)").CommandLine
# 使用 -match 搭配變數來避免解析問題
$pattern = 'mount\s+"?([^":]+):([^"]*)"?\s+"?([^"]+)"?'
if ($cmdLine -match $pattern) {
$allMounts += [PSCustomObject]@{
HostAlias = $Matches[1]
RemotePath = $Matches[2]
MountPoint = $Matches[3]
ProcessId = $process.Id
}
}
} catch {
# 忽略錯誤
}
}
if ($allMounts.Count -eq 0) {
Write-ColorMessage -Level INFO -Message "目前沒有 rclone 掛載"
return
}
$totalMounts = $allMounts.Count
Write-ColorMessage -Level INFO -Message "找到 $totalMounts 個 rclone 掛載,開始批量卸載..."
Write-Host ""
$current = 0
foreach ($mount in $allMounts) {
$current++
Write-ColorMessage -Level INFO -Message "==== [$current/$totalMounts] 卸載 $($mount.MountPoint) ===="
# 使用核心卸載函數
if (Invoke-Unmount -MountPoint $mount.MountPoint -HostAlias $mount.HostAlias) {
$successCount++
Write-ColorMessage -Level INFO -Message "✓ $($mount.MountPoint) 卸載成功"
} else {
$failCount++
$failedMounts += $mount.MountPoint
Write-ColorMessage -Level WARN -Message "✗ $($mount.MountPoint) 卸載失敗,繼續下一個"
}
Write-Host ""
}
# 顯示統計
Write-Host ""
Write-ColorMessage -Level INFO -Message "==== 批量卸載完成 ===="
Write-ColorMessage -Level INFO -Message "成功: $successCount, 失敗: $failCount, 總計: $totalMounts"
if ($failCount -gt 0) {
Write-ColorMessage -Level WARN -Message "失敗的掛載點: $($failedMounts -join ', ')"
}
}
# ==================== 輔助函數: 其他功能 ====================
<#
.SYNOPSIS
顯示所有掛載中的 rclone
#>
function Show-RcloneMounts {
[CmdletBinding()]
param()
Write-ColorMessage -Level INFO -Message "檢查所有 rclone 掛載..."
Write-Host ""
$rcloneProcesses = Get-Process -Name rclone -ErrorAction SilentlyContinue
if (-not $rcloneProcesses) {
Write-ColorMessage -Level INFO -Message "目前沒有 rclone 掛載"
return
}
Write-Host "=== 已掛載的 rclone 遠端 ===" -ForegroundColor Green
Write-Host ""
foreach ($process in $rcloneProcesses) {
try {
$cmdLine = (Get-CimInstance Win32_Process -Filter "ProcessId = $($process.Id)").CommandLine
$pattern = 'mount\s+"?([^":]+):([^"]*)"?\s+"?([^"]+)"?'
if ($cmdLine -match $pattern) {
$remote = $Matches[1]
$remotePath = $Matches[2]
$mountPoint = $Matches[3]
Write-Host "Remote: " -ForegroundColor Yellow -NoNewline
Write-Host $remote
Write-Host "路徑: " -ForegroundColor Yellow -NoNewline
Write-Host $(if ($remotePath) { $remotePath } else { "~" })
Write-Host "掛載點: " -ForegroundColor Yellow -NoNewline
Write-Host $mountPoint
# 測試掛載點訪問
try {
$null = Get-ChildItem -Path $mountPoint -ErrorAction Stop
Write-Host "狀態: " -ForegroundColor Yellow -NoNewline
Write-Host "✓ 可訪問" -ForegroundColor Green
} catch {
Write-Host "狀態: " -ForegroundColor Yellow -NoNewline
Write-Host "✗ 無法訪問" -ForegroundColor Red
}
Write-Host ""
}
} catch {
# 忽略錯誤
}
}
}
<#
.SYNOPSIS
獲取可用的 SSH Host 清單
#>
function Get-AvailableRemotes {
[CmdletBinding()]
param()
$sshConfigPath = $script:Config.SSHConfig
if (-not (Test-Path $sshConfigPath)) {
return
}
$content = Get-Content $sshConfigPath
$sshHosts = $content | Where-Object { $_ -match '^\s*Host\s+(.+)$' } | ForEach-Object {
$Matches[1] -split '\s+' | Where-Object { $_ -notmatch '\*' -and $_ -ne 'github.com' }
} | Select-Object -Unique | Sort-Object
foreach ($sshHost in $sshHosts) {
# 檢查此 host 是否已掛載
$mountInfo = Find-MountPoints -HostAlias $sshHost
if ($mountInfo.Count -gt 0) {
$firstMount = $mountInfo[0].MountPoint
if ($mountInfo.Count -gt 1) {
$status = "已掛載 {0} 個: {1} ..." -f $mountInfo.Count, $firstMount
Write-Host "$sshHost [$status]" -ForegroundColor Yellow
} else {
$status = "已掛載: {0}" -f $firstMount
Write-Host "$sshHost [$status]" -ForegroundColor Yellow
}
} else {
Write-Host $sshHost
}
}
}
<#
.SYNOPSIS
顯示使用說明
#>
function Show-Help {
[CmdletBinding()]
param()
Write-Host "rclone Mount 管理工具" -ForegroundColor Cyan
Write-Host ""
Write-Host "使用方式:" -ForegroundColor Yellow
Write-Host ' .\rclonemm.ps1 <operation> <remote_name>[:<remote_path>] [custom_mount_point]'
Write-Host ' .\rclonemm.ps1 <operation> --all|--a'
Write-Host ""
Write-Host "參數:" -ForegroundColor Yellow
Write-Host " operation 操作類型"
Write-Host " - mount/mnt: 掛載遠端儲存"
Write-Host " - unmount/umount/umnt: 卸載掛載點"
Write-Host " - status: 檢查掛載狀態"
Write-Host " - show: 顯示所有掛載中的 rclone"
Write-Host " - help: 顯示此說明"
Write-Host " remote_name SSH config 中的 Host 名稱"
Write-Host " remote_path 選填,遠端路徑(僅用於 mount 命令)"
Write-Host " custom_mount_point 選填,自定義本地掛載點(僅用於 mount 命令)"
Write-Host " --all, --a 批量操作所有 hosts(mount/unmount 支援)"
Write-Host ""
Write-Host "單一掛載範例:" -ForegroundColor Yellow
Write-Host " .\rclonemm.ps1 mount mom # 掛載 mom 根目錄到預設位置"
Write-Host " .\rclonemm.ps1 mount mom:/WEB/logs # 掛載 mom 指定目錄到預設位置"
Write-Host " .\rclonemm.ps1 mount mom D:\custom\path # 掛載 mom 根目錄到自定義位置"
Write-Host " .\rclonemm.ps1 mount mom:/WEB D:\custom\path # 掛載 mom 指定目錄到自定義位置"
Write-Host " .\rclonemm.ps1 unmount mom # 卸載 mom(自動找到掛載點)"
Write-Host " .\rclonemm.ps1 status mom # 檢查 mom 狀態(自動找到掛載點)"
Write-Host ""
Write-Host "批量操作範例:" -ForegroundColor Yellow
Write-Host " .\rclonemm.ps1 mount --all # 掛載所有 SSH Host(失敗自動跳過)"
Write-Host " .\rclonemm.ps1 mount --a # 同上(簡寫)"
Write-Host " .\rclonemm.ps1 unmount --all # 卸載所有 rclone 掛載"
Write-Host " .\rclonemm.ps1 umount --a # 同上(簡寫)"
Write-Host ""
Write-Host "其他範例:" -ForegroundColor Yellow
Write-Host " .\rclonemm.ps1 show # 顯示所有掛載"
Write-Host " .\rclonemm.ps1 help # 顯示此說明"
Write-Host ""
Write-Host "可用的 SSH Hosts:" -ForegroundColor Yellow
Get-AvailableRemotes
}
<#
.SYNOPSIS
檢查 SSH Host 是否存在於設定檔中
#>
function Test-RemoteExists {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias
)
$sshConfigPath = $script:Config.SSHConfig
if (-not (Test-Path $sshConfigPath)) {
Write-ColorMessage -Level ERROR -Message "SSH config 不存在: $sshConfigPath"
return $false
}
$content = Get-Content $sshConfigPath
$found = $content | Where-Object { $_ -match "^\s*Host\s+.*\b$HostAlias\b" }
if (-not $found) {
Write-ColorMessage -Level ERROR -Message "SSH Host '$HostAlias' 不存在於 SSH config 中"
Write-Host "可用的 Host: " -NoNewline
Get-AvailableRemotes
return $false
}
return $true
}
# ==================== 主程式 ====================
function Main {
# 檢查依賴
Test-Dependencies
# 清理過期日誌
Clear-OldLogs
# 檢查參數
if (-not $Operation) {
Write-ColorMessage -Level ERROR -Message "缺少必要參數"
Show-Help
exit 1
}
# 處理 help 操作
if ($Operation -in @('help', '-h', '--help')) {
Show-Help
exit 0
}
# 處理 show 操作(不需要 remote 名稱)
if ($Operation -eq 'show') {
Show-RcloneMounts
exit 0
}
# 處理批量操作(--all 或 -a)
if ($RemoteSpec -in @('--all', '--a')) {
# 確保日誌目錄存在
$logDir = Split-Path $script:LogFile -Parent
if (-not (Test-Path $logDir)) {
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}
Write-ColorMessage -Level INFO -Message "批量操作: $Operation 所有 remotes,日誌: $($script:LogFile)"
switch ($Operation) {
{ $_ -in @('mount', 'mnt') } {
Mount-AllRemotes
exit 0
}
{ $_ -in @('unmount', 'umount', 'umnt') } {
Dismount-AllRemotes
exit 0
}
default {
Write-ColorMessage -Level ERROR -Message "操作 '$Operation' 不支援 --all/--a 參數"
Write-ColorMessage -Level INFO -Message "支援 --all/--a 的操作: mount, unmount"
exit 1
}
}
}
# 檢查是否提供 remote 規格
if (-not $RemoteSpec) {
Write-ColorMessage -Level ERROR -Message "缺少 SSH Host 名稱參數或 --all/--a 選項"
Show-Help
exit 1
}
# 解析 host alias 和路徑
# 格式: host_alias 或 host_alias:path
$hostAlias = ''
$remotePath = ''
$pattern = '^(.+?):(.*)$'
if ($RemoteSpec -match $pattern) {
$hostAlias = $Matches[1]
$remotePath = $Matches[2]
} else {
$hostAlias = $RemoteSpec
$remotePath = ''
}
# 檢查 SSH Host 是否存在
if (-not (Test-RemoteExists -HostAlias $hostAlias)) {
exit 1
}
# 確保日誌目錄存在
$logDir = Split-Path $script:LogFile -Parent
if (-not (Test-Path $logDir)) {
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}
# 執行對應操作
Write-ColorMessage -Level INFO -Message "操作: $Operation,日誌: $($script:LogFile)"
switch ($Operation) {
{ $_ -in @('mount', 'mnt') } {
Mount-RcloneRemote -HostAlias $hostAlias -RemotePath $remotePath -CustomMountPoint $CustomMountPoint
}
{ $_ -in @('unmount', 'umount', 'umnt') } {
Dismount-RcloneRemote -HostAlias $hostAlias -CustomMountPoint $CustomMountPoint
}
'status' {
Get-RcloneStatus -HostAlias $hostAlias -RemotePath $remotePath
}
default {
Write-ColorMessage -Level ERROR -Message "未知操作: $Operation"
Show-Help
exit 1
}
}
}
# 執行主程式
Main
#!/bin/bash
# rclone mount 管理腳本
# 功能: mount, unmount, status, help
# 使用方式: rclonemm [operation] <remote_name>[:<remote_path>] [custom_mount_point]
# 範例:rclonemm mount mom:/WEB/logs /custom/path
# 作者: GitHub Copilot
# 日期: 2025-11-04
# 更新: 2025-11-12 - 新增自定義掛載點支援
# 更新: 2025-12-19 - 改用 SSH config,不再依賴 rclone.conf
set -euo pipefail
# 常數
# 設定檔案路徑
# 根據環境變數設定掛載基礎目錄
if [[ -n "${PREFIX:-}" ]]; then
echo "使用自定義 PREFIX: $PREFIX"
else
PREFIX=""
fi
SSH_CONFIG="$HOME/.ssh/config"
MOUNT_BASE_DIR="$PREFIX/mnt"
LOG_DIR="$PREFIX/var/log"
LOG_FILE="${LOG_DIR}/rclone_mount_$(date '+%Y%m%d').log"
# 變數
# 日誌保留天數
LOG_RETENTION_DAYS=7
# 掛載超時設定(秒)
MOUNT_TIMEOUT=10
ACCESS_TEST_TIMEOUT=2
# 獲取當前用戶的 UID 和 GID
USER="${USER:-$(whoami)}"
USER_UID=$(id -u "$USER")
USER_GID=$(id -g "$USER")
# 基本 RCLONE 參數
RCLONE_MOUNT_OPTIONS="--allow-other \
--vfs-cache-mode full \
--default-permissions \
--uid $USER_UID \
--gid $USER_GID \
--multi-thread-streams 4 \
--multi-thread-cutoff 100M \
--low-level-retries 2 \
--log-level ERROR \
--log-file $LOG_FILE"
# 基本 SSH 命令
ssh_cmd="ssh -o StrictHostKeyChecking=no -o ControlMaster=auto -o ControlPath=~/.ssh/cm-%r@%h:%p -o ControlPersist=10 -o ConnectTimeout=2"
# 依賴檢查函數
check_dependencies() {
local missing_deps=()
# 檢查 rclone
if ! command -v rclone >/dev/null 2>&1; then
missing_deps+=("rclone")
fi
# 檢查 fusermount 或 fusermount3
if ! command -v fusermount >/dev/null 2>&1 && ! command -v fusermount3 >/dev/null 2>&1; then
missing_deps+=("fusermount (fuse)")
fi
# 檢查 sudo
if ! command -v sudo >/dev/null 2>&1; then
missing_deps+=("sudo")
fi
# 如果有缺失的依賴,顯示錯誤並退出
if [[ ${#missing_deps[@]} -gt 0 ]]; then
echo -e "${RED}[ERROR]${NC} 缺少必要的依賴套件:"
for dep in "${missing_deps[@]}"; do
echo -e " ${RED}✗${NC} $dep"
done
echo
echo -e "${YELLOW}請安裝缺少的套件:${NC}"
echo " Debian/Ubuntu: sudo apt-get install rclone fuse3 sudo"
echo " Alpine: apk add rclone fuse3 sudo"
echo " Termux: pkg install rclone libfuse3 tsu"
exit 1
fi
}
# 檢查 ps 命令是否可用
has_ps_command() {
command -v ps >/dev/null 2>&1
}
# 安全地執行 ps 命令
safe_ps() {
if has_ps_command; then
safe_ps 2>/dev/null || ps -ef 2>/dev/null
else
# 沒有 ps 命令,返回空
return 1
fi
}
# === SSH Config 解析函數 ===
# 解析 SSH config 獲取指定 Host 的指定屬性
parse_ssh_config() {
local host_alias="$1"
local key="$2"
awk -v host="$host_alias" -v key="$key" '
tolower($1) == "host" {
for (i = 2; i <= NF; i++) {
if ($i == host) {
found = 1
next
}
}
found = 0
}
found && tolower($1) == tolower(key) {
print $2
exit
}
' "$SSH_CONFIG"
}
# 獲取 SSH Host 的完整設定
get_ssh_host_config() {
local host_alias="$1"
local hostname user port identity_file proxy_jump
hostname=$(parse_ssh_config "$host_alias" "HostName")
user=$(parse_ssh_config "$host_alias" "User")
port=$(parse_ssh_config "$host_alias" "Port")
identity_file=$(parse_ssh_config "$host_alias" "IdentityFile")
proxy_jump=$(parse_ssh_config "$host_alias" "ProxyJump")
# 如果沒有明確的 HostName,使用 Host alias
hostname="${hostname:-$host_alias}"
# 展開 ~ 為實際路徑
if [[ -n "$identity_file" ]]; then
identity_file="${identity_file/#\~/$HOME}"
fi
# 輸出格式: hostname|user|port|identity_file|proxy_jump
echo "$hostname|$user|$port|$identity_file|$proxy_jump"
}
# 組建 rclone SFTP backend 字串(使用 key_file)
# 參數: $1 = host_alias 或 config_data
build_sftp_backend_with_key() {
local input="$1"
local config_data hostname user port key_file proxy_jump
# 判斷輸入是 host_alias 還是 config_data
if [[ "$input" == *"|"* ]]; then
# 已經是 config_data
config_data="$input"
else
# 是 host_alias,需要取得 config
config_data=$(get_ssh_host_config "$input")
fi
IFS='|' read -r hostname user port key_file proxy_jump <<< "$config_data"
# 基本 backend 字串
local backend=":sftp,host=$hostname"
# 添加可選參數
[[ -n "$user" ]] && backend="$backend,user=$user"
[[ -n "$port" ]] && backend="$backend,port=$port"
[[ -n "$key_file" ]] && backend="$backend,key_file=$key_file"
echo "$backend"
}
# 組建 rclone SFTP backend 字串(不使用 key_file,配合 --sftp-ssh)
# 參數: $1 = host_alias 或 config_data
build_sftp_backend_without_key() {
local input="$1"
local config_data hostname user port key_file proxy_jump
# 判斷輸入是 host_alias 還是 config_data
if [[ "$input" == *"|"* ]]; then
# 已經是 config_data
config_data="$input"
else
# 是 host_alias,需要取得 config
config_data=$(get_ssh_host_config "$input")
fi
IFS='|' read -r hostname user port key_file proxy_jump <<< "$config_data"
# 基本 backend 字串
local backend=":sftp,host=$hostname"
# 添加可選參數
[[ -n "$user" ]] && backend="$backend,user=$user"
[[ -n "$port" ]] && backend="$backend,port=$port"
echo "$backend"
}
# 檢查是否有 key file
# 參數: $1 = host_alias 或 config_data
has_key_file() {
local input="$1"
local config_data key_file
# 判斷輸入是 host_alias 還是 config_data
if [[ "$input" == *"|"* ]]; then
# 已經是 config_data
config_data="$input"
else
# 是 host_alias,需要取得 config
config_data=$(get_ssh_host_config "$input")
fi
IFS='|' read -r _ _ _ key_file _ <<< "$config_data"
[[ -n "$key_file" ]] && [[ -f "$key_file" ]]
}
# 組建 --sftp-ssh 選項
# 參數: $1 = host_alias 或 config_data
build_sftp_ssh_option() {
local input="$1"
local config_data hostname user port key_file proxy_jump
# 判斷輸入是 host_alias 還是 config_data
if [[ "$input" == *"|"* ]]; then
# 已經是 config_data
config_data="$input"
else
# 是 host_alias,需要取得 config
config_data=$(get_ssh_host_config "$input")
fi
IFS='|' read -r hostname user port key_file proxy_jump <<< "$config_data"
# 添加 Port
if [[ -n "$port" ]]; then
ssh_cmd="$ssh_cmd -p $port"
fi
# 添加 IdentityFile
if [[ -n "$key_file" ]]; then
ssh_cmd="$ssh_cmd -i $key_file"
fi
# 添加 ProxyJump
if [[ -n "$proxy_jump" ]]; then
ssh_cmd="$ssh_cmd -J $proxy_jump"
fi
# 組建完整的 SSH 連線字串 user@hostname
local ssh_target=""
if [[ -n "$user" ]]; then
ssh_target="${user}@${hostname}"
else
ssh_target="${hostname}"
fi
echo "--sftp-ssh \"$ssh_cmd $ssh_target\""
}
# === 掛載點查找函數 ===
# 通過 mount 命令查找指定 host_alias 的所有掛載點
# 返回格式:每行包含 "remote_path|mount_point"
find_mount_points_by_mount() {
local host_alias="$1"
# 獲取 hostname
# local hostname=$(parse_ssh_config "$host_alias" "HostName")
# hostname="${hostname:-$host_alias}"
# 從 mount 輸出中查找所有匹配的掛載
# 支援格式:hostname: 或 hostname{xxx}:
mount | grep "type fuse.rclone" 2>/dev/null | while read -r line; do
[[ -z "$line" ]] && continue
local remote_part
remote_part=$(echo "$line" | awk '{print $1}')
local mount_point
mount_point=$(echo "$line" | awk '{print $3}')
# 檢查 remote_part 是否以 hostname 開頭
# 支援 hostname: 或 hostname{xxx}: 格式
if [[ "$remote_part" =~ ^${host_alias}(\{[^}]+\})?:(.*)$ ]]; then
# 提取 remote_path(冒號後的部分)
local remote_path="${BASH_REMATCH[2]}"
# 輸出格式:remote_path|mount_point
echo "${remote_path}|${mount_point}"
fi
done || true
}
# 查找匹配 host_alias 和掛載點的 rclone 進程 PID
# 參數: $1 = host_alias, $2 = mount_point (可選)
find_rclone_pids() {
local host_alias="$1"
local mount_point="${2:-}"
if [[ -n "$mount_point" ]]; then
# 同時匹配 host_alias 和掛載點
safe_ps | grep "rclone.*$host_alias" | grep "$mount_point" | grep -v grep | grep -v "rclonemm" | awk '{print $1}' | grep -E '^[0-9]+$'
else
# 只匹配 host_alias
safe_ps | grep "rclone.*$host_alias" | grep -v grep | grep -v "rclonemm" | awk '{print $1}' | grep -E '^[0-9]+$'
fi
}
# 顏色定義
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 清理過期日誌
cleanup_old_logs() {
# 確保日誌目錄存在
if [[ ! -d "$LOG_DIR" ]]; then
return 0
fi
# 刪除超過保留天數的日誌檔案
# 格式: rclone_mount_YYYYMMDD.log
sudo find "$LOG_DIR" -name "rclone_mount_*.log" -type f -mtime +${LOG_RETENTION_DAYS} -delete 2>/dev/null || true
}
# 顯示使用說明
show_help() {
echo -e "${BLUE}rclone Mount 管理工具${NC}"
echo
echo -e "${YELLOW}使用方式:${NC}"
echo " $(basename "$0") <operation> <remote_name>[:<remote_path>] [custom_mount_point]"
echo " $(basename "$0") <operation> --all|-a"
echo
echo -e "${YELLOW}參數:${NC}"
echo " operation 操作類型"
echo " - mount/mnt: 掛載遠端儲存"
echo " - unmount/umount/umnt: 卸載掛載點"
echo " - status: 檢查掛載狀態"
echo " - show: 顯示所有掛載中的rclone"
echo " - help: 顯示此說明"
echo " remote_name SSH config 中的 Host 名稱"
echo " remote_path 選填,遠端路徑(僅用於mount命令)"
echo " custom_mount_point 選填,自定義本地掛載點(僅用於mount命令)"
echo " --all, -a 批量操作所有hosts(mount/unmount支援)"
echo
echo -e "${YELLOW}單一掛載範例:${NC}"
echo " $(basename "$0") mount mom # 掛載mom根目錄到預設位置"
echo " $(basename "$0") mount mom:/WEB/logs # 掛載mom指定目錄到預設位置"
echo " $(basename "$0") mount mom /custom/path # 掛載mom根目錄到自定義位置"
echo " $(basename "$0") mount mom:/WEB /custom/path # 掛載mom指定目錄到自定義位置"
echo " $(basename "$0") unmount mom # 卸載mom(自動找到掛載點)"
echo " $(basename "$0") status mom # 檢查mom狀態(自動找到掛載點)"
echo
echo -e "${YELLOW}批量操作範例:${NC}"
echo " $(basename "$0") mount --all # 掛載所有SSH Host(失敗自動跳過)"
echo " $(basename "$0") mount -a # 同上(簡寫)"
echo " $(basename "$0") unmount --all # 卸載所有rclone掛載"
echo " $(basename "$0") umount -a # 同上(簡寫)"
echo
echo -e "${YELLOW}其他範例:${NC}"
echo " $(basename "$0") show # 顯示所有掛載"
echo " $(basename "$0") help # 顯示此說明"
echo
echo -e "${YELLOW}可用的 SSH Hosts:${NC}"
get_available_remotes
}
# 記錄日誌
log_message() {
local level="$1"
local message="$2"
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] [$level] $message" | sudo tee -a "$LOG_FILE" >/dev/null 2>&1 || true
}
# 輸出訊息
print_message() {
local level="$1"
local message="$2"
local color=""
case "$level" in
"INFO") color="$GREEN" ;;
"WARN") color="$YELLOW" ;;
"ERROR") color="$RED" ;;
"DEBUG") color="$BLUE" ;;
esac
echo -e "${color}[$level]${NC} $message"
# 避免log_message失敗導致腳本退出
log_message "$level" "$message" || true
}
# 獲取可用的 SSH Host 清單
get_available_remotes() {
if [[ ! -f "$SSH_CONFIG" ]]; then
return 1
fi
# 獲取所有 Host 名稱(排除萬用字元和特殊用途)
local hosts
hosts=$(grep "^Host " "$SSH_CONFIG" | awk '{print $2}' | grep -v '\*' | grep -v 'github.com' | sort)
# 逐行處理每個 host
while IFS= read -r host; do
[[ -z "$host" ]] && continue
# 檢查此 host 是否已掛載
local mount_info
mount_info=$(find_mount_points_by_mount "$host")
if [[ -n "$mount_info" ]]; then
# 計算掛載數量
local mount_count
mount_count=$(echo "$mount_info" | wc -l)
# 取得第一個掛載點
local first_mount
first_mount=$(echo "$mount_info" | head -1 | cut -d'|' -f2)
if [[ $mount_count -gt 1 ]]; then
echo -e "$host ${YELLOW}[已掛載 ${mount_count} 個: $first_mount ...]${NC}"
else
echo -e "$host ${YELLOW}[已掛載: $first_mount]${NC}"
fi
else
echo "$host"
fi
done <<< "$hosts"
}
# 檢查 SSH Host 是否存在於設定檔中
check_remote_exists() {
local host_alias="$1"
if [[ ! -f "$SSH_CONFIG" ]]; then
print_message "ERROR" "SSH config 不存在: $SSH_CONFIG"
return 1
fi
if ! grep -q "^Host .*\\b$host_alias\\b" "$SSH_CONFIG"; then
print_message "ERROR" "SSH Host '$host_alias' 不存在於 SSH config 中"
print_message "INFO" "可用的 Host: $(get_available_remotes | tr '\n' ' ')"
return 1
fi
return 0
}
# 檢查並建立掛載點
ensure_mount_point() {
local mount_point="$1"
if [[ ! -d "$mount_point" ]]; then
print_message "INFO" "建立掛載點: $mount_point"
sudo mkdir -p "$mount_point"
sudo chown "$USER:$USER" "$mount_point"
fi
# 檢查目錄權限
if [[ ! -w "$mount_point" ]]; then
print_message "WARN" "掛載點沒有寫入權限,嘗試修正權限"
sudo chown "$USER:$USER" "$mount_point"
fi
}
# 檢查是否已掛載
is_mounted() {
local mount_point="$1"
mount | grep -q " $mount_point " || return 1
return 0
}
# 核心掛載函數(單一 host 的掛載邏輯,包含 timeout 等待)
# 參數: $1 = host_alias, $2 = remote_path (可選), $3 = mount_point
# 返回: 0 成功, 1 失敗
do_mount_single() {
local host_alias="$1"
local remote_path="${2:-}"
local mount_point="$3"
local sftp_ssh_option=""
local remote_source
local config_data
local hostname user port key_file proxy_jump
# 取得 SSH Host 設定
config_data=$(get_ssh_host_config "$host_alias")
IFS='|' read -r hostname user port key_file proxy_jump <<< "$config_data"
# 統一使用 --sftp-ssh 選項進行掛載
sftp_ssh_option=$(build_sftp_ssh_option "$config_data")
# 在掛載前先建立 rclone config(使用 pass 參數避免互動提示)
if ! sudo rclone config create "$host_alias" sftp pass "" >/dev/null 2>&1; then
print_message "ERROR" "建立 rclone config 失敗"
return 1
fi
# 組合遠端來源路徑
if [[ -n "$remote_path" ]]; then
remote_source="${host_alias}:$remote_path"
else
remote_source="${host_alias}:"
fi
# 檢查目標掛載點是否被占用
if is_mounted "$mount_point"; then
print_message "ERROR" "掛載點 $mount_point 已被其他檔案系統占用"
local existing_mount
existing_mount=$(mount | grep " $mount_point ")
print_message "INFO" "現有掛載: $existing_mount"
# 清除 rclone config
sudo rclone config delete "$host_alias" >/dev/null 2>&1 || true
return 1
fi
# 確保掛載點存在
ensure_mount_point "$mount_point"
# 建構rclone指令
local cmd="sudo rclone mount $remote_source $mount_point"
cmd="$cmd $RCLONE_MOUNT_OPTIONS"
cmd="$cmd $sftp_ssh_option"
cmd="$cmd --daemon"
print_message "INFO" "掛載指令:$cmd"
print_message "INFO" "提示: 如果 SSH 需要密碼,請在提示時輸入"
# 執行掛載命令(容錯處理)
# 使用變數捕獲退出碼,避免 set -e 導致腳本退出
# 注意:不恢復 set -e,讓調用者控制錯誤處理行為
local saved_errexit
if [[ $- =~ e ]]; then
saved_errexit=1
else
saved_errexit=0
fi
set +e
eval "$cmd" 2>&1
local mount_exit_code=$?
if [[ $saved_errexit -eq 1 ]]; then
set -e
fi
if [[ $mount_exit_code -ne 0 ]]; then
print_message "ERROR" "rclone mount 命令執行失敗 (退出碼: $mount_exit_code),詳細錯誤請查看日誌: $LOG_FILE"
print_message "WARN" "執行清理操作..."
# 清除 rclone config
print_message "INFO" "清除 rclone config..."
sudo rclone config delete "$host_alias" >/dev/null 2>&1 || true
# 刪除掛載點(如果是預設位置)
if [[ "$mount_point" == "$MOUNT_BASE_DIR/$host_alias" ]] && [[ -d "$mount_point" ]]; then
print_message "INFO" "刪除掛載點: $mount_point"
sudo rmdir "$mount_point" 2>/dev/null || sudo rm -rf "$mount_point" 2>/dev/null || true
fi
return 1
fi
# 等待掛載完成
local count=0
while [[ $count -lt $MOUNT_TIMEOUT ]]; do
print_message "INFO" "等待掛載中... ($((count + 1))/$MOUNT_TIMEOUT)"
sleep 1 || true
if is_mounted "$mount_point"; then
print_message "INFO" "掛載成功: $host_alias:$remote_path -> $mount_point"
# 測試掛載點是否可訪問
print_message "INFO" "測試掛載點訪問..."
if timeout $ACCESS_TEST_TIMEOUT ls "$mount_point" >/dev/null 2>&1; then
print_message "INFO" "✓ 掛載點測試通過,日誌: $LOG_FILE"
# 掛載成功後清除 rclone config
sudo rclone config delete "$host_alias" >/dev/null 2>&1 || true
return 0
else
print_message "ERROR" "✗ 掛載點無法訪問,掛載失敗"
print_message "WARN" "執行清理操作..."
# 執行清理(不調用 unmount_remote 避免循環依賴)
sudo umount "$mount_point" 2>/dev/null || sudo umount -f -l "$mount_point" 2>/dev/null || true
local rclone_pids
rclone_pids=$(find_rclone_pids "$host_alias" "$mount_point" || true)
if [[ -n "$rclone_pids" ]]; then
echo "$rclone_pids" | xargs sudo kill -TERM 2>/dev/null || true
sleep 1 || true
fi
# 清除 rclone config
sudo rclone config delete "$host_alias" >/dev/null 2>&1 || true
return 1
fi
fi
count=$((count + 1))
done
print_message "ERROR" "掛載超時,請檢查日誌: $LOG_FILE"
print_message "WARN" "執行清理操作..."
# 執行清理
sudo umount "$mount_point" 2>/dev/null || sudo umount -f -l "$mount_point" 2>/dev/null || true
local rclone_pids
rclone_pids=$(find_rclone_pids "$host_alias" "$mount_point" || true)
if [[ -n "$rclone_pids" ]]; then
echo "$rclone_pids" | xargs sudo kill -TERM 2>/dev/null || true
sleep 1 || true
fi
# 清除 rclone config
sudo rclone config delete "$host_alias" >/dev/null 2>&1 || true
return 1
}
# 掛載remote(用戶接口函數)
mount_remote() {
local host_alias="$1"
local remote_path="${2:-}"
local custom_mount_point="${3:-}"
local mount_point
# 決定掛載點:自定義 > 預設
if [[ -n "$custom_mount_point" ]]; then
mount_point="$custom_mount_point"
else
mount_point="$MOUNT_BASE_DIR/$host_alias"
fi
print_message "INFO" "使用 --sftp-ssh 選項進行掛載"
print_message "INFO" "掛載 $host_alias:$remote_path 到 $mount_point"
if [[ -n "$custom_mount_point" ]]; then
print_message "INFO" "使用自定義掛載點: $custom_mount_point"
fi
# 調用核心掛載函數
do_mount_single "$host_alias" "$remote_path" "$mount_point"
return $?
}
# 卸載remote
unmount_remote() {
local host_alias="$1"
local custom_mount_point="${2:-}"
# local hostname
# # 獲取 hostname 用於進程識別
# hostname=$(parse_ssh_config "$host_alias" "HostName")
# hostname="${hostname:-$host_alias}"
# 查找所有掛載點
local mount_info
mount_info=$(find_mount_points_by_mount "$host_alias")
if [[ -z "$mount_info" ]]; then
print_message "WARN" "$host_alias 未掛載"
# 檢查是否還有相關的rclone進程在運行
local rclone_pids
rclone_pids=$(find_rclone_pids "$host_alias")
if [[ -n "$rclone_pids" ]]; then
print_message "INFO" "發現殘留的rclone進程: $(echo "$rclone_pids" | tr '\n' ' ')"
print_message "INFO" "正在終止進程..."
echo "$rclone_pids" | xargs sudo kill
sleep 2
print_message "INFO" "✓ rclone進程已終止"
fi
return 0
fi
# 如果指定了 remote_path,檢查是否存在
if [[ -n "$custom_mount_point" ]]; then
local found=false
local remote_path
local target_mount_point=""
while IFS='|' read -r existing_path existing_point; do
if [[ "$existing_point" == "$custom_mount_point" ]]; then
found=true
target_mount_point="$existing_point"
remote_path="$existing_path"
break
fi
done <<< "$mount_info"
if [[ "$found" == false ]]; then
print_message "ERROR" "找不到掛載的路徑: $host_alias $custom_mount_point"
print_message "INFO" "當前 $host_alias 的掛載:"
while IFS='|' read -r existing_path existing_point; do
local path_display="${existing_path:-~}"
print_message "INFO" " [$path_display] → $existing_point"
done <<< "$mount_info"
return 1
fi
# 執行單一卸載
print_message "INFO" "卸載 $host_alias:$remote_path on $custom_mount_point"
perform_unmount "$target_mount_point" "$host_alias"
return $?
else
# 未指定 remote_path,逐筆詢問確認
print_message "INFO" "找到 $host_alias 的 $(echo "$mount_info" | wc -l) 個掛載:"
# 使用 process substitution 避免 subshell,讓 read 可以從 /dev/tty 讀取
while IFS='|' read -r existing_path existing_point; do
local path_display="${existing_path:-~}"
print_message "INFO" ""
print_message "INFO" "掛載: [$path_display] on $existing_point"
echo -n "是否要卸載此掛載? [y/N]: "
# 從 /dev/tty 讀取以確保可以獲取用戶輸入
read -r answer </dev/tty || {
echo
print_message "WARN" "輸入被中斷,取消操作"
return 1
}
if [[ "$answer" =~ ^[Yy]$ ]]; then
perform_unmount "$existing_point" "$host_alias"
else
print_message "INFO" "跳過 $existing_point"
fi
done <<< "$mount_info"
return 0
fi
}
# 執行實際的卸載操作
perform_unmount() {
local mount_point="$1"
local host_alias="$2"
# local hostname="$3"
print_message "INFO" "卸載 $mount_point"
# 嘗試正常卸載
if sudo umount "$mount_point" 2>/dev/null; then
print_message "INFO" "卸載成功: $mount_point"
# 確保相關進程結束
local rclone_pids
rclone_pids=$(find_rclone_pids "$host_alias" "$mount_point" || true)
if [[ -n "$rclone_pids" ]]; then
print_message "INFO" "發現相關的rclone進程: $(echo "$rclone_pids" | tr '\n' ' ')"
print_message "INFO" "正在終止進程..."
echo "$rclone_pids" | xargs sudo kill 2>/dev/null || true
sleep 1 || true
print_message "INFO" "✓ rclone進程已終止"
fi
# 刪除預設掛載點,自定義掛載點嘗試刪除(如果為空)
if [[ "$mount_point" == "$MOUNT_BASE_DIR/$host_alias" ]]; then
if [[ -d "$mount_point" ]]; then
print_message "INFO" "刪除掛載點: $mount_point"
sudo rmdir "$mount_point" 2>/dev/null || sudo rm -rf "$mount_point"
fi
else
# 自定義掛載點:嘗試刪除空目錄,如果不為空則保留
if [[ -d "$mount_point" ]]; then
if sudo rmdir "$mount_point" 2>/dev/null; then
print_message "INFO" "刪除空的自定義掛載點: $mount_point"
else
print_message "INFO" "保留非空的自定義掛載點: $mount_point"
fi
fi
fi
return 0
fi
# 強制卸載
print_message "WARN" "正常卸載失敗,嘗試強制卸載"
if sudo umount -f -l "$mount_point" 2>/dev/null; then
print_message "INFO" "強制卸載成功: $mount_point"
# 確保相關進程結束
local rclone_pids
rclone_pids=$(find_rclone_pids "$host_alias" "$mount_point" || true)
if [[ -n "$rclone_pids" ]]; then
print_message "INFO" "發現相關的rclone進程: $(echo "$rclone_pids" | tr '\n' ' ')"
print_message "INFO" "正在終止進程..."
echo "$rclone_pids" | xargs sudo kill 2>/dev/null || true
sleep 1 || true
print_message "INFO" "✓ rclone進程已終止"
fi
# 刪除預設掛載點,自定義掛載點嘗試刪除(如果為空)
if [[ "$mount_point" == "$MOUNT_BASE_DIR/$host_alias" ]]; then
if [[ -d "$mount_point" ]]; then
print_message "INFO" "刪除掛載點: $mount_point"
sudo rmdir "$mount_point" 2>/dev/null || sudo rm -rf "$mount_point"
fi
else
# 自定義掛載點:嘗試刪除空目錄,如果不為空則保留
if [[ -d "$mount_point" ]]; then
if sudo rmdir "$mount_point" 2>/dev/null; then
print_message "INFO" "刪除空的自定義掛載點: $mount_point"
else
print_message "INFO" "保留非空的自定義掛載點: $mount_point"
fi
fi
fi
return 0
fi
# 最後手段:先kill rclone進程再嘗試卸載
print_message "WARN" "強制卸載失敗,嘗試終止rclone進程後再卸載"
local rclone_pids
rclone_pids=$(find_rclone_pids "$host_alias" "$mount_point" || true)
if [[ -n "$rclone_pids" ]]; then
print_message "INFO" "發現rclone進程: $(echo "$rclone_pids" | tr '\n' ' ')"
print_message "INFO" "正在發送TERM信號..."
echo "$rclone_pids" | xargs sudo kill -TERM 2>/dev/null || true
sleep 3 || true
# 檢查進程是否還在運行,如果是則強制終止
local remaining_pids
remaining_pids=$(find_rclone_pids "$host_alias" "$mount_point" || true)
if [[ -n "$remaining_pids" ]]; then
print_message "WARN" "進程仍在運行: $(echo "$remaining_pids" | tr '\n' ' ')"
print_message "INFO" "正在發送KILL信號強制終止..."
echo "$remaining_pids" | xargs sudo kill -KILL 2>/dev/null || true
sleep 2 || true
print_message "INFO" "✓ rclone進程已強制終止"
else
print_message "INFO" "✓ rclone進程已終止"
fi
# 再次嘗試卸載
sudo umount "$mount_point" 2>/dev/null || true
print_message "INFO" "進程終止後卸載成功: $mount_point"
# 刪除預設掛載點,自定義掛載點嘗試刪除(如果為空)
if [[ "$mount_point" == "$MOUNT_BASE_DIR/$host_alias" ]]; then
if [[ -d "$mount_point" ]]; then
print_message "INFO" "刪除掛載點: $mount_point"
sudo rmdir "$mount_point" 2>/dev/null || sudo rm -rf "$mount_point"
fi
else
# 自定義掛載點:嘗試刪除空目錄,如果不為空則保留
if [[ -d "$mount_point" ]]; then
if sudo rmdir "$mount_point" 2>/dev/null; then
print_message "INFO" "刪除空的自定義掛載點: $mount_point"
else
print_message "INFO" "保留非空的自定義掛載點: $mount_point"
fi
fi
fi
return 0
fi
print_message "ERROR" "卸載失敗: $mount_point"
return 1
}
# 檢查掛載狀態
check_status() {
local host_alias="$1"
local remote_path="${2:-}"
print_message "INFO" "檢查 $host_alias 的掛載狀態"
# 查找所有掛載點
local mount_info
mount_info=$(find_mount_points_by_mount "$host_alias")
if [[ -z "$mount_info" ]]; then
print_message "INFO" "✗ 未掛載"
return 0
fi
# 如果指定了 remote_path,只顯示匹配的
if [[ -n "$remote_path" ]]; then
local found=false
while IFS='|' read -r existing_path mount_point; do
if [[ "$existing_path" == "$remote_path" ]]; then
found=true
local full_mount_info
full_mount_info=$(mount | grep " $mount_point ")
print_message "INFO" "✓ 已掛載: $full_mount_info"
# 檢查掛載點是否可訪問
if timeout 5 ls "$mount_point" >/dev/null 2>&1; then
print_message "INFO" "✓ 掛載點可正常訪問"
else
print_message "WARN" "⚠ 掛載點無法訪問,可能需要重新掛載"
fi
fi
done <<< "$mount_info"
if [[ "$found" == false ]]; then
print_message "INFO" "✗ 指定路徑 $remote_path 未掛載"
fi
else
# 顯示所有掛載
print_message "INFO" "找到 $(echo "$mount_info" | wc -l) 個掛載:"
while IFS='|' read -r existing_path mount_point; do
local path_display="${existing_path:-~}"
print_message "INFO" " [$path_display] → $mount_point"
# 檢查掛載點是否可訪問
if timeout 2 ls "$mount_point" >/dev/null 2>&1; then
print_message "INFO" " ✓ 可訪問"
else
print_message "WARN" " ✗ 無法訪問"
fi
done <<< "$mount_info"
fi
}
# 顯示所有掛載中的rclone
show_all_mounts() {
print_message "INFO" "檢查所有rclone掛載..."
echo
# 獲取所有rclone類型的掛載
local mounts
mounts=$(mount | grep "type fuse.rclone")
if [[ -z "$mounts" ]]; then
print_message "INFO" "目前沒有rclone掛載"
return 0
fi
echo -e "${GREEN}=== 已掛載的rclone遠端 ===${NC}"
echo
# 解析並顯示每個掛載
while IFS= read -r line; do
# 提取remote名稱和掛載點
local remote
remote=$(echo "$line" | awk '{print $1}' | cut -d: -f1)
local mount_point
mount_point=$(echo "$line" | awk '{print $3}')
echo -e "${YELLOW}Remote:${NC} $remote"
echo -e "${YELLOW}掛載點:${NC} $mount_point"
# 測試掛載點訪問
if timeout 2 ls "$mount_point" >/dev/null 2>&1; then
echo -e "${YELLOW}狀態:${NC} ${GREEN}✓ 可訪問${NC}"
else
echo -e "${YELLOW}狀態:${NC} ${RED}✗ 無法訪問${NC}"
fi
# 顯示掛載詳細資訊
echo -e "${YELLOW}詳細:${NC} $line"
echo
done <<< "$mounts"
# 顯示相關的rclone進程
local rclone_pids
rclone_pids=$(safe_ps | grep "rclone mount" | grep -v grep | grep -v "rclonemm")
if [[ -n "$rclone_pids" ]]; then
echo -e "${GREEN}=== rclone進程 ===${NC}"
echo "$rclone_pids"
fi
}
# 批量掛載所有可用的 SSH hosts
mount_all_remotes() {
local success_count=0
local fail_count=0
local failed_hosts=()
print_message "INFO" "獲取所有可用的 SSH Host..."
# 獲取所有可用的 hosts
local all_hosts
all_hosts=$(grep "^Host " "$SSH_CONFIG" | awk '{print $2}' | grep -v '\*' | grep -v 'github.com' | sort)
if [[ -z "$all_hosts" ]]; then
print_message "WARN" "沒有可掛載的 SSH Host"
return 0
fi
local total_hosts
total_hosts=$(echo "$all_hosts" | wc -l)
print_message "INFO" "找到 $total_hosts 個 SSH Host,開始批量掛載..."
echo
# 暫時禁用 errexit,避免單個失敗中斷整個流程
set +e
local current=0
while IFS= read -r host; do
[[ -z "$host" ]] && continue
((current++))
local mount_point="$MOUNT_BASE_DIR/$host"
# 檢查是否已掛載
if is_mounted "$mount_point"; then
print_message "INFO" "[$current/$total_hosts] $host 已經掛載在 $mount_point,跳過"
((success_count++))
continue
fi
print_message "INFO" "==== [$current/$total_hosts] 掛載 $host ===="
# 使用核心掛載函數
if do_mount_single "$host" "" "$mount_point"; then
((success_count++))
print_message "INFO" "✓ $host 掛載成功"
else
((fail_count++))
failed_hosts+=("$host")
print_message "WARN" "✗ $host 掛載失敗,繼續下一個"
fi
echo
done <<< "$all_hosts"
# 恢復 errexit
set -e
# 顯示統計
echo
print_message "INFO" "==== 批量掛載完成 ===="
print_message "INFO" "成功: $success_count, 失敗: $fail_count, 總計: $total_hosts"
if [[ $fail_count -gt 0 ]]; then
print_message "WARN" "失敗的 hosts: ${failed_hosts[*]}"
return 1
fi
return 0
}
# 批量卸載所有 rclone 掛載
unmount_all_remotes() {
local success_count=0
local fail_count=0
local failed_mounts=()
print_message "INFO" "檢查所有 rclone 掛載..."
# 獲取所有 rclone 掛載
local all_mounts
all_mounts=$(mount | grep "type fuse.rclone" || true)
if [[ -z "$all_mounts" ]]; then
print_message "INFO" "目前沒有 rclone 掛載"
return 0
fi
local total_mounts
total_mounts=$(echo "$all_mounts" | wc -l)
print_message "INFO" "找到 $total_mounts 個 rclone 掛載,開始批量卸載..."
echo
# 暫時禁用 errexit
set +e
local current=0
while IFS= read -r line; do
[[ -z "$line" ]] && continue
((current++))
local mount_point
mount_point=$(echo "$line" | awk '{print $3}')
local remote_part
remote_part=$(echo "$line" | awk '{print $1}')
# 提取 host alias(去除可能的參數和路徑)
local host_alias
if [[ "$remote_part" =~ ^([^{:]+) ]]; then
host_alias="${BASH_REMATCH[1]}"
else
host_alias=$(echo "$remote_part" | cut -d: -f1)
fi
print_message "INFO" "==== [$current/$total_mounts] 卸載 $mount_point ===="
# 使用核心卸載函數 perform_unmount
if perform_unmount "$mount_point" "$host_alias"; then
((success_count++))
print_message "INFO" "✓ $mount_point 卸載成功"
else
((fail_count++))
failed_mounts+=("$mount_point")
print_message "WARN" "✗ $mount_point 卸載失敗,繼續下一個"
fi
echo
done <<< "$all_mounts"
# 恢復 errexit
set -e
# 顯示統計
echo
print_message "INFO" "==== 批量卸載完成 ===="
print_message "INFO" "成功: $success_count, 失敗: $fail_count, 總計: $total_mounts"
if [[ $fail_count -gt 0 ]]; then
print_message "WARN" "失敗的掛載點: ${failed_mounts[*]}"
return 1
fi
return 0
}
# 主函數
main() {
# 檢查依賴
check_dependencies
# 清理過期日誌
cleanup_old_logs
# 檢查參數
if [[ $# -eq 0 ]]; then
print_message "ERROR" "缺少必要參數"
show_help
exit 1
fi
local operation="$1"
local remote_spec="${2:-}"
local custom_mount_point="${3:-}"
# 處理help操作
if [[ "$operation" == "help" || "$operation" == "-h" || "$operation" == "--help" ]]; then
show_help
exit 0
fi
# 處理show操作(不需要remote名稱)
if [[ "$operation" == "show" ]]; then
show_all_mounts
exit 0
fi
# 處理批量操作(--all 或 -a)
if [[ "$remote_spec" == "--all" || "$remote_spec" == "-a" ]]; then
# 確保日誌目錄存在
sudo mkdir -p "$LOG_DIR"
print_message "INFO" "批量操作: $operation 所有 remotes,日誌: $LOG_FILE"
case "$operation" in
"mount"|"mnt")
mount_all_remotes
exit $?
;;
"unmount"|"umount"|"umnt")
unmount_all_remotes
exit $?
;;
*)
print_message "ERROR" "操作 '$operation' 不支援 --all/-a 參數"
print_message "INFO" "支援 --all/-a 的操作: mount, unmount"
exit 1
;;
esac
fi
# 檢查是否提供remote規格
if [[ -z "$remote_spec" ]]; then
print_message "ERROR" "缺少 SSH Host 名稱參數或 --all/-a 選項"
show_help
exit 1
fi
# 解析 host alias 和路徑
# 格式: host_alias 或 host_alias:path
local host_alias remote_path
if [[ "$remote_spec" =~ ^([^:]+):(.*)$ ]]; then
host_alias="${BASH_REMATCH[1]}"
remote_path="${BASH_REMATCH[2]}"
else
host_alias="$remote_spec"
remote_path=""
fi
# 檢查 SSH Host 是否存在
if ! check_remote_exists "$host_alias"; then
exit 1
fi
# 確保日誌目錄存在
sudo mkdir -p "$LOG_DIR"
# 執行對應操作
print_message "INFO" "操作: $operation,日誌: $LOG_FILE"
case "$operation" in
"mount"|"mnt")
mount_remote "$host_alias" "$remote_path" "$custom_mount_point"
;;
"unmount"|"umount"|"umnt")
unmount_remote "$host_alias" "$custom_mount_point"
;;
"status")
check_status "$host_alias" "$remote_path"
;;
*)
print_message "ERROR" "未知操作: $operation"
show_help
exit 1
;;
esac
}
# 執行主函數
main "$@"
<#
.SYNOPSIS
rclone Mount Management Tool (PowerShell Version)
.DESCRIPTION
Features: mount, unmount, status, show, help
Uses SSH config to mount rclone SFTP remote to local directory
.PARAMETER Operation
Operation type: mount/mnt, unmount/umount/umnt, status, show, help
.PARAMETER RemoteSpec
Host name from SSH config, format: <remote_name>[:<remote_path>]
Or use --all/--a for batch operations
.PARAMETER CustomMountPoint
Custom local mount point (optional)
.EXAMPLE
.\rclonemm_en.ps1 mount mom
Mount mom root directory to default location
.EXAMPLE
.\rclonemm_en.ps1 mount mom:/WEB/logs
Mount mom specified directory to default location
.EXAMPLE
.\rclonemm_en.ps1 mount mom D:\custom\path
Mount mom root directory to custom location
.EXAMPLE
.\rclonemm_en.ps1 mount --all
Batch mount all SSH Hosts
.EXAMPLE
.\rclonemm_en.ps1 unmount mom
Unmount mom
.EXAMPLE
.\rclonemm_en.ps1 status mom
Check mom status
.NOTES
Version: 1.0.0
Date: 2026-01-02
Author: Translated from Bash version by GitHub Copilot
Original Bash version update: 2025-12-19
#>
[CmdletBinding()]
param(
[Parameter(Position = 0)]
[string]$Operation,
[Parameter(Position = 1)]
[string]$RemoteSpec,
[Parameter(Position = 2)]
[string]$CustomMountPoint
)
# ==================== Error Handling Settings ====================
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
# ==================== Global Configuration ====================
$script:Config = @{
SSHConfig = "$env:USERPROFILE\.ssh\config"
MountBaseDir = "C:\mnt"
LogDir = "$env:LOCALAPPDATA\rclonemm\logs"
LogRetentionDays = 7
MountTimeout = 10
AccessTestTimeout = 10
}
# Support custom PREFIX environment variable
if ($env:RCLONEMM_PREFIX) {
Write-Verbose "Using custom PREFIX: $env:RCLONEMM_PREFIX"
$script:Config.MountBaseDir = "$env:RCLONEMM_PREFIX\mnt"
$script:Config.LogDir = "$env:RCLONEMM_PREFIX\var\log"
}
# Log file path
$script:LogFile = Join-Path $script:Config.LogDir "rclone_mount_$(Get-Date -Format 'yyyyMMdd').log"
# ==================== Helper Functions: Logging & Messages ====================
<#
.SYNOPSIS
Output colored message and log to file
#>
function Write-ColorMessage {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('INFO', 'WARN', 'ERROR', 'DEBUG')]
[string]$Level,
[Parameter(Mandatory)]
[string]$Message
)
$color = switch ($Level) {
'INFO' { 'Green' }
'WARN' { 'Yellow' }
'ERROR' { 'Red' }
'DEBUG' { 'Cyan' }
}
Write-Host "[$Level] $Message" -ForegroundColor $color
# Log to file
Write-Log -Level $Level -Message $Message
}
<#
.SYNOPSIS
Write to log file
#>
function Write-Log {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Level,
[Parameter(Mandatory)]
[string]$Message
)
try {
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$logEntry = "[$timestamp] [$Level] $Message"
# Ensure log directory exists
$logDir = Split-Path $script:LogFile -Parent
if (-not (Test-Path $logDir)) {
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}
# Write log
Add-Content -Path $script:LogFile -Value $logEntry -ErrorAction SilentlyContinue
} catch {
# Ignore log write errors
}
}
<#
.SYNOPSIS
Clean up old log files
#>
function Clear-OldLogs {
[CmdletBinding()]
param()
try {
$logDir = $script:Config.LogDir
if (-not (Test-Path $logDir)) {
return
}
$cutoffDate = (Get-Date).AddDays(-$script:Config.LogRetentionDays)
Get-ChildItem -Path $logDir -Filter "rclone_mount_*.log" |
Where-Object { $_.LastWriteTime -lt $cutoffDate } |
Remove-Item -Force -ErrorAction SilentlyContinue
} catch {
# Ignore cleanup errors
}
}
# ==================== Helper Functions: Dependency Check ====================
<#
.SYNOPSIS
Check required dependencies
#>
function Test-Dependencies {
[CmdletBinding()]
param()
$missingDeps = @()
# Check rclone
if (-not (Get-Command rclone -ErrorAction SilentlyContinue)) {
$missingDeps += "rclone"
}
# Check WinFsp (via registry or filesystem)
$winfspPaths = @(
"$env:ProgramFiles\WinFsp",
"${env:ProgramFiles(x86)}\WinFsp"
)
$winfspInstalled = $winfspPaths | Where-Object { Test-Path $_ }
if (-not $winfspInstalled) {
$missingDeps += "WinFsp"
}
# If there are missing dependencies, show error and exit
if ($missingDeps.Count -gt 0) {
Write-ColorMessage -Level ERROR -Message "Missing required dependencies:"
foreach ($dep in $missingDeps) {
Write-Host " x $dep" -ForegroundColor Red
}
Write-Host ""
Write-Host "Please install the missing packages:" -ForegroundColor Yellow
Write-Host " rclone: https://rclone.org/downloads/" -ForegroundColor Yellow
Write-Host " WinFsp: https://winfsp.dev/" -ForegroundColor Yellow
exit 1
}
}
<#
.SYNOPSIS
Check if running with administrator privileges
#>
function Test-AdminPrivileges {
[CmdletBinding()]
param()
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]$identity
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
# ==================== Helper Functions: SSH Config Parsing ====================
<#
.SYNOPSIS
Parse SSH config to get specified property for a Host
#>
function Get-SSHConfigValue {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias,
[Parameter(Mandatory)]
[string]$Key
)
$sshConfigPath = $script:Config.SSHConfig
if (-not (Test-Path $sshConfigPath)) {
return $null
}
$content = Get-Content $sshConfigPath -Raw
$lines = $content -split "`r?`n"
$inHostBlock = $false
foreach ($line in $lines) {
# Check if entering target Host block
if ($line -match '^\s*Host\s+(.+)$') {
$hostPatterns = $Matches[1] -split '\s+'
$inHostBlock = $hostPatterns -contains $HostAlias
continue
}
# If in target Host block, find property
if ($inHostBlock) {
if ($line -match "^\s*$Key\s+(.+)$") {
return $Matches[1].Trim()
}
# Reached next Host block, stop searching
if ($line -match '^\s*Host\s+') {
break
}
}
}
return $null
}
<#
.SYNOPSIS
Get complete SSH Host configuration
#>
function Get-SSHHostConfig {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias
)
$hostname = Get-SSHConfigValue -HostAlias $HostAlias -Key 'HostName'
$user = Get-SSHConfigValue -HostAlias $HostAlias -Key 'User'
$port = Get-SSHConfigValue -HostAlias $HostAlias -Key 'Port'
$identityFile = Get-SSHConfigValue -HostAlias $HostAlias -Key 'IdentityFile'
$proxyJump = Get-SSHConfigValue -HostAlias $HostAlias -Key 'ProxyJump'
# If no explicit HostName, use Host alias
if (-not $hostname) {
$hostname = $HostAlias
}
# Expand ~ to actual path
if ($identityFile -and $identityFile.StartsWith('~')) {
$identityFile = $identityFile -replace '^~', $env:USERPROFILE
}
return [PSCustomObject]@{
HostAlias = $HostAlias
HostName = $hostname
User = $user
Port = $port
IdentityFile = $identityFile
ProxyJump = $proxyJump
}
}
<#
.SYNOPSIS
Build --sftp-ssh option
#>
function Get-SftpSshOption {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[PSCustomObject]$Config
)
$sshCmd = "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=2"
# Add Port
if ($Config.Port) {
$sshCmd += " -p $($Config.Port)"
}
# Add IdentityFile
if ($Config.IdentityFile) {
$sshCmd += " -i `"$($Config.IdentityFile)`""
}
# Add ProxyJump
if ($Config.ProxyJump) {
$sshCmd += " -J $($Config.ProxyJump)"
}
# Build complete SSH connection string user@hostname
$sshTarget = if ($Config.User) {
"$($Config.User)@$($Config.HostName)"
} else {
$Config.HostName
}
# Return complete SSH command (without outer quotes, as it will be passed as parameter)
return "$sshCmd $sshTarget"
}
# ==================== Helper Functions: Mount Point Management ====================
<#
.SYNOPSIS
Check if specified path is mounted
#>
function Test-Mounted {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$MountPoint
)
# Find rclone processes via Get-Process and check command line
$rcloneProcesses = Get-Process -Name rclone -ErrorAction SilentlyContinue
if (-not $rcloneProcesses) {
return $false
}
foreach ($process in $rcloneProcesses) {
try {
$cmdLine = (Get-CimInstance Win32_Process -Filter "ProcessId = $($process.Id)").CommandLine
if ($cmdLine -like "*mount*" -and $cmdLine -like "*$MountPoint*") {
return $true
}
} catch {
# Ignore permission errors
}
}
return $false
}
<#
.SYNOPSIS
Ensure parent directory of mount point exists, but mount point itself must not exist
On Windows, rclone requires mount point to not be an existing directory
#>
function Ensure-MountPoint {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$MountPoint
)
# Check if mount point already exists
if (Test-Path $MountPoint) {
Write-ColorMessage -Level ERROR -Message "Mount point already exists: $MountPoint (On Windows, rclone requires mount point to not exist)"
return $false
}
# Check if parent directory exists
$parentPath = Split-Path $MountPoint -Parent
if (-not $parentPath) {
Write-ColorMessage -Level ERROR -Message "Invalid mount point path: $MountPoint"
return $false
}
if (-not (Test-Path $parentPath)) {
Write-ColorMessage -Level INFO -Message "Creating parent directory: $parentPath"
try {
New-Item -Path $parentPath -ItemType Directory -Force | Out-Null
} catch {
Write-ColorMessage -Level ERROR -Message "Cannot create parent directory: $parentPath - $_"
return $false
}
}
Write-ColorMessage -Level INFO -Message "Mount point path verified: $MountPoint"
return $true
}
<#
.SYNOPSIS
Find all mount points for specified host alias
Return format: @{RemotePath=''; MountPoint=''}
#>
function Find-MountPoints {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias
)
$mountPoints = @()
$rcloneProcesses = Get-Process -Name rclone -ErrorAction SilentlyContinue
if (-not $rcloneProcesses) {
return ,$mountPoints
}
foreach ($process in $rcloneProcesses) {
try {
$cmdLine = (Get-CimInstance Win32_Process -Filter "ProcessId = $($process.Id)").CommandLine
# Check if it's a mount operation
if ($cmdLine -notlike "*mount*") {
continue
}
# Parse command line, extract remote source and mount point
# Format: rclone mount "remote:path" "mount_point" [options]
# Or: rclone mount remote:path mount_point [options]
$pattern = 'mount\s+"?([^":]+):([^"]*)"?\s+"?([^"]+)"?'
if ($cmdLine -match $pattern) {
$remote = $Matches[1]
$remotePath = $Matches[2]
$mountPoint = $Matches[3]
if ($remote -eq $HostAlias) {
$mountPoints += [PSCustomObject]@{
RemotePath = $remotePath
MountPoint = $mountPoint
ProcessId = $process.Id
}
}
}
} catch {
# Ignore errors
}
}
return ,$mountPoints
}
<#
.SYNOPSIS
Find rclone process PIDs matching host alias and mount point
#>
function Find-RclonePids {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias,
[Parameter()]
[string]$MountPoint
)
$pids = @()
$rcloneProcesses = Get-Process -Name rclone -ErrorAction SilentlyContinue
if (-not $rcloneProcesses) {
return ,$pids
}
foreach ($process in $rcloneProcesses) {
try {
$cmdLine = (Get-CimInstance Win32_Process -Filter "ProcessId = $($process.Id)").CommandLine
# Check if matches host alias
if ($cmdLine -notlike "*$HostAlias*") {
continue
}
# If mount point specified, also match mount point
if ($MountPoint -and $cmdLine -notlike "*$MountPoint*") {
continue
}
$pids += $process.Id
} catch {
# Ignore errors
}
}
return ,$pids
}
# ==================== Core Functions: Mount Operations ====================
<#
.SYNOPSIS
Core mount function (single host mount logic)
#>
function Invoke-MountSingle {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias,
[Parameter()]
[string]$RemotePath = '',
[Parameter(Mandatory)]
[string]$MountPoint
)
# Get SSH Host configuration
$sshConfig = Get-SSHHostConfig -HostAlias $HostAlias
# Build --sftp-ssh option
$sftpSshOption = Get-SftpSshOption -Config $sshConfig
# Combine remote source path
$remoteSource = if ($RemotePath) {
"${HostAlias}:$RemotePath"
} else {
"${HostAlias}:"
}
# Check if target mount point is occupied
if (Test-Mounted -MountPoint $MountPoint) {
Write-ColorMessage -Level ERROR -Message "Mount point $MountPoint is already occupied by another filesystem"
return $false
}
# Ensure mount point path is valid (parent directory exists, but mount point itself does not)
if (-not (Ensure-MountPoint -MountPoint $MountPoint)) {
return $false
}
# Create rclone config before mounting
Write-ColorMessage -Level INFO -Message "Creating rclone config: $HostAlias"
try {
$configResult = & rclone config create $HostAlias sftp 2>&1
if ($LASTEXITCODE -ne 0) {
Write-ColorMessage -Level ERROR -Message "Failed to create rclone config: $configResult"
return $false
}
} catch {
Write-ColorMessage -Level ERROR -Message "Failed to create rclone config: $_"
return $false
}
# Build rclone arguments
$argString = "mount `"$remoteSource`" `"$MountPoint`" --vfs-cache-mode full --default-permissions --multi-thread-streams 4 --multi-thread-cutoff 100M --low-level-retries 2 --sftp-disable-hashcheck --sftp-ssh `"$sftpSshOption`""
Write-ColorMessage -Level INFO -Message "Mount command: rclone $argString"
Write-ColorMessage -Level INFO -Message "Tip: If SSH requires password, enter it when prompted"
# Execute mount command (run in background directly)
try {
# Use Start-Process and pass arguments directly
# Pass entire command string, let PowerShell handle quotes automatically
$process = Start-Process -FilePath "rclone" -ArgumentList $argString -NoNewWindow -PassThru
# Wait for mount to complete
$count = 0
while ($count -lt $script:Config.MountTimeout) {
Write-ColorMessage -Level INFO -Message "Waiting for mount... ($($count + 1)/$($script:Config.MountTimeout))"
Start-Sleep -Seconds 1
if (Test-Mounted -MountPoint $MountPoint) {
Write-ColorMessage -Level INFO -Message "Mount process started: ${HostAlias}:$RemotePath -> $MountPoint"
# Wait for rclone service to fully start
# Test if mount point is accessible, retry a few times
Write-ColorMessage -Level INFO -Message "Waiting for rclone service to fully start..."
$accessTestCount = 0
$maxAccessTests = 5
$accessSuccess = $false
while ($accessTestCount -lt $maxAccessTests) {
Start-Sleep -Seconds 2
$accessTestCount++
Write-ColorMessage -Level INFO -Message "Testing mount point access... (attempt $accessTestCount/$maxAccessTests)"
try {
# Try to access mount point
if (Test-Path $MountPoint) {
$testResult = Get-ChildItem -Path $MountPoint -ErrorAction Stop | Select-Object -First 1
Write-ColorMessage -Level INFO -Message "Mount point test passed, log: $($script:LogFile)"
$accessSuccess = $true
break
}
} catch {
Write-ColorMessage -Level DEBUG -Message "Access test failed (attempt $accessTestCount/$maxAccessTests): $_"
# Continue retry
}
}
if ($accessSuccess) {
# Clear rclone config after successful mount
Write-ColorMessage -Level INFO -Message "Clearing rclone config: $HostAlias"
& rclone config delete $HostAlias 2>&1 | Out-Null
return $true
} else {
Write-ColorMessage -Level ERROR -Message "Mount point still inaccessible after $maxAccessTests attempts, mount failed"
Write-ColorMessage -Level WARN -Message "Performing cleanup..."
# Perform cleanup
$rclonePids = Find-RclonePids -HostAlias $HostAlias -MountPoint $MountPoint
foreach ($procId in $rclonePids) {
Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
}
# Clear rclone config
Write-ColorMessage -Level INFO -Message "Clearing rclone config: $HostAlias"
& rclone config delete $HostAlias 2>&1 | Out-Null
return $false
}
}
$count++
}
Write-ColorMessage -Level ERROR -Message "Mount timeout, please check log: $($script:LogFile)"
Write-ColorMessage -Level WARN -Message "Performing cleanup..."
# Perform cleanup
$rclonePids = Find-RclonePids -HostAlias $HostAlias -MountPoint $MountPoint
foreach ($procId in $rclonePids) {
Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
}
# Clear rclone config
Write-ColorMessage -Level INFO -Message "Clearing rclone config: $HostAlias"
& rclone config delete $HostAlias 2>&1 | Out-Null
return $false
} catch {
Write-ColorMessage -Level ERROR -Message "rclone mount command failed: $_"
Write-ColorMessage -Level WARN -Message "Performing cleanup..."
# Clear rclone config
Write-ColorMessage -Level INFO -Message "Clearing rclone config: $HostAlias"
& rclone config delete $HostAlias 2>&1 | Out-Null
return $false
}
}
<#
.SYNOPSIS
Mount remote (user interface function)
#>
function Mount-RcloneRemote {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias,
[Parameter()]
[string]$RemotePath = '',
[Parameter()]
[string]$CustomMountPoint = ''
)
# Determine mount point: custom > default
$mountPoint = if ($CustomMountPoint) {
$CustomMountPoint
} else {
Join-Path $script:Config.MountBaseDir $HostAlias
}
Write-ColorMessage -Level INFO -Message "Using --sftp-ssh option for mounting"
Write-ColorMessage -Level INFO -Message "Mounting ${HostAlias}:$RemotePath to $mountPoint"
if ($CustomMountPoint) {
Write-ColorMessage -Level INFO -Message "Using custom mount point: $CustomMountPoint"
}
# Call core mount function
return Invoke-MountSingle -HostAlias $HostAlias -RemotePath $RemotePath -MountPoint $mountPoint
}
# ==================== Core Functions: Unmount Operations ====================
<#
.SYNOPSIS
Perform actual unmount operation
#>
function Invoke-Unmount {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$MountPoint,
[Parameter(Mandatory)]
[string]$HostAlias
)
Write-ColorMessage -Level INFO -Message "Unmounting $MountPoint"
# Find related rclone processes
$rclonePids = Find-RclonePids -HostAlias $HostAlias -MountPoint $MountPoint
if ($rclonePids.Count -eq 0) {
Write-ColorMessage -Level WARN -Message "No related rclone processes found"
return $false
}
Write-ColorMessage -Level INFO -Message "Found related rclone processes: $($rclonePids -join ', ')"
Write-ColorMessage -Level INFO -Message "Terminating processes..."
# Try normal termination first
foreach ($procId in $rclonePids) {
try {
Stop-Process -Id $procId -ErrorAction Stop
} catch {
Write-ColorMessage -Level WARN -Message "Cannot terminate process $procId, trying force termination"
Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
}
}
Start-Sleep -Seconds 2
Write-ColorMessage -Level INFO -Message "rclone processes terminated"
# On Windows, do not delete mount point directory after unmount
# Because rclone requires path to not exist before mount, should not auto-delete after unmount
# If directory exists, it may have other uses or leftover files
if (Test-Path $MountPoint) {
Write-ColorMessage -Level INFO -Message "Mount point directory still exists: $MountPoint (not auto-deleted)"
} else {
Write-ColorMessage -Level INFO -Message "Mount point cleared: $MountPoint"
}
return $true
}
<#
.SYNOPSIS
Unmount remote
#>
function Dismount-RcloneRemote {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias,
[Parameter()]
[string]$CustomMountPoint = ''
)
# Find all mount points
$mountInfo = Find-MountPoints -HostAlias $HostAlias
if ($mountInfo.Count -eq 0) {
Write-ColorMessage -Level WARN -Message "$HostAlias is not mounted"
# Check if there are still related rclone processes running
$rclonePids = Find-RclonePids -HostAlias $HostAlias
if ($rclonePids.Count -gt 0) {
Write-ColorMessage -Level INFO -Message "Found residual rclone processes: $($rclonePids -join ', ')"
Write-ColorMessage -Level INFO -Message "Terminating processes..."
foreach ($procId in $rclonePids) {
Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
}
Start-Sleep -Seconds 2
Write-ColorMessage -Level INFO -Message "rclone processes terminated"
}
return $true
}
# If mount point specified, only unmount matching ones
if ($CustomMountPoint) {
$found = $false
foreach ($mount in $mountInfo) {
if ($mount.MountPoint -eq $CustomMountPoint) {
$found = $true
Invoke-Unmount -MountPoint $mount.MountPoint -HostAlias $HostAlias
break
}
}
if (-not $found) {
Write-ColorMessage -Level ERROR -Message "Cannot find mounted path: $HostAlias $CustomMountPoint"
Write-ColorMessage -Level INFO -Message "Current $HostAlias mounts:"
foreach ($mount in $mountInfo) {
$pathDisplay = if ($mount.RemotePath) { $mount.RemotePath } else { "~" }
Write-ColorMessage -Level INFO -Message " [$pathDisplay] -> $($mount.MountPoint)"
}
return $false
}
} else {
# No mount point specified, ask for confirmation one by one
Write-ColorMessage -Level INFO -Message "Found $($mountInfo.Count) mount(s) for ${HostAlias}:"
foreach ($mount in $mountInfo) {
$pathDisplay = if ($mount.RemotePath) { $mount.RemotePath } else { "~" }
Write-Host ""
Write-ColorMessage -Level INFO -Message "Mount: [$pathDisplay] on $($mount.MountPoint)"
$answer = Read-Host "Do you want to unmount this? [y/N]"
if ($answer -match '^[Yy]$') {
Invoke-Unmount -MountPoint $mount.MountPoint -HostAlias $HostAlias
} else {
Write-ColorMessage -Level INFO -Message "Skipping $($mount.MountPoint)"
}
}
}
return $true
}
# ==================== Core Functions: Status Check ====================
<#
.SYNOPSIS
Check mount status
#>
function Get-RcloneStatus {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias,
[Parameter()]
[string]$RemotePath = ''
)
Write-ColorMessage -Level INFO -Message "Checking mount status for $HostAlias"
# Find all mount points
$mountInfo = Find-MountPoints -HostAlias $HostAlias
if ($mountInfo.Count -eq 0) {
Write-ColorMessage -Level INFO -Message "Not mounted"
return
}
# If RemotePath specified, only show matching ones
if ($RemotePath) {
$found = $false
foreach ($mount in $mountInfo) {
if ($mount.RemotePath -eq $RemotePath) {
$found = $true
Write-ColorMessage -Level INFO -Message "Mounted: $($mount.MountPoint)"
# Check if mount point is accessible
try {
$null = Get-ChildItem -Path $mount.MountPoint -ErrorAction Stop
Write-ColorMessage -Level INFO -Message "Mount point accessible"
} catch {
Write-ColorMessage -Level WARN -Message "Mount point inaccessible, may need remounting"
}
}
}
if (-not $found) {
Write-ColorMessage -Level INFO -Message "Specified path $RemotePath is not mounted"
}
} else {
# Show all mounts
Write-ColorMessage -Level INFO -Message "Found $($mountInfo.Count) mount(s):"
foreach ($mount in $mountInfo) {
$pathDisplay = if ($mount.RemotePath) { $mount.RemotePath } else { "~" }
Write-ColorMessage -Level INFO -Message " [$pathDisplay] -> $($mount.MountPoint)"
# Check if mount point is accessible
try {
$null = Get-ChildItem -Path $mount.MountPoint -ErrorAction Stop
Write-ColorMessage -Level INFO -Message " Accessible"
} catch {
Write-ColorMessage -Level WARN -Message " Inaccessible"
}
}
}
}
# ==================== Helper Functions: Batch Operations ====================
<#
.SYNOPSIS
Batch mount all available SSH hosts
#>
function Mount-AllRemotes {
[CmdletBinding()]
param()
$successCount = 0
$failCount = 0
$failedHosts = @()
Write-ColorMessage -Level INFO -Message "Getting all available SSH Hosts..."
# Get all available hosts
$sshConfigPath = $script:Config.SSHConfig
if (-not (Test-Path $sshConfigPath)) {
Write-ColorMessage -Level WARN -Message "SSH config does not exist: $sshConfigPath"
return
}
$content = Get-Content $sshConfigPath
$allHosts = $content | Where-Object { $_ -match '^\s*Host\s+(.+)$' } | ForEach-Object {
$Matches[1] -split '\s+' | Where-Object { $_ -notmatch '\*' -and $_ -ne 'github.com' }
} | Select-Object -Unique | Sort-Object
if ($allHosts.Count -eq 0) {
Write-ColorMessage -Level WARN -Message "No SSH Hosts available for mounting"
return
}
$totalHosts = $allHosts.Count
Write-ColorMessage -Level INFO -Message "Found $totalHosts SSH Hosts, starting batch mount..."
Write-Host ""
$current = 0
foreach ($sshHost in $allHosts) {
$current++
$mountPoint = Join-Path $script:Config.MountBaseDir $sshHost
# Check if already mounted
if (Test-Mounted -MountPoint $mountPoint) {
Write-ColorMessage -Level INFO -Message "[$current/$totalHosts] $sshHost already mounted at $mountPoint, skipping"
$successCount++
continue
}
Write-ColorMessage -Level INFO -Message "==== [$current/$totalHosts] Mounting $sshHost ===="
# Use core mount function
if (Invoke-MountSingle -HostAlias $sshHost -RemotePath '' -MountPoint $mountPoint) {
$successCount++
Write-ColorMessage -Level INFO -Message "$sshHost mount successful"
} else {
$failCount++
$failedHosts += $sshHost
Write-ColorMessage -Level WARN -Message "$sshHost mount failed, continuing to next"
}
Write-Host ""
}
# Show statistics
Write-Host ""
Write-ColorMessage -Level INFO -Message "==== Batch mount completed ===="
Write-ColorMessage -Level INFO -Message "Success: $successCount, Failed: $failCount, Total: $totalHosts"
if ($failCount -gt 0) {
Write-ColorMessage -Level WARN -Message "Failed hosts: $($failedHosts -join ', ')"
}
}
<#
.SYNOPSIS
Batch unmount all rclone mounts
#>
function Dismount-AllRemotes {
[CmdletBinding()]
param()
$successCount = 0
$failCount = 0
$failedMounts = @()
Write-ColorMessage -Level INFO -Message "Checking all rclone mounts..."
# Get all rclone processes
$rcloneProcesses = Get-Process -Name rclone -ErrorAction SilentlyContinue
if (-not $rcloneProcesses) {
Write-ColorMessage -Level INFO -Message "No rclone mounts currently"
return
}
$allMounts = @()
foreach ($process in $rcloneProcesses) {
try {
$cmdLine = (Get-CimInstance Win32_Process -Filter "ProcessId = $($process.Id)").CommandLine
# Use -match with variable to avoid parsing issues
$pattern = 'mount\s+"?([^":]+):([^"]*)"?\s+"?([^"]+)"?'
if ($cmdLine -match $pattern) {
$allMounts += [PSCustomObject]@{
HostAlias = $Matches[1]
RemotePath = $Matches[2]
MountPoint = $Matches[3]
ProcessId = $process.Id
}
}
} catch {
# Ignore errors
}
}
if ($allMounts.Count -eq 0) {
Write-ColorMessage -Level INFO -Message "No rclone mounts currently"
return
}
$totalMounts = $allMounts.Count
Write-ColorMessage -Level INFO -Message "Found $totalMounts rclone mounts, starting batch unmount..."
Write-Host ""
$current = 0
foreach ($mount in $allMounts) {
$current++
Write-ColorMessage -Level INFO -Message "==== [$current/$totalMounts] Unmounting $($mount.MountPoint) ===="
# Use core unmount function
if (Invoke-Unmount -MountPoint $mount.MountPoint -HostAlias $mount.HostAlias) {
$successCount++
Write-ColorMessage -Level INFO -Message "$($mount.MountPoint) unmount successful"
} else {
$failCount++
$failedMounts += $mount.MountPoint
Write-ColorMessage -Level WARN -Message "$($mount.MountPoint) unmount failed, continuing to next"
}
Write-Host ""
}
# Show statistics
Write-Host ""
Write-ColorMessage -Level INFO -Message "==== Batch unmount completed ===="
Write-ColorMessage -Level INFO -Message "Success: $successCount, Failed: $failCount, Total: $totalMounts"
if ($failCount -gt 0) {
Write-ColorMessage -Level WARN -Message "Failed mount points: $($failedMounts -join ', ')"
}
}
# ==================== Helper Functions: Other Features ====================
<#
.SYNOPSIS
Show all mounted rclone
#>
function Show-RcloneMounts {
[CmdletBinding()]
param()
Write-ColorMessage -Level INFO -Message "Checking all rclone mounts..."
Write-Host ""
$rcloneProcesses = Get-Process -Name rclone -ErrorAction SilentlyContinue
if (-not $rcloneProcesses) {
Write-ColorMessage -Level INFO -Message "No rclone mounts currently"
return
}
Write-Host "=== Mounted rclone remotes ===" -ForegroundColor Green
Write-Host ""
foreach ($process in $rcloneProcesses) {
try {
$cmdLine = (Get-CimInstance Win32_Process -Filter "ProcessId = $($process.Id)").CommandLine
$pattern = 'mount\s+"?([^":]+):([^"]*)"?\s+"?([^"]+)"?'
if ($cmdLine -match $pattern) {
$remote = $Matches[1]
$remotePath = $Matches[2]
$mountPoint = $Matches[3]
Write-Host "Remote: " -ForegroundColor Yellow -NoNewline
Write-Host $remote
Write-Host "Path: " -ForegroundColor Yellow -NoNewline
Write-Host $(if ($remotePath) { $remotePath } else { "~" })
Write-Host "Mount point: " -ForegroundColor Yellow -NoNewline
Write-Host $mountPoint
# Test mount point access
try {
$null = Get-ChildItem -Path $mountPoint -ErrorAction Stop
Write-Host "Status: " -ForegroundColor Yellow -NoNewline
Write-Host "Accessible" -ForegroundColor Green
} catch {
Write-Host "Status: " -ForegroundColor Yellow -NoNewline
Write-Host "Inaccessible" -ForegroundColor Red
}
Write-Host ""
}
} catch {
# Ignore errors
}
}
}
<#
.SYNOPSIS
Get available SSH Host list
#>
function Get-AvailableRemotes {
[CmdletBinding()]
param()
$sshConfigPath = $script:Config.SSHConfig
if (-not (Test-Path $sshConfigPath)) {
return
}
$content = Get-Content $sshConfigPath
$sshHosts = $content | Where-Object { $_ -match '^\s*Host\s+(.+)$' } | ForEach-Object {
$Matches[1] -split '\s+' | Where-Object { $_ -notmatch '\*' -and $_ -ne 'github.com' }
} | Select-Object -Unique | Sort-Object
foreach ($sshHost in $sshHosts) {
# Check if this host is already mounted
$mountInfo = Find-MountPoints -HostAlias $sshHost
if ($mountInfo.Count -gt 0) {
$firstMount = $mountInfo[0].MountPoint
if ($mountInfo.Count -gt 1) {
$status = "mounted {0}: {1} ..." -f $mountInfo.Count, $firstMount
Write-Host "$sshHost [$status]" -ForegroundColor Yellow
} else {
$status = "mounted: {0}" -f $firstMount
Write-Host "$sshHost [$status]" -ForegroundColor Yellow
}
} else {
Write-Host $sshHost
}
}
}
<#
.SYNOPSIS
Show usage help
#>
function Show-Help {
[CmdletBinding()]
param()
Write-Host "rclone Mount Management Tool" -ForegroundColor Cyan
Write-Host ""
Write-Host "Usage:" -ForegroundColor Yellow
Write-Host ' .\rclonemm_en.ps1 <operation> <remote_name>[:<remote_path>] [custom_mount_point]'
Write-Host ' .\rclonemm_en.ps1 <operation> --all|--a'
Write-Host ""
Write-Host "Parameters:" -ForegroundColor Yellow
Write-Host " operation Operation type"
Write-Host " - mount/mnt: Mount remote storage"
Write-Host " - unmount/umount/umnt: Unmount mount point"
Write-Host " - status: Check mount status"
Write-Host " - show: Show all mounted rclone"
Write-Host " - help: Show this help"
Write-Host " remote_name Host name from SSH config"
Write-Host " remote_path Optional, remote path (only for mount command)"
Write-Host " custom_mount_point Optional, custom local mount point (only for mount command)"
Write-Host " --all, --a Batch operation on all hosts (mount/unmount supported)"
Write-Host ""
Write-Host "Single mount examples:" -ForegroundColor Yellow
Write-Host " .\rclonemm_en.ps1 mount mom # Mount mom root to default location"
Write-Host " .\rclonemm_en.ps1 mount mom:/WEB/logs # Mount mom specified directory to default location"
Write-Host " .\rclonemm_en.ps1 mount mom D:\custom\path # Mount mom root to custom location"
Write-Host " .\rclonemm_en.ps1 mount mom:/WEB D:\custom\path # Mount mom specified directory to custom location"
Write-Host " .\rclonemm_en.ps1 unmount mom # Unmount mom (auto-find mount point)"
Write-Host " .\rclonemm_en.ps1 status mom # Check mom status (auto-find mount point)"
Write-Host ""
Write-Host "Batch operation examples:" -ForegroundColor Yellow
Write-Host " .\rclonemm_en.ps1 mount --all # Mount all SSH Hosts (auto-skip failures)"
Write-Host " .\rclonemm_en.ps1 mount --a # Same as above (shorthand)"
Write-Host " .\rclonemm_en.ps1 unmount --all # Unmount all rclone mounts"
Write-Host " .\rclonemm_en.ps1 umount --a # Same as above (shorthand)"
Write-Host ""
Write-Host "Other examples:" -ForegroundColor Yellow
Write-Host " .\rclonemm_en.ps1 show # Show all mounts"
Write-Host " .\rclonemm_en.ps1 help # Show this help"
Write-Host ""
Write-Host "Available SSH Hosts:" -ForegroundColor Yellow
Get-AvailableRemotes
}
<#
.SYNOPSIS
Check if SSH Host exists in config file
#>
function Test-RemoteExists {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias
)
$sshConfigPath = $script:Config.SSHConfig
if (-not (Test-Path $sshConfigPath)) {
Write-ColorMessage -Level ERROR -Message "SSH config does not exist: $sshConfigPath"
return $false
}
$content = Get-Content $sshConfigPath
$found = $content | Where-Object { $_ -match "^\s*Host\s+.*\b$HostAlias\b" }
if (-not $found) {
Write-ColorMessage -Level ERROR -Message "SSH Host '$HostAlias' does not exist in SSH config"
Write-Host "Available Hosts: " -NoNewline
Get-AvailableRemotes
return $false
}
return $true
}
# ==================== Main Program ====================
function Main {
# Check dependencies
Test-Dependencies
# Clean up old logs
Clear-OldLogs
# Check parameters
if (-not $Operation) {
Write-ColorMessage -Level ERROR -Message "Missing required parameters"
Show-Help
exit 1
}
# Handle help operation
if ($Operation -in @('help', '-h', '--help')) {
Show-Help
exit 0
}
# Handle show operation (no remote name needed)
if ($Operation -eq 'show') {
Show-RcloneMounts
exit 0
}
# Handle batch operations (--all or -a)
if ($RemoteSpec -in @('--all', '--a')) {
# Ensure log directory exists
$logDir = Split-Path $script:LogFile -Parent
if (-not (Test-Path $logDir)) {
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}
Write-ColorMessage -Level INFO -Message "Batch operation: $Operation all remotes, log: $($script:LogFile)"
switch ($Operation) {
{ $_ -in @('mount', 'mnt') } {
Mount-AllRemotes
exit 0
}
{ $_ -in @('unmount', 'umount', 'umnt') } {
Dismount-AllRemotes
exit 0
}
default {
Write-ColorMessage -Level ERROR -Message "Operation '$Operation' does not support --all/--a parameter"
Write-ColorMessage -Level INFO -Message "Operations supporting --all/--a: mount, unmount"
exit 1
}
}
}
# Check if remote spec is provided
if (-not $RemoteSpec) {
Write-ColorMessage -Level ERROR -Message "Missing SSH Host name parameter or --all/--a option"
Show-Help
exit 1
}
# Parse host alias and path
# Format: host_alias or host_alias:path
$hostAlias = ''
$remotePath = ''
$pattern = '^(.+?):(.*)$'
if ($RemoteSpec -match $pattern) {
$hostAlias = $Matches[1]
$remotePath = $Matches[2]
} else {
$hostAlias = $RemoteSpec
$remotePath = ''
}
# Check if SSH Host exists
if (-not (Test-RemoteExists -HostAlias $hostAlias)) {
exit 1
}
# Ensure log directory exists
$logDir = Split-Path $script:LogFile -Parent
if (-not (Test-Path $logDir)) {
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}
# Execute corresponding operation
Write-ColorMessage -Level INFO -Message "Operation: $Operation, log: $($script:LogFile)"
switch ($Operation) {
{ $_ -in @('mount', 'mnt') } {
Mount-RcloneRemote -HostAlias $hostAlias -RemotePath $remotePath -CustomMountPoint $CustomMountPoint
}
{ $_ -in @('unmount', 'umount', 'umnt') } {
Dismount-RcloneRemote -HostAlias $hostAlias -CustomMountPoint $CustomMountPoint
}
'status' {
Get-RcloneStatus -HostAlias $hostAlias -RemotePath $remotePath
}
default {
Write-ColorMessage -Level ERROR -Message "Unknown operation: $Operation"
Show-Help
exit 1
}
}
}
# Execute main program
Main
#!/bin/bash
# rclone mount management script
# Features: mount, unmount, status, help
# Usage: rclonemm [operation] <remote_name>[:<remote_path>] [custom_mount_point]
# Example: rclonemm mount mom:/WEB/logs /custom/path
# Author: GitHub Copilot
# Date: 2025-11-04
# Update: 2025-11-12 - Added custom mount point support
# Update: 2025-12-19 - Using SSH config, no longer depends on rclone.conf
set -euo pipefail
# Constants
# Configuration file paths
# Set mount base directory based on environment variable
if [[ -n "${PREFIX:-}" ]]; then
echo "Using custom PREFIX: $PREFIX"
else
PREFIX=""
fi
SSH_CONFIG="$HOME/.ssh/config"
MOUNT_BASE_DIR="$PREFIX/mnt"
LOG_DIR="$PREFIX/var/log"
LOG_FILE="${LOG_DIR}/rclone_mount_$(date '+%Y%m%d').log"
# Variables
# Log retention days
LOG_RETENTION_DAYS=7
# Mount timeout setting (seconds)
MOUNT_TIMEOUT=10
ACCESS_TEST_TIMEOUT=2
# Get current user's UID and GID
USER="${USER:-$(whoami)}"
USER_UID=$(id -u "$USER")
USER_GID=$(id -g "$USER")
# Basic RCLONE parameters
RCLONE_MOUNT_OPTIONS="--allow-other \
--vfs-cache-mode full \
--default-permissions \
--uid $USER_UID \
--gid $USER_GID \
--multi-thread-streams 4 \
--multi-thread-cutoff 100M \
--low-level-retries 2 \
--log-level ERROR \
--log-file $LOG_FILE"
# Basic SSH command
ssh_cmd="ssh -o StrictHostKeyChecking=no -o ControlMaster=auto -o ControlPath=~/.ssh/cm-%r@%h:%p -o ControlPersist=10 -o ConnectTimeout=2"
# Dependency check function
check_dependencies() {
local missing_deps=()
# Check rclone
if ! command -v rclone >/dev/null 2>&1; then
missing_deps+=("rclone")
fi
# Check fusermount or fusermount3
if ! command -v fusermount >/dev/null 2>&1 && ! command -v fusermount3 >/dev/null 2>&1; then
missing_deps+=("fusermount (fuse)")
fi
# Check sudo
if ! command -v sudo >/dev/null 2>&1; then
missing_deps+=("sudo")
fi
# If there are missing dependencies, show error and exit
if [[ ${#missing_deps[@]} -gt 0 ]]; then
echo -e "${RED}[ERROR]${NC} Missing required dependencies:"
for dep in "${missing_deps[@]}"; do
echo -e " ${RED}✗${NC} $dep"
done
echo
echo -e "${YELLOW}Please install the missing packages:${NC}"
echo " Debian/Ubuntu: sudo apt-get install rclone fuse3 sudo"
echo " Alpine: apk add rclone fuse3 sudo"
echo " Termux: pkg install rclone libfuse3 tsu"
exit 1
fi
}
# Check if ps command is available
has_ps_command() {
command -v ps >/dev/null 2>&1
}
# Safely execute ps command
safe_ps() {
if has_ps_command; then
safe_ps 2>/dev/null || ps -ef 2>/dev/null
else
# No ps command, return empty
return 1
fi
}
# === SSH Config parsing functions ===
# Parse SSH config to get specified property for a Host
parse_ssh_config() {
local host_alias="$1"
local key="$2"
awk -v host="$host_alias" -v key="$key" '
tolower($1) == "host" {
for (i = 2; i <= NF; i++) {
if ($i == host) {
found = 1
next
}
}
found = 0
}
found && tolower($1) == tolower(key) {
print $2
exit
}
' "$SSH_CONFIG"
}
# Get complete SSH Host configuration
get_ssh_host_config() {
local host_alias="$1"
local hostname user port identity_file proxy_jump
hostname=$(parse_ssh_config "$host_alias" "HostName")
user=$(parse_ssh_config "$host_alias" "User")
port=$(parse_ssh_config "$host_alias" "Port")
identity_file=$(parse_ssh_config "$host_alias" "IdentityFile")
proxy_jump=$(parse_ssh_config "$host_alias" "ProxyJump")
# If no explicit HostName, use Host alias
hostname="${hostname:-$host_alias}"
# Expand ~ to actual path
if [[ -n "$identity_file" ]]; then
identity_file="${identity_file/#\~/$HOME}"
fi
# Output format: hostname|user|port|identity_file|proxy_jump
echo "$hostname|$user|$port|$identity_file|$proxy_jump"
}
# Build rclone SFTP backend string (using key_file)
# Parameters: $1 = host_alias or config_data
build_sftp_backend_with_key() {
local input="$1"
local config_data hostname user port key_file proxy_jump
# Determine if input is host_alias or config_data
if [[ "$input" == *"|"* ]]; then
# Already config_data
config_data="$input"
else
# Is host_alias, need to get config
config_data=$(get_ssh_host_config "$input")
fi
IFS='|' read -r hostname user port key_file proxy_jump <<< "$config_data"
# Basic backend string
local backend=":sftp,host=$hostname"
# Add optional parameters
[[ -n "$user" ]] && backend="$backend,user=$user"
[[ -n "$port" ]] && backend="$backend,port=$port"
[[ -n "$key_file" ]] && backend="$backend,key_file=$key_file"
echo "$backend"
}
# Build rclone SFTP backend string (without key_file, for use with --sftp-ssh)
# Parameters: $1 = host_alias or config_data
build_sftp_backend_without_key() {
local input="$1"
local config_data hostname user port key_file proxy_jump
# Determine if input is host_alias or config_data
if [[ "$input" == *"|"* ]]; then
# Already config_data
config_data="$input"
else
# Is host_alias, need to get config
config_data=$(get_ssh_host_config "$input")
fi
IFS='|' read -r hostname user port key_file proxy_jump <<< "$config_data"
# Basic backend string
local backend=":sftp,host=$hostname"
# Add optional parameters
[[ -n "$user" ]] && backend="$backend,user=$user"
[[ -n "$port" ]] && backend="$backend,port=$port"
echo "$backend"
}
# Check if key file exists
# Parameters: $1 = host_alias or config_data
has_key_file() {
local input="$1"
local config_data key_file
# Determine if input is host_alias or config_data
if [[ "$input" == *"|"* ]]; then
# Already config_data
config_data="$input"
else
# Is host_alias, need to get config
config_data=$(get_ssh_host_config "$input")
fi
IFS='|' read -r _ _ _ key_file _ <<< "$config_data"
[[ -n "$key_file" ]] && [[ -f "$key_file" ]]
}
# Build --sftp-ssh option
# Parameters: $1 = host_alias or config_data
build_sftp_ssh_option() {
local input="$1"
local config_data hostname user port key_file proxy_jump
# Determine if input is host_alias or config_data
if [[ "$input" == *"|"* ]]; then
# Already config_data
config_data="$input"
else
# Is host_alias, need to get config
config_data=$(get_ssh_host_config "$input")
fi
IFS='|' read -r hostname user port key_file proxy_jump <<< "$config_data"
# Add Port
if [[ -n "$port" ]]; then
ssh_cmd="$ssh_cmd -p $port"
fi
# Add IdentityFile
if [[ -n "$key_file" ]]; then
ssh_cmd="$ssh_cmd -i $key_file"
fi
# Add ProxyJump
if [[ -n "$proxy_jump" ]]; then
ssh_cmd="$ssh_cmd -J $proxy_jump"
fi
# Build complete SSH connection string user@hostname
local ssh_target=""
if [[ -n "$user" ]]; then
ssh_target="${user}@${hostname}"
else
ssh_target="${hostname}"
fi
echo "--sftp-ssh \"$ssh_cmd $ssh_target\""
}
# === Mount point lookup functions ===
# Find all mount points for specified host_alias via mount command
# Return format: each line contains "remote_path|mount_point"
find_mount_points_by_mount() {
local host_alias="$1"
# Get hostname
# local hostname=$(parse_ssh_config "$host_alias" "HostName")
# hostname="${hostname:-$host_alias}"
# Find all matching mounts from mount output
# Support formats: hostname: or hostname{xxx}:
mount | grep "type fuse.rclone" 2>/dev/null | while read -r line; do
[[ -z "$line" ]] && continue
local remote_part
remote_part=$(echo "$line" | awk '{print $1}')
local mount_point
mount_point=$(echo "$line" | awk '{print $3}')
# Check if remote_part starts with hostname
# Support hostname: or hostname{xxx}: format
if [[ "$remote_part" =~ ^${host_alias}(\{[^}]+\})?:(.*)$ ]]; then
# Extract remote_path (part after colon)
local remote_path="${BASH_REMATCH[2]}"
# Output format: remote_path|mount_point
echo "${remote_path}|${mount_point}"
fi
done || true
}
# Find rclone process PIDs matching host_alias and mount point
# Parameters: $1 = host_alias, $2 = mount_point (optional)
find_rclone_pids() {
local host_alias="$1"
local mount_point="${2:-}"
if [[ -n "$mount_point" ]]; then
# Match both host_alias and mount point
safe_ps | grep "rclone.*$host_alias" | grep "$mount_point" | grep -v grep | grep -v "rclonemm" | awk '{print $1}' | grep -E '^[0-9]+$'
else
# Match only host_alias
safe_ps | grep "rclone.*$host_alias" | grep -v grep | grep -v "rclonemm" | awk '{print $1}' | grep -E '^[0-9]+$'
fi
}
# Color definitions
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Cleanup old logs
cleanup_old_logs() {
# Ensure log directory exists
if [[ ! -d "$LOG_DIR" ]]; then
return 0
fi
# Delete log files older than retention days
# Format: rclone_mount_YYYYMMDD.log
sudo find "$LOG_DIR" -name "rclone_mount_*.log" -type f -mtime +${LOG_RETENTION_DAYS} -delete 2>/dev/null || true
}
# Show usage help
show_help() {
echo -e "${BLUE}rclone Mount Management Tool${NC}"
echo
echo -e "${YELLOW}Usage:${NC}"
echo " $(basename "$0") <operation> <remote_name>[:<remote_path>] [custom_mount_point]"
echo " $(basename "$0") <operation> --all|-a"
echo
echo -e "${YELLOW}Parameters:${NC}"
echo " operation Operation type"
echo " - mount/mnt: Mount remote storage"
echo " - unmount/umount/umnt: Unmount mount point"
echo " - status: Check mount status"
echo " - show: Show all mounted rclone"
echo " - help: Show this help"
echo " remote_name Host name from SSH config"
echo " remote_path Optional, remote path (only for mount command)"
echo " custom_mount_point Optional, custom local mount point (only for mount command)"
echo " --all, -a Batch operation on all hosts (mount/unmount supported)"
echo
echo -e "${YELLOW}Single mount examples:${NC}"
echo " $(basename "$0") mount mom # Mount mom root to default location"
echo " $(basename "$0") mount mom:/WEB/logs # Mount mom specified directory to default location"
echo " $(basename "$0") mount mom /custom/path # Mount mom root to custom location"
echo " $(basename "$0") mount mom:/WEB /custom/path # Mount mom specified directory to custom location"
echo " $(basename "$0") unmount mom # Unmount mom (auto-find mount point)"
echo " $(basename "$0") status mom # Check mom status (auto-find mount point)"
echo
echo -e "${YELLOW}Batch operation examples:${NC}"
echo " $(basename "$0") mount --all # Mount all SSH Hosts (auto-skip failures)"
echo " $(basename "$0") mount -a # Same as above (shorthand)"
echo " $(basename "$0") unmount --all # Unmount all rclone mounts"
echo " $(basename "$0") umount -a # Same as above (shorthand)"
echo
echo -e "${YELLOW}Other examples:${NC}"
echo " $(basename "$0") show # Show all mounts"
echo " $(basename "$0") help # Show this help"
echo
echo -e "${YELLOW}Available SSH Hosts:${NC}"
get_available_remotes
}
# Log message
log_message() {
local level="$1"
local message="$2"
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] [$level] $message" | sudo tee -a "$LOG_FILE" >/dev/null 2>&1 || true
}
# Print message
print_message() {
local level="$1"
local message="$2"
local color=""
case "$level" in
"INFO") color="$GREEN" ;;
"WARN") color="$YELLOW" ;;
"ERROR") color="$RED" ;;
"DEBUG") color="$BLUE" ;;
esac
echo -e "${color}[$level]${NC} $message"
# Avoid log_message failure causing script exit
log_message "$level" "$message" || true
}
# Get available SSH Host list
get_available_remotes() {
if [[ ! -f "$SSH_CONFIG" ]]; then
return 1
fi
# Get all Host names (exclude wildcards and special purpose)
local hosts
hosts=$(grep "^Host " "$SSH_CONFIG" | awk '{print $2}' | grep -v '\*' | grep -v 'github.com' | sort)
# Process each host line by line
while IFS= read -r host; do
[[ -z "$host" ]] && continue
# Check if this host is already mounted
local mount_info
mount_info=$(find_mount_points_by_mount "$host")
if [[ -n "$mount_info" ]]; then
# Count number of mounts
local mount_count
mount_count=$(echo "$mount_info" | wc -l)
# Get first mount point
local first_mount
first_mount=$(echo "$mount_info" | head -1 | cut -d'|' -f2)
if [[ $mount_count -gt 1 ]]; then
echo -e "$host ${YELLOW}[mounted ${mount_count}: $first_mount ...]${NC}"
else
echo -e "$host ${YELLOW}[mounted: $first_mount]${NC}"
fi
else
echo "$host"
fi
done <<< "$hosts"
}
# Check if SSH Host exists in config file
check_remote_exists() {
local host_alias="$1"
if [[ ! -f "$SSH_CONFIG" ]]; then
print_message "ERROR" "SSH config does not exist: $SSH_CONFIG"
return 1
fi
if ! grep -q "^Host .*\\b$host_alias\\b" "$SSH_CONFIG"; then
print_message "ERROR" "SSH Host '$host_alias' does not exist in SSH config"
print_message "INFO" "Available Hosts: $(get_available_remotes | tr '\n' ' ')"
return 1
fi
return 0
}
# Check and create mount point
ensure_mount_point() {
local mount_point="$1"
if [[ ! -d "$mount_point" ]]; then
print_message "INFO" "Creating mount point: $mount_point"
sudo mkdir -p "$mount_point"
sudo chown "$USER:$USER" "$mount_point"
fi
# Check directory permissions
if [[ ! -w "$mount_point" ]]; then
print_message "WARN" "Mount point has no write permission, attempting to fix"
sudo chown "$USER:$USER" "$mount_point"
fi
}
# Check if already mounted
is_mounted() {
local mount_point="$1"
mount | grep -q " $mount_point " || return 1
return 0
}
# Core mount function (single host mount logic, includes timeout waiting)
# Parameters: $1 = host_alias, $2 = remote_path (optional), $3 = mount_point
# Returns: 0 success, 1 failure
do_mount_single() {
local host_alias="$1"
local remote_path="${2:-}"
local mount_point="$3"
local sftp_ssh_option=""
local remote_source
local config_data
local hostname user port key_file proxy_jump
# Get SSH Host configuration
config_data=$(get_ssh_host_config "$host_alias")
IFS='|' read -r hostname user port key_file proxy_jump <<< "$config_data"
# Uniformly use --sftp-ssh option for mounting
sftp_ssh_option=$(build_sftp_ssh_option "$config_data")
# Create rclone config before mounting (use pass parameter to avoid interactive prompts)
if ! sudo rclone config create "$host_alias" sftp pass "" >/dev/null 2>&1; then
print_message "ERROR" "Failed to create rclone config"
return 1
fi
# Combine remote source path
if [[ -n "$remote_path" ]]; then
remote_source="${host_alias}:$remote_path"
else
remote_source="${host_alias}:"
fi
# Check if target mount point is occupied
if is_mounted "$mount_point"; then
print_message "ERROR" "Mount point $mount_point is already occupied by another filesystem"
local existing_mount
existing_mount=$(mount | grep " $mount_point ")
print_message "INFO" "Existing mount: $existing_mount"
# Clear rclone config
sudo rclone config delete "$host_alias" >/dev/null 2>&1 || true
return 1
fi
# Ensure mount point exists
ensure_mount_point "$mount_point"
# Build rclone command
local cmd="sudo rclone mount $remote_source $mount_point"
cmd="$cmd $RCLONE_MOUNT_OPTIONS"
cmd="$cmd $sftp_ssh_option"
cmd="$cmd --daemon"
print_message "INFO" "Mount command: $cmd"
print_message "INFO" "Tip: If SSH requires password, enter it when prompted"
# Execute mount command (error handling)
# Use variable to capture exit code, avoid set -e causing script exit
# Note: Do not restore set -e, let caller control error handling behavior
local saved_errexit
if [[ $- =~ e ]]; then
saved_errexit=1
else
saved_errexit=0
fi
set +e
eval "$cmd" 2>&1
local mount_exit_code=$?
if [[ $saved_errexit -eq 1 ]]; then
set -e
fi
if [[ $mount_exit_code -ne 0 ]]; then
print_message "ERROR" "rclone mount command failed (exit code: $mount_exit_code), see log for details: $LOG_FILE"
print_message "WARN" "Performing cleanup..."
# Clear rclone config
print_message "INFO" "Clearing rclone config..."
sudo rclone config delete "$host_alias" >/dev/null 2>&1 || true
# Delete mount point (if default location)
if [[ "$mount_point" == "$MOUNT_BASE_DIR/$host_alias" ]] && [[ -d "$mount_point" ]]; then
print_message "INFO" "Deleting mount point: $mount_point"
sudo rmdir "$mount_point" 2>/dev/null || sudo rm -rf "$mount_point" 2>/dev/null || true
fi
return 1
fi
# Wait for mount to complete
local count=0
while [[ $count -lt $MOUNT_TIMEOUT ]]; do
print_message "INFO" "Waiting for mount... ($((count + 1))/$MOUNT_TIMEOUT)"
sleep 1 || true
if is_mounted "$mount_point"; then
print_message "INFO" "Mount successful: $host_alias:$remote_path -> $mount_point"
# Test if mount point is accessible
print_message "INFO" "Testing mount point access..."
if timeout $ACCESS_TEST_TIMEOUT ls "$mount_point" >/dev/null 2>&1; then
print_message "INFO" "Mount point test passed, log: $LOG_FILE"
# Clear rclone config after successful mount
sudo rclone config delete "$host_alias" >/dev/null 2>&1 || true
return 0
else
print_message "ERROR" "Mount point inaccessible, mount failed"
print_message "WARN" "Performing cleanup..."
# Perform cleanup (don't call unmount_remote to avoid circular dependency)
sudo umount "$mount_point" 2>/dev/null || sudo umount -f -l "$mount_point" 2>/dev/null || true
local rclone_pids
rclone_pids=$(find_rclone_pids "$host_alias" "$mount_point" || true)
if [[ -n "$rclone_pids" ]]; then
echo "$rclone_pids" | xargs sudo kill -TERM 2>/dev/null || true
sleep 1 || true
fi
# Clear rclone config
sudo rclone config delete "$host_alias" >/dev/null 2>&1 || true
return 1
fi
fi
count=$((count + 1))
done
print_message "ERROR" "Mount timeout, please check log: $LOG_FILE"
print_message "WARN" "Performing cleanup..."
# Perform cleanup
sudo umount "$mount_point" 2>/dev/null || sudo umount -f -l "$mount_point" 2>/dev/null || true
local rclone_pids
rclone_pids=$(find_rclone_pids "$host_alias" "$mount_point" || true)
if [[ -n "$rclone_pids" ]]; then
echo "$rclone_pids" | xargs sudo kill -TERM 2>/dev/null || true
sleep 1 || true
fi
# Clear rclone config
sudo rclone config delete "$host_alias" >/dev/null 2>&1 || true
return 1
}
# Mount remote (user interface function)
mount_remote() {
local host_alias="$1"
local remote_path="${2:-}"
local custom_mount_point="${3:-}"
local mount_point
# Determine mount point: custom > default
if [[ -n "$custom_mount_point" ]]; then
mount_point="$custom_mount_point"
else
mount_point="$MOUNT_BASE_DIR/$host_alias"
fi
print_message "INFO" "Using --sftp-ssh option for mounting"
print_message "INFO" "Mounting $host_alias:$remote_path to $mount_point"
if [[ -n "$custom_mount_point" ]]; then
print_message "INFO" "Using custom mount point: $custom_mount_point"
fi
# Call core mount function
do_mount_single "$host_alias" "$remote_path" "$mount_point"
return $?
}
# Unmount remote
unmount_remote() {
local host_alias="$1"
local custom_mount_point="${2:-}"
# local hostname
# # Get hostname for process identification
# hostname=$(parse_ssh_config "$host_alias" "HostName")
# hostname="${hostname:-$host_alias}"
# Find all mount points
local mount_info
mount_info=$(find_mount_points_by_mount "$host_alias")
if [[ -z "$mount_info" ]]; then
print_message "WARN" "$host_alias is not mounted"
# Check if there are still related rclone processes running
local rclone_pids
rclone_pids=$(find_rclone_pids "$host_alias")
if [[ -n "$rclone_pids" ]]; then
print_message "INFO" "Found residual rclone processes: $(echo "$rclone_pids" | tr '\n' ' ')"
print_message "INFO" "Terminating processes..."
echo "$rclone_pids" | xargs sudo kill
sleep 2
print_message "INFO" "rclone processes terminated"
fi
return 0
fi
# If remote_path is specified, check if it exists
if [[ -n "$custom_mount_point" ]]; then
local found=false
local remote_path
local target_mount_point=""
while IFS='|' read -r existing_path existing_point; do
if [[ "$existing_point" == "$custom_mount_point" ]]; then
found=true
target_mount_point="$existing_point"
remote_path="$existing_path"
break
fi
done <<< "$mount_info"
if [[ "$found" == false ]]; then
print_message "ERROR" "Cannot find mounted path: $host_alias $custom_mount_point"
print_message "INFO" "Current $host_alias mounts:"
while IFS='|' read -r existing_path existing_point; do
local path_display="${existing_path:-~}"
print_message "INFO" " [$path_display] -> $existing_point"
done <<< "$mount_info"
return 1
fi
# Perform single unmount
print_message "INFO" "Unmounting $host_alias:$remote_path on $custom_mount_point"
perform_unmount "$target_mount_point" "$host_alias"
return $?
else
# No remote_path specified, ask for confirmation one by one
print_message "INFO" "Found $(echo "$mount_info" | wc -l) mount(s) for $host_alias:"
# Use process substitution to avoid subshell, let read get input from /dev/tty
while IFS='|' read -r existing_path existing_point; do
local path_display="${existing_path:-~}"
print_message "INFO" ""
print_message "INFO" "Mount: [$path_display] on $existing_point"
echo -n "Do you want to unmount this? [y/N]: "
# Read from /dev/tty to ensure user input
read -r answer </dev/tty || {
echo
print_message "WARN" "Input interrupted, canceling operation"
return 1
}
if [[ "$answer" =~ ^[Yy]$ ]]; then
perform_unmount "$existing_point" "$host_alias"
else
print_message "INFO" "Skipping $existing_point"
fi
done <<< "$mount_info"
return 0
fi
}
# Perform actual unmount operation
perform_unmount() {
local mount_point="$1"
local host_alias="$2"
# local hostname="$3"
print_message "INFO" "Unmounting $mount_point"
# Try normal unmount
if sudo umount "$mount_point" 2>/dev/null; then
print_message "INFO" "Unmount successful: $mount_point"
# Ensure related processes are terminated
local rclone_pids
rclone_pids=$(find_rclone_pids "$host_alias" "$mount_point" || true)
if [[ -n "$rclone_pids" ]]; then
print_message "INFO" "Found related rclone processes: $(echo "$rclone_pids" | tr '\n' ' ')"
print_message "INFO" "Terminating processes..."
echo "$rclone_pids" | xargs sudo kill 2>/dev/null || true
sleep 1 || true
print_message "INFO" "rclone processes terminated"
fi
# Delete default mount point, try to delete custom mount point (if empty)
if [[ "$mount_point" == "$MOUNT_BASE_DIR/$host_alias" ]]; then
if [[ -d "$mount_point" ]]; then
print_message "INFO" "Deleting mount point: $mount_point"
sudo rmdir "$mount_point" 2>/dev/null || sudo rm -rf "$mount_point"
fi
else
# Custom mount point: try to delete empty directory, keep if not empty
if [[ -d "$mount_point" ]]; then
if sudo rmdir "$mount_point" 2>/dev/null; then
print_message "INFO" "Deleted empty custom mount point: $mount_point"
else
print_message "INFO" "Keeping non-empty custom mount point: $mount_point"
fi
fi
fi
return 0
fi
# Force unmount
print_message "WARN" "Normal unmount failed, trying force unmount"
if sudo umount -f -l "$mount_point" 2>/dev/null; then
print_message "INFO" "Force unmount successful: $mount_point"
# Ensure related processes are terminated
local rclone_pids
rclone_pids=$(find_rclone_pids "$host_alias" "$mount_point" || true)
if [[ -n "$rclone_pids" ]]; then
print_message "INFO" "Found related rclone processes: $(echo "$rclone_pids" | tr '\n' ' ')"
print_message "INFO" "Terminating processes..."
echo "$rclone_pids" | xargs sudo kill 2>/dev/null || true
sleep 1 || true
print_message "INFO" "rclone processes terminated"
fi
# Delete default mount point, try to delete custom mount point (if empty)
if [[ "$mount_point" == "$MOUNT_BASE_DIR/$host_alias" ]]; then
if [[ -d "$mount_point" ]]; then
print_message "INFO" "Deleting mount point: $mount_point"
sudo rmdir "$mount_point" 2>/dev/null || sudo rm -rf "$mount_point"
fi
else
# Custom mount point: try to delete empty directory, keep if not empty
if [[ -d "$mount_point" ]]; then
if sudo rmdir "$mount_point" 2>/dev/null; then
print_message "INFO" "Deleted empty custom mount point: $mount_point"
else
print_message "INFO" "Keeping non-empty custom mount point: $mount_point"
fi
fi
fi
return 0
fi
# Last resort: kill rclone process first then try unmount
print_message "WARN" "Force unmount failed, trying to terminate rclone process first"
local rclone_pids
rclone_pids=$(find_rclone_pids "$host_alias" "$mount_point" || true)
if [[ -n "$rclone_pids" ]]; then
print_message "INFO" "Found rclone processes: $(echo "$rclone_pids" | tr '\n' ' ')"
print_message "INFO" "Sending TERM signal..."
echo "$rclone_pids" | xargs sudo kill -TERM 2>/dev/null || true
sleep 3 || true
# Check if processes are still running, force kill if so
local remaining_pids
remaining_pids=$(find_rclone_pids "$host_alias" "$mount_point" || true)
if [[ -n "$remaining_pids" ]]; then
print_message "WARN" "Processes still running: $(echo "$remaining_pids" | tr '\n' ' ')"
print_message "INFO" "Sending KILL signal to force terminate..."
echo "$remaining_pids" | xargs sudo kill -KILL 2>/dev/null || true
sleep 2 || true
print_message "INFO" "rclone processes force terminated"
else
print_message "INFO" "rclone processes terminated"
fi
# Try unmount again
sudo umount "$mount_point" 2>/dev/null || true
print_message "INFO" "Unmount after process termination successful: $mount_point"
# Delete default mount point, try to delete custom mount point (if empty)
if [[ "$mount_point" == "$MOUNT_BASE_DIR/$host_alias" ]]; then
if [[ -d "$mount_point" ]]; then
print_message "INFO" "Deleting mount point: $mount_point"
sudo rmdir "$mount_point" 2>/dev/null || sudo rm -rf "$mount_point"
fi
else
# Custom mount point: try to delete empty directory, keep if not empty
if [[ -d "$mount_point" ]]; then
if sudo rmdir "$mount_point" 2>/dev/null; then
print_message "INFO" "Deleted empty custom mount point: $mount_point"
else
print_message "INFO" "Keeping non-empty custom mount point: $mount_point"
fi
fi
fi
return 0
fi
print_message "ERROR" "Unmount failed: $mount_point"
return 1
}
# Check mount status
check_status() {
local host_alias="$1"
local remote_path="${2:-}"
print_message "INFO" "Checking mount status for $host_alias"
# Find all mount points
local mount_info
mount_info=$(find_mount_points_by_mount "$host_alias")
if [[ -z "$mount_info" ]]; then
print_message "INFO" "Not mounted"
return 0
fi
# If remote_path is specified, only show matching ones
if [[ -n "$remote_path" ]]; then
local found=false
while IFS='|' read -r existing_path mount_point; do
if [[ "$existing_path" == "$remote_path" ]]; then
found=true
local full_mount_info
full_mount_info=$(mount | grep " $mount_point ")
print_message "INFO" "Mounted: $full_mount_info"
# Check if mount point is accessible
if timeout 5 ls "$mount_point" >/dev/null 2>&1; then
print_message "INFO" "Mount point accessible"
else
print_message "WARN" "Mount point inaccessible, may need remounting"
fi
fi
done <<< "$mount_info"
if [[ "$found" == false ]]; then
print_message "INFO" "Specified path $remote_path is not mounted"
fi
else
# Show all mounts
print_message "INFO" "Found $(echo "$mount_info" | wc -l) mount(s):"
while IFS='|' read -r existing_path mount_point; do
local path_display="${existing_path:-~}"
print_message "INFO" " [$path_display] -> $mount_point"
# Check if mount point is accessible
if timeout 2 ls "$mount_point" >/dev/null 2>&1; then
print_message "INFO" " Accessible"
else
print_message "WARN" " Inaccessible"
fi
done <<< "$mount_info"
fi
}
# Show all mounted rclone
show_all_mounts() {
print_message "INFO" "Checking all rclone mounts..."
echo
# Get all rclone type mounts
local mounts
mounts=$(mount | grep "type fuse.rclone")
if [[ -z "$mounts" ]]; then
print_message "INFO" "No rclone mounts currently"
return 0
fi
echo -e "${GREEN}=== Mounted rclone remotes ===${NC}"
echo
# Parse and display each mount
while IFS= read -r line; do
# Extract remote name and mount point
local remote
remote=$(echo "$line" | awk '{print $1}' | cut -d: -f1)
local mount_point
mount_point=$(echo "$line" | awk '{print $3}')
echo -e "${YELLOW}Remote:${NC} $remote"
echo -e "${YELLOW}Mount point:${NC} $mount_point"
# Test mount point access
if timeout 2 ls "$mount_point" >/dev/null 2>&1; then
echo -e "${YELLOW}Status:${NC} ${GREEN}Accessible${NC}"
else
echo -e "${YELLOW}Status:${NC} ${RED}Inaccessible${NC}"
fi
# Show mount details
echo -e "${YELLOW}Details:${NC} $line"
echo
done <<< "$mounts"
# Show related rclone processes
local rclone_pids
rclone_pids=$(safe_ps | grep "rclone mount" | grep -v grep | grep -v "rclonemm")
if [[ -n "$rclone_pids" ]]; then
echo -e "${GREEN}=== rclone processes ===${NC}"
echo "$rclone_pids"
fi
}
# Batch mount all available SSH hosts
mount_all_remotes() {
local success_count=0
local fail_count=0
local failed_hosts=()
print_message "INFO" "Getting all available SSH Hosts..."
# Get all available hosts
local all_hosts
all_hosts=$(grep "^Host " "$SSH_CONFIG" | awk '{print $2}' | grep -v '\*' | grep -v 'github.com' | sort)
if [[ -z "$all_hosts" ]]; then
print_message "WARN" "No SSH Hosts available for mounting"
return 0
fi
local total_hosts
total_hosts=$(echo "$all_hosts" | wc -l)
print_message "INFO" "Found $total_hosts SSH Hosts, starting batch mount..."
echo
# Temporarily disable errexit to avoid single failure interrupting entire flow
set +e
local current=0
while IFS= read -r host; do
[[ -z "$host" ]] && continue
((current++))
local mount_point="$MOUNT_BASE_DIR/$host"
# Check if already mounted
if is_mounted "$mount_point"; then
print_message "INFO" "[$current/$total_hosts] $host already mounted at $mount_point, skipping"
((success_count++))
continue
fi
print_message "INFO" "==== [$current/$total_hosts] Mounting $host ===="
# Use core mount function
if do_mount_single "$host" "" "$mount_point"; then
((success_count++))
print_message "INFO" "$host mount successful"
else
((fail_count++))
failed_hosts+=("$host")
print_message "WARN" "$host mount failed, continuing to next"
fi
echo
done <<< "$all_hosts"
# Restore errexit
set -e
# Show statistics
echo
print_message "INFO" "==== Batch mount completed ===="
print_message "INFO" "Success: $success_count, Failed: $fail_count, Total: $total_hosts"
if [[ $fail_count -gt 0 ]]; then
print_message "WARN" "Failed hosts: ${failed_hosts[*]}"
return 1
fi
return 0
}
# Batch unmount all rclone mounts
unmount_all_remotes() {
local success_count=0
local fail_count=0
local failed_mounts=()
print_message "INFO" "Checking all rclone mounts..."
# Get all rclone mounts
local all_mounts
all_mounts=$(mount | grep "type fuse.rclone" || true)
if [[ -z "$all_mounts" ]]; then
print_message "INFO" "No rclone mounts currently"
return 0
fi
local total_mounts
total_mounts=$(echo "$all_mounts" | wc -l)
print_message "INFO" "Found $total_mounts rclone mounts, starting batch unmount..."
echo
# Temporarily disable errexit
set +e
local current=0
while IFS= read -r line; do
[[ -z "$line" ]] && continue
((current++))
local mount_point
mount_point=$(echo "$line" | awk '{print $3}')
local remote_part
remote_part=$(echo "$line" | awk '{print $1}')
# Extract host alias (remove possible parameters and path)
local host_alias
if [[ "$remote_part" =~ ^([^{:]+) ]]; then
host_alias="${BASH_REMATCH[1]}"
else
host_alias=$(echo "$remote_part" | cut -d: -f1)
fi
print_message "INFO" "==== [$current/$total_mounts] Unmounting $mount_point ===="
# Use core unmount function perform_unmount
if perform_unmount "$mount_point" "$host_alias"; then
((success_count++))
print_message "INFO" "$mount_point unmount successful"
else
((fail_count++))
failed_mounts+=("$mount_point")
print_message "WARN" "$mount_point unmount failed, continuing to next"
fi
echo
done <<< "$all_mounts"
# Restore errexit
set -e
# Show statistics
echo
print_message "INFO" "==== Batch unmount completed ===="
print_message "INFO" "Success: $success_count, Failed: $fail_count, Total: $total_mounts"
if [[ $fail_count -gt 0 ]]; then
print_message "WARN" "Failed mount points: ${failed_mounts[*]}"
return 1
fi
return 0
}
# Main function
main() {
# Check dependencies
check_dependencies
# Cleanup old logs
cleanup_old_logs
# Check parameters
if [[ $# -eq 0 ]]; then
print_message "ERROR" "Missing required parameters"
show_help
exit 1
fi
local operation="$1"
local remote_spec="${2:-}"
local custom_mount_point="${3:-}"
# Handle help operation
if [[ "$operation" == "help" || "$operation" == "-h" || "$operation" == "--help" ]]; then
show_help
exit 0
fi
# Handle show operation (no remote name needed)
if [[ "$operation" == "show" ]]; then
show_all_mounts
exit 0
fi
# Handle batch operations (--all or -a)
if [[ "$remote_spec" == "--all" || "$remote_spec" == "-a" ]]; then
# Ensure log directory exists
sudo mkdir -p "$LOG_DIR"
print_message "INFO" "Batch operation: $operation all remotes, log: $LOG_FILE"
case "$operation" in
"mount"|"mnt")
mount_all_remotes
exit $?
;;
"unmount"|"umount"|"umnt")
unmount_all_remotes
exit $?
;;
*)
print_message "ERROR" "Operation '$operation' does not support --all/-a parameter"
print_message "INFO" "Operations supporting --all/-a: mount, unmount"
exit 1
;;
esac
fi
# Check if remote spec is provided
if [[ -z "$remote_spec" ]]; then
print_message "ERROR" "Missing SSH Host name parameter or --all/-a option"
show_help
exit 1
fi
# Parse host alias and path
# Format: host_alias or host_alias:path
local host_alias remote_path
if [[ "$remote_spec" =~ ^([^:]+):(.*)$ ]]; then
host_alias="${BASH_REMATCH[1]}"
remote_path="${BASH_REMATCH[2]}"
else
host_alias="$remote_spec"
remote_path=""
fi
# Check if SSH Host exists
if ! check_remote_exists "$host_alias"; then
exit 1
fi
# Ensure log directory exists
sudo mkdir -p "$LOG_DIR"
# Execute corresponding operation
print_message "INFO" "Operation: $operation, log: $LOG_FILE"
case "$operation" in
"mount"|"mnt")
mount_remote "$host_alias" "$remote_path" "$custom_mount_point"
;;
"unmount"|"umount"|"umnt")
unmount_remote "$host_alias" "$custom_mount_point"
;;
"status")
check_status "$host_alias" "$remote_path"
;;
*)
print_message "ERROR" "Unknown operation: $operation"
show_help
exit 1
;;
esac
}
# Execute main function
main "$@"
<#
.SYNOPSIS
sclone - 輕量 rclone SFTP 包裹器 (PowerShell 版本)
.DESCRIPTION
透過讀取 SSH config 動態建立 SFTP backend
支援 ProxyJump 和多 remote 操作
.PARAMETER RcloneCommand
rclone 命令 (如: ls, copy, sync, cat 等)
.PARAMETER Args
傳遞給 rclone 的其餘參數
.EXAMPLE
.\sclone.ps1 ls mom:
列出 mom 根目錄
.EXAMPLE
.\sclone.ps1 ls mom:/WEB
列出 mom:/WEB
.EXAMPLE
.\sclone.ps1 cat mom:/file.txt
顯示檔案內容
.EXAMPLE
.\sclone.ps1 copy mom:/src /local/dest
複製到本地
.EXAMPLE
.\sclone.ps1 sync /local/src mom:/dest
同步到遠端
.EXAMPLE
.\sclone.ps1 copy host1:/src host2:/dest
多 remote 複製(無 proxy 限定)
.NOTES
版本: 1.0.0
日期: 2026-01-11
作者: Translated from Bash version
#>
[CmdletBinding()]
param(
[Parameter(Position = 0)]
[string]$RcloneCommand,
[Parameter(Position = 1, ValueFromRemainingArguments)]
[string[]]$RcloneArgs
)
# ==================== 錯誤處理設定 ====================
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
# ==================== 全域設定 ====================
$script:Config = @{
SSHConfig = if ($env:SCLONE_SSH_CONFIG) { $env:SCLONE_SSH_CONFIG } else { "$env:USERPROFILE\.ssh\config" }
}
# 基本 SSH 命令模板
$script:SSHCmdBase = "ssh -o StrictHostKeyChecking=no -o ControlMaster=auto -o ControlPath=~/.ssh/cm-%r@%h:%p -o ControlPersist=10 -o ConnectTimeout=2"
# 用於追蹤需要清理的 hosts
$script:CleanupHosts = @()
# ==================== 輔助函數: 訊息輸出 ====================
<#
.SYNOPSIS
輸出帶顏色的訊息
#>
function Write-ColorMessage {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('INFO', 'WARN', 'ERROR', 'DEBUG')]
[string]$Level,
[Parameter(Mandatory)]
[string]$Message
)
$color = switch ($Level) {
'INFO' { 'Green' }
'WARN' { 'Yellow' }
'ERROR' { 'Red' }
'DEBUG' { 'Cyan' }
}
Write-Host "[$Level] $Message" -ForegroundColor $color
}
# ==================== SSH Config 解析函數 ====================
<#
.SYNOPSIS
解析 SSH config 獲取指定 Host 的指定屬性
#>
function Get-SSHConfigValue {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias,
[Parameter(Mandatory)]
[string]$Key
)
$sshConfigPath = $script:Config.SSHConfig
if (-not (Test-Path $sshConfigPath)) {
return $null
}
$content = Get-Content $sshConfigPath -Raw
$lines = $content -split "`r?`n"
$inHostBlock = $false
foreach ($line in $lines) {
# 檢查是否進入目標 Host 區塊
if ($line -match '^\s*Host\s+(.+)$') {
$hostPatterns = $Matches[1] -split '\s+'
$inHostBlock = $hostPatterns -contains $HostAlias
continue
}
# 如果在目標 Host 區塊內,查找屬性
if ($inHostBlock) {
if ($line -match "^\s*$Key\s+(.+)$") {
return $Matches[1].Trim()
}
# 遇到下一個 Host 區塊,停止搜尋
if ($line -match '^\s*Host\s+') {
break
}
}
}
return $null
}
<#
.SYNOPSIS
獲取 SSH Host 的完整設定
#>
function Get-SSHHostConfig {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias
)
$hostname = Get-SSHConfigValue -HostAlias $HostAlias -Key 'HostName'
$user = Get-SSHConfigValue -HostAlias $HostAlias -Key 'User'
$port = Get-SSHConfigValue -HostAlias $HostAlias -Key 'Port'
$identityFile = Get-SSHConfigValue -HostAlias $HostAlias -Key 'IdentityFile'
$proxyJump = Get-SSHConfigValue -HostAlias $HostAlias -Key 'ProxyJump'
# 如果沒有明確的 HostName,使用 Host alias
if (-not $hostname) {
$hostname = $HostAlias
}
# 展開 ~ 為實際路徑
if ($identityFile -and $identityFile.StartsWith('~')) {
$identityFile = $identityFile -replace '^~', $env:USERPROFILE
}
return [PSCustomObject]@{
HostAlias = $HostAlias
HostName = $hostname
User = $user
Port = $port
IdentityFile = $identityFile
ProxyJump = $proxyJump
}
}
<#
.SYNOPSIS
檢查 host 是否需要 proxy
#>
function Test-HasProxy {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias
)
$proxyJump = Get-SSHConfigValue -HostAlias $HostAlias -Key 'ProxyJump'
return [bool]$proxyJump
}
<#
.SYNOPSIS
組建完整 SSH 命令字串(用於 --sftp-ssh 模式)
#>
function Get-SSHCommand {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias
)
$config = Get-SSHHostConfig -HostAlias $HostAlias
$sshCmd = $script:SSHCmdBase
# 添加 Port
if ($config.Port) {
$sshCmd += " -p $($config.Port)"
}
# 添加 IdentityFile
if ($config.IdentityFile) {
$sshCmd += " -i `"$($config.IdentityFile)`""
}
# 添加 ProxyJump
if ($config.ProxyJump) {
$sshCmd += " -J $($config.ProxyJump)"
}
# 組建完整的 SSH 連線字串 user@hostname
$sshTarget = if ($config.User) {
"$($config.User)@$($config.HostName)"
} else {
$config.HostName
}
return "$sshCmd $sshTarget"
}
# ==================== Host 識別函數 ====================
<#
.SYNOPSIS
檢查 host 是否存在於 SSH config
#>
function Test-HostExistsInSSHConfig {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias
)
$sshConfigPath = $script:Config.SSHConfig
if (-not (Test-Path $sshConfigPath)) {
return $false
}
$content = Get-Content $sshConfigPath
$found = $content | Where-Object { $_ -match "^\s*Host\s+.*\b$([regex]::Escape($HostAlias))\b" }
return [bool]$found
}
<#
.SYNOPSIS
獲取可用的 SSH Host 清單
#>
function Get-AvailableHosts {
[CmdletBinding()]
param()
$sshConfigPath = $script:Config.SSHConfig
if (-not (Test-Path $sshConfigPath)) {
return @()
}
$content = Get-Content $sshConfigPath
$hosts = $content | Where-Object { $_ -match '^\s*Host\s+(.+)$' } | ForEach-Object {
$Matches[1] -split '\s+' | Where-Object { $_ -notmatch '\*' -and $_ -ne 'github.com' }
} | Select-Object -Unique | Sort-Object
return $hosts
}
<#
.SYNOPSIS
從參數中識別 SSH host
輸出格式: 唯一的 host alias 陣列
#>
function Find-SSHHostsInArgs {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string[]]$InputArgs
)
$foundHosts = @()
foreach ($arg in $InputArgs) {
# 檢查是否符合 xxx: 或 xxx:/path 格式
if ($arg -match '^([a-zA-Z0-9_-]+):') {
$potentialHost = $Matches[1]
# 檢查是否存在於 SSH config
if (Test-HostExistsInSSHConfig -HostAlias $potentialHost) {
$foundHosts += $potentialHost
}
}
}
# 回傳去重後的 hosts
return $foundHosts | Select-Object -Unique
}
# ==================== rclone Config 建立函數 ====================
<#
.SYNOPSIS
為無 proxy 的 host 建立原生 SFTP 設定
#>
function New-NativeSftpConfig {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias
)
$config = Get-SSHHostConfig -HostAlias $HostAlias
# 建立設定參數
$configArgs = @($HostAlias, 'sftp', "host=$($config.HostName)")
if ($config.User) {
$configArgs += "user=$($config.User)"
}
if ($config.Port) {
$configArgs += "port=$($config.Port)"
}
if ($config.IdentityFile) {
$configArgs += "key_file=$($config.IdentityFile)"
}
try {
$result = & rclone config create @configArgs --non-interactive 2>&1
if ($LASTEXITCODE -ne 0) {
Write-ColorMessage -Level ERROR -Message "建立 rclone config 失敗: $result"
return $false
}
return $true
} catch {
Write-ColorMessage -Level ERROR -Message "建立 rclone config 失敗: $_"
return $false
}
}
<#
.SYNOPSIS
為有 proxy 的 host 建立使用 ssh 選項的設定
#>
function New-SSHSftpConfig {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias
)
$sshCmd = Get-SSHCommand -HostAlias $HostAlias
try {
$result = & rclone config create $HostAlias sftp "ssh=$sshCmd" --non-interactive 2>&1
if ($LASTEXITCODE -ne 0) {
Write-ColorMessage -Level ERROR -Message "建立 rclone config 失敗: $result"
return $false
}
return $true
} catch {
Write-ColorMessage -Level ERROR -Message "建立 rclone config 失敗: $_"
return $false
}
}
# ==================== 清理函數 ====================
<#
.SYNOPSIS
清理所有已建立的 rclone config
#>
function Invoke-Cleanup {
[CmdletBinding()]
param()
foreach ($hostItem in $script:CleanupHosts) {
try {
& rclone config delete $hostItem 2>&1 | Out-Null
} catch {
# 忽略清理錯誤
}
}
}
# ==================== 使用說明 ====================
<#
.SYNOPSIS
顯示使用說明
#>
function Show-Help {
[CmdletBinding()]
param()
Write-Host "sclone - 輕量 rclone SFTP 包裹器" -ForegroundColor Cyan
Write-Host ""
Write-Host "使用方式:" -ForegroundColor Yellow
Write-Host " .\sclone.ps1 <rclone-command> <host_alias>[:/path] [args...]"
Write-Host ""
Write-Host "說明:" -ForegroundColor Yellow
Write-Host " 透過讀取 SSH config 自動建立 SFTP 連線,支援 SSH 所有原生參數。"
Write-Host ""
Write-Host "範例:" -ForegroundColor Yellow
Write-Host " .\sclone.ps1 ls mom: # 列出 mom 根目錄"
Write-Host " .\sclone.ps1 ls mom:/WEB # 列出 mom:/WEB"
Write-Host " .\sclone.ps1 cat mom:/file.txt # 顯示檔案內容"
Write-Host " .\sclone.ps1 copy mom:/src C:\local\dest # 複製到本地"
Write-Host " .\sclone.ps1 sync C:\local\src mom:/dest # 同步到遠端"
Write-Host " .\sclone.ps1 copy host1:/src host2:/dest # 多 remote 複製(無 proxy 限定)"
Write-Host ""
Write-Host "環境變數:" -ForegroundColor Yellow
Write-Host " SCLONE_SSH_CONFIG SSH 設定檔路徑 (預設: ~\.ssh\config)"
Write-Host ""
Write-Host "注意:" -ForegroundColor Yellow
Write-Host " - 支援多 remote 操作(僅限無 ProxyJump 的 host)"
Write-Host " - 若任一目標 host 有 ProxyJump 設定,僅支援單一 remote"
Write-Host ""
Write-Host "可用的 SSH Hosts:" -ForegroundColor Yellow
$availableHosts = Get-AvailableHosts
if ($availableHosts.Count -gt 0) {
Write-Host " $($availableHosts -join ', ')"
} else {
Write-Host " (無可用的 SSH Host)"
}
}
# ==================== 主函數 ====================
function Main {
# 無參數或請求幫助
if (-not $RcloneCommand -or $RcloneCommand -in @('-h', '--help', 'help')) {
Show-Help
exit 0
}
# 檢查 SSH config 是否存在
$sshConfigPath = $script:Config.SSHConfig
if (-not (Test-Path $sshConfigPath)) {
Write-ColorMessage -Level ERROR -Message "SSH config 不存在: $sshConfigPath"
exit 1
}
# 識別參數中的 SSH hosts
$allArgs = @($RcloneCommand) + @($RcloneArgs)
if (-not $RcloneArgs -or $RcloneArgs.Count -eq 0) {
Write-ColorMessage -Level ERROR -Message "參數中未找到有效的 SSH Host"
$availableHosts = Get-AvailableHosts
Write-Host "可用的 SSH Hosts: $($availableHosts -join ', ')" -ForegroundColor Yellow
exit 1
}
$sshHosts = @(Find-SSHHostsInArgs -InputArgs $RcloneArgs)
# 檢查是否找到 SSH host
if ($sshHosts.Count -eq 0) {
Write-ColorMessage -Level ERROR -Message "參數中未找到有效的 SSH Host"
$availableHosts = Get-AvailableHosts
Write-Host "可用的 SSH Hosts: $($availableHosts -join ', ')" -ForegroundColor Yellow
exit 1
}
# 計算找到的 host 數量
$hostCount = $sshHosts.Count
# 檢測是否有任何 host 需要 proxy
$hasAnyProxy = $false
foreach ($hostItem in $sshHosts) {
if (Test-HasProxy -HostAlias $hostItem) {
$hasAnyProxy = $true
break
}
}
# 多 remote + proxy = 不支援
if ($hostCount -gt 1 -and $hasAnyProxy) {
Write-ColorMessage -Level ERROR -Message "多 remote 場景不支援 ProxyJump"
Write-Host "偵測到多個 SSH Hosts: $($sshHosts -join ', ')" -ForegroundColor Yellow
Write-Host "請直接使用 rclone 並手動設定 backend" -ForegroundColor Yellow
exit 1
}
# 使用 try/finally 確保清理
try {
# 建立各 host 的 rclone config
foreach ($hostItem in $sshHosts) {
$script:CleanupHosts += $hostItem
if (Test-HasProxy -HostAlias $hostItem) {
if (-not (New-SSHSftpConfig -HostAlias $hostItem)) {
Write-ColorMessage -Level ERROR -Message "建立 rclone config 失敗: $hostItem"
exit 1
}
} else {
if (-not (New-NativeSftpConfig -HostAlias $hostItem)) {
Write-ColorMessage -Level ERROR -Message "建立 rclone config 失敗: $hostItem"
exit 1
}
}
}
# 執行 rclone 命令
Write-ColorMessage -Level DEBUG -Message "執行: rclone $RcloneCommand $($RcloneArgs -join ' ')"
& rclone $RcloneCommand @RcloneArgs
$exitCode = $LASTEXITCODE
} finally {
# 清理 rclone config
Invoke-Cleanup
}
exit $exitCode
}
# 執行主程式
Main
#!/bin/bash
# sclone - 輕量 rclone SFTP 包裹器
# 透過讀取 ~/.ssh/config 動態建立 SFTP backend
# 使用方式: sclone <rclone-command> <host_alias>[:/path] [args...]
# 範例: sclone ls mom:/WEB
set -euo pipefail
# === 設定 ===
SSH_CONFIG="${SCLONE_SSH_CONFIG:-$HOME/.ssh/config}"
# 基本 SSH 命令模板
SSH_CMD_BASE="ssh -o StrictHostKeyChecking=no -o ControlMaster=auto -o ControlPath=~/.ssh/cm-%r@%h:%p -o ControlPersist=10 -o ConnectTimeout=2"
# 用於追蹤需要清理的 hosts(支援多個)
CLEANUP_HOSTS=()
# === SSH Config 解析函數 ===
# 解析 SSH config 獲取指定 Host 的指定屬性
parse_ssh_config() {
local host_alias="$1"
local key="$2"
awk -v host="$host_alias" -v key="$key" '
tolower($1) == "host" {
for (i = 2; i <= NF; i++) {
if ($i == host) {
found = 1
next
}
}
found = 0
}
found && tolower($1) == tolower(key) {
print $2
exit
}
' "$SSH_CONFIG"
}
# 獲取 SSH Host 的完整設定
# 輸出格式: hostname|user|port|identity_file|proxy_jump
get_ssh_host_config() {
local host_alias="$1"
local hostname user port identity_file proxy_jump
hostname=$(parse_ssh_config "$host_alias" "HostName")
user=$(parse_ssh_config "$host_alias" "User")
port=$(parse_ssh_config "$host_alias" "Port")
identity_file=$(parse_ssh_config "$host_alias" "IdentityFile")
proxy_jump=$(parse_ssh_config "$host_alias" "ProxyJump")
# 如果沒有明確的 HostName,使用 Host alias
hostname="${hostname:-$host_alias}"
# 展開 ~ 為實際路徑
if [[ -n "$identity_file" ]]; then
identity_file="${identity_file/#\~/$HOME}"
fi
echo "$hostname|$user|$port|$identity_file|$proxy_jump"
}
# 檢測 host 是否需要 proxy
has_proxy() {
local host_alias="$1"
local proxy_jump
proxy_jump=$(parse_ssh_config "$host_alias" "ProxyJump")
[[ -n "$proxy_jump" ]]
}
# 組建完整 SSH 命令字串(用於 --sftp-ssh 模式)
build_ssh_command() {
local host_alias="$1"
local config_data hostname user port key_file proxy_jump
local ssh_cmd="$SSH_CMD_BASE"
config_data=$(get_ssh_host_config "$host_alias")
IFS='|' read -r hostname user port key_file proxy_jump <<< "$config_data"
# 添加 Port
if [[ -n "$port" ]]; then
ssh_cmd="$ssh_cmd -p $port"
fi
# 添加 IdentityFile
if [[ -n "$key_file" ]]; then
ssh_cmd="$ssh_cmd -i $key_file"
fi
# 添加 ProxyJump
if [[ -n "$proxy_jump" ]]; then
ssh_cmd="$ssh_cmd -J $proxy_jump"
fi
# 組建完整的 SSH 連線字串 user@hostname
local ssh_target=""
if [[ -n "$user" ]]; then
ssh_target="${user}@${hostname}"
else
ssh_target="${hostname}"
fi
echo "$ssh_cmd $ssh_target"
}
# 為無 proxy 的 host 建立原生 SFTP 設定
create_native_sftp_config() {
local host_alias="$1"
local config_data hostname user port key_file proxy_jump
config_data=$(get_ssh_host_config "$host_alias")
IFS='|' read -r hostname user port key_file proxy_jump <<< "$config_data"
# 建立設定參數
local args=("$host_alias" "sftp" "host=$hostname")
[[ -n "$user" ]] && args+=("user=$user")
[[ -n "$port" ]] && args+=("port=$port")
[[ -n "$key_file" ]] && args+=("key_file=$key_file")
rclone config create "${args[@]}" --non-interactive >/dev/null 2>&1
}
# 為有 proxy 的 host 建立使用 ssh 選項的設定
create_ssh_sftp_config() {
local host_alias="$1"
local ssh_cmd
ssh_cmd=$(build_ssh_command "$host_alias")
rclone config create "$host_alias" sftp "ssh=$ssh_cmd" --non-interactive >/dev/null 2>&1
}
# === Host 識別函數 ===
# 檢查 host 是否存在於 SSH config
host_exists_in_ssh_config() {
local host_alias="$1"
if [[ ! -f "$SSH_CONFIG" ]]; then
return 1
fi
grep -q "^Host .*\\b${host_alias}\\b" "$SSH_CONFIG" 2>/dev/null
}
# 獲取可用的 SSH Host 清單
get_available_hosts() {
if [[ ! -f "$SSH_CONFIG" ]]; then
return 1
fi
grep "^Host " "$SSH_CONFIG" | awk '{print $2}' | grep -v '\*' | grep -v 'github.com' | sort | tr '\n' ' '
}
# 從參數中識別 SSH host
# 輸出格式: host_alias (可能有多個,每行一個)
find_ssh_hosts_in_args() {
local args=("$@")
local found_hosts=()
for arg in "${args[@]}"; do
# 檢查是否符合 xxx: 或 xxx:/path 格式
if [[ "$arg" =~ ^([a-zA-Z0-9_-]+): ]]; then
local potential_host="${BASH_REMATCH[1]}"
# 檢查是否存在於 SSH config
if host_exists_in_ssh_config "$potential_host"; then
found_hosts+=("$potential_host")
fi
fi
done
# 輸出找到的 hosts(去重)
printf '%s\n' "${found_hosts[@]}" | sort -u
}
# === 清理函數 ===
cleanup() {
for host in "${CLEANUP_HOSTS[@]}"; do
rclone config delete "$host" 2>/dev/null || true
done
}
# === 使用說明 ===
show_help() {
cat << 'EOF'
sclone - 輕量 rclone SFTP 包裹器
使用方式:
sclone <rclone-command> <host_alias>[:/path] [args...]
說明:
透過讀取 SSH config 自動建立 SFTP 連線,支援 SSH 所有原生參數。
範例:
sclone ls mom: # 列出 mom 根目錄
sclone ls mom:/WEB # 列出 mom:/WEB
sclone cat mom:/file.txt # 顯示檔案內容
sclone copy mom:/src /local/dest # 複製到本地
sclone sync /local/src mom:/dest # 同步到遠端
sclone copy host1:/src host2:/dest # 多 remote 複製(無 proxy 限定)
環境變數:
SCLONE_SSH_CONFIG SSH 設定檔路徑 (預設: ~/.ssh/config)
注意:
- 支援多 remote 操作(僅限無 ProxyJump 的 host)
- 若任一目標 host 有 ProxyJump 設定,僅支援單一 remote
EOF
}
# === 主函數 ===
main() {
# 無參數或請求幫助
if [[ $# -eq 0 ]] || [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]] || [[ "$1" == "help" ]]; then
show_help
exit 0
fi
# 檢查 SSH config 是否存在
if [[ ! -f "$SSH_CONFIG" ]]; then
echo "錯誤: SSH config 不存在: $SSH_CONFIG" >&2
exit 1
fi
# 識別參數中的 SSH hosts
local ssh_hosts
ssh_hosts=$(find_ssh_hosts_in_args "$@")
# 檢查是否找到 SSH host
if [[ -z "$ssh_hosts" ]]; then
echo "錯誤: 參數中未找到有效的 SSH Host" >&2
echo "可用的 SSH Hosts: $(get_available_hosts)" >&2
exit 1
fi
# 計算找到的 host 數量
local host_count
host_count=$(echo "$ssh_hosts" | wc -l)
# 檢測是否有任何 host 需要 proxy
local has_any_proxy=false
while IFS= read -r host; do
if has_proxy "$host"; then
has_any_proxy=true
break
fi
done <<< "$ssh_hosts"
# 多 remote + proxy = 不支援
if [[ $host_count -gt 1 ]] && [[ "$has_any_proxy" == "true" ]]; then
echo "錯誤: 多 remote 場景不支援 ProxyJump" >&2
echo "偵測到多個 SSH Hosts: $(echo "$ssh_hosts" | tr '\n' ' ')" >&2
echo "請直接使用 rclone 並手動設定 backend" >&2
exit 1
fi
# 設定清理 trap
trap cleanup EXIT
# 建立各 host 的 rclone config
while IFS= read -r host; do
CLEANUP_HOSTS+=("$host")
if has_proxy "$host"; then
if ! create_ssh_sftp_config "$host"; then
echo "錯誤: 建立 rclone config 失敗: $host" >&2
exit 1
fi
else
if ! create_native_sftp_config "$host"; then
echo "錯誤: 建立 rclone config 失敗: $host" >&2
exit 1
fi
fi
done <<< "$ssh_hosts"
# 執行 rclone 命令
rclone "$@"
}
main "$@"
<#
.SYNOPSIS
sclone - Lightweight rclone SFTP wrapper (PowerShell Version)
.DESCRIPTION
Dynamically creates SFTP backend by reading SSH config
Supports ProxyJump and multi-remote operations
.PARAMETER RcloneCommand
rclone command (e.g.: ls, copy, sync, cat, etc.)
.PARAMETER Args
Remaining arguments to pass to rclone
.EXAMPLE
.\sclone_en.ps1 ls mom:
List mom root directory
.EXAMPLE
.\sclone_en.ps1 ls mom:/WEB
List mom:/WEB
.EXAMPLE
.\sclone_en.ps1 cat mom:/file.txt
Display file contents
.EXAMPLE
.\sclone_en.ps1 copy mom:/src /local/dest
Copy to local
.EXAMPLE
.\sclone_en.ps1 sync /local/src mom:/dest
Sync to remote
.EXAMPLE
.\sclone_en.ps1 copy host1:/src host2:/dest
Multi-remote copy (no proxy only)
.NOTES
Version: 1.0.0
Date: 2026-01-11
Author: Translated from Bash version
#>
[CmdletBinding()]
param(
[Parameter(Position = 0)]
[string]$RcloneCommand,
[Parameter(Position = 1, ValueFromRemainingArguments)]
[string[]]$RcloneArgs
)
# ==================== Error Handling Settings ====================
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
# ==================== Global Configuration ====================
$script:Config = @{
SSHConfig = if ($env:SCLONE_SSH_CONFIG) { $env:SCLONE_SSH_CONFIG } else { "$env:USERPROFILE\.ssh\config" }
}
# Basic SSH command template
$script:SSHCmdBase = "ssh -o StrictHostKeyChecking=no -o ControlMaster=auto -o ControlPath=~/.ssh/cm-%r@%h:%p -o ControlPersist=10 -o ConnectTimeout=2"
# Track hosts that need cleanup
$script:CleanupHosts = @()
# ==================== Helper Functions: Message Output ====================
<#
.SYNOPSIS
Output colored message
#>
function Write-ColorMessage {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('INFO', 'WARN', 'ERROR', 'DEBUG')]
[string]$Level,
[Parameter(Mandatory)]
[string]$Message
)
$color = switch ($Level) {
'INFO' { 'Green' }
'WARN' { 'Yellow' }
'ERROR' { 'Red' }
'DEBUG' { 'Cyan' }
}
Write-Host "[$Level] $Message" -ForegroundColor $color
}
# ==================== SSH Config Parsing Functions ====================
<#
.SYNOPSIS
Parse SSH config to get specified property for a Host
#>
function Get-SSHConfigValue {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias,
[Parameter(Mandatory)]
[string]$Key
)
$sshConfigPath = $script:Config.SSHConfig
if (-not (Test-Path $sshConfigPath)) {
return $null
}
$content = Get-Content $sshConfigPath -Raw
$lines = $content -split "`r?`n"
$inHostBlock = $false
foreach ($line in $lines) {
# Check if entering target Host block
if ($line -match '^\s*Host\s+(.+)$') {
$hostPatterns = $Matches[1] -split '\s+'
$inHostBlock = $hostPatterns -contains $HostAlias
continue
}
# If in target Host block, find property
if ($inHostBlock) {
if ($line -match "^\s*$Key\s+(.+)$") {
return $Matches[1].Trim()
}
# Reached next Host block, stop searching
if ($line -match '^\s*Host\s+') {
break
}
}
}
return $null
}
<#
.SYNOPSIS
Get complete SSH Host configuration
#>
function Get-SSHHostConfig {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias
)
$hostname = Get-SSHConfigValue -HostAlias $HostAlias -Key 'HostName'
$user = Get-SSHConfigValue -HostAlias $HostAlias -Key 'User'
$port = Get-SSHConfigValue -HostAlias $HostAlias -Key 'Port'
$identityFile = Get-SSHConfigValue -HostAlias $HostAlias -Key 'IdentityFile'
$proxyJump = Get-SSHConfigValue -HostAlias $HostAlias -Key 'ProxyJump'
# If no explicit HostName, use Host alias
if (-not $hostname) {
$hostname = $HostAlias
}
# Expand ~ to actual path
if ($identityFile -and $identityFile.StartsWith('~')) {
$identityFile = $identityFile -replace '^~', $env:USERPROFILE
}
return [PSCustomObject]@{
HostAlias = $HostAlias
HostName = $hostname
User = $user
Port = $port
IdentityFile = $identityFile
ProxyJump = $proxyJump
}
}
<#
.SYNOPSIS
Check if host requires proxy
#>
function Test-HasProxy {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias
)
$proxyJump = Get-SSHConfigValue -HostAlias $HostAlias -Key 'ProxyJump'
return [bool]$proxyJump
}
<#
.SYNOPSIS
Build complete SSH command string (for --sftp-ssh mode)
#>
function Get-SSHCommand {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias
)
$config = Get-SSHHostConfig -HostAlias $HostAlias
$sshCmd = $script:SSHCmdBase
# Add Port
if ($config.Port) {
$sshCmd += " -p $($config.Port)"
}
# Add IdentityFile
if ($config.IdentityFile) {
$sshCmd += " -i `"$($config.IdentityFile)`""
}
# Add ProxyJump
if ($config.ProxyJump) {
$sshCmd += " -J $($config.ProxyJump)"
}
# Build complete SSH connection string user@hostname
$sshTarget = if ($config.User) {
"$($config.User)@$($config.HostName)"
} else {
$config.HostName
}
return "$sshCmd $sshTarget"
}
# ==================== Host Identification Functions ====================
<#
.SYNOPSIS
Check if host exists in SSH config
#>
function Test-HostExistsInSSHConfig {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias
)
$sshConfigPath = $script:Config.SSHConfig
if (-not (Test-Path $sshConfigPath)) {
return $false
}
$content = Get-Content $sshConfigPath
$found = $content | Where-Object { $_ -match "^\s*Host\s+.*\b$([regex]::Escape($HostAlias))\b" }
return [bool]$found
}
<#
.SYNOPSIS
Get available SSH Host list
#>
function Get-AvailableHosts {
[CmdletBinding()]
param()
$sshConfigPath = $script:Config.SSHConfig
if (-not (Test-Path $sshConfigPath)) {
return @()
}
$content = Get-Content $sshConfigPath
$hosts = $content | Where-Object { $_ -match '^\s*Host\s+(.+)$' } | ForEach-Object {
$Matches[1] -split '\s+' | Where-Object { $_ -notmatch '\*' -and $_ -ne 'github.com' }
} | Select-Object -Unique | Sort-Object
return $hosts
}
<#
.SYNOPSIS
Identify SSH hosts from arguments
Output format: unique host alias array
#>
function Find-SSHHostsInArgs {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string[]]$InputArgs
)
$foundHosts = @()
foreach ($arg in $InputArgs) {
# Check if matches xxx: or xxx:/path format
if ($arg -match '^([a-zA-Z0-9_-]+):') {
$potentialHost = $Matches[1]
# Check if exists in SSH config
if (Test-HostExistsInSSHConfig -HostAlias $potentialHost) {
$foundHosts += $potentialHost
}
}
}
# Return deduplicated hosts
return $foundHosts | Select-Object -Unique
}
# ==================== rclone Config Creation Functions ====================
<#
.SYNOPSIS
Create native SFTP config for hosts without proxy
#>
function New-NativeSftpConfig {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias
)
$config = Get-SSHHostConfig -HostAlias $HostAlias
# Build config parameters
$configArgs = @($HostAlias, 'sftp', "host=$($config.HostName)")
if ($config.User) {
$configArgs += "user=$($config.User)"
}
if ($config.Port) {
$configArgs += "port=$($config.Port)"
}
if ($config.IdentityFile) {
$configArgs += "key_file=$($config.IdentityFile)"
}
try {
$result = & rclone config create @configArgs --non-interactive 2>&1
if ($LASTEXITCODE -ne 0) {
Write-ColorMessage -Level ERROR -Message "Failed to create rclone config: $result"
return $false
}
return $true
} catch {
Write-ColorMessage -Level ERROR -Message "Failed to create rclone config: $_"
return $false
}
}
<#
.SYNOPSIS
Create config using ssh option for hosts with proxy
#>
function New-SSHSftpConfig {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$HostAlias
)
$sshCmd = Get-SSHCommand -HostAlias $HostAlias
try {
$result = & rclone config create $HostAlias sftp "ssh=$sshCmd" --non-interactive 2>&1
if ($LASTEXITCODE -ne 0) {
Write-ColorMessage -Level ERROR -Message "Failed to create rclone config: $result"
return $false
}
return $true
} catch {
Write-ColorMessage -Level ERROR -Message "Failed to create rclone config: $_"
return $false
}
}
# ==================== Cleanup Function ====================
<#
.SYNOPSIS
Clean up all created rclone configs
#>
function Invoke-Cleanup {
[CmdletBinding()]
param()
foreach ($hostItem in $script:CleanupHosts) {
try {
& rclone config delete $hostItem 2>&1 | Out-Null
} catch {
# Ignore cleanup errors
}
}
}
# ==================== Usage Help ====================
<#
.SYNOPSIS
Show usage help
#>
function Show-Help {
[CmdletBinding()]
param()
Write-Host "sclone - Lightweight rclone SFTP wrapper" -ForegroundColor Cyan
Write-Host ""
Write-Host "Usage:" -ForegroundColor Yellow
Write-Host " .\sclone_en.ps1 <rclone-command> <host_alias>[:/path] [args...]"
Write-Host ""
Write-Host "Description:" -ForegroundColor Yellow
Write-Host " Automatically creates SFTP connection by reading SSH config, supports all native SSH parameters."
Write-Host ""
Write-Host "Examples:" -ForegroundColor Yellow
Write-Host " .\sclone_en.ps1 ls mom: # List mom root directory"
Write-Host " .\sclone_en.ps1 ls mom:/WEB # List mom:/WEB"
Write-Host " .\sclone_en.ps1 cat mom:/file.txt # Display file contents"
Write-Host " .\sclone_en.ps1 copy mom:/src C:\local\dest # Copy to local"
Write-Host " .\sclone_en.ps1 sync C:\local\src mom:/dest # Sync to remote"
Write-Host " .\sclone_en.ps1 copy host1:/src host2:/dest # Multi-remote copy (no proxy only)"
Write-Host ""
Write-Host "Environment variables:" -ForegroundColor Yellow
Write-Host " SCLONE_SSH_CONFIG SSH config file path (default: ~\.ssh\config)"
Write-Host ""
Write-Host "Notes:" -ForegroundColor Yellow
Write-Host " - Supports multi-remote operations (only for hosts without ProxyJump)"
Write-Host " - If any target host has ProxyJump config, only single remote is supported"
Write-Host ""
Write-Host "Available SSH Hosts:" -ForegroundColor Yellow
$availableHosts = Get-AvailableHosts
if ($availableHosts.Count -gt 0) {
Write-Host " $($availableHosts -join ', ')"
} else {
Write-Host " (No available SSH Hosts)"
}
}
# ==================== Main Function ====================
function Main {
# No arguments or help requested
if (-not $RcloneCommand -or $RcloneCommand -in @('-h', '--help', 'help')) {
Show-Help
exit 0
}
# Check if SSH config exists
$sshConfigPath = $script:Config.SSHConfig
if (-not (Test-Path $sshConfigPath)) {
Write-ColorMessage -Level ERROR -Message "SSH config does not exist: $sshConfigPath"
exit 1
}
# Identify SSH hosts in arguments
$allArgs = @($RcloneCommand) + @($RcloneArgs)
if (-not $RcloneArgs -or $RcloneArgs.Count -eq 0) {
Write-ColorMessage -Level ERROR -Message "No valid SSH Host found in arguments"
$availableHosts = Get-AvailableHosts
Write-Host "Available SSH Hosts: $($availableHosts -join ', ')" -ForegroundColor Yellow
exit 1
}
$sshHosts = @(Find-SSHHostsInArgs -InputArgs $RcloneArgs)
# Check if SSH host found
if ($sshHosts.Count -eq 0) {
Write-ColorMessage -Level ERROR -Message "No valid SSH Host found in arguments"
$availableHosts = Get-AvailableHosts
Write-Host "Available SSH Hosts: $($availableHosts -join ', ')" -ForegroundColor Yellow
exit 1
}
# Count number of hosts found
$hostCount = $sshHosts.Count
# Detect if any host requires proxy
$hasAnyProxy = $false
foreach ($hostItem in $sshHosts) {
if (Test-HasProxy -HostAlias $hostItem) {
$hasAnyProxy = $true
break
}
}
# Multiple remotes + proxy = not supported
if ($hostCount -gt 1 -and $hasAnyProxy) {
Write-ColorMessage -Level ERROR -Message "Multi-remote scenario does not support ProxyJump"
Write-Host "Detected multiple SSH Hosts: $($sshHosts -join ', ')" -ForegroundColor Yellow
Write-Host "Please use rclone directly and configure backend manually" -ForegroundColor Yellow
exit 1
}
# Use try/finally to ensure cleanup
try {
# Create rclone config for each host
foreach ($hostItem in $sshHosts) {
$script:CleanupHosts += $hostItem
if (Test-HasProxy -HostAlias $hostItem) {
if (-not (New-SSHSftpConfig -HostAlias $hostItem)) {
Write-ColorMessage -Level ERROR -Message "Failed to create rclone config: $hostItem"
exit 1
}
} else {
if (-not (New-NativeSftpConfig -HostAlias $hostItem)) {
Write-ColorMessage -Level ERROR -Message "Failed to create rclone config: $hostItem"
exit 1
}
}
}
# Execute rclone command
Write-ColorMessage -Level DEBUG -Message "Executing: rclone $RcloneCommand $($RcloneArgs -join ' ')"
& rclone $RcloneCommand @RcloneArgs
$exitCode = $LASTEXITCODE
} finally {
# Clean up rclone config
Invoke-Cleanup
}
exit $exitCode
}
# Execute main program
Main
#!/bin/bash
# sclone - Lightweight rclone SFTP wrapper
# Dynamically creates SFTP backend by reading ~/.ssh/config
# Usage: sclone <rclone-command> <host_alias>[:/path] [args...]
# Example: sclone ls mom:/WEB
set -euo pipefail
# === Configuration ===
SSH_CONFIG="${SCLONE_SSH_CONFIG:-$HOME/.ssh/config}"
# Basic SSH command template
SSH_CMD_BASE="ssh -o StrictHostKeyChecking=no -o ControlMaster=auto -o ControlPath=~/.ssh/cm-%r@%h:%p -o ControlPersist=10 -o ConnectTimeout=2"
# Track hosts that need cleanup (supports multiple)
CLEANUP_HOSTS=()
# === SSH Config parsing functions ===
# Parse SSH config to get specified property for a Host
parse_ssh_config() {
local host_alias="$1"
local key="$2"
awk -v host="$host_alias" -v key="$key" '
tolower($1) == "host" {
for (i = 2; i <= NF; i++) {
if ($i == host) {
found = 1
next
}
}
found = 0
}
found && tolower($1) == tolower(key) {
print $2
exit
}
' "$SSH_CONFIG"
}
# Get complete SSH Host configuration
# Output format: hostname|user|port|identity_file|proxy_jump
get_ssh_host_config() {
local host_alias="$1"
local hostname user port identity_file proxy_jump
hostname=$(parse_ssh_config "$host_alias" "HostName")
user=$(parse_ssh_config "$host_alias" "User")
port=$(parse_ssh_config "$host_alias" "Port")
identity_file=$(parse_ssh_config "$host_alias" "IdentityFile")
proxy_jump=$(parse_ssh_config "$host_alias" "ProxyJump")
# If no explicit HostName, use Host alias
hostname="${hostname:-$host_alias}"
# Expand ~ to actual path
if [[ -n "$identity_file" ]]; then
identity_file="${identity_file/#\~/$HOME}"
fi
echo "$hostname|$user|$port|$identity_file|$proxy_jump"
}
# Detect if host requires proxy
has_proxy() {
local host_alias="$1"
local proxy_jump
proxy_jump=$(parse_ssh_config "$host_alias" "ProxyJump")
[[ -n "$proxy_jump" ]]
}
# Build complete SSH command string (for --sftp-ssh mode)
build_ssh_command() {
local host_alias="$1"
local config_data hostname user port key_file proxy_jump
local ssh_cmd="$SSH_CMD_BASE"
config_data=$(get_ssh_host_config "$host_alias")
IFS='|' read -r hostname user port key_file proxy_jump <<< "$config_data"
# Add Port
if [[ -n "$port" ]]; then
ssh_cmd="$ssh_cmd -p $port"
fi
# Add IdentityFile
if [[ -n "$key_file" ]]; then
ssh_cmd="$ssh_cmd -i $key_file"
fi
# Add ProxyJump
if [[ -n "$proxy_jump" ]]; then
ssh_cmd="$ssh_cmd -J $proxy_jump"
fi
# Build complete SSH connection string user@hostname
local ssh_target=""
if [[ -n "$user" ]]; then
ssh_target="${user}@${hostname}"
else
ssh_target="${hostname}"
fi
echo "$ssh_cmd $ssh_target"
}
# Create native SFTP config for hosts without proxy
create_native_sftp_config() {
local host_alias="$1"
local config_data hostname user port key_file proxy_jump
config_data=$(get_ssh_host_config "$host_alias")
IFS='|' read -r hostname user port key_file proxy_jump <<< "$config_data"
# Build config parameters
local args=("$host_alias" "sftp" "host=$hostname")
[[ -n "$user" ]] && args+=("user=$user")
[[ -n "$port" ]] && args+=("port=$port")
[[ -n "$key_file" ]] && args+=("key_file=$key_file")
rclone config create "${args[@]}" --non-interactive >/dev/null 2>&1
}
# Create config using ssh option for hosts with proxy
create_ssh_sftp_config() {
local host_alias="$1"
local ssh_cmd
ssh_cmd=$(build_ssh_command "$host_alias")
rclone config create "$host_alias" sftp "ssh=$ssh_cmd" --non-interactive >/dev/null 2>&1
}
# === Host identification functions ===
# Check if host exists in SSH config
host_exists_in_ssh_config() {
local host_alias="$1"
if [[ ! -f "$SSH_CONFIG" ]]; then
return 1
fi
grep -q "^Host .*\\b${host_alias}\\b" "$SSH_CONFIG" 2>/dev/null
}
# Get available SSH Host list
get_available_hosts() {
if [[ ! -f "$SSH_CONFIG" ]]; then
return 1
fi
grep "^Host " "$SSH_CONFIG" | awk '{print $2}' | grep -v '\*' | grep -v 'github.com' | sort | tr '\n' ' '
}
# Identify SSH hosts from arguments
# Output format: host_alias (possibly multiple, one per line)
find_ssh_hosts_in_args() {
local args=("$@")
local found_hosts=()
for arg in "${args[@]}"; do
# Check if matches xxx: or xxx:/path format
if [[ "$arg" =~ ^([a-zA-Z0-9_-]+): ]]; then
local potential_host="${BASH_REMATCH[1]}"
# Check if exists in SSH config
if host_exists_in_ssh_config "$potential_host"; then
found_hosts+=("$potential_host")
fi
fi
done
# Output found hosts (deduplicated)
printf '%s\n' "${found_hosts[@]}" | sort -u
}
# === Cleanup function ===
cleanup() {
for host in "${CLEANUP_HOSTS[@]}"; do
rclone config delete "$host" 2>/dev/null || true
done
}
# === Usage help ===
show_help() {
cat << 'EOF'
sclone - Lightweight rclone SFTP wrapper
Usage:
sclone <rclone-command> <host_alias>[:/path] [args...]
Description:
Automatically creates SFTP connection by reading SSH config, supports all native SSH parameters.
Examples:
sclone ls mom: # List mom root directory
sclone ls mom:/WEB # List mom:/WEB
sclone cat mom:/file.txt # Display file contents
sclone copy mom:/src /local/dest # Copy to local
sclone sync /local/src mom:/dest # Sync to remote
sclone copy host1:/src host2:/dest # Multi-remote copy (no proxy only)
Environment variables:
SCLONE_SSH_CONFIG SSH config file path (default: ~/.ssh/config)
Notes:
- Supports multi-remote operations (only for hosts without ProxyJump)
- If any target host has ProxyJump config, only single remote is supported
EOF
}
# === Main function ===
main() {
# No arguments or help requested
if [[ $# -eq 0 ]] || [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]] || [[ "$1" == "help" ]]; then
show_help
exit 0
fi
# Check if SSH config exists
if [[ ! -f "$SSH_CONFIG" ]]; then
echo "Error: SSH config does not exist: $SSH_CONFIG" >&2
exit 1
fi
# Identify SSH hosts in arguments
local ssh_hosts
ssh_hosts=$(find_ssh_hosts_in_args "$@")
# Check if SSH host found
if [[ -z "$ssh_hosts" ]]; then
echo "Error: No valid SSH Host found in arguments" >&2
echo "Available SSH Hosts: $(get_available_hosts)" >&2
exit 1
fi
# Count number of hosts found
local host_count
host_count=$(echo "$ssh_hosts" | wc -l)
# Detect if any host requires proxy
local has_any_proxy=false
while IFS= read -r host; do
if has_proxy "$host"; then
has_any_proxy=true
break
fi
done <<< "$ssh_hosts"
# Multiple remotes + proxy = not supported
if [[ $host_count -gt 1 ]] && [[ "$has_any_proxy" == "true" ]]; then
echo "Error: Multi-remote scenario does not support ProxyJump" >&2
echo "Detected multiple SSH Hosts: $(echo "$ssh_hosts" | tr '\n' ' ')" >&2
echo "Please use rclone directly and configure backend manually" >&2
exit 1
fi
# Set cleanup trap
trap cleanup EXIT
# Create rclone config for each host
while IFS= read -r host; do
CLEANUP_HOSTS+=("$host")
if has_proxy "$host"; then
if ! create_ssh_sftp_config "$host"; then
echo "Error: Failed to create rclone config: $host" >&2
exit 1
fi
else
if ! create_native_sftp_config "$host"; then
echo "Error: Failed to create rclone config: $host" >&2
exit 1
fi
fi
done <<< "$ssh_hosts"
# Execute rclone command
rclone "$@"
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment