2026-06-22

001_dryrun.yaml

 TestName: "ランナースクリプト動作確認(ローカル&httpbin)"


# 1. 基本設定: ローカルPCへのPing (Command型のテスト)

Setup:

  - Type: "Command"

    Command: "ping"

    Arguments: "127.0.0.1 -n 1"


# 2. 基本試験: 公開テストAPIへのGETリクエスト (API型のテスト)

BaseSteps:

  - Type: "API"

    Target: "DUMMY-API"

    Method: "GET"

    Endpoint: "https://httpbin.org/get"

    Assert:

      ResponseContains: "url"


# 3. ループ試験: 3種類のJSONデータを順番にPOSTする

LoopBlock:

  Target: "DUMMY-API"

  Method: "POST"

  Endpoint: "https://httpbin.org/post"

  PayloadFormat: "raw_json"

  Configs:

    - '{ "action": "accept", "test_id": 1 }'

    - '{ "action": "deny", "test_id": 2 }'

    - '{ "action": "ipsec", "test_id": 3 }'

  

  # POST直後に毎回実行される確認コマンド

  LoopSteps:

    - Type: "Command"

      Command: "curl.exe"

      Arguments: "-s -o /dev/null -w '%{http_code}' https://httpbin.org/status/200"


# 4. 後片付け: 逆順実行の確認 (Teardownフェーズ)

Teardown:

  - Type: "Command"

    Command: "ping"

    Arguments: "127.0.0.1 -n 1"

  - Type: "Command"

    Command: "ping"

    Arguments: "127.0.0.1 -n 2"

Run-Test.ps1

 <#

.SYNOPSIS

  コンパイル済みYAMLを実行する試験ランナー

.EXAMPLE

  .\Run-Test.ps1 -YamlPath ".\compiled_tests\001_fgt_policy.yaml"

#>

param (

    [Parameter(Mandatory=$true)]

    [string]$YamlPath

)


# --- Init: 依存モジュールの確認とYAMLパース ---

if (-not (Get-Module -ListAvailable -Name "powershell-yaml")) {

    Write-Warning "powershell-yaml モジュールが必要です。Install-Module powershell-yaml -Scope CurrentUser を実行してください。"

    exit 1

}


$YamlText = Get-Content $YamlPath -Raw

$Scenario = ConvertFrom-Yaml $YamlText


# --- 共通実行関数 ---

function Invoke-TestTask {

    param ($Task)


    Write-Host "[Exec] $($Task.Type) - $($Task.Target) $($Task.Endpoint)" -ForegroundColor Cyan


    try {

        if ($Task.Type -eq "API") {

            # 認証ヘッダーの簡易ルーティング (環境変数から取得)

            $Headers = @{ "Content-Type" = "application/json" }

            if ($Task.Target -match "FGT") { $Headers["Authorization"] = "Bearer $env:FGT_TOKEN" }

            elseif ($Task.Target -match "PA") { $Headers["X-PAN-KEY"] = $env:PA_TOKEN }


            # API送信

            $res = Invoke-RestMethod -Uri $Task.Endpoint -Method $Task.Method -Headers $Headers -Body $Task.Payload -SkipCertificateCheck

            

            # 簡易アサート

            if ($Task.Assert -and $Task.Assert.ResponseContains) {

                $resStr = $res | ConvertTo-Json -Depth 10

                if ($resStr -notmatch $Task.Assert.ResponseContains) {

                    throw "Assert Failed: レスポンスに $($Task.Assert.ResponseContains) が含まれていません。"

                }

            }

        }

        elseif ($Task.Type -eq "Command") {

            # 外部コマンド実行

            & $Task.Command $Task.Arguments

            if ($LASTEXITCODE -ne 0) { throw "Command Failed with ExitCode $LASTEXITCODE" }

        }

        elseif ($Task.Type -eq "Wait") {

            Start-Sleep -Milliseconds $Task.WaitAfterMs

        }

    }

    catch {

        Write-Error "[Error] Task Failed: $_"

        throw $_ # フェイルファスト(即時停止)

    }

}


# --- メイン実行ライフサイクル ---

try {

    # 1. Setup (基本設定)

    if ($Scenario.Setup) {

        Write-Host "=== 1. Setup ===" -ForegroundColor Yellow

        foreach ($task in $Scenario.Setup) { Invoke-TestTask -Task $task }

    }


    # 2. BaseSteps (基本試験)

    if ($Scenario.BaseSteps) {

        Write-Host "=== 2. BaseSteps ===" -ForegroundColor Yellow

        foreach ($task in $Scenario.BaseSteps) { Invoke-TestTask -Task $task }

    }


    # 3. LoopBlock (部分設定ループ配列)

    if ($Scenario.LoopBlock) {

        Write-Host "=== 3. LoopBlock ===" -ForegroundColor Yellow

        $loop = $Scenario.LoopBlock

        

        foreach ($config in $loop.Configs) {

            Write-Host ">>> Loop Config 適用: $config" -ForegroundColor Green

            # 設定変更タスクを動的生成して実行

            $configTask = [PSCustomObject]@{

                Type = "API"

                Target = $loop.Target

                Method = $loop.Method

                Endpoint = $loop.Endpoint

                Payload = $config

            }

            Invoke-TestTask -Task $configTask


            # ループ内試験の実行

            foreach ($step in $loop.LoopSteps) { Invoke-TestTask -Task $step }

        }

    }

}

finally {

    # 4. Teardown (後片付け:必ず逆順で実行し依存関係エラーを回避)

    if ($Scenario.Teardown) {

        Write-Host "=== 4. Teardown (Reverse) ===" -ForegroundColor Yellow

        for ($i = $Scenario.Teardown.Count - 1; $i -ge 0; $i--) {

            try { Invoke-TestTask -Task $Scenario.Teardown[$i] }

            catch { Write-Host "[Teardown Warn] $_" -ForegroundColor DarkGray }

        }

    }

}

test.yaml

 TestName: "FWポリシー・アクション変更に伴う疎通試験(置換済み)"


Setup:

  - Type: "API"

    Target: "FGT-A"

    Endpoint: "/api/v2/cmdb/firewall/policy"

    PayloadFormat: "raw_json"

    Payload: '{ "name": "Test-Policy", "srcintf": [{"name": "port1"}] }'


BaseSteps:

  - Type: "Command"

    Command: "ping"

    Arguments: "10.0.0.1 -n 1"


LoopBlock:

  Target: "FGT-A"

  Method: "PUT"

  Endpoint: "/api/v2/cmdb/firewall/policy/Test-Policy"

  PayloadFormat: "raw_json"

  # 変更パターンの配列(固定値)

  Configs:

    - '{ "action": "accept" }'

    - '{ "action": "deny" }'

    - '{ "action": "ipsec" }'

  

  # 上記のConfigを注入するたびに実行される試験

  LoopSteps:

    - Type: "Command"

      Command: "curl.exe"

      Arguments: "https://10.0.0.1 -s -o /dev/null"


Teardown:

  - Type: "API"

    Target: "FGT-A"

    Method: "DELETE"

    Endpoint: "/api/v2/cmdb/firewall/policy/Test-Policy"

2026-06-18

fgtest-runner lab.sample.json

 {

  "FGT_HOST": "192.0.2.1",

  "FGT_SERIAL": "CHANGE_ME",

  "FGT_VDOM": "root",

  "CLIENT_IP": "192.0.2.10",

  "CLIENT_MASK": "255.255.255.255",

  "URLFILTER_ID": "1",

  "TEST_ADDR_NAME": "zo3_test_client",

  "CURL_TIMEOUT_SEC": "10"

}


fgtest-runner fgt_webfilter_curl.yaml

 SuiteID: fgt_webfilter_curl

Description: FortiGate WebFilter setting patterns with curl fact collection. No pass/fail judgment.


Targets:

  FGT:

    Type: FortiGate

    Host: %%FGT_HOST%%

    Vdom: %%FGT_VDOM%%

    ExpectedSerial: %%FGT_SERIAL%%

    TokenEnv: FGT_TOKEN

    SkipCertificateCheck: true


PreCleanup:

  - ID: delete_test_address_old

    Target: FGT

    Type: API

    Method: DELETE

    Endpoint: /api/v2/cmdb/firewall/address/%%TEST_ADDR_NAME%%


Setup:

  - ID: create_test_address

    Target: FGT

    Type: API

    Method: POST

    Endpoint: /api/v2/cmdb/firewall/address

    PayloadFormat: raw_json

    Payload: |

      {

        "name": "%%TEST_ADDR_NAME%%",

        "subnet": "%%CLIENT_IP%% %%CLIENT_MASK%%"

      }


# Settings are data rows. The runner applies each row to ApplySetting, runs all Requests, then cleans up.

Settings:

  - ID: wf_block_example_simple

    wf_url_pattern: "example.com/malware"

    wf_type: "simple"

    wf_action: "block"


  - ID: wf_allow_example_simple

    wf_url_pattern: "example.com/business"

    wf_type: "simple"

    wf_action: "allow"


  - ID: wf_monitor_example_wildcard

    wf_url_pattern: "*.example.org"

    wf_type: "wildcard"

    wf_action: "monitor"


ApplySetting:

  - ID: apply_urlfilter_entry

    Target: FGT

    Type: API

    Method: PUT

    Endpoint: /api/v2/cmdb/webfilter/urlfilter/%%URLFILTER_ID%%

    PayloadFormat: raw_json

    Payload: |

      {

        "entries": [

          {

            "id": 1,

            "url": "%%wf_url_pattern%%",

            "type": "%%wf_type%%",

            "action": "%%wf_action%%",

            "status": "enable"

          }

        ]

      }

    WaitAfterMs: 1000


Requests:

  - ID: curl_http_malware

    Target: LOCAL

    Type: Command

    Command: curl.exe

    TimeoutMs: 30000

    Args:

      - "-i"

      - "-L"

      - "--max-time"

      - "%%CURL_TIMEOUT_SEC%%"

      - "http://example.com/malware"


  - ID: curl_https_malware_insecure

    Target: LOCAL

    Type: Command

    Command: curl.exe

    TimeoutMs: 30000

    Args:

      - "-k"

      - "-i"

      - "-L"

      - "--max-time"

      - "%%CURL_TIMEOUT_SEC%%"

      - "https://example.com/malware"


  - ID: curl_http_business

    Target: LOCAL

    Type: Command

    Command: curl.exe

    TimeoutMs: 30000

    Args:

      - "-i"

      - "-L"

      - "--max-time"

      - "%%CURL_TIMEOUT_SEC%%"

      - "http://example.com/business"


  - ID: get_fgt_system_status_after_request

    Target: FGT

    Type: API

    Method: GET

    Endpoint: /api/v2/monitor/system/status


CleanupSetting:

  - ID: clear_urlfilter_entries

    Target: FGT

    Type: API

    Method: PUT

    Endpoint: /api/v2/cmdb/webfilter/urlfilter/%%URLFILTER_ID%%

    PayloadFormat: raw_json

    Payload: |

      {

        "entries": []

      }

    WaitAfterMs: 500


Teardown:

  - ID: delete_test_address

    Target: FGT

    Type: API

    Method: DELETE

    Endpoint: /api/v2/cmdb/firewall/address/%%TEST_ADDR_NAME%%


fgtest-runner run.ps1

 #requires -Version 7.0

<#

.SYNOPSIS

  Minimal network test runner for lab use.


.DESCRIPTION

  YAML 1 file = 1 test suite.

  The runner changes target settings, executes requests, records facts, and cleans up.

  It does not judge pass/fail.


  Flow:

    PreCleanup (optional, reverse order)

    Setup

    For each Settings item:

      CleanupSetting (reverse order, pre-clean)

      ApplySetting

      Requests

      CleanupSetting (reverse order)

    Teardown (reverse order, always)


.NOTES

  Required module: powershell-yaml

  Install is attempted automatically for CurrentUser if missing.

#>


[CmdletBinding()]

param(

    [Parameter(Mandatory = $false)]

    [string]$Scenario = ".\tests\fgt_webfilter_curl.yaml",


    [Parameter(Mandatory = $false)]

    [string]$ScenarioDir,


    [Parameter(Mandatory = $false)]

    [string]$EnvFile = ".\env\lab.json",


    [Parameter(Mandatory = $false)]

    [string]$OutDir = ".\results",


    [Parameter(Mandatory = $false)]

    [switch]$SkipSerialCheck,


    [Parameter(Mandatory = $false)]

    [switch]$NoModuleInstall

)


Set-StrictMode -Version Latest

$ErrorActionPreference = "Stop"


function Ensure-YamlModule {

    if (Get-Module -ListAvailable -Name powershell-yaml) {

        Import-Module powershell-yaml -ErrorAction Stop

        return

    }


    if ($NoModuleInstall) {

        throw "Required module 'powershell-yaml' is not installed. Install it or rerun without -NoModuleInstall."

    }


    Write-Host "Installing powershell-yaml for CurrentUser..."

    Install-Module powershell-yaml -Scope CurrentUser -Force -AllowClobber

    Import-Module powershell-yaml -ErrorAction Stop

}


function ConvertTo-HashtableRecursive {

    param([Parameter(ValueFromPipeline = $true)]$InputObject)


    process {

        if ($null -eq $InputObject) { return $null }


        if ($InputObject -is [System.Collections.IDictionary]) {

            $h = [ordered]@{}

            foreach ($key in $InputObject.Keys) {

                $h[[string]$key] = ConvertTo-HashtableRecursive $InputObject[$key]

            }

            return $h

        }


        if ($InputObject -is [pscustomobject]) {

            $h = [ordered]@{}

            foreach ($p in $InputObject.PSObject.Properties) {

                $h[$p.Name] = ConvertTo-HashtableRecursive $p.Value

            }

            return $h

        }


        if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {

            $arr = @()

            foreach ($item in $InputObject) {

                $arr += ,(ConvertTo-HashtableRecursive $item)

            }

            return $arr

        }


        return $InputObject

    }

}


function Load-JsonAsHashtable {

    param([Parameter(Mandatory = $true)][string]$Path)


    if (-not (Test-Path -LiteralPath $Path)) {

        throw "Env file not found: $Path"

    }


    $json = Get-Content -LiteralPath $Path -Raw -Encoding UTF8

    if ([string]::IsNullOrWhiteSpace($json)) { return [ordered]@{} }


    $obj = $json | ConvertFrom-Json -Depth 100

    return ConvertTo-HashtableRecursive $obj

}


function Get-VarValue {

    param(

        [Parameter(Mandatory = $true)][string]$Name,

        [Parameter(Mandatory = $true)][hashtable]$Vars,

        [Parameter(Mandatory = $false)]$DefaultValue = ""

    )


    if ($Name.StartsWith("ENV_")) {

        $envName = $Name.Substring(4)

        $envValue = [Environment]::GetEnvironmentVariable($envName)

        if ($null -ne $envValue) { return $envValue }

        return $DefaultValue

    }


    if ($Vars.ContainsKey($Name) -and $null -ne $Vars[$Name]) {

        return [string]$Vars[$Name]

    }


    return $DefaultValue

}


function Render-String {

    param(

        [AllowNull()][string]$Text,

        [Parameter(Mandatory = $true)][hashtable]$Vars

    )


    if ($null -eq $Text) { return $null }


    # %%?name%% means non-string JSON literal. If undefined, use literal null.

    $result = [regex]::Replace($Text, '%%\?([A-Za-z0-9_]+)%%', {

        param($m)

        $name = $m.Groups[1].Value

        return Get-VarValue -Name $name -Vars $Vars -DefaultValue "null"

    })


    # %%name%% means string replacement. If undefined, empty string.

    $result = [regex]::Replace($result, '%%([A-Za-z0-9_]+)%%', {

        param($m)

        $name = $m.Groups[1].Value

        return Get-VarValue -Name $name -Vars $Vars -DefaultValue ""

    })


    return $result

}


function Render-Object {

    param(

        [AllowNull()]$InputObject,

        [Parameter(Mandatory = $true)][hashtable]$Vars

    )


    if ($null -eq $InputObject) { return $null }


    if ($InputObject -is [string]) {

        return Render-String -Text $InputObject -Vars $Vars

    }


    if ($InputObject -is [System.Collections.IDictionary]) {

        $h = [ordered]@{}

        foreach ($key in $InputObject.Keys) {

            $h[$key] = Render-Object -InputObject $InputObject[$key] -Vars $Vars

        }

        return $h

    }


    if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {

        $arr = @()

        foreach ($item in $InputObject) {

            $arr += ,(Render-Object -InputObject $item -Vars $Vars)

        }

        return $arr

    }


    return $InputObject

}


function Merge-Vars {

    param(

        [Parameter(Mandatory = $true)][hashtable]$Base,

        [Parameter(Mandatory = $false)]$Extra

    )


    $merged = [ordered]@{}

    foreach ($key in $Base.Keys) { $merged[$key] = $Base[$key] }


    if ($null -ne $Extra) {

        $extraHash = ConvertTo-HashtableRecursive $Extra

        if ($extraHash -is [System.Collections.IDictionary]) {

            foreach ($key in $extraHash.Keys) { $merged[$key] = $extraHash[$key] }

        }

    }

    return $merged

}


function Get-StepArray {

    param(

        [Parameter(Mandatory = $true)]$Suite,

        [Parameter(Mandatory = $true)][string]$Name

    )


    if (-not $Suite.ContainsKey($Name) -or $null -eq $Suite[$Name]) { return @() }


    if ($Suite[$Name] -is [System.Collections.IEnumerable] -and $Suite[$Name] -isnot [string] -and $Suite[$Name] -isnot [System.Collections.IDictionary]) {

        return @($Suite[$Name])

    }


    return @($Suite[$Name])

}


function Get-ReversedArray {

    param([array]$Items)


    $copy = @($Items)

    [array]::Reverse($copy)

    return $copy

}


function Add-FortiGateVdomQuery {

    param(

        [Parameter(Mandatory = $true)][string]$Endpoint,

        [Parameter(Mandatory = $false)][string]$Vdom

    )


    if ([string]::IsNullOrWhiteSpace($Vdom)) { return $Endpoint }

    if ($Endpoint -match '(\?|&)vdom=') { return $Endpoint }


    $sep = '?'

    if ($Endpoint.Contains('?')) { $sep = '&' }

    return "$Endpoint${sep}vdom=$Vdom"

}


function Get-TargetBaseUri {

    param([Parameter(Mandatory = $true)]$Target)


    if (-not $Target.ContainsKey('Host')) { throw "Target requires Host." }

    $scheme = "https"

    if ($Target.ContainsKey('Scheme') -and -not [string]::IsNullOrWhiteSpace($Target.Scheme)) {

        $scheme = [string]$Target.Scheme

    }

    return "$scheme://$($Target.Host)"

}


function New-StepResultBase {

    param(

        [Parameter(Mandatory = $true)][string]$SuiteId,

        [Parameter(Mandatory = $false)][string]$SettingId,

        [Parameter(Mandatory = $true)]$Step,

        [Parameter(Mandatory = $false)][string]$Phase,

        [Parameter(Mandatory = $false)][string]$TargetName,

        [Parameter(Mandatory = $false)][string]$TargetType

    )


    return [ordered]@{

        suiteId    = $SuiteId

        settingId  = $SettingId

        phase      = $Phase

        stepId     = if ($Step.ContainsKey('ID')) { $Step.ID } else { "" }

        target     = $TargetName

        targetType = $TargetType

        type       = if ($Step.ContainsKey('Type')) { $Step.Type } else { "" }

        startedAt  = (Get-Date).ToString("o")

        endedAt    = $null

        durationMs = $null

        ok         = $false

        error      = $null

    }

}


function Write-StepResult {

    param(

        [Parameter(Mandatory = $true)][string]$RunDir,

        [Parameter(Mandatory = $true)]$Result

    )


    $setting = if ([string]::IsNullOrWhiteSpace($Result.settingId)) { "_global" } else { $Result.settingId }

    $phase = if ([string]::IsNullOrWhiteSpace($Result.phase)) { "phase" } else { $Result.phase }

    $step = if ([string]::IsNullOrWhiteSpace($Result.stepId)) { "step" } else { $Result.stepId }

    $safeName = ($phase + "__" + $setting + "__" + $step) -replace '[^A-Za-z0-9_.-]', '_'


    $stepDir = Join-Path $RunDir "steps"

    New-Item -ItemType Directory -Force -Path $stepDir | Out-Null

    $path = Join-Path $stepDir ("{0:0000}__{1}.json" -f $script:StepCounter, $safeName)

    $script:StepCounter++


    $Result | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $path -Encoding UTF8

}


function Invoke-ApiStep {

    param(

        [Parameter(Mandatory = $true)]$Step,

        [Parameter(Mandatory = $true)]$Target,

        [Parameter(Mandatory = $true)][string]$TargetName,

        [Parameter(Mandatory = $true)][string]$SuiteId,

        [Parameter(Mandatory = $false)][string]$SettingId,

        [Parameter(Mandatory = $true)][string]$Phase,

        [Parameter(Mandatory = $true)][string]$RunDir,

        [Parameter(Mandatory = $false)][switch]$Allow404

    )


    $targetType = if ($Target.ContainsKey('Type')) { [string]$Target.Type } else { "Generic" }

    $result = New-StepResultBase -SuiteId $SuiteId -SettingId $SettingId -Step $Step -Phase $Phase -TargetName $TargetName -TargetType $targetType

    $sw = [System.Diagnostics.Stopwatch]::StartNew()


    try {

        if (-not $Step.ContainsKey('Method')) { throw "API step requires Method." }

        if (-not $Step.ContainsKey('Endpoint')) { throw "API step requires Endpoint." }


        $method = [string]$Step.Method

        $endpoint = [string]$Step.Endpoint

        if ($targetType -eq "FortiGate" -and $Target.ContainsKey('Vdom')) {

            $endpoint = Add-FortiGateVdomQuery -Endpoint $endpoint -Vdom ([string]$Target.Vdom)

        }

        $uri = (Get-TargetBaseUri -Target $Target) + $endpoint


        $headers = @{}

        if ($Target.ContainsKey('TokenEnv') -and -not [string]::IsNullOrWhiteSpace($Target.TokenEnv)) {

            $token = [Environment]::GetEnvironmentVariable([string]$Target.TokenEnv)

            if ([string]::IsNullOrWhiteSpace($token)) { throw "Environment variable '$($Target.TokenEnv)' is empty or undefined." }

            $headers["Authorization"] = "Bearer $token"

        }

        if ($Target.ContainsKey('Headers') -and $Target.Headers -is [System.Collections.IDictionary]) {

            foreach ($key in $Target.Headers.Keys) { $headers[$key] = [string]$Target.Headers[$key] }

        }


        $body = $null

        $contentType = "application/json"

        if ($Step.ContainsKey('PayloadFormat') -and $null -ne $Step.PayloadFormat) {

            switch ([string]$Step.PayloadFormat) {

                "native" {

                    $body = ($Step.Payload | ConvertTo-Json -Depth 100 -Compress)

                    $contentType = "application/json"

                }

                "raw_json" {

                    $body = [string]$Step.Payload

                    $null = $body | ConvertFrom-Json -Depth 100

                    $contentType = "application/json"

                }

                "raw_xml" {

                    $body = [string]$Step.Payload

                    $null = [xml]$body

                    $contentType = "application/xml"

                }

                "text_list" {

                    $body = ($Step.Payload -join "`n")

                    $contentType = "text/plain"

                }

                default { throw "Unsupported PayloadFormat: $($Step.PayloadFormat)" }

            }

        }


        if ($Step.ContainsKey('ContentType') -and -not [string]::IsNullOrWhiteSpace($Step.ContentType)) {

            $contentType = [string]$Step.ContentType

        }


        $params = @{

            Uri         = $uri

            Method      = $method

            Headers     = $headers

            TimeoutSec  = if ($Step.ContainsKey('TimeoutSec')) { [int]$Step.TimeoutSec } else { 60 }

            ErrorAction = 'Stop'

        }

        if ($null -ne $body) {

            $params['Body'] = $body

            $params['ContentType'] = $contentType

        }

        if ($Target.ContainsKey('SkipCertificateCheck') -and [bool]$Target.SkipCertificateCheck) {

            $params['SkipCertificateCheck'] = $true

        }


        $resp = Invoke-WebRequest @params


        $result.method = $method

        $result.endpoint = $endpoint

        $result.statusCode = [int]$resp.StatusCode

        $result.responseBody = [string]$resp.Content

        $result.ok = $true

    }

    catch {

        $statusCode = $null

        $responseBody = $null


        if ($_.Exception.Response) {

            try { $statusCode = [int]$_.Exception.Response.StatusCode } catch {}

            try {

                $stream = $_.Exception.Response.GetResponseStream()

                if ($null -ne $stream) {

                    $reader = [System.IO.StreamReader]::new($stream)

                    $responseBody = $reader.ReadToEnd()

                }

            } catch {}

            try {

                if ($_.Exception.Response.Content) {

                    $responseBody = $_.Exception.Response.Content.ReadAsStringAsync().GetAwaiter().GetResult()

                }

            } catch {}

        }


        $result.statusCode = $statusCode

        $result.responseBody = $responseBody

        $result.error = $_.Exception.Message

        if ($Allow404 -and $statusCode -eq 404) {

            $result.ok = $true

            $result.ignored = "404 allowed"

        } else {

            $result.ok = $false

        }

    }

    finally {

        $sw.Stop()

        $result.endedAt = (Get-Date).ToString("o")

        $result.durationMs = [int64]$sw.ElapsedMilliseconds

        Write-StepResult -RunDir $RunDir -Result $result

    }


    if (-not $result.ok) {

        throw "API step failed: phase=$Phase setting=$SettingId step=$($result.stepId) status=$($result.statusCode) error=$($result.error)"

    }

}


function Invoke-CommandStep {

    param(

        [Parameter(Mandatory = $true)]$Step,

        [Parameter(Mandatory = $true)][string]$SuiteId,

        [Parameter(Mandatory = $false)][string]$SettingId,

        [Parameter(Mandatory = $true)][string]$Phase,

        [Parameter(Mandatory = $true)][string]$RunDir

    )


    $result = New-StepResultBase -SuiteId $SuiteId -SettingId $SettingId -Step $Step -Phase $Phase -TargetName "LOCAL" -TargetType "Local"

    $sw = [System.Diagnostics.Stopwatch]::StartNew()


    try {

        if (-not $Step.ContainsKey('Command')) { throw "Command step requires Command." }

        $command = [string]$Step.Command

        $args = @()

        if ($Step.ContainsKey('Args') -and $null -ne $Step.Args) { $args = @($Step.Args) }


        $psi = [System.Diagnostics.ProcessStartInfo]::new()

        $psi.FileName = $command

        foreach ($a in $args) { [void]$psi.ArgumentList.Add([string]$a) }

        $psi.RedirectStandardOutput = $true

        $psi.RedirectStandardError = $true

        $psi.UseShellExecute = $false

        $psi.CreateNoWindow = $true


        $proc = [System.Diagnostics.Process]::new()

        $proc.StartInfo = $psi

        [void]$proc.Start()


        $stdoutTask = $proc.StandardOutput.ReadToEndAsync()

        $stderrTask = $proc.StandardError.ReadToEndAsync()

        $timeoutMs = if ($Step.ContainsKey('TimeoutMs')) { [int]$Step.TimeoutMs } else { 60000 }

        $finished = $proc.WaitForExit($timeoutMs)

        if (-not $finished) {

            try { $proc.Kill($true) } catch {}

            $result.timedOut = $true

        }


        $stdout = $stdoutTask.GetAwaiter().GetResult()

        $stderr = $stderrTask.GetAwaiter().GetResult()


        $result.command = $command

        $result.args = $args

        $result.exitCode = if ($finished) { [int]$proc.ExitCode } else { $null }

        $result.stdout = $stdout

        $result.stderr = $stderr

        $result.ok = $finished


        # This runner records facts only. Non-zero exit code is not pass/fail.

        # Process timeout is treated as runner failure because stdout/stderr may be incomplete.

    }

    catch {

        $result.ok = $false

        $result.error = $_.Exception.Message

    }

    finally {

        $sw.Stop()

        $result.endedAt = (Get-Date).ToString("o")

        $result.durationMs = [int64]$sw.ElapsedMilliseconds

        Write-StepResult -RunDir $RunDir -Result $result

    }


    if (-not $result.ok) {

        throw "Command step failed or timed out: phase=$Phase setting=$SettingId step=$($result.stepId) error=$($result.error)"

    }

}


function Invoke-Step {

    param(

        [Parameter(Mandatory = $true)]$RawStep,

        [Parameter(Mandatory = $true)][hashtable]$Vars,

        [Parameter(Mandatory = $true)]$Targets,

        [Parameter(Mandatory = $true)][string]$SuiteId,

        [Parameter(Mandatory = $false)][string]$SettingId,

        [Parameter(Mandatory = $true)][string]$Phase,

        [Parameter(Mandatory = $true)][string]$RunDir,

        [Parameter(Mandatory = $false)][switch]$Allow404

    )


    $step = Render-Object -InputObject $RawStep -Vars $Vars

    if (-not $step.ContainsKey('Type')) { throw "Step requires Type. Phase=$Phase" }


    switch ([string]$step.Type) {

        "API" {

            $targetName = if ($step.ContainsKey('Target') -and -not [string]::IsNullOrWhiteSpace($step.Target)) { [string]$step.Target } else { "FGT" }

            if (-not $Targets.ContainsKey($targetName)) { throw "Target '$targetName' not found." }

            Invoke-ApiStep -Step $step -Target $Targets[$targetName] -TargetName $targetName -SuiteId $SuiteId -SettingId $SettingId -Phase $Phase -RunDir $RunDir -Allow404:$Allow404

        }

        "Command" {

            Invoke-CommandStep -Step $step -SuiteId $SuiteId -SettingId $SettingId -Phase $Phase -RunDir $RunDir

        }

        "Wait" {

            $ms = if ($step.ContainsKey('Ms')) { [int]$step.Ms } elseif ($step.ContainsKey('WaitAfterMs')) { [int]$step.WaitAfterMs } else { 1000 }

            $result = New-StepResultBase -SuiteId $SuiteId -SettingId $SettingId -Step $step -Phase $Phase -TargetName "LOCAL" -TargetType "Local"

            Start-Sleep -Milliseconds $ms

            $result.ms = $ms

            $result.ok = $true

            $result.endedAt = (Get-Date).ToString("o")

            $result.durationMs = $ms

            Write-StepResult -RunDir $RunDir -Result $result

        }

        default { throw "Unsupported step Type: $($step.Type)" }

    }


    if ($step.ContainsKey('WaitAfterMs') -and [int]$step.WaitAfterMs -gt 0) {

        Start-Sleep -Milliseconds ([int]$step.WaitAfterMs)

    }

}


function Invoke-StepList {

    param(

        [Parameter(Mandatory = $true)][array]$Steps,

        [Parameter(Mandatory = $true)][hashtable]$Vars,

        [Parameter(Mandatory = $true)]$Targets,

        [Parameter(Mandatory = $true)][string]$SuiteId,

        [Parameter(Mandatory = $false)][string]$SettingId,

        [Parameter(Mandatory = $true)][string]$Phase,

        [Parameter(Mandatory = $true)][string]$RunDir,

        [Parameter(Mandatory = $false)][switch]$Reverse,

        [Parameter(Mandatory = $false)][switch]$Allow404

    )


    $items = @($Steps)

    if ($Reverse) { $items = Get-ReversedArray -Items $items }


    foreach ($s in $items) {

        Invoke-Step -RawStep $s -Vars $Vars -Targets $Targets -SuiteId $SuiteId -SettingId $SettingId -Phase $Phase -RunDir $RunDir -Allow404:$Allow404

    }

}


function Test-TargetSafety {

    param(

        [Parameter(Mandatory = $true)]$Targets,

        [Parameter(Mandatory = $true)][string]$SuiteId,

        [Parameter(Mandatory = $true)][string]$RunDir

    )


    foreach ($name in $Targets.Keys) {

        $t = $Targets[$name]

        if (-not $t.ContainsKey('ExpectedSerial') -or [string]::IsNullOrWhiteSpace($t.ExpectedSerial)) { continue }

        if ($SkipSerialCheck) { continue }

        if ($t.ExpectedSerial -eq "CHANGE_ME") { throw "Target '$name' ExpectedSerial is CHANGE_ME. Set the real serial or use -SkipSerialCheck deliberately." }


        if ($t.ContainsKey('Type') -and $t.Type -eq "FortiGate") {

            $step = [ordered]@{

                ID       = "safety_check_$name"

                Type     = "API"

                Target   = $name

                Method   = "GET"

                Endpoint = "/api/v2/monitor/system/status"

            }

            Invoke-ApiStep -Step $step -Target $t -TargetName $name -SuiteId $SuiteId -SettingId "" -Phase "SafetyCheck" -RunDir $RunDir


            $latest = Get-ChildItem -Path (Join-Path $RunDir "steps") -Filter "*.json" | Sort-Object Name -Descending | Select-Object -First 1

            $obj = Get-Content -LiteralPath $latest.FullName -Raw | ConvertFrom-Json -Depth 100

            if ([string]$obj.responseBody -notlike "*$($t.ExpectedSerial)*") {

                throw "Safety check failed. Target '$name' response did not contain ExpectedSerial '$($t.ExpectedSerial)'."

            }

        }

    }

}


function Invoke-ScenarioFile {

    param([Parameter(Mandatory = $true)][string]$Path)


    if (-not (Test-Path -LiteralPath $Path)) { throw "Scenario file not found: $Path" }


    $envVars = Load-JsonAsHashtable -Path $EnvFile

    $rawYaml = Get-Content -LiteralPath $Path -Raw -Encoding UTF8

    $renderedYaml = Render-String -Text $rawYaml -Vars $envVars

    $suite = ConvertTo-HashtableRecursive (ConvertFrom-Yaml -Yaml $renderedYaml)


    if (-not $suite.ContainsKey('SuiteID')) { throw "Scenario requires SuiteID: $Path" }

    if (-not $suite.ContainsKey('Targets')) { throw "Scenario requires Targets: $Path" }


    $suiteId = [string]$suite.SuiteID

    $timestamp = Get-Date -Format "yyyyMMdd_HHmmss"

    $runDir = Join-Path (Join-Path $OutDir $suiteId) $timestamp

    New-Item -ItemType Directory -Force -Path $runDir | Out-Null

    $script:StepCounter = 1


    $targets = $suite.Targets

    $settings = Get-StepArray -Suite $suite -Name "Settings"

    if ($settings.Count -eq 0) { $settings = @([ordered]@{ ID = "default" }) }


    $summary = [ordered]@{

        suiteId     = $suiteId

        scenario    = (Resolve-Path -LiteralPath $Path).Path

        envFile     = (Resolve-Path -LiteralPath $EnvFile).Path

        startedAt   = (Get-Date).ToString("o")

        endedAt     = $null

        result      = "Running"

        settings    = @($settings | ForEach-Object { if ($_ -is [System.Collections.IDictionary] -and $_.ContainsKey('ID')) { $_.ID } else { "" } })

        runDir      = (Resolve-Path -LiteralPath $runDir).Path

        error       = $null

    }


    try {

        Test-TargetSafety -Targets $targets -SuiteId $suiteId -RunDir $runDir


        Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "PreCleanup") -Vars $envVars -Targets $targets -SuiteId $suiteId -SettingId "" -Phase "PreCleanup" -RunDir $runDir -Reverse -Allow404

        Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "Setup") -Vars $envVars -Targets $targets -SuiteId $suiteId -SettingId "" -Phase "Setup" -RunDir $runDir


        foreach ($setting in $settings) {

            $settingId = if ($setting -is [System.Collections.IDictionary] -and $setting.ContainsKey('ID')) { [string]$setting.ID } else { "default" }

            $vars = Merge-Vars -Base $envVars -Extra $setting


            Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "CleanupSetting") -Vars $vars -Targets $targets -SuiteId $suiteId -SettingId $settingId -Phase "CleanupSettingBefore" -RunDir $runDir -Reverse -Allow404

            Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "ApplySetting") -Vars $vars -Targets $targets -SuiteId $suiteId -SettingId $settingId -Phase "ApplySetting" -RunDir $runDir

            Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "Requests") -Vars $vars -Targets $targets -SuiteId $suiteId -SettingId $settingId -Phase "Requests" -RunDir $runDir

            Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "CleanupSetting") -Vars $vars -Targets $targets -SuiteId $suiteId -SettingId $settingId -Phase "CleanupSettingAfter" -RunDir $runDir -Reverse -Allow404

        }


        $summary.result = "Completed"

    }

    catch {

        $summary.result = "Failed"

        $summary.error = $_.Exception.Message

        throw

    }

    finally {

        try {

            Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "Teardown") -Vars $envVars -Targets $targets -SuiteId $suiteId -SettingId "" -Phase "Teardown" -RunDir $runDir -Reverse -Allow404

        }

        catch {

            $summary.result = "FailedDuringTeardown"

            $summary.error = if ($summary.error) { $summary.error + " / Teardown: " + $_.Exception.Message } else { "Teardown: " + $_.Exception.Message }

        }


        $summary.endedAt = (Get-Date).ToString("o")

        $summaryPath = Join-Path $runDir "summary.json"

        $summary | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $summaryPath -Encoding UTF8

        Write-Host "Result: $summaryPath"

    }

}


Ensure-YamlModule


if ($ScenarioDir) {

    $files = Get-ChildItem -LiteralPath $ScenarioDir -Filter "*.yaml" | Sort-Object Name

    foreach ($f in $files) { Invoke-ScenarioFile -Path $f.FullName }

} else {

    Invoke-ScenarioFile -Path $Scenario

}


2026-04-09

zo3検証ツール 簡易MIMEメール展開スクリプト Expand-MimeMessage.ps1

<#
.SYNOPSIS
    zo3検証ツール 簡易MIMEメール展開スクリプト
    Expand-MimeMessage.ps1

.DESCRIPTION
    EML ファイルを解析し、本文と添付ファイルを指定ディレクトリに展開する。
    New-MimeMessage.ps1 で生成した EML の検証用途を主目的とする。
    検証環境・クローズド環境での使用を前提とする。

.PARAMETER EmlFile
    展開対象の EML ファイルパス。

.PARAMETER OutDir
    展開先ディレクトリ。存在しない場合は自動作成。デフォルト: .\out

.EXAMPLE
    .\Expand-MimeMessage.ps1 -EmlFile test.eml

.EXAMPLE
    .\Expand-MimeMessage.ps1 -EmlFile attach.eml -OutDir C:\tmp\expand

.OUTPUTS
    <OutDir>\body.txt        : 本文テキスト
    <OutDir>\<filename>      : 添付ファイル(ファイル名はメールヘッダから取得)

.NOTES
    対応エンコード : Base64 添付, RFC2231 ファイル名デコード
    非対応        : HTML パート, Base64 エンコード本文, multipart/alternative
    前提          : New-MimeMessage.ps1 が生成した CRLF 区切り EML

    MIT License
    Copyright (c) 2026  yoshio@zo3
#>


param(
    [string]$EmlFile,
    [string]$OutDir = ".\out"
)

$CRLF = "`r`n"

# 出力先
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null

# 全文取得
#$content = Get-Content -Raw -Path $EmlFile
$content = [System.IO.File]::ReadAllText( $EmlFile, [System.Text.Encoding]::UTF8)
$content = $content -replace "`r`n", "`n" -replace "`r", "`n" -replace "`n", $CRLF

# ヘッダとボディ分離
$parts = $content -split "$CRLF$CRLF", 2
$headers = $parts[0]
$body = $parts[1]

# boundary取得
$boundary = $null
if ( $headers -match 'boundary="([^"]+)"') {
    $boundary = $matches[1]
}

# RFC2231 decode
function Decode-RFC2231($s) {
    if ($s -match "UTF-8''(.+)") {
        $enc = $matches[1]
        $bytes = @()
        for ($i = 0; $i -lt $enc.Length; ) {
            if ($enc[$i] -eq '%') {
                $bytes += [Convert]::ToByte($enc.Substring($i + 1, 2), 16)
                $i += 3
            }
            else {
                $bytes += [byte][char]$enc[$i]
                $i++
            }
        }
        return [System.Text.Encoding]::UTF8.GetString($bytes)
    }
    return $s
}

# Base64 decode
function Decode-Base64($text) {
    $clean = ($text -replace '\s', '')
    return [Convert]::FromBase64String($clean)
}

if ( $null -eq $boundary ) {
    # 単一パート 本文のみ
    $textPath = Join-Path $OutDir "body.txt"
    Set-Content -Path $textPath -Value $body -Encoding UTF8
}
else {
    # マルチパート分解
    #$sections = $body -split "--$boundary"
    $escaped = [Regex]::Escape("--$boundary")
    $sections = $body -split $escaped

    foreach ($sec in $sections) {

        if ($sec -match "--\s*$") { continue }
        if ($sec.Trim() -eq "") { continue }

        $sp = $sec -split "$CRLF$CRLF", 2
        if ( $sp.Count -lt 2 ) { continue }

        $h = $sp[0]
        $b = $sp[1].Trim()

        # 添付判定
        if ( -not ( $h -match "Content-Disposition: attachment" ) ) {
            # 本文
            $textPath = Join-Path $OutDir "body.txt"
            Set-Content -Path $textPath -Value $b -Encoding UTF8
        }
        else {
            # filename取得(優先:filename*)
            $filename = "unknown.bin"

            if ($h -match "filename\*=(.+)") {
                # 修正
                $raw = $matches[1].Trim() -replace ';.*$', ''
                $filename = Decode-RFC2231 $raw
                #$filename = Decode-RFC2231 $matches[1].Trim()
                $filename = [System.IO.Path]::GetFileName( $filename )  # パス成分を除去
            }
            elseif ($h -match 'filename="([^"]+)"') {
                $filename = $matches[1]
            }

            # Base64デコード
            if ($h -match "base64") {
                $bytes = Decode-Base64 $b
                $outPath = Join-Path $OutDir $filename
                [System.IO.File]::WriteAllBytes($outPath, $bytes)
            }
        }
    }
}

zo3検証ツール 簡易MIMEメール作成スクリプト New-MimeMessage.ps1

<#
.SYNOPSIS
    zo3検証ツール 簡易MIMEメール作成スクリプト
    New-MimeMessage.ps1

.DESCRIPTION
    指定したヘッダ情報・本文・添付ファイルから RFC 2822 準拠の EML ファイルを生成する。
    生成した EML は curl の --upload-file で SMTP 送信に使用できる。
    検証環境・クローズド環境での使用を前提とする。

.PARAMETER From
    送信者アドレス。例: user1@example.test

.PARAMETER To
    宛先アドレス(複数指定可)。例: user2@example.test, user3@example.test

.PARAMETER Subject
    件名。日本語可(RFC2047 Base64 エンコード)。

.PARAMETER Body
    本文。日本語可(UTF-8 / 8bit)。

.PARAMETER Attachments
    添付ファイルパスの配列(省略可)。日本語ファイル名可(RFC2231 エンコード)。

.PARAMETER OutFile
    出力する EML ファイルパス。デフォルト: mail.eml

.EXAMPLE
    .\New-MimeMessage.ps1 -From user1@example.test -To user2@example.test `
        -Subject "テスト" -Body "本文" -OutFile test.eml

.EXAMPLE
    .\New-MimeMessage.ps1 -From user1@example.test -To user2@example.test `
        -Subject "添付テスト" -Body "本文" `
        -Attachments @("C:\tmp\資料.pdf", "C:\tmp\image.png") -OutFile attach.eml

.NOTES
    対応エンコード : Subject=RFC2047 B, ファイル名=RFC2231, 本文=UTF-8/8bit
    非対応        : HTML メール, multipart/alternative, DKIM 署名, CC/BCC ヘッダ自動付与
    送信例        :
        curl.exe --url smtp://mailserver:25 `
            --mail-from user1@example.test `
            --mail-rcpt user2@example.test `
            --upload-file test.eml

    MIT License
    Copyright (c) 2026  yoshio@zo3
#>

param(
    [string]$From,
    [string[]]$To,
    [string]$Subject,
    [string]$Body,
    [string[]]$Attachments = @(),
    [string]$OutFile = "mail.eml"
)

# ===== 基本 =====
$CRLF = "`r`n"

# ===== RFC2047 Subject =====
function Encode-Subject( $string ) {
    $bytes = [System.Text.Encoding]::UTF8.GetBytes( $string )
    $b64data = [Convert]::ToBase64String( $bytes )
    return "=?UTF-8?B?${b64data}?="
}

# ===== Base64(76文字折返し)=====
function To-Base64Lines( $bytes ) {
    $b64data = [Convert]::ToBase64String( $bytes )
    return ( ${b64data} -split "(.{1,76})" | Where-Object { $_ }) -join $CRLF
}

# ===== RFC2231 filename* =====
function Encode-RFC2231( $string ) {
    $bytes = [System.Text.Encoding]::UTF8.GetBytes( $string )
    $enc = ""
    foreach ( $byte in $bytes ) {
        if (
            ( $byte -ge 0x30 -and $byte -le 0x39 ) -or
            ( $byte -ge 0x41 -and $byte -le 0x5A ) -or
            ( $byte -ge 0x61 -and $byte -le 0x7A ) -or
            $byte -in 0x2D, 0x2E, 0x5F, 0x7E
        ) {
            $enc += [char]$byte
        }
        else {
            $enc += "%" + $byte.ToString( "X2" )
        }
    }
    return "UTF-8''$enc"
}

# ===== Date / Message-ID =====
$now = Get-Date
#$dateStr = $now.ToString("ddd, dd MMM yyyy HH:mm:ss K", [System.Globalization.CultureInfo]::InvariantCulture)
$tz = $now.ToString("zzz").Replace(":", "")
$dateStr = $now.ToString("ddd, dd MMM yyyy HH:mm:ss", [System.Globalization.CultureInfo]::InvariantCulture) + " $tz"

$domain = ($From -split "@")[-1]
$msgid = "<" + [guid]::NewGuid().ToString() + "@$domain>"

# ===== boundary =====
$boundary = "----=_Boundary_" + [guid]::NewGuid().ToString("N")

# ===== ヘッダ =====
$headers = @()
$headers += "Date: $dateStr"
$headers += "Message-ID: $msgid"
$headers += "From: $From"
$headers += "To: " + ($To -join ", ")
$headers += "Subject: " + ( Encode-Subject $Subject )
$headers += "MIME-Version: 1.0"

if ($Attachments.Count -gt 0) {
    $headers += "Content-Type: multipart/mixed; boundary=""$boundary"""
}
else {
    $headers += "Content-Type: text/plain; charset=UTF-8"
    $headers += "Content-Transfer-Encoding: 8bit"
}

# ===== 本文 =====
$bodyLines = @()
# 修正(本文取り込み前に正規化)
$Body = $Body -replace "`r`n", "`n" -replace "`n", $CRLF

if ( $Attachments.Count -eq 0 ) {

    $bodyLines += $Body
}
else {
    # 本文パート
    $bodyLines += "--$boundary"
    $bodyLines += "Content-Type: text/plain; charset=UTF-8"
    $bodyLines += "Content-Transfer-Encoding: 8bit"
    $bodyLines += ""
    $bodyLines += $Body

    # 添付
    foreach ( $file in $Attachments ) {

        $bytes = [System.IO.File]::ReadAllBytes( $file )
        $name = [System.IO.Path]::GetFileName( $file )

        # ASCIIフォールバック
        $nameAscii = $name -replace '[^\x20-\x7E]', '_'

        # RFC2231
        $name2231 = Encode-RFC2231 $name

        $bodyLines += ""
        $bodyLines += "--$boundary"
        $bodyLines += "Content-Type: application/octet-stream; name=""$nameAscii"""
        $bodyLines += "Content-Transfer-Encoding: base64"
        $bodyLines += "Content-Disposition: attachment; filename=""$nameAscii""; filename*=$name2231"
        $bodyLines += ""
        $bodyLines += (To-Base64Lines $bytes)
    }
    # 終端
    $bodyLines += ""
    $bodyLines += "--$boundary--"

}


# ===== 結合 =====
$all = ( $headers -join $CRLF ) + $CRLF + $CRLF + ( $bodyLines -join $CRLF )

# ===== 出力(CRLF維持)=====
#[System.IO.File]::WriteAllText( $OutFile , $all , [System.Text.Encoding]::ASCII)
[System.IO.File]::WriteAllBytes($OutFile, [System.Text.Encoding]::UTF8.GetBytes($all))

2026-04-06

curl でのプロトコルテストコマンド


前提確認

curl --version  # protocols: に smtp, pop3, imap, ftp が含まれているか確認

SMTP / SMTPS

# SMTP (25番 or 587番) — メール送信テスト
curl smtp://mail.example.com:25 --mail-from sender@example.com --mail-rcpt recipient@example.com --upload-file mail.txt -v

# SMTP STARTTLS (587番)
curl smtp://mail.example.com:587 --mail-from sender@example.com --mail-rcpt recipient@example.com --upload-file mail.txt --ssl-reqd -v

# SMTPS (465番 — 最初からTLS)
curl smtps://mail.example.com:465 --mail-from sender@example.com --mail-rcpt recipient@example.com --upload-file mail.txt -v

# 認証あり
curl smtps://mail.example.com:465 -u "user@example.com:password" --mail-from user@example.com --mail-rcpt to@example.com --upload-file mail.txt -v

mail.txt の中身(最低限):

From: sender@example.com
To: recipient@example.com
Subject: test

Hello

POP3 / POP3S

# POP3 (110番) — メール一覧
curl pop3://mail.example.com -u "user:pass" -v

# 特定メール取得 (メッセージ番号1)
curl pop3://mail.example.com/1 -u "user:pass" -v

# POP3 STARTTLS
curl pop3://mail.example.com --ssl-reqd -u "user:pass" -v

# POP3S (995番)
curl pop3s://mail.example.com -u "user:pass" -v

IMAP / IMAPS

# IMAP (143番) — INBOXの一覧
curl imap://mail.example.com/INBOX -u "user:pass" -v

# 特定メール取得 (UID=1)
curl "imap://mail.example.com/INBOX;UID=1" -u "user:pass" -v

# IMAP STARTTLS
curl imap://mail.example.com/INBOX --ssl-reqd -u "user:pass" -v

# IMAPS (993番)
curl imaps://mail.example.com/INBOX -u "user:pass" -v

# フォルダ一覧
curl imaps://mail.example.com/ -u "user:pass" --request "LIST \"\" \"*\"" -v

FTP / FTPS

# FTP (21番) — ファイル一覧
curl ftp://ftp.example.com/ -u "user:pass" -v

# ファイルダウンロード
curl ftp://ftp.example.com/file.txt -u "user:pass" -o file.txt -v

# ファイルアップロード
curl ftp://ftp.example.com/upload.txt -u "user:pass" --upload-file upload.txt -v

# FTPS (Explicit TLS — STARTTLS方式, 21番)
curl ftp://ftp.example.com/ -u "user:pass" --ssl-reqd -v

# FTPS (Implicit TLS — 990番)
curl ftps://ftp.example.com/ -u "user:pass" -v

共通オプション

オプション 用途
-v 詳細ログ(必須級)
--ssl-reqd STARTTLSを強制
-k 自己署名証明書を無視(テスト環境向け)
--trace-ascii - バイナリ含む全通信ダンプ
--resolve host:port:IP DNS代わりに直接IP指定
-u "user:pass" 認証