From 134c1b202613d008d84088a3e2957f27d39733ee Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Mon, 23 Feb 2026 02:59:42 -0300 Subject: [PATCH 01/21] Add support for OpenSSH Servers on Windows - Add Windows SSH server installation/uninstallation PowerShell scripts with support for Windows Server 2022, 2025, and desktop editions - Add platform-specific log file paths (logpath_unix.go, logpath_windows.go) - Update verify command to use platform-independent configuration paths - Improve OpenSSH version detection to handle Windows version format (e.g., 'OpenSSH_for_Windows_9.5p2, LibreSSL 3.8.2') - Add Windows ReadHome implementation for user profile resolution - Update GoReleaser config for Windows builds and release assets - Add policy plugin directory path as platform-aware function - Add GitHub Actions workflows for Windows SSH integration tests (Windows Server 2022 and 2025) - Improve SSH certificate error messages with type and key details - Add OS name detection utility for system details Fix #370. --- .github/workflows/gha-windows-2025.yml | 106 ++ .github/workflows/gha-windows.yml | 107 ++ .goreleaser.yaml | 17 +- commands/readhome_windows.go | 84 +- internal/sysdetails/openssh.go | 21 +- internal/sysdetails/os.go | 7 + logpath_unix.go | 26 + logpath_windows.go | 35 + main.go | 53 +- policy/enforcer.go | 9 +- scripts/windows/Install-OpksshServer.ps1 | 1104 +++++++++++++++++ scripts/windows/Test-OpksshInstallation.ps1 | 372 ++++++ scripts/windows/Uninstall-OpksshServer.ps1 | 468 +++++++ .../test/Install-OpksshServer.Tests.ps1 | 58 + sshcert/sshcert.go | 15 +- 15 files changed, 2445 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/gha-windows-2025.yml create mode 100644 .github/workflows/gha-windows.yml create mode 100644 logpath_unix.go create mode 100644 logpath_windows.go create mode 100644 scripts/windows/Install-OpksshServer.ps1 create mode 100644 scripts/windows/Test-OpksshInstallation.ps1 create mode 100644 scripts/windows/Uninstall-OpksshServer.ps1 create mode 100644 scripts/windows/test/Install-OpksshServer.Tests.ps1 diff --git a/.github/workflows/gha-windows-2025.yml b/.github/workflows/gha-windows-2025.yml new file mode 100644 index 00000000..42bfcb95 --- /dev/null +++ b/.github/workflows/gha-windows-2025.yml @@ -0,0 +1,106 @@ +name: Test GitHub Provider + +on: + push: + +jobs: + build: + name: Test on Windows Server 2025 + runs-on: windows-2025 + permissions: + id-token: write + contents: read + timeout-minutes: 10 + + steps: + - name: Install OpenSSH Server + run: | + Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 + + - name: Create default OpenSSH config files + run: | + Start-Service sshd + Start-Sleep -Seconds 2 + Stop-Service sshd + + - name: Enable OpenSSH Server logs + run: | + $sshdConfig = "$env:ProgramData\ssh\sshd_config" + (Get-Content $sshdConfig -Raw).Replace('#SyslogFacility AUTH', 'SyslogFacility LOCAL0').Replace('#LogLevel INFO', 'LogLevel Debug3') | + Set-Content $sshdConfig -Encoding ascii + + - name: Set runneradmin password + run: net user runneradmin "P@ssw0rd123!" + + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Cache Go modules + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Install dependencies + run: go mod download + + - name: Build opkssh + run: go build -v -o opkssh.exe + + - name: Install opkssh with local binary + run: | + powershell -ExecutionPolicy Bypass -File "scripts/windows/Install-OpksshServer.ps1" ` + -InstallFrom "$PWD\opkssh.exe" ` + -NoSshdRestart ` + -Verbose + + - name: Add GitHub provider to opkssh configuration + run: | + $providersPath = 'C:\ProgramData\opk\providers' + if ((Get-Content -Path $providersPath -Raw) -notmatch "`r?`n$") { + Add-Content -Path $providersPath -Value '' + } + Add-Content -Path $providersPath -Value 'https://token.actions.githubusercontent.com github oidc' + + - name: Add current repository to policy + run: | + & 'C:\Program Files\opkssh\opkssh.exe' add runneradmin "repo:${env:GITHUB_REPOSITORY}:ref:${env:GITHUB_REF}" https://token.actions.githubusercontent.com + + - name: Start SSH service + run: Start-Service sshd + + - name: Test SSH connection without opkssh (should fail) + run: | + ssh -o StrictHostKeyChecking=no -o PasswordAuthentication=no runneradmin@localhost dir + continue-on-error: true + + - name: Login with GitHub OIDC + run: | + & 'C:\Program Files\opkssh\opkssh.exe' login github --print-id-token + + - name: Test SSH connection with opkssh (should pass) + run: | + ssh -o StrictHostKeyChecking=no -o PasswordAuthentication=no runneradmin@localhost dir + + - name: Debug - Dump opkssh config + run: | + Get-ChildItem 'C:\ProgramData\opk' -Recurse -ErrorAction Continue + Get-Content 'C:\ProgramData\opk\providers' -ErrorAction Continue + Get-Content 'C:\ProgramData\opk\auth_id' -ErrorAction Continue + if: always() + + - name: Debug - Dump opkssh logs + run: | + Get-Content 'C:\ProgramData\opk\logs\opkssh.log' -ErrorAction Continue + if: always() + + - name: Debug - Dump sshd logs + run: | + Get-Content 'C:\ProgramData\ssh\logs\sshd.log' -ErrorAction Continue + if: always() diff --git a/.github/workflows/gha-windows.yml b/.github/workflows/gha-windows.yml new file mode 100644 index 00000000..d5e754ae --- /dev/null +++ b/.github/workflows/gha-windows.yml @@ -0,0 +1,107 @@ +name: Test GitHub Provider + +on: + push: + +jobs: + build: + name: Test on Windows Server 2022 + runs-on: windows-2022 + permissions: + id-token: write + contents: read + timeout-minutes: 10 + + steps: + - name: Install OpenSSH Server + run: | + Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 + + - name: Create default OpenSSH config files + run: | + Start-Service sshd + Start-Sleep -Seconds 2 + Stop-Service sshd + + - name: Enable OpenSSH Server logs + run: | + $sshdConfig = "$env:ProgramData\ssh\sshd_config" + (Get-Content $sshdConfig -Raw).Replace('#SyslogFacility AUTH', 'SyslogFacility LOCAL0').Replace('#LogLevel INFO', 'LogLevel Debug3') | + Set-Content $sshdConfig -Encoding ascii + + - name: Set runneradmin password + run: net user runneradmin "P@ssw0rd123!" + + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Cache Go modules + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Install dependencies + run: go mod download + + - name: Build opkssh + run: go build -v -o opkssh.exe + + - name: Install opkssh with local binary + run: | + powershell -ExecutionPolicy Bypass -File "scripts/windows/Install-OpksshServer.ps1" ` + -InstallFrom "$PWD\opkssh.exe" ` + -NoSshdRestart ` + -AuthCmdUser 'opksshuser' ` + -Verbose + + - name: Add GitHub provider to opkssh configuration + run: | + $providersPath = 'C:\ProgramData\opk\providers' + if ((Get-Content -Path $providersPath -Raw) -notmatch "`r?`n$") { + Add-Content -Path $providersPath -Value '' + } + Add-Content -Path $providersPath -Value 'https://token.actions.githubusercontent.com github oidc' + + - name: Add current repository to policy + run: | + & 'C:\Program Files\opkssh\opkssh.exe' add runneradmin "repo:${env:GITHUB_REPOSITORY}:ref:${env:GITHUB_REF}" https://token.actions.githubusercontent.com + + - name: Start SSH service + run: Start-Service sshd + + - name: Test SSH connection without opkssh (should fail) + run: | + ssh -o StrictHostKeyChecking=no -o PasswordAuthentication=no runneradmin@localhost dir + continue-on-error: true + + - name: Login with GitHub OIDC + run: | + & 'C:\Program Files\opkssh\opkssh.exe' login github --print-id-token + + - name: Test SSH connection with opkssh (should pass) + run: | + ssh -o StrictHostKeyChecking=no -o PasswordAuthentication=no runneradmin@localhost dir + + - name: Debug - Dump opkssh config + run: | + Get-ChildItem 'C:\ProgramData\opk' -Recurse -ErrorAction Continue + Get-Content 'C:\ProgramData\opk\providers' -ErrorAction Continue + Get-Content 'C:\ProgramData\opk\auth_id' -ErrorAction Continue + if: always() + + - name: Debug - Dump opkssh logs + run: | + Get-Content 'C:\ProgramData\opk\logs\opkssh.log' -ErrorAction Continue + if: always() + + - name: Debug - Dump sshd logs + run: | + Get-Content 'C:\ProgramData\ssh\logs\sshd.log' -ErrorAction Continue + if: always() diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 31a354e1..94df77b1 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -20,10 +20,6 @@ builds: goarch: - amd64 - arm64 - # Ignore some builds until they are not tested - ignore: - - goos: windows - goarch: arm64 # Make binaries available with the same naming format as before archives: @@ -51,6 +47,14 @@ nfpms: - archlinux # Archlinux # TODO: Add debian overrides and match with https://salsa.debian.org/go-team/packages/opkssh +# Include additional files in releases and configure release settings +release: + draft: true + make_latest: true + extra_files: + - glob: scripts/windows/Install-OpksshServer.ps1 + - glob: scripts/windows/Uninstall-OpksshServer.ps1 + # Create checksums file checksum: name_template: 'checksums.txt' @@ -72,8 +76,3 @@ changelog: order: 1 - title: 🧰 Maintenance order: 999 - -# Define how to make GitHub releases -release: - draft: true - make_latest: true diff --git a/commands/readhome_windows.go b/commands/readhome_windows.go index 86f70a7c..1d8dd457 100644 --- a/commands/readhome_windows.go +++ b/commands/readhome_windows.go @@ -19,10 +19,88 @@ package commands import ( - "errors" + "bytes" + "fmt" + "os" + "os/user" + "path/filepath" + "regexp" + "strings" + + "github.com/openpubkey/opkssh/policy/files" + "github.com/spf13/afero" ) -// ReadHome is not currently supported on Windows +// ReadHome reads the home policy file for the user with the specified +// username on Windows. It verifies file ownership via the file's ACL +// owner SID to ensure the policy file belongs to the expected user. func ReadHome(username string) ([]byte, error) { - return nil, errors.New("readhome not supported on windows") + // Validate username: allow alphanumeric, dash, dot, underscore + if matched, _ := regexp.MatchString(`^[a-zA-Z0-9_\-.]+$`, username); !matched { + return nil, fmt.Errorf("%s is not a valid Windows username", username) + } + + // Look up the user to get their SID and home directory + userObj, err := user.Lookup(username) + if err != nil { + // On Windows, user.Lookup may need DOMAIN\user format. Try with + // the bare username first, then fall back if needed. + return nil, fmt.Errorf("failed to find user %s: %w", username, err) + } + + homePolicyPath := filepath.Join(userObj.HomeDir, ".opk", "auth_id") + + // Verify file exists + if _, err := os.Stat(homePolicyPath); err != nil { + return nil, fmt.Errorf("failed to access %s: %w", homePolicyPath, err) + } + + // Resolve the expected owner SID from the user object + expectedSID, _, err := files.ResolveAccountToSID(username) + if err != nil { + // Try with the full username which may include domain prefix + expectedSID, _, err = files.ResolveAccountToSID(userObj.Username) + if err != nil { + return nil, fmt.Errorf("failed to resolve SID for user %s: %w", username, err) + } + } + + // Verify file ownership via ACL: check the file owner SID matches the user + fs := afero.NewOsFs() + verifier := files.NewDefaultACLVerifier(fs) + report, err := verifier.VerifyACL(homePolicyPath, files.ExpectedACLFromPerm(files.RequiredPerms.HomePolicy)) + if err != nil { + return nil, fmt.Errorf("failed to verify ACL on %s: %w", homePolicyPath, err) + } + + // Compare owner SIDs + if len(report.OwnerSID) == 0 { + return nil, fmt.Errorf("could not determine file owner for %s", homePolicyPath) + } + if !bytes.Equal(report.OwnerSID, expectedSID) { + // Convert SIDs to string form for a readable error message + expectedSIDStr := userObj.Uid // user.User.Uid is the SID on Windows + actualSIDStr := report.OwnerSIDStr + if actualSIDStr == "" { + actualSIDStr = "" + } + ownerName := report.Owner + if ownerName == "" { + ownerName = actualSIDStr + } + return nil, fmt.Errorf("unsafe file ownership on %s: expected owner %s (SID %s) got %s (SID %s)", + homePolicyPath, username, expectedSIDStr, ownerName, actualSIDStr) + } + + // Verify there are no ACL problems flagged + if len(report.Problems) > 0 { + return nil, fmt.Errorf("ACL problems on %s: %s", homePolicyPath, strings.Join(report.Problems, "; ")) + } + + // Read and return file contents + content, err := os.ReadFile(homePolicyPath) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", homePolicyPath, err) + } + return content, nil } diff --git a/internal/sysdetails/openssh.go b/internal/sysdetails/openssh.go index cb12f4fb..7fa5effe 100644 --- a/internal/sysdetails/openssh.go +++ b/internal/sysdetails/openssh.go @@ -56,12 +56,25 @@ func GetOpenSSHVersion() string { if output, err := cmd.CombinedOutput(); err == nil && len(strings.TrimSpace(string(output))) > 0 { return strings.TrimSpace(string(output)) } + + case OSTypeWindows: + // For Windows, try ssh.exe in PATH + cmd := exec.Command("ssh.exe", "-V") + output, err := cmd.CombinedOutput() + if err == nil && len(strings.TrimSpace(string(output))) > 0 { + return strings.TrimSpace(string(output)) + } + default: log.Printf("Warning: Could not determine OpenSSH version using OS-specific methods for %s", osType) } // Try ssh -V (works on most systems) - cmd := exec.Command("ssh", "-V") + sshCmd := "ssh" + if osType == OSTypeWindows { + sshCmd = "ssh.exe" + } + cmd := exec.Command(sshCmd, "-V") output, err := cmd.CombinedOutput() if err == nil && len(strings.TrimSpace(string(output))) > 0 { return strings.TrimSpace(string(output)) @@ -69,7 +82,11 @@ func GetOpenSSHVersion() string { log.Println("Warning: Error executing ssh -V:", err) // Try sshd -V as fallback - cmd = exec.Command("sshd", "-V") + sshdCmd := "sshd" + if osType == OSTypeWindows { + sshdCmd = "sshd.exe" + } + cmd = exec.Command(sshdCmd, "-V") output, err = cmd.CombinedOutput() if err == nil && len(strings.TrimSpace(string(output))) > 0 { return strings.TrimSpace(string(output)) diff --git a/internal/sysdetails/os.go b/internal/sysdetails/os.go index 69e354f3..ee779162 100644 --- a/internal/sysdetails/os.go +++ b/internal/sysdetails/os.go @@ -18,6 +18,7 @@ package sysdetails import ( "os" + "runtime" "strings" ) @@ -31,10 +32,16 @@ const ( OSTypeDebian OSType = "debian" OSTypeArch OSType = "arch" OSTypeSUSE OSType = "suse" + OSTypeWindows OSType = "windows" ) // DetectOS determines the type of operating system. func DetectOS() OSType { + // Check for Windows using runtime.GOOS + if runtime.GOOS == "windows" { + return OSTypeWindows + } + // Check for RedHat-based systems if _, err := os.Stat("/etc/redhat-release"); err == nil { return OSTypeRHEL diff --git a/logpath_unix.go b/logpath_unix.go new file mode 100644 index 00000000..63d2d45b --- /dev/null +++ b/logpath_unix.go @@ -0,0 +1,26 @@ +//go:build !windows +// +build !windows + +// Copyright 2025 OpenPubkey +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +// GetLogFilePath returns the path to the opkssh log file. +// On Unix-like systems, this is /var/log/opkssh.log +func GetLogFilePath() string { + return "/var/log/opkssh.log" +} diff --git a/logpath_windows.go b/logpath_windows.go new file mode 100644 index 00000000..c0f5d99d --- /dev/null +++ b/logpath_windows.go @@ -0,0 +1,35 @@ +//go:build windows +// +build windows + +// Copyright 2025 OpenPubkey +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "path/filepath" +) + +// GetLogFilePath returns the path to the opkssh log file. +// On Windows, this is %ProgramData%\opk\logs\opkssh.log +func GetLogFilePath() string { + programData := os.Getenv("ProgramData") + if programData == "" { + programData = `C:\ProgramData` + } + return filepath.Join(programData, "opk", "logs", "opkssh.log") +} diff --git a/main.go b/main.go index af1bfca9..9d0a2cae 100644 --- a/main.go +++ b/main.go @@ -21,11 +21,11 @@ package main import ( "context" - "errors" "fmt" "log" "os" "os/signal" + "path/filepath" "regexp" "runtime" "strings" @@ -48,10 +48,14 @@ import ( var ( // These can be overridden at build time using ldflags. For example: // go build -v -o /usr/local/bin/opkssh -ldflags "-X main.Version=version" - Version = "unversioned" - logFilePathServer = "/var/log/opkssh.log" // Remember if you change this, change it in the install script as well + Version = "unversioned" ) +// GetLogFilePathServer returns the platform-specific log file path +func GetLogFilePathServer() string { + return GetLogFilePath() +} + func main() { os.Exit(run()) } @@ -313,11 +317,12 @@ Arguments: ctx := context.Background() // Setup logger - logFile, err := os.OpenFile(logFilePathServer, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0660) // Owner and group can read/write + logFilePath := GetLogFilePathServer() + logFile, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0660) // Owner and group can read/write if err != nil { fmt.Fprintf(os.Stderr, "Error opening log file: %v\n", err) // It could be very difficult to figure out what is going on if the log file was deleted. Hopefully this message saves someone an hour of debugging. - fmt.Fprintf(os.Stderr, "Check if log exists at %v, if it does not create it with permissions: chown root:opksshuser %v; chmod 660 %v\n", logFilePathServer, logFilePathServer, logFilePathServer) + fmt.Fprintf(os.Stderr, "Check if log exists at %v, if it does not create it with permissions: chown root:opksshuser %v; chmod 660 %v\n", logFilePath, logFilePath, logFilePath) } else { defer logFile.Close() log.SetOutput(logFile) @@ -335,10 +340,10 @@ Arguments: typArg := args[2] extraArgs := args[3:] - providerPolicyPath := "/etc/opk/providers" + providerPolicyPath := filepath.Join(policy.GetSystemConfigBasePath(), "providers") providerPolicy, err := policy.NewProviderFileLoader().LoadProviderPolicy(providerPolicyPath) if err != nil { - log.Println("Failed to open /etc/opk/providers:", err) + log.Printf("Failed to open %s: %v\n", providerPolicyPath, err) return err } @@ -367,7 +372,8 @@ Arguments: } }, } - verifyCmd.Flags().StringVar(&serverConfigPathArg, "config-path", "/etc/opk/config.yml", "Path to the server config file. Default: /etc/opk/config.yml.") + defaultConfigPath := filepath.Join(policy.GetSystemConfigBasePath(), "config.yml") + verifyCmd.Flags().StringVar(&serverConfigPathArg, "config-path", defaultConfigPath, fmt.Sprintf("Path to the server config file. Default: %s", defaultConfigPath)) rootCmd.AddCommand(verifyCmd) auditCmd := &cobra.Command{ @@ -543,24 +549,33 @@ func checkOpenSSHVersion() { } } -func isOpenSSHVersion8Dot1OrGreater(opensshVersionStr string) (bool, error) { - // To handle versions like 9.9p1; we only need the initial numeric part for the comparison - re, err := regexp.Compile(`^(\d+(?:\.\d+)*).*`) +func isOpenSSHVersion8Dot1OrGreater(opensshVersion string) (bool, error) { + // Extract version number from various formats: + // - "OpenSSH_9.5p1" -> "9.5" + // - "OpenSSH_for_Windows_9.5p2, LibreSSL 3.8.2" -> "9.5" + + // First, get the part before comma (to handle LibreSSL suffix on Windows) + opensshVersion = strings.Split(opensshVersion, ",")[0] + + // Try to extract version using regex that handles both Unix and Windows formats + // Matches: "OpenSSH_9.5", "OpenSSH_for_Windows_9.5", etc. + re, err := regexp.Compile(`OpenSSH[_a-zA-Z]*[_](\d+\.\d+)`) if err != nil { fmt.Println("Error compiling regex:", err) return false, err } - opensshVersion := strings.TrimPrefix( - strings.Split(opensshVersionStr, ", ")[0], - "OpenSSH_", - ) - matches := re.FindStringSubmatch(opensshVersion) - if len(matches) <= 0 { - fmt.Println("Invalid OpenSSH version") - return false, errors.New("invalid OpenSSH version") + if len(matches) < 2 { + // If regex didn't match, try a simpler approach: find any version pattern + simpleRe := regexp.MustCompile(`(\d+\.\d+)`) + matches = simpleRe.FindStringSubmatch(opensshVersion) + + if len(matches) < 2 { + log.Printf("Invalid OpenSSH version format: %s", opensshVersion) + return false, fmt.Errorf("invalid OpenSSH version format: %s", opensshVersion) + } } version := "v" + matches[1] // semver requires that version strings start with 'v' diff --git a/policy/enforcer.go b/policy/enforcer.go index 20696402..6f1e2c5f 100644 --- a/policy/enforcer.go +++ b/policy/enforcer.go @@ -22,6 +22,7 @@ import ( "fmt" "log" "os" + "path/filepath" "strings" "github.com/openpubkey/openpubkey/pktoken" @@ -103,8 +104,11 @@ func (s *checkedClaims) UnmarshalJSON(data []byte) error { return nil } -// The default location for policy plugins -const pluginPolicyDir = "/etc/opk/policy.d" +// GetPluginPolicyDir returns the default location for policy plugins. +// On Unix: /etc/opk/policy.d, On Windows: %ProgramData%\opk\policy.d +func GetPluginPolicyDir() string { + return filepath.Join(GetSystemConfigBasePath(), "policy.d") +} // EscapedSplit splits a string by a separator while ignoring the separator in quoted sections. // This is useful for strings that may contain the separator character as part of the string @@ -184,6 +188,7 @@ func (p *Enforcer) CheckPolicy(principalDesired string, pkt *pktoken.PKToken, us } pluginPolicy := plugins.NewPolicyPluginEnforcer() + pluginPolicyDir := GetPluginPolicyDir() results, err := pluginPolicy.CheckPolicies(pluginPolicyDir, pkt, userInfoJson, principalDesired, sshCert, keyType, extraArgs) if err != nil { diff --git a/scripts/windows/Install-OpksshServer.ps1 b/scripts/windows/Install-OpksshServer.ps1 new file mode 100644 index 00000000..f1aa6168 --- /dev/null +++ b/scripts/windows/Install-OpksshServer.ps1 @@ -0,0 +1,1104 @@ +#Requires -Version 5.1 +#Requires -RunAsAdministrator + +<# +.SYNOPSIS + Installs and configures opkssh on Windows Server 2022 with OpenSSH Server. + +.DESCRIPTION + This script downloads and installs the opkssh binary, creates necessary configuration + files and directories, and configures the OpenSSH Server to use opkssh for + authentication via OpenID Connect. + +.PARAMETER NoSshdRestart + Do not restart the sshd service after installation. + You must manually restart the service for changes to take effect. + +.PARAMETER OverwriteConfig + Overwrite existing AuthorizedKeysCommand configuration in sshd_config. + Use this if the script detects existing configuration that conflicts. + +.PARAMETER InstallFrom + Path to a local opkssh.exe file to install instead of downloading from GitHub. + +.PARAMETER InstallVersion + Specific version to install from GitHub (e.g., "v0.10.0"). + Default is "latest". + +.PARAMETER InstallDir + Directory where opkssh.exe will be installed. + Default is "C:\Program Files\opkssh". + +.PARAMETER ConfigPath + Directory where opkssh configuration files will be created. + Default is "C:\ProgramData\opk". + +.PARAMETER AuthCmdUser + User account that will run the AuthorizedKeysCommand. + Default is "System" (the OpenSSH service account). + You can specify "opksshuser" to create a dedicated local user instead. + +.PARAMETER GitHubRepo + GitHub repository to download from (format: owner/repo). + Default is "openpubkey/opkssh". + +.EXAMPLE + .\Install-OpksshServer.ps1 + + Basic installation with default settings. + +.EXAMPLE + .\Install-OpksshServer.ps1 -InstallFrom "C:\Downloads\opkssh.exe" + + Install from a local file instead of downloading. + +.EXAMPLE + .\Install-OpksshServer.ps1 -InstallVersion "v0.10.0" -Verbose + + Install a specific version with verbose output. + +.EXAMPLE + .\Install-OpksshServer.ps1 -AuthCmdUser "opksshuser" + + Install using a dedicated local user account instead of System. + +.NOTES + Author: OpenPubkey Project + Requires: Windows Server 2022 (or Windows 10/11 with OpenSSH Server installed) + Requires: PowerShell 5.1 or higher + Requires: Administrator privileges + Requires: OpenSSH Server installed and configured +#> + +[CmdletBinding(SupportsShouldProcess=$true)] +param( + [Parameter(HelpMessage="Do not restart sshd service after installation")] + [switch]$NoSshdRestart, + + [Parameter(HelpMessage="Overwrite existing AuthorizedKeysCommand configuration")] + [switch]$OverwriteConfig, + + [Parameter(HelpMessage="Path to local opkssh.exe file")] + [ValidateScript({ + if ($_ -and -not (Test-Path $_)) { + throw "File not found: $_" + } + $true + })] + [string]$InstallFrom = "", + + [Parameter(HelpMessage="Version to install (e.g., 'v0.10.0' or 'latest')")] + [string]$InstallVersion = "latest", + + [Parameter(HelpMessage="Installation directory for opkssh.exe")] + [string]$InstallDir = "C:\Program Files\opkssh", + + [Parameter(HelpMessage="Configuration directory path")] + [string]$ConfigPath = "C:\ProgramData\opk", + + [Parameter(HelpMessage="User account for AuthorizedKeysCommand")] + [ValidateSet("System", "opksshuser")] + [string]$AuthCmdUser = "System", + + [Parameter(HelpMessage="GitHub repository (owner/repo)")] + [string]$GitHubRepo = "openpubkey/opkssh" +) + +#region Helper Functions + +function Write-Log { + <# + .SYNOPSIS + Writes a message to the console and optionally to a log file. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] + [string]$Message, + + [Parameter()] + [ValidateSet('Info', 'Warning', 'Error', 'Success', 'Verbose')] + [string]$Level = 'Info', + + [Parameter()] + [string]$LogFile + ) + + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $logMessage = "[$timestamp] [$Level] $Message" + + switch ($Level) { + 'Info' { Write-Host $Message } + 'Warning' { Write-Warning $Message } + 'Error' { Write-Error $Message } + 'Success' { Write-Host $Message -ForegroundColor Green } + 'Verbose' { Write-Verbose $Message } + } + + if ($LogFile -and (Test-Path (Split-Path $LogFile -Parent))) { + Add-Content -Path $LogFile -Value $logMessage + } +} + +function Test-Prerequisites { + <# + .SYNOPSIS + Verifies that all prerequisites are met for installation. + #> + [CmdletBinding()] + param() + + Write-Verbose "Checking prerequisites..." + + # Check PowerShell version + if ($PSVersionTable.PSVersion.Major -lt 5) { + throw "PowerShell 5.1 or higher is required. Current version: $($PSVersionTable.PSVersion)" + } + Write-Verbose " PowerShell version: $($PSVersionTable.PSVersion)" + + # Check if running as Administrator + $currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) + $isAdmin = $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + + if (-not $isAdmin) { + throw "This script must be run as Administrator. Please restart PowerShell with elevated privileges." + } + Write-Verbose " Running with Administrator privileges: OK" + + # Check OpenSSH Server capability + Write-Verbose " Checking OpenSSH Server installation..." + $sshCapability = Get-WindowsCapability -Online -Name "OpenSSH.Server*" -ErrorAction SilentlyContinue + + if (-not $sshCapability) { + throw "OpenSSH Server capability not found. This script requires Windows Server 2019 or later, or Windows 10/11." + } + + if ($sshCapability.State -ne 'Installed') { + throw "OpenSSH Server is not installed. Install it using: Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0" + } + Write-Verbose " OpenSSH Server is installed" + + # Check sshd service + $sshdService = Get-Service -Name sshd -ErrorAction SilentlyContinue + if (-not $sshdService) { + throw "sshd service not found. OpenSSH Server may not be properly configured." + } + Write-Verbose " sshd service found: $($sshdService.Status)" + + # Check OpenSSH version when using System account + if ($AuthCmdUser -eq "System") { + Write-Verbose " Validating OpenSSH version for LocalSystem account..." + + try { + $canUseSystemAccount = (Get-Command sshd).Version -ge [version]'8.9' + } catch { + throw "Unexpected: sshd.exe not in PATH?" + } + + $sshdVersion = (Get-Command sshd).Version + Write-Verbose " Detected OpenSSH Server version: $sshdVersion" + + if (-not $canUseSystemAccount) { + $errorMessage = @" + +======================================== +ERROR: OpenSSH Version Too Old +======================================== + +Your OpenSSH Server version ($sshdVersion) does not support using 'LocalSystem' +as the AuthorizedKeysCommandUser. + +OpenSSH Server 8.9.0 or higher is required to use the LocalSystem account. + +SOLUTION: +Run the installer with the -AuthCmdUser parameter: + + .\Install-OpksshServer.ps1 -AuthCmdUser "opksshuser" + +This will create and use a dedicated 'opksshuser' account instead. + +======================================== +"@ + throw $errorMessage + } + + Write-Verbose " OpenSSH version is compatible with LocalSystem account" + } else { + Write-Verbose " Using custom user account, no version restriction" + } + + # Verify sshd_config exists + $sshdConfigPath = "C:\ProgramData\ssh\sshd_config" + if (-not (Test-Path $sshdConfigPath)) { + throw "sshd_config not found at $sshdConfigPath" + } + Write-Verbose " sshd_config found at: $sshdConfigPath" + + Write-Verbose "All prerequisites met." + return $true +} + +function Get-SystemArchitecture { + <# + .SYNOPSIS + Determines the CPU architecture of the system. + #> + [CmdletBinding()] + [OutputType([string])] + param() + + $arch = $env:PROCESSOR_ARCHITECTURE + Write-Verbose "Detected processor architecture: $arch" + + switch ($arch) { + "AMD64" { + Write-Verbose "Using architecture: amd64" + return "amd64" + } + "ARM64" { + Write-Verbose "Using architecture: arm64" + return "arm64" + } + default { + throw "Unsupported CPU architecture: $arch. Supported architectures are AMD64 and ARM64." + } + } +} + +function Test-OpksshVersion { + <# + .SYNOPSIS + Validates that the requested version is supported by this script. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] + [string]$Version + ) + + if ($Version -eq "latest") { + Write-Verbose "Installing latest version" + return $true + } + + # Minimum supported version + $minVersionString = "0.10.0" + $minVersion = [version]$minVersionString + + # Parse requested version (allow optional prerelease/build suffixes) + $versionMatch = [regex]::Match($Version, '^v?(?\d+\.\d+\.\d+)(?[-+].+)?$') + if (-not $versionMatch.Success) { + throw "Invalid version format: $Version. Use format 'v0.10.0' or '0.10.0' (optionally with -suffix)" + } + + $versionString = $versionMatch.Groups['core'].Value + + try { + $requestedVersion = [version]$versionString + } catch { + throw "Invalid version format: $Version. Use format 'v0.10.0' or '0.10.0' (optionally with -suffix)" + } + + if ($requestedVersion -lt $minVersion) { + throw @" +Installing opkssh $Version with this script is not supported. +Minimum supported version is v$minVersionString. +For older versions, please use the installation script from that release. +"@ + } + + Write-Verbose "Version $Version is supported" + return $true +} + +function New-OpksshUser { + <# + .SYNOPSIS + Creates a dedicated local user account for running AuthorizedKeysCommand. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [Parameter(Mandatory=$true)] + [string]$Username + ) + + if ($Username -eq "System") { + Write-Verbose "Using built-in service account: System" + return $true + } + + Write-Verbose "Checking if user '$Username' exists..." + $existingUser = Get-LocalUser -Name $Username -ErrorAction SilentlyContinue + + if ($existingUser) { + Write-Verbose "User '$Username' already exists" + return $true + } + + if ($PSCmdlet.ShouldProcess($Username, "Create local user")) { + Write-Verbose "Creating local user: $Username" + + # Generate a random password + Add-Type -AssemblyName 'System.Web' + $password = [System.Web.Security.Membership]::GeneratePassword(32, 10) + $securePassword = ConvertTo-SecureString $password -AsPlainText -Force + + # Create user with security settings + try { + New-LocalUser -Name $Username ` + -Password $securePassword ` + -Description "OpenPubkey SSH verification user" ` + -UserMayNotChangePassword ` + -PasswordNeverExpires ` + -AccountNeverExpires ` + -ErrorAction Stop | Out-Null + + Write-Log "Created user: $Username" -Level Success + + # Note: Denying interactive logon requires editing local security policy + # This would typically be done via secedit or Group Policy + Write-Warning "Manual step required: Deny interactive logon rights for user '$Username' via Local Security Policy" + + } catch { + throw "Failed to create user '$Username': $($_.Exception.Message)" + } + } + + return $true +} + +function Install-OpksshBinary { + <# + .SYNOPSIS + Downloads or copies the opkssh binary to the installation directory. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [Parameter(Mandatory=$true)] + [string]$InstallDir, + + [Parameter()] + [string]$LocalFile, + + [Parameter(Mandatory=$true)] + [string]$Version, + + [Parameter(Mandatory=$true)] + [string]$Architecture, + + [Parameter(Mandatory=$true)] + [string]$GitHubRepo + ) + + $binaryName = "opkssh.exe" + $binaryPath = Join-Path $InstallDir $binaryName + + # Create installation directory if it doesn't exist + if (-not (Test-Path $InstallDir)) { + Write-Verbose "Creating installation directory: $InstallDir" + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + } + + if ($LocalFile) { + # Install from local file + Write-Log "Installing from local file: $LocalFile" + + if (-not (Test-Path $LocalFile)) { + throw "Local file not found: $LocalFile" + } + + if ($PSCmdlet.ShouldProcess($LocalFile, "Copy to $binaryPath")) { + Copy-Item $LocalFile $binaryPath -Force + Write-Verbose "Copied $LocalFile to $binaryPath" + } + } else { + # Download from GitHub + if ($Version -eq "latest") { + $downloadUrl = "https://github.com/$GitHubRepo/releases/latest/download/opkssh-windows-$Architecture.exe" + } else { + $downloadUrl = "https://github.com/$GitHubRepo/releases/download/$Version/opkssh-windows-$Architecture.exe" + } + + Write-Log "Downloading opkssh version $Version from GitHub..." + Write-Verbose "Download URL: $downloadUrl" + + if ($PSCmdlet.ShouldProcess($downloadUrl, "Download to $binaryPath")) { + try { + # Use TLS 1.2 + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + # Download with progress + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $downloadUrl -OutFile $binaryPath -UseBasicParsing -ErrorAction Stop + $ProgressPreference = 'Continue' + + Write-Verbose "Downloaded to: $binaryPath" + } catch { + throw "Failed to download opkssh binary: $($_.Exception.Message)" + } + } + } + + # Verify the binary exists and is executable + if (-not (Test-Path $binaryPath)) { + throw "Installation failed: Binary not found at $binaryPath" + } + + $fileInfo = Get-Item $binaryPath + Write-Verbose "Binary size: $($fileInfo.Length) bytes" + + # Test that the binary is valid by running --version + try { + $versionOutput = & $binaryPath --version 2>&1 + Write-Verbose "Binary version: $versionOutput" + } catch { + Write-Warning "Could not verify binary version: $($_.Exception.Message)" + } + + Write-Log "Installed opkssh to: $binaryPath" -Level Success + return $binaryPath +} + +function Install-UninstallScript { + <# + .SYNOPSIS + Downloads the uninstall script and places it in the installation directory. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [Parameter(Mandatory=$true)] + [string]$InstallDir, + + [Parameter(Mandatory=$true)] + [string]$Version, + + [Parameter(Mandatory=$true)] + [string]$GitHubRepo + ) + + $scriptName = "Uninstall-OpksshServer.ps1" + $scriptPath = Join-Path $InstallDir $scriptName + + # Download from GitHub + if ($Version -eq "latest") { + $downloadUrl = "https://github.com/$GitHubRepo/releases/latest/download/$scriptName" + } else { + $downloadUrl = "https://github.com/$GitHubRepo/releases/download/$Version/$scriptName" + } + + Write-Log "Downloading uninstall script..." + Write-Verbose "Download URL: $downloadUrl" + + if ($PSCmdlet.ShouldProcess($downloadUrl, "Download to $scriptPath")) { + try { + # Use TLS 1.2 + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + # Download with progress + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $downloadUrl -OutFile $scriptPath -UseBasicParsing -ErrorAction Stop + $ProgressPreference = 'Continue' + + Write-Log "Installed uninstall script to: $scriptPath" -Level Success + Write-Verbose "Downloaded to: $scriptPath" + } catch { + # Non-fatal error - uninstall script is optional + Write-Warning "Could not download uninstall script: $($_.Exception.Message)" + Write-Warning "You can manually download it from: $downloadUrl" + return $null + } + } + + return $scriptPath +} + +function New-OpksshConfiguration { + <# + .SYNOPSIS + Creates opkssh configuration directory structure and files. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [Parameter(Mandatory=$true)] + [string]$ConfigPath, + + [Parameter(Mandatory=$true)] + [string]$AuthCmdUser + ) + + Write-Log "Configuring opkssh at: $ConfigPath" + + # Define directory structure + $directories = @( + $ConfigPath, + (Join-Path $ConfigPath "policy.d"), + (Join-Path $ConfigPath "logs") + ) + + # Create directories + foreach ($dir in $directories) { + if (-not (Test-Path $dir)) { + if ($PSCmdlet.ShouldProcess($dir, "Create directory")) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + Write-Verbose " Created directory: $dir" + } + } else { + Write-Verbose " Directory exists: $dir" + } + } + + # Define configuration files + $authIdPath = Join-Path $ConfigPath "auth_id" + $configYmlPath = Join-Path $ConfigPath "config.yml" + $providersPath = Join-Path $ConfigPath "providers" + + # Create auth_id if it doesn't exist + if (-not (Test-Path $authIdPath)) { + if ($PSCmdlet.ShouldProcess($authIdPath, "Create auth_id file")) { + New-Item -ItemType File -Path $authIdPath -Force | Out-Null + Write-Verbose " Created file: auth_id" + } + } else { + Write-Verbose " File exists: auth_id" + } + + # Create config.yml if it doesn't exist + if (-not (Test-Path $configYmlPath)) { + if ($PSCmdlet.ShouldProcess($configYmlPath, "Create config.yml file")) { + New-Item -ItemType File -Path $configYmlPath -Force | Out-Null + Write-Verbose " Created file: config.yml" + } + } else { + Write-Verbose " File exists: config.yml" + } + + # Create or update providers file + if (-not (Test-Path $providersPath)) { + $providersContent = @" +# Issuer Client-ID expiration-policy +https://accounts.google.com 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com 24h +https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096ce0a3-5e72-4da8-9c86-12924b294a01 24h +https://gitlab.com 8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923 24h +https://issuer.hello.coop app_xejobTKEsDNSRd5vofKB2iay_2rN 24h +"@ + + if ($PSCmdlet.ShouldProcess($providersPath, "Create providers file")) { + Set-Content -Path $providersPath -Value $providersContent -NoNewline + Write-Verbose " Created file: providers" + } + } else { + $existingContent = Get-Content $providersPath -Raw + if ([string]::IsNullOrWhiteSpace($existingContent)) { + Write-Warning " The providers file exists but is empty. Keeping it empty." + } else { + Write-Verbose " The providers file is not empty. Keeping existing values." + } + } + + Write-Log "Configuration created successfully" -Level Success + return $true +} + +function Set-SshdConfiguration { + <# + .SYNOPSIS + Configures sshd_config to use opkssh for AuthorizedKeysCommand. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [Parameter(Mandatory=$true)] + [string]$BinaryPath, + + [Parameter(Mandatory=$true)] + [string]$AuthCmdUser, + + [Parameter()] + [bool]$OverwriteConfig = $false, + + [Parameter()] + [string]$SshdConfigPath = "C:\ProgramData\ssh\sshd_config" + ) + + Write-Log "Configuring OpenSSH Server..." + Write-Verbose " sshd_config path: $sshdConfigPath" + + if (-not (Test-Path $sshdConfigPath)) { + throw "sshd_config not found at: $sshdConfigPath" + } + + # Read current configuration + $configLines = Get-Content $sshdConfigPath + + # Prepare new configuration lines + # Note: Windows paths with spaces must be quoted + $quotedBinaryPath = "`"$BinaryPath`"" + $authKeyCmdLine = "AuthorizedKeysCommand $quotedBinaryPath verify %u %k %t" + $authKeyUserLine = "AuthorizedKeysCommandUser $AuthCmdUser" + + # Check for existing AuthorizedKeysCommand configuration + $hasAuthKeyCmd = $configLines | Where-Object { + $_ -match '^\s*AuthorizedKeysCommand\s+' -and $_ -notmatch '^\s*#' + } + $hasAuthKeyUser = $configLines | Where-Object { + $_ -match '^\s*AuthorizedKeysCommandUser\s+' -and $_ -notmatch '^\s*#' + } + + $matchesAuthKeyCmd = $hasAuthKeyCmd | Where-Object { $_.Trim() -eq $authKeyCmdLine } + $matchesAuthKeyUser = $hasAuthKeyUser | Where-Object { $_.Trim() -eq $authKeyUserLine } + + if ($matchesAuthKeyCmd -and $matchesAuthKeyUser) { + Write-Log " Existing opkssh sshd_config entries already match desired configuration" -Level Success + return $true + } + + if (($hasAuthKeyCmd -or $hasAuthKeyUser) -and -not $OverwriteConfig) { + Write-Warning "Existing AuthorizedKeysCommand configuration detected:" + $hasAuthKeyCmd | ForEach-Object { Write-Warning " $_" } + $hasAuthKeyUser | ForEach-Object { Write-Warning " $_" } + Write-Warning "" + Write-Warning "To overwrite this configuration, run the script with -OverwriteConfig" + return $false + } + + # Backup existing configuration + $timestamp = Get-Date -Format "yyyyMMddHHmmss" + $backupPath = "$sshdConfigPath.backup.$timestamp" + + if ($PSCmdlet.ShouldProcess($sshdConfigPath, "Create backup at $backupPath")) { + Copy-Item $sshdConfigPath $backupPath -Force + Write-Verbose " Created backup: $backupPath" + } + + # Process configuration + $newConfigLines = @() + + foreach ($line in $configLines) { + if ($line -match '^\s*AuthorizedKeysCommand\s+') { + # Comment out existing AuthorizedKeysCommand + $newConfigLines += "# $line" + Write-Verbose " Commented out: $line" + } elseif ($line -match '^\s*AuthorizedKeysCommandUser\s+') { + # Comment out existing AuthorizedKeysCommandUser + $newConfigLines += "# $line" + Write-Verbose " Commented out: $line" + } else { + $newConfigLines += $line + } + } + + # Add opkssh configuration at the end + $newConfigLines += "" + $newConfigLines += "# opkssh configuration - added by Install-OpksshServer.ps1 on $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" + $newConfigLines += $authKeyCmdLine + $newConfigLines += $authKeyUserLine + + # Write new configuration + if ($PSCmdlet.ShouldProcess($sshdConfigPath, "Update sshd_config")) { + Set-Content -Path $sshdConfigPath -Value $newConfigLines -Force + Write-Verbose " Updated sshd_config" + } + + # Validate configuration by attempting to parse it + # Note: OpenSSH on Windows doesn't have a built-in config test like sshd -t + # We'll do a basic sanity check + Write-Verbose " Validating configuration..." + $finalConfig = Get-Content $sshdConfigPath -Raw + if ($finalConfig -match [regex]::Escape($authKeyCmdLine)) { + Write-Log " OpenSSH Server configured successfully" -Level Success + return $true + } else { + Write-Error "Configuration validation failed" + if ($PSCmdlet.ShouldProcess($backupPath, "Restore from backup")) { + Copy-Item $backupPath $sshdConfigPath -Force + Write-Warning "Restored configuration from backup" + } + return $false + } +} + +function Restart-SshdService { + <# + .SYNOPSIS + Restarts the OpenSSH Server (sshd) service. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [Parameter()] + [bool]$NoRestart = $false + ) + + if ($NoRestart) { + Write-Warning "Skipping sshd service restart (NoRestart parameter specified)" + Write-Warning "You must manually restart the sshd service for changes to take effect:" + Write-Warning " Restart-Service sshd" + return $true + } + + Write-Log "Restarting sshd service..." + + if ($PSCmdlet.ShouldProcess("sshd", "Restart service")) { + try { + Restart-Service sshd -Force -ErrorAction Stop + + # Wait a moment for service to stabilize + Start-Sleep -Seconds 2 + + # Verify service is running + $service = Get-Service sshd + if ($service.Status -eq 'Running') { + Write-Log " sshd service restarted successfully" -Level Success + + # Ensure service starts automatically + $startType = (Get-Service sshd).StartType + if ($startType -ne 'Automatic') { + Set-Service sshd -StartupType Automatic + Write-Verbose " Set sshd service to start automatically" + } + + return $true + } else { + throw "Service is in state: $($service.Status)" + } + } catch { + Write-Error "Failed to restart sshd service: $($_.Exception.Message)" + Write-Warning "Please restart the service manually: Restart-Service sshd" + return $false + } + } + + return $true +} + +function Add-OpksshToPath { + <# + .SYNOPSIS + Adds opkssh installation directory to the system PATH without expanding environment variables. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [Parameter(Mandatory=$true)] + [string]$InstallDir + ) + + if ($PSCmdlet.ShouldProcess("System PATH", "Add $InstallDir")) { + try { + # Use Registry to preserve environment variable expansion (e.g., %SystemRoot%) + $keyName = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment' + $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($keyName, $true) + try { + # Get current PATH without expanding environment variables + $currentPathFolders = $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') -split [IO.Path]::PathSeparator + + # Check if already in PATH (case-insensitive) + $normalizedInstallDir = $InstallDir.TrimEnd([IO.Path]::DirectorySeparatorChar) + $alreadyInPath = $currentPathFolders | Where-Object { + $_.TrimEnd([IO.Path]::DirectorySeparatorChar) -eq $normalizedInstallDir + } + + if ($alreadyInPath) { + Write-Verbose "Installation directory already in PATH" + return $true + } + + # Add new folder to the current PATH + $newPathFolders = $currentPathFolders + @($normalizedInstallDir) + + # Normalize folders to remove trailing slashes and duplicates + $result = [Collections.Generic.HashSet[string]]::new([StringComparer]::InvariantCultureIgnoreCase) + $newPathFolders | + ForEach-Object { + $normalized = $_.TrimEnd([IO.Path]::DirectorySeparatorChar).Trim() + if ($normalized -ne '') { + $result.Add($normalized) > $null + } + } + + # Build new PATH and save it + $newPath = $result -join [IO.Path]::PathSeparator + $key.SetValue('Path', $newPath, 'ExpandString') + + Write-Log " Added to system PATH: $InstallDir" -Level Success + Write-Warning "You may need to restart your PowerShell session for PATH changes to take effect" + return $true + } finally { + if ($null -ne $key) { + $key.Dispose() + } + } + } catch { + Write-Warning "Failed to add to PATH: $($_.Exception.Message)" + Write-Warning "You can manually add '$InstallDir' to your PATH" + return $false + } + } + + return $true +} + +function Write-InstallationLog { + <# + .SYNOPSIS + Logs installation details to a file. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] + [string]$LogPath, + + [Parameter(Mandatory=$true)] + [string]$BinaryPath, + + [Parameter(Mandatory=$true)] + [hashtable]$InstallParams + ) + + # Ensure log directory exists + $logDir = Split-Path $LogPath -Parent + if (-not (Test-Path $logDir)) { + New-Item -ItemType Directory -Path $logDir -Force | Out-Null + } + + # Get version from binary + try { + $version = & $BinaryPath --version 2>&1 + } catch { + $version = "Unknown (failed to execute --version)" + } + + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + + $logEntry = @" + +======================================== +opkssh Installation Log +======================================== +Timestamp: $timestamp +Version: $version +Binary Path: $BinaryPath +Install Version Parameter: $($InstallParams.InstallVersion) +Local Install File: $($InstallParams.InstallFrom) +SSH Restarted: $(-not $InstallParams.NoRestart) +Auth Command User: $($InstallParams.AuthCmdUser) +Configuration Path: $($InstallParams.ConfigPath) +PowerShell Version: $($PSVersionTable.PSVersion) +OS Version: $([System.Environment]::OSVersion.VersionString) +Computer Name: $env:COMPUTERNAME +User: $env:USERNAME +======================================== + +"@ + + try { + Add-Content -Path $LogPath -Value $logEntry -ErrorAction Stop + Write-Verbose "Installation logged to: $LogPath" + } catch { + Write-Warning "Failed to write installation log: $($_.Exception.Message)" + } +} + +#endregion Helper Functions + +#region Main Installation Logic + +function Install-OpksshServer { + <# + .SYNOPSIS + Main installation function. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param() + + $ErrorActionPreference = 'Stop' + + try { + Write-Host "" + Write-Host "========================================" -ForegroundColor Cyan + Write-Host " opkssh Installation for Windows" -ForegroundColor Cyan + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "" + + # Step 1: Verify prerequisites + Write-Host "[1/10] Checking prerequisites..." -ForegroundColor Yellow + Test-Prerequisites | Out-Null + Write-Host " Prerequisites OK" -ForegroundColor Green + Write-Host "" + + # Step 2: Get system architecture + Write-Host "[2/10] Detecting system architecture..." -ForegroundColor Yellow + $arch = Get-SystemArchitecture + Write-Host " Architecture: $arch" -ForegroundColor Green + Write-Host "" + + # Step 3: Validate version + Write-Host "[3/10] Validating opkssh version..." -ForegroundColor Yellow + Test-OpksshVersion -Version $InstallVersion | Out-Null + Write-Host " Version OK: $InstallVersion" -ForegroundColor Green + Write-Host "" + + # Step 4: Create user account (if needed) + Write-Host "[4/10] Configuring authentication user..." -ForegroundColor Yellow + New-OpksshUser -Username $AuthCmdUser | Out-Null + Write-Host " Auth user: $AuthCmdUser" -ForegroundColor Green + Write-Host "" + + # Step 5: Install binary + Write-Host "[5/10] Installing opkssh binary..." -ForegroundColor Yellow + $binaryPath = Install-OpksshBinary -InstallDir $InstallDir ` + -LocalFile $InstallFrom ` + -Version $InstallVersion ` + -Architecture $arch ` + -GitHubRepo $GitHubRepo + Write-Host " Installed: $binaryPath" -ForegroundColor Green + Write-Host "" + + # Step 6: Install uninstall script + Write-Host "[6/10] Installing uninstall script..." -ForegroundColor Yellow + $uninstallPath = Install-UninstallScript -InstallDir $InstallDir ` + -Version $InstallVersion ` + -GitHubRepo $GitHubRepo + if ($uninstallPath) { + Write-Host " Installed: $uninstallPath" -ForegroundColor Green + } else { + Write-Host " Uninstall script not available (optional)" -ForegroundColor Yellow + } + Write-Host "" + + # Step 7: Create configuration + Write-Host "[7/11] Creating configuration..." -ForegroundColor Yellow + New-OpksshConfiguration -ConfigPath $ConfigPath -AuthCmdUser $AuthCmdUser | Out-Null + Write-Host " Configuration: $ConfigPath" -ForegroundColor Green + Write-Host "" + + # Step 8: Configure permissions using opkssh + Write-Host "[8/11] Configuring file permissions..." -ForegroundColor Yellow + $permArgs = @("permissions", "install") + if ($Verbose) { + $permArgs += "-v" + } + + try { + & $binaryPath $permArgs + + if ($LASTEXITCODE -ne 0) { + Write-Warning "Permission configuration returned non-zero exit code but continuing..." + } else { + Write-Host " Permissions configured successfully" -ForegroundColor Green + } + } catch { + Write-Warning "Failed to configure permissions: $($_.Exception.Message)" + Write-Warning "You may need to run: & '$binaryPath' permissions fix" + } + Write-Host "" + + # Step 9: Configure sshd + Write-Host "[9/11] Configuring OpenSSH Server..." -ForegroundColor Yellow + $sshdConfigResult = Set-SshdConfiguration -BinaryPath $binaryPath ` + -AuthCmdUser $AuthCmdUser ` + -OverwriteConfig $OverwriteConfig + if (-not $sshdConfigResult) { + throw "Failed to configure sshd_config" + } + Write-Host " sshd_config updated" -ForegroundColor Green + Write-Host "" + + # Step 10: Restart sshd service + Write-Host "[10/11] Restarting OpenSSH Server..." -ForegroundColor Yellow + Restart-SshdService -NoRestart $NoSshdRestart | Out-Null + if (-not $NoSshdRestart) { + Write-Host " Service restarted" -ForegroundColor Green + } else { + Write-Host " Service restart skipped" -ForegroundColor Yellow + } + Write-Host "" + + # Step 11: Add to PATH and log + Write-Host "[11/11] Finalizing installation..." -ForegroundColor Yellow + Add-OpksshToPath -InstallDir $InstallDir | Out-Null + + $logPath = Join-Path $ConfigPath "logs\opkssh-install.log" + Write-InstallationLog -LogPath $logPath ` + -BinaryPath $binaryPath ` + -InstallParams @{ + InstallVersion = $InstallVersion + InstallFrom = $InstallFrom + NoRestart = $NoSshdRestart + AuthCmdUser = $AuthCmdUser + ConfigPath = $ConfigPath + } + Write-Host " Installation log: $logPath" -ForegroundColor Green + Write-Host "" + + # Success message + Write-Host "========================================" -ForegroundColor Green + Write-Host " Installation Successful!" -ForegroundColor Green + Write-Host "========================================" -ForegroundColor Green + Write-Host "" + Write-Host "Next steps:" -ForegroundColor Cyan + Write-Host " 1. Authorize users to access this server:" -ForegroundColor White + Write-Host " & '$binaryPath' add " -ForegroundColor Gray + Write-Host "" + Write-Host " 2. Example - Allow alice@gmail.com to SSH as 'Administrator':" -ForegroundColor White + Write-Host " & '$binaryPath' add Administrator alice@gmail.com google" -ForegroundColor Gray + Write-Host "" + + if ($uninstallPath) { + Write-Host " 3. To uninstall opkssh:" -ForegroundColor White + Write-Host " & '$uninstallPath'" -ForegroundColor Gray + Write-Host "" + } + + Write-Host "Documentation: https://github.com/openpubkey/opkssh" -ForegroundColor White + Write-Host "" + + } catch { + Write-Host "" + Write-Host "========================================" -ForegroundColor Red + Write-Host " Installation Failed" -ForegroundColor Red + Write-Host "========================================" -ForegroundColor Red + Write-Host "" + Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red + Write-Host "" + Write-Host "Stack Trace:" -ForegroundColor Red + Write-Host $_.ScriptStackTrace -ForegroundColor Gray + Write-Host "" + + # Log error + $errorLogPath = Join-Path $ConfigPath "logs\opkssh-install-error.log" + try { + $errorDir = Split-Path $errorLogPath -Parent + if (-not (Test-Path $errorDir)) { + New-Item -ItemType Directory -Path $errorDir -Force | Out-Null + } + + $errorDetails = @" + +======================================== +Installation Error Log +======================================== +Timestamp: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss") +Error Message: $($_.Exception.Message) +Stack Trace: +$($_.ScriptStackTrace) +======================================== + +"@ + Add-Content -Path $errorLogPath -Value $errorDetails + Write-Host "Error details logged to: $errorLogPath" -ForegroundColor Yellow + } catch { + # Ignore logging errors + } + + throw + } +} + +#endregion Main Installation Logic + +# Execute main installation +try { + Install-OpksshServer +} +catch { + # Final catch to improve error display. + $_.Exception | Write-Host -ForegroundColor Red +} diff --git a/scripts/windows/Test-OpksshInstallation.ps1 b/scripts/windows/Test-OpksshInstallation.ps1 new file mode 100644 index 00000000..c0deb787 --- /dev/null +++ b/scripts/windows/Test-OpksshInstallation.ps1 @@ -0,0 +1,372 @@ +#Requires -Version 5.1 + +<# +.SYNOPSIS + Tests the opkssh installation on Windows Server. + +.DESCRIPTION + This script validates that opkssh has been correctly installed and configured + on a Windows Server. It performs various checks to ensure all components are + in place and properly configured. + +.PARAMETER Verbose + Show detailed information about each test. + +.EXAMPLE + .\Test-OpksshInstallation.ps1 + +.EXAMPLE + .\Test-OpksshInstallation.ps1 -Verbose + +.NOTES + This script can be run by any user (does not require Administrator privileges). +#> + +[CmdletBinding()] +param() + +# Test results tracking +$script:PassedTests = 0 +$script:FailedTests = 0 +$script:WarningTests = 0 +$script:TotalTests = 0 + +function Write-TestResult { + <# + .SYNOPSIS + Writes a formatted test result. + #> + param( + [Parameter(Mandatory=$true)] + [string]$TestName, + + [Parameter(Mandatory=$true)] + [ValidateSet('Pass', 'Fail', 'Warning', 'Info')] + [string]$Result, + + [Parameter()] + [string]$Message = "" + ) + + $script:TotalTests++ + + $symbol = switch ($Result) { + 'Pass' { "PASS"; $script:PassedTests++; $color = 'Green' } + 'Fail' { "FAIL"; $script:FailedTests++; $color = 'Red' } + 'Warning' { "WARN"; $script:WarningTests++; $color = 'Yellow' } + 'Info' { "INFO"; $color = 'Cyan' } + } + + $resultText = "[$symbol] $TestName" + Write-Host $resultText -ForegroundColor $color + + if ($Message) { + Write-Host " $Message" -ForegroundColor Gray + } +} + +function Test-BinaryInstallation { + <# + .SYNOPSIS + Tests if opkssh binary is installed. + #> + param() + + Write-Host "`nBinary Installation:" -ForegroundColor Cyan + + # Test 1: Binary exists + $binaryPath = "C:\Program Files\opkssh\opkssh.exe" + if (Test-Path $binaryPath) { + Write-TestResult -TestName "Binary exists" -Result Pass -Message $binaryPath + + # Test 2: Binary is executable + try { + $version = & $binaryPath --version 2>&1 + Write-TestResult -TestName "Binary is executable" -Result Pass -Message "Version: $version" + } catch { + Write-TestResult -TestName "Binary is executable" -Result Fail -Message $_.Exception.Message + } + + # Test 3: Binary size check + $fileSize = (Get-Item $binaryPath).Length + if ($fileSize -gt 1MB) { + Write-TestResult -TestName "Binary size reasonable" -Result Pass -Message "$([math]::Round($fileSize/1MB, 2)) MB" + } else { + Write-TestResult -TestName "Binary size reasonable" -Result Warning -Message "File seems small: $([math]::Round($fileSize/1KB, 2)) KB" + } + + } else { + Write-TestResult -TestName "Binary exists" -Result Fail -Message "Not found at $binaryPath" + Write-TestResult -TestName "Binary is executable" -Result Fail -Message "Skipped (binary not found)" + Write-TestResult -TestName "Binary size reasonable" -Result Fail -Message "Skipped (binary not found)" + } + + # Test 4: Binary in PATH + $pathDirs = $env:Path -split ';' + if ($pathDirs -contains "C:\Program Files\opkssh") { + Write-TestResult -TestName "Binary in system PATH" -Result Pass + } else { + Write-TestResult -TestName "Binary in system PATH" -Result Warning -Message "Not in PATH (you may need to restart your shell)" + } +} + +function Test-ConfigurationFiles { + <# + .SYNOPSIS + Tests if configuration files and directories exist. + #> + param() + + Write-Host "`nConfiguration Files:" -ForegroundColor Cyan + + $configBase = "C:\ProgramData\opk" + + # Test 1: Config directory exists + if (Test-Path $configBase) { + Write-TestResult -TestName "Config directory exists" -Result Pass -Message $configBase + } else { + Write-TestResult -TestName "Config directory exists" -Result Fail -Message "Not found at $configBase" + return + } + + # Test 2: Required files + $requiredFiles = @{ + 'auth_id' = Join-Path $configBase "auth_id" + 'providers' = Join-Path $configBase "providers" + 'config.yml' = Join-Path $configBase "config.yml" + } + + foreach ($file in $requiredFiles.GetEnumerator()) { + if (Test-Path $file.Value) { + Write-TestResult -TestName "$($file.Key) file exists" -Result Pass + } else { + Write-TestResult -TestName "$($file.Key) file exists" -Result Fail -Message "Not found at $($file.Value)" + } + } + + # Test 3: Required directories + $requiredDirs = @{ + 'policy.d' = Join-Path $configBase "policy.d" + 'logs' = Join-Path $configBase "logs" + } + + foreach ($dir in $requiredDirs.GetEnumerator()) { + if (Test-Path $dir.Value) { + Write-TestResult -TestName "$($dir.Key) directory exists" -Result Pass + } else { + Write-TestResult -TestName "$($dir.Key) directory exists" -Result Warning -Message "Not found at $($dir.Value)" + } + } + + # Test 4: Providers file content + $providersPath = Join-Path $configBase "providers" + if (Test-Path $providersPath) { + $providersContent = Get-Content $providersPath -Raw + if ($providersContent -match 'accounts.google.com|login.microsoftonline.com') { + Write-TestResult -TestName "Providers file has content" -Result Pass + } else { + Write-TestResult -TestName "Providers file has content" -Result Warning -Message "File exists but may be empty" + } + } +} + +function Test-SshdConfiguration { + <# + .SYNOPSIS + Tests OpenSSH Server configuration. + #> + param() + + Write-Host "`nOpenSSH Server Configuration:" -ForegroundColor Cyan + + # Test 1: sshd service exists + $sshdService = Get-Service -Name sshd -ErrorAction SilentlyContinue + if ($sshdService) { + Write-TestResult -TestName "sshd service exists" -Result Pass + + # Test 2: sshd service is running + if ($sshdService.Status -eq 'Running') { + Write-TestResult -TestName "sshd service is running" -Result Pass + } else { + Write-TestResult -TestName "sshd service is running" -Result Warning -Message "Service is $($sshdService.Status)" + } + + # Test 3: sshd service startup type + if ($sshdService.StartType -eq 'Automatic') { + Write-TestResult -TestName "sshd starts automatically" -Result Pass + } else { + Write-TestResult -TestName "sshd starts automatically" -Result Warning -Message "StartType is $($sshdService.StartType)" + } + } else { + Write-TestResult -TestName "sshd service exists" -Result Fail -Message "Service not found" + Write-TestResult -TestName "sshd service is running" -Result Fail -Message "Skipped (service not found)" + Write-TestResult -TestName "sshd starts automatically" -Result Fail -Message "Skipped (service not found)" + } + + # Test 4: sshd_config exists + $sshdConfigPath = "C:\ProgramData\ssh\sshd_config" + if (Test-Path $sshdConfigPath) { + Write-TestResult -TestName "sshd_config exists" -Result Pass + + # Test 5: sshd_config has AuthorizedKeysCommand + $configContent = Get-Content $sshdConfigPath -Raw + if ($configContent -match 'AuthorizedKeysCommand.*opkssh.*verify') { + Write-TestResult -TestName "AuthorizedKeysCommand configured" -Result Pass + } else { + Write-TestResult -TestName "AuthorizedKeysCommand configured" -Result Fail -Message "opkssh not found in AuthorizedKeysCommand" + } + + # Test 6: sshd_config has AuthorizedKeysCommandUser + if ($configContent -match 'AuthorizedKeysCommandUser') { + Write-TestResult -TestName "AuthorizedKeysCommandUser configured" -Result Pass + } else { + Write-TestResult -TestName "AuthorizedKeysCommandUser configured" -Result Fail -Message "Not found in sshd_config" + } + + # Test 7: Backup exists + $backupFiles = Get-ChildItem "C:\ProgramData\ssh\sshd_config.backup.*" -ErrorAction SilentlyContinue + if ($backupFiles) { + $latestBackup = $backupFiles | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + Write-TestResult -TestName "sshd_config backup exists" -Result Pass -Message "Latest: $($latestBackup.Name)" + } else { + Write-TestResult -TestName "sshd_config backup exists" -Result Warning -Message "No backup found" + } + } else { + Write-TestResult -TestName "sshd_config exists" -Result Fail -Message "Not found at $sshdConfigPath" + } +} + +function Test-Permissions { + <# + .SYNOPSIS + Tests file permissions (requires admin). + #> + param() + + Write-Host "`nFile Permissions:" -ForegroundColor Cyan + + # Check if running as admin + $currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) + $isAdmin = $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + + if (-not $isAdmin) { + Write-TestResult -TestName "Permission checks" -Result Warning -Message "Run as Administrator for detailed permission checks" + return + } + + $configPath = "C:\ProgramData\opk" + + if (Test-Path $configPath) { + try { + $acl = Get-Acl $configPath + + # Check if SYSTEM has access + $systemAccess = $acl.Access | Where-Object { $_.IdentityReference -like "*SYSTEM*" } + if ($systemAccess) { + Write-TestResult -TestName "SYSTEM has access" -Result Pass + } else { + Write-TestResult -TestName "SYSTEM has access" -Result Warning -Message "SYSTEM access not found" + } + + # Check if Administrators have access + $adminAccess = $acl.Access | Where-Object { $_.IdentityReference -like "*Administrators*" } + if ($adminAccess) { + Write-TestResult -TestName "Administrators have access" -Result Pass + } else { + Write-TestResult -TestName "Administrators have access" -Result Warning -Message "Administrators access not found" + } + + } catch { + Write-TestResult -TestName "Permission checks" -Result Warning -Message $_.Exception.Message + } + } +} + +function Test-InstallationLog { + <# + .SYNOPSIS + Checks if installation log exists. + #> + param() + + Write-Host "`nInstallation Logs:" -ForegroundColor Cyan + + $logPath = "C:\ProgramData\opk\logs\opkssh-install.log" + if (Test-Path $logPath) { + $logInfo = Get-Item $logPath + Write-TestResult -TestName "Installation log exists" -Result Pass -Message "Last modified: $($logInfo.LastWriteTime)" + + # Show last few lines + $lastLines = Get-Content $logPath -Tail 5 + if ($Verbose) { + Write-Host "`n Last log entries:" -ForegroundColor Gray + $lastLines | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } + } + } else { + Write-TestResult -TestName "Installation log exists" -Result Warning -Message "Not found at $logPath" + } + + # Check for error log + $errorLogPath = "C:\ProgramData\opk\logs\opkssh-install-error.log" + if (Test-Path $errorLogPath) { + Write-TestResult -TestName "Error log check" -Result Warning -Message "Error log exists - installation may have had issues" + } else { + Write-TestResult -TestName "Error log check" -Result Pass -Message "No error log found" + } +} + +function Test-NetworkConnectivity { + <# + .SYNOPSIS + Tests network connectivity to OpenID Providers. + #> + param() + + Write-Host "`nNetwork Connectivity:" -ForegroundColor Cyan + + $providers = @( + @{ Name = "Google"; Url = "https://accounts.google.com/.well-known/openid-configuration" } + @{ Name = "Microsoft"; Url = "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration" } + @{ Name = "GitLab"; Url = "https://gitlab.com/.well-known/openid-configuration" } + ) + + foreach ($provider in $providers) { + try { + $null = Invoke-WebRequest -Uri $provider.Url -Method Head -TimeoutSec 5 -UseBasicParsing -ErrorAction Stop + Write-TestResult -TestName "$($provider.Name) reachable" -Result Pass + } catch { + Write-TestResult -TestName "$($provider.Name) reachable" -Result Warning -Message "Cannot reach $($provider.Url)" + } + } +} + +# Main execution +Write-Host "" +Write-Host "========================================" -ForegroundColor Cyan +Write-Host " opkssh Installation Test Suite" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan + +Test-BinaryInstallation +Test-ConfigurationFiles +Test-SshdConfiguration +Test-Permissions +Test-InstallationLog +Test-NetworkConnectivity + +# Summary +Write-Host "`n========================================" -ForegroundColor Cyan +Write-Host " Test Summary" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Total Tests: $script:TotalTests" -ForegroundColor White +Write-Host "Passed: $script:PassedTests" -ForegroundColor Green +Write-Host "Failed: $script:FailedTests" -ForegroundColor Red +Write-Host "Warnings: $script:WarningTests" -ForegroundColor Yellow +Write-Host "" + +if ($script:FailedTests -eq 0) { + Write-Host "All critical tests passed!" -ForegroundColor Green + exit 0 +} else { + Write-Host "Some tests failed. Please review the results above." -ForegroundColor Red + exit 1 +} diff --git a/scripts/windows/Uninstall-OpksshServer.ps1 b/scripts/windows/Uninstall-OpksshServer.ps1 new file mode 100644 index 00000000..51e4e9d3 --- /dev/null +++ b/scripts/windows/Uninstall-OpksshServer.ps1 @@ -0,0 +1,468 @@ +#Requires -Version 5.1 +#Requires -RunAsAdministrator + +<# +.SYNOPSIS + Uninstalls opkssh from Windows Server. + +.DESCRIPTION + This script removes opkssh from a Windows Server installation by: + - Restoring the original sshd_config + - Removing the opkssh binary + - Removing configuration files + - Removing the opkssh user (if created) + - Removing from system PATH + +.PARAMETER KeepConfig + Keep configuration files in C:\ProgramData\opk\ for potential reinstallation. + +.PARAMETER KeepLogs + Keep log files in C:\ProgramData\opk\logs\. + +.PARAMETER NoSshdRestart + Do not restart the sshd service after uninstallation. + +.PARAMETER Force + Skip confirmation prompts. + +.EXAMPLE + .\Uninstall-OpksshServer.ps1 + +.EXAMPLE + .\Uninstall-OpksshServer.ps1 -KeepConfig -KeepLogs + +.EXAMPLE + .\Uninstall-OpksshServer.ps1 -Force + +.NOTES + Requires Administrator privileges. +#> + +[CmdletBinding(SupportsShouldProcess=$true, ConfirmImpact='High')] +param( + [Parameter(HelpMessage="Keep configuration files")] + [switch]$KeepConfig, + + [Parameter(HelpMessage="Keep log files")] + [switch]$KeepLogs, + + [Parameter(HelpMessage="Do not restart sshd service")] + [switch]$NoSshdRestart, + + [Parameter(HelpMessage="Skip confirmation prompts")] + [switch]$Force +) + +$ErrorActionPreference = 'Stop' + +function Write-UninstallLog { + <# + .SYNOPSIS + Writes a message to the console and optionally to a log file. + #> + param( + [Parameter(Mandatory=$true)] + [string]$Message, + + [Parameter()] + [ValidateSet('Info', 'Warning', 'Error', 'Success')] + [string]$Level = 'Info' + ) + + switch ($Level) { + 'Info' { Write-Host $Message } + 'Warning' { Write-Warning $Message } + 'Error' { Write-Error $Message } + 'Success' { Write-Host $Message -ForegroundColor Green } + } +} + +function Restore-SshdConfiguration { + <# + .SYNOPSIS + Restores the original sshd_config from backup. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param() + + $sshdConfigPath = "C:\ProgramData\ssh\sshd_config" + + if (-not (Test-Path $sshdConfigPath)) { + Write-UninstallLog "sshd_config not found at $sshdConfigPath" -Level Warning + return $false + } + + # Find the most recent backup + $backupFiles = Get-ChildItem "C:\ProgramData\ssh\sshd_config.backup.*" -ErrorAction SilentlyContinue + + if (-not $backupFiles) { + Write-UninstallLog "No sshd_config backup found. Manual configuration restoration required." -Level Warning + Write-UninstallLog "Please edit $sshdConfigPath and remove the AuthorizedKeysCommand lines." -Level Warning + return $false + } + + $latestBackup = $backupFiles | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + + if ($PSCmdlet.ShouldProcess($sshdConfigPath, "Restore from backup: $($latestBackup.Name)")) { + try { + Copy-Item $latestBackup.FullName $sshdConfigPath -Force + Write-UninstallLog " Restored sshd_config from $($latestBackup.Name)" -Level Success + return $true + } catch { + Write-UninstallLog "Failed to restore sshd_config: $($_.Exception.Message)" -Level Error + return $false + } + } + + return $true +} + +function Remove-OpksshBinary { + <# + .SYNOPSIS + Removes the opkssh binary and installation directory. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param() + + $installDir = "C:\Program Files\opkssh" + + if (-not (Test-Path $installDir)) { + Write-Verbose "Installation directory not found: $installDir" + return $true + } + + # Check if this script is running from the installation directory + $scriptPath = $PSCommandPath + $scriptInInstallDir = $scriptPath -and (Split-Path $scriptPath -Parent) -eq $installDir + + if ($PSCmdlet.ShouldProcess($installDir, "Remove directory and contents")) { + try { + if ($scriptInInstallDir) { + # If this script is in the install directory, schedule it for deletion + # and remove other files now + Write-UninstallLog " Scheduling uninstall script for deletion after exit" -Level Info + + # Remove all files except this script + Get-ChildItem $installDir -File | Where-Object { $_.FullName -ne $scriptPath } | ForEach-Object { + Remove-Item $_.FullName -Force -ErrorAction Stop + } + + # Create a self-delete PowerShell script + $cleanupScript = @" +Start-Sleep -Seconds 2 +Remove-Item -Path '$scriptPath' -Force -ErrorAction SilentlyContinue +Remove-Item -Path '$installDir' -Force -ErrorAction SilentlyContinue +Remove-Item -Path `$PSCommandPath -Force -ErrorAction SilentlyContinue +"@ + $cleanupPath = [System.IO.Path]::GetTempFileName() + ".ps1" + $cleanupScript | Out-File -FilePath $cleanupPath -Encoding UTF8 -Force + + # Schedule the cleanup script to run in a new PowerShell process + Start-Process -FilePath "powershell.exe" -ArgumentList "-NoProfile", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", "`"$cleanupPath`"" -WindowStyle Hidden + + Write-UninstallLog " Removed opkssh binary from $installDir (cleanup pending)" -Level Success + } else { + # Script is not in the install directory, safe to delete everything + Remove-Item $installDir -Recurse -Force -ErrorAction Stop + Write-UninstallLog " Removed opkssh binary from $installDir" -Level Success + } + return $true + } catch { + Write-UninstallLog "Failed to remove $installDir`: $($_.Exception.Message)" -Level Error + return $false + } + } + + return $true +} + +function Remove-OpksshConfiguration { + <# + .SYNOPSIS + Removes opkssh configuration files and directories. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [bool]$KeepConfig = $false, + [bool]$KeepLogs = $false + ) + + $configPath = "C:\ProgramData\opk" + + if (-not (Test-Path $configPath)) { + Write-Verbose "Configuration directory not found: $configPath" + return $true + } + + if ($KeepConfig) { + Write-UninstallLog " Keeping configuration files (KeepConfig specified)" -Level Warning + + # Only remove logs if KeepLogs is not set + if (-not $KeepLogs) { + $logsPath = Join-Path $configPath "logs" + if (Test-Path $logsPath) { + if ($PSCmdlet.ShouldProcess($logsPath, "Remove logs directory")) { + try { + Remove-Item $logsPath -Recurse -Force -ErrorAction Stop + Write-UninstallLog " Removed logs from $logsPath" -Level Success + } catch { + Write-UninstallLog "Failed to remove logs: $($_.Exception.Message)" -Level Warning + } + } + } + } + + return $true + } + + # Create a final backup of configuration before removing + if ($PSCmdlet.ShouldProcess($configPath, "Backup before removal")) { + try { + $timestamp = Get-Date -Format "yyyyMMddHHmmss" + $backupPath = "C:\ProgramData\opk-backup-$timestamp" + Copy-Item $configPath $backupPath -Recurse -Force + Write-UninstallLog " Created backup at $backupPath" -Level Info + } catch { + Write-UninstallLog "Failed to create backup: $($_.Exception.Message)" -Level Warning + } + } + + if ($PSCmdlet.ShouldProcess($configPath, "Remove configuration directory")) { + try { + Remove-Item $configPath -Recurse -Force -ErrorAction Stop + Write-UninstallLog " Removed configuration from $configPath" -Level Success + return $true + } catch { + Write-UninstallLog "Failed to remove $configPath`: $($_.Exception.Message)" -Level Error + return $false + } + } + + return $true +} + +function Remove-OpksshUser { + <# + .SYNOPSIS + Removes the opkssh user account if it exists. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param() + + $username = "opksshuser" + + $user = Get-LocalUser -Name $username -ErrorAction SilentlyContinue + + if (-not $user) { + Write-Verbose "User '$username' does not exist" + return $true + } + + if ($PSCmdlet.ShouldProcess($username, "Remove local user")) { + try { + Remove-LocalUser -Name $username -ErrorAction Stop + Write-UninstallLog " Removed user: $username" -Level Success + return $true + } catch { + Write-UninstallLog "Failed to remove user '$username': $($_.Exception.Message)" -Level Warning + return $false + } + } + + return $true +} + +function Remove-OpksshFromPath { + <# + .SYNOPSIS + Removes opkssh installation directory from system PATH without expanding environment variables. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param() + + $installDir = "C:\Program Files\opkssh" + + if ($PSCmdlet.ShouldProcess("System PATH", "Remove $installDir")) { + try { + # Use Registry to preserve environment variable expansion (e.g., %SystemRoot%) + $keyName = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment' + $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($keyName, $true) + try { + # Get current PATH without expanding environment variables + $currentPathFolders = $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') -split [IO.Path]::PathSeparator + + # Normalize folder to remove + $normalizedInstallDir = $installDir.TrimEnd([IO.Path]::DirectorySeparatorChar) + + # Check if in PATH + $foundInPath = $currentPathFolders | Where-Object { + $_.TrimEnd([IO.Path]::DirectorySeparatorChar) -eq $normalizedInstallDir + } + + if (-not $foundInPath) { + Write-Verbose "Installation directory not in PATH" + return $true + } + + # Filter out the folder to remove (case-insensitive) + $result = [Collections.Generic.HashSet[string]]::new([StringComparer]::InvariantCultureIgnoreCase) + $currentPathFolders | + Where-Object { + $normalizedFolder = $_.TrimEnd([IO.Path]::DirectorySeparatorChar) + $normalizedFolder -ne $normalizedInstallDir + } | + ForEach-Object { $result.Add($_) > $null } + + # Build new PATH and save it + $newPath = $result -join [IO.Path]::PathSeparator + $key.SetValue('Path', $newPath, 'ExpandString') + + Write-UninstallLog " Removed from system PATH" -Level Success + return $true + } finally { + if ($null -ne $key) { + $key.Dispose() + } + } + } catch { + Write-UninstallLog "Failed to update PATH: $($_.Exception.Message)" -Level Warning + return $false + } + } + + return $true +} + +function Restart-SshdService { + <# + .SYNOPSIS + Restarts the OpenSSH Server service. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [bool]$NoRestart = $false + ) + + if ($NoRestart) { + Write-Warning "Skipping sshd service restart (NoRestart specified)" + Write-Warning "You must manually restart the sshd service:" + Write-Warning " Restart-Service sshd" + return $true + } + + if ($PSCmdlet.ShouldProcess("sshd", "Restart service")) { + try { + Restart-Service sshd -Force -ErrorAction Stop + + Start-Sleep -Seconds 2 + + $service = Get-Service sshd + if ($service.Status -eq 'Running') { + Write-UninstallLog " sshd service restarted successfully" -Level Success + return $true + } else { + throw "Service is in state: $($service.Status)" + } + } catch { + Write-UninstallLog "Failed to restart sshd service: $($_.Exception.Message)" -Level Warning + Write-Warning "Please restart the service manually: Restart-Service sshd" + return $false + } + } + + return $true +} + +# Main uninstallation logic +try { + Write-Host "" + Write-Host "========================================" -ForegroundColor Cyan + Write-Host " opkssh Uninstallation for Windows" -ForegroundColor Cyan + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "" + + # Confirmation (unless -Force is specified) + if (-not $Force) { + Write-Host "This will remove opkssh from your system." -ForegroundColor Yellow + Write-Host "" + Write-Host "The following items will be removed:" -ForegroundColor Yellow + Write-Host " - opkssh binary (C:\Program Files\opkssh\)" -ForegroundColor White + + if (-not $KeepConfig) { + Write-Host " - Configuration files (C:\ProgramData\opk\)" -ForegroundColor White + } else { + Write-Host " - Configuration files (KEPT - KeepConfig specified)" -ForegroundColor Green + } + + Write-Host " - sshd_config modifications (restored from backup)" -ForegroundColor White + Write-Host " - opksshuser account (if exists)" -ForegroundColor White + Write-Host " - System PATH entry" -ForegroundColor White + Write-Host "" + + $confirmation = Read-Host "Are you sure you want to continue? (yes/no)" + if ($confirmation -ne 'yes') { + Write-Host "Uninstallation cancelled." -ForegroundColor Yellow + exit 0 + } + Write-Host "" + } + + # Step 1: Restore sshd configuration + Write-Host "[1/6] Restoring sshd_config..." -ForegroundColor Yellow + Restore-SshdConfiguration | Out-Null + Write-Host "" + + # Step 2: Remove binary + Write-Host "[2/6] Removing opkssh binary..." -ForegroundColor Yellow + Remove-OpksshBinary | Out-Null + Write-Host "" + + # Step 3: Remove configuration + Write-Host "[3/6] Removing configuration..." -ForegroundColor Yellow + Remove-OpksshConfiguration -KeepConfig $KeepConfig -KeepLogs $KeepLogs | Out-Null + Write-Host "" + + # Step 4: Remove user + Write-Host "[4/6] Removing opkssh user..." -ForegroundColor Yellow + Remove-OpksshUser | Out-Null + Write-Host "" + + # Step 5: Remove from PATH + Write-Host "[5/6] Removing from system PATH..." -ForegroundColor Yellow + Remove-OpksshFromPath | Out-Null + Write-Host "" + + # Step 6: Restart sshd + Write-Host "[6/6] Restarting sshd service..." -ForegroundColor Yellow + Restart-SshdService -NoRestart $NoSshdRestart | Out-Null + Write-Host "" + + # Success message + Write-Host "========================================" -ForegroundColor Green + Write-Host " Uninstallation Complete!" -ForegroundColor Green + Write-Host "========================================" -ForegroundColor Green + Write-Host "" + + if ($KeepConfig) { + Write-Host "Configuration files preserved at: C:\ProgramData\opk\" -ForegroundColor Cyan + Write-Host "" + } + + Write-Host "opkssh has been removed from your system." -ForegroundColor White + Write-Host "" + +} catch { + Write-Host "" + Write-Host "========================================" -ForegroundColor Red + Write-Host " Uninstallation Failed" -ForegroundColor Red + Write-Host "========================================" -ForegroundColor Red + Write-Host "" + Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red + Write-Host "" + Write-Host "Stack Trace:" -ForegroundColor Red + Write-Host $_.ScriptStackTrace -ForegroundColor Gray + Write-Host "" + + throw +} diff --git a/scripts/windows/test/Install-OpksshServer.Tests.ps1 b/scripts/windows/test/Install-OpksshServer.Tests.ps1 new file mode 100644 index 00000000..f9a39f88 --- /dev/null +++ b/scripts/windows/test/Install-OpksshServer.Tests.ps1 @@ -0,0 +1,58 @@ +# Requires -Version 5.1 + +BeforeAll { + $scriptPath = Join-Path $PSScriptRoot "..\Install-OpksshServer.ps1" + . $scriptPath +} + +Describe "Set-SshdConfiguration" { + It "returns true when sshd_config already matches desired configuration" { + $tempPath = Join-Path $env:TEMP "sshd_config.test.$([guid]::NewGuid().ToString())" + $binaryPath = "C:\Program Files\opkssh\opkssh.exe" + $authUser = "opksshuser" + $quotedBinary = "`"$binaryPath`"" + + @( + "# Comment", + "AuthorizedKeysCommand $quotedBinary verify %u %k %t", + "AuthorizedKeysCommandUser $authUser" + ) | Set-Content -Path $tempPath -Force + + $result = Set-SshdConfiguration -BinaryPath $binaryPath -AuthCmdUser $authUser -SshdConfigPath $tempPath + $result | Should -BeTrue + + $final = Get-Content -Path $tempPath -Raw + $final | Should -Match [regex]::Escape("AuthorizedKeysCommand $quotedBinary verify %u %k %t") + $final | Should -Match [regex]::Escape("AuthorizedKeysCommandUser $authUser") + } + + It "returns false when a different AuthorizedKeysCommand is present and overwrite is not set" { + $tempPath = Join-Path $env:TEMP "sshd_config.test.$([guid]::NewGuid().ToString())" + @( + "AuthorizedKeysCommand \"C:\\Other\\opkssh.exe\" verify %u %k %t", + "AuthorizedKeysCommandUser otheruser" + ) | Set-Content -Path $tempPath -Force + + $result = Set-SshdConfiguration -BinaryPath "C:\Program Files\opkssh\opkssh.exe" -AuthCmdUser "opksshuser" -SshdConfigPath $tempPath + $result | Should -BeFalse + } + + It "overwrites existing configuration when -OverwriteConfig is set" { + $tempPath = Join-Path $env:TEMP "sshd_config.test.$([guid]::NewGuid().ToString())" + @( + "AuthorizedKeysCommand \"C:\\Other\\opkssh.exe\" verify %u %k %t", + "AuthorizedKeysCommandUser otheruser" + ) | Set-Content -Path $tempPath -Force + + $binaryPath = "C:\Program Files\opkssh\opkssh.exe" + $authUser = "opksshuser" + $quotedBinary = "`"$binaryPath`"" + + $result = Set-SshdConfiguration -BinaryPath $binaryPath -AuthCmdUser $authUser -OverwriteConfig $true -SshdConfigPath $tempPath + $result | Should -BeTrue + + $final = Get-Content -Path $tempPath -Raw + $final | Should -Match [regex]::Escape("AuthorizedKeysCommand $quotedBinary verify %u %k %t") + $final | Should -Match [regex]::Escape("AuthorizedKeysCommandUser $authUser") + } +} \ No newline at end of file diff --git a/sshcert/sshcert.go b/sshcert/sshcert.go index 31398173..9272d368 100644 --- a/sshcert/sshcert.go +++ b/sshcert/sshcert.go @@ -82,12 +82,23 @@ func New(pkt *pktoken.PKToken, accessToken []byte, principals []string) (*SshCer } func NewFromAuthorizedKey(certType string, certB64 string) (*SshCertSmuggler, error) { - if certPubkey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certType + " " + certB64)); err != nil { + authorizedKeyStr := certType + " " + certB64 + if certPubkey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(authorizedKeyStr)); err != nil { return nil, err } else { sshCert, ok := certPubkey.(*ssh.Certificate) if !ok { - return nil, fmt.Errorf("parsed SSH authorized_key is not an SSH certificate") + // Get the actual type for debugging + actualType := fmt.Sprintf("%T", certPubkey) + + // Truncate the base64 data for display (show first 50 chars) + truncatedB64 := certB64 + if len(certB64) > 50 { + truncatedB64 = certB64[:50] + "..." + } + + return nil, fmt.Errorf("parsed SSH authorized_key is not an SSH certificate. Got type: %s, Key type: %s, Base64 (truncated): %s. Expected a certificate (key type should end with '-cert-v01@openssh.com')", + actualType, certType, truncatedB64) } opkcert := &SshCertSmuggler{ SshCert: sshCert, From 26ce26e8c5f892e25a6c5d1ce4d87cbc7b07244e Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Tue, 24 Mar 2026 22:36:49 -0300 Subject: [PATCH 02/21] Address PR #480 review feedback - Rename workflow names to be distinct (Windows Server 2022 vs 2025) - Remove unnecessary -Encoding ascii from workflow Set-Content - Remove unused runneradmin password step from workflows - Fix copyright year in logpath_unix.go (2025 -> 2026) - Remove GetLogFilePathServer wrapper, call GetLogFilePath directly - Revert NOP variable extraction in sshcert.go NewFromAuthorizedKey - Simplify error message in sshcert.go type assertion failure - Keep release section at bottom of .goreleaser.yaml, add new fields - Remove redundant Windows fallbacks in openssh.go GetOpenSSHVersion - Add comment to PS1 test describe block - Add test coverage for ReadHome on Windows - Document Windows paths in docs/config.md --- .github/workflows/gha-windows-2025.yml | 7 +- .github/workflows/gha-windows.yml | 7 +- .goreleaser.yaml | 16 ++-- commands/readhome_windows_test.go | 75 +++++++++++++++++++ docs/config.md | 10 +-- internal/sysdetails/openssh.go | 12 +-- logpath_unix.go | 2 +- main.go | 7 +- .../test/Install-OpksshServer.Tests.ps1 | 2 + sshcert/sshcert.go | 15 +--- 10 files changed, 100 insertions(+), 53 deletions(-) create mode 100644 commands/readhome_windows_test.go diff --git a/.github/workflows/gha-windows-2025.yml b/.github/workflows/gha-windows-2025.yml index 42bfcb95..6f618d8c 100644 --- a/.github/workflows/gha-windows-2025.yml +++ b/.github/workflows/gha-windows-2025.yml @@ -1,4 +1,4 @@ -name: Test GitHub Provider +name: Test GitHub Provider Windows Server 2025 on: push: @@ -27,11 +27,8 @@ jobs: run: | $sshdConfig = "$env:ProgramData\ssh\sshd_config" (Get-Content $sshdConfig -Raw).Replace('#SyslogFacility AUTH', 'SyslogFacility LOCAL0').Replace('#LogLevel INFO', 'LogLevel Debug3') | - Set-Content $sshdConfig -Encoding ascii + Set-Content $sshdConfig - - name: Set runneradmin password - run: net user runneradmin "P@ssw0rd123!" - - name: Checkout uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: diff --git a/.github/workflows/gha-windows.yml b/.github/workflows/gha-windows.yml index d5e754ae..b2e5643c 100644 --- a/.github/workflows/gha-windows.yml +++ b/.github/workflows/gha-windows.yml @@ -1,4 +1,4 @@ -name: Test GitHub Provider +name: Test GitHub Provider Windows Server 2022 on: push: @@ -27,11 +27,8 @@ jobs: run: | $sshdConfig = "$env:ProgramData\ssh\sshd_config" (Get-Content $sshdConfig -Raw).Replace('#SyslogFacility AUTH', 'SyslogFacility LOCAL0').Replace('#LogLevel INFO', 'LogLevel Debug3') | - Set-Content $sshdConfig -Encoding ascii + Set-Content $sshdConfig - - name: Set runneradmin password - run: net user runneradmin "P@ssw0rd123!" - - name: Checkout uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 94df77b1..7e43a22c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -47,14 +47,6 @@ nfpms: - archlinux # Archlinux # TODO: Add debian overrides and match with https://salsa.debian.org/go-team/packages/opkssh -# Include additional files in releases and configure release settings -release: - draft: true - make_latest: true - extra_files: - - glob: scripts/windows/Install-OpksshServer.ps1 - - glob: scripts/windows/Uninstall-OpksshServer.ps1 - # Create checksums file checksum: name_template: 'checksums.txt' @@ -76,3 +68,11 @@ changelog: order: 1 - title: 🧰 Maintenance order: 999 + +# Define how to make GitHub releases +release: + draft: true + make_latest: true + extra_files: + - glob: scripts/windows/Install-OpksshServer.ps1 + - glob: scripts/windows/Uninstall-OpksshServer.ps1 diff --git a/commands/readhome_windows_test.go b/commands/readhome_windows_test.go new file mode 100644 index 00000000..447fd7b5 --- /dev/null +++ b/commands/readhome_windows_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 OpenPubkey +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package commands + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReadHome_InvalidUsername(t *testing.T) { + tests := []struct { + name string + username string + }{ + {name: "empty string", username: ""}, + {name: "contains backslash", username: `DOMAIN\user`}, + {name: "contains space", username: "user name"}, + {name: "contains slash", username: "user/name"}, + {name: "shell injection", username: "user;rm -rf /"}, + {name: "path traversal", username: "../etc/passwd"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ReadHome(tt.username) + require.Error(t, err) + require.Contains(t, err.Error(), "not a valid Windows username") + }) + } +} + +func TestReadHome_NonexistentUser(t *testing.T) { + _, err := ReadHome("nonexistent_user_abc123xyz") + require.Error(t, err) + require.Contains(t, err.Error(), "failed to find user") +} + +func TestReadHome_ValidUsernameFormat(t *testing.T) { + // These usernames are syntactically valid but the users don't exist, + // so they should fail at user lookup, not at validation. + tests := []struct { + name string + username string + }{ + {name: "simple", username: "testuser"}, + {name: "with dot", username: "test.user"}, + {name: "with dash", username: "test-user"}, + {name: "with underscore", username: "test_user"}, + {name: "alphanumeric", username: "user123"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ReadHome(tt.username) + require.Error(t, err) + // Should fail at user lookup, not validation + require.NotContains(t, err.Error(), "not a valid Windows username") + }) + } +} diff --git a/docs/config.md b/docs/config.md index 055aacfc..81af5771 100644 --- a/docs/config.md +++ b/docs/config.md @@ -61,7 +61,7 @@ providers: ``` -## Server config `/etc/opk/config.yml` +## Server config `/etc/opk/config.yml` (Linux) or `%ProgramData%\opk\config.yml` (Windows) This is the config file for opkssh when used on the SSH server. It supports setting additional environment variables when `opkssh verify` is called. @@ -98,7 +98,7 @@ sudo chown root:opksshuser /etc/opk/config.yml sudo chmod 640 /etc/opk/config.yml ``` -## Allowed OpenID Providers: `/etc/opk/providers` +## Allowed OpenID Providers: `/etc/opk/providers` (Linux) or `%ProgramData%\opk\providers` (Windows) This file functions as an access control list that enables admins to determine the OpenID Providers and Client IDs they wish to use. This file contains a list of allowed OPKSSH OPs (OpenID Providers) and the associated client ID. @@ -121,7 +121,7 @@ https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096c https://gitlab.com 8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923 24h ``` -## Authorized identities files: `/etc/opk/auth_id` and `/home/{USER}/.opk/auth_id` +## Authorized identities files: `/etc/opk/auth_id` and `/home/{USER}/.opk/auth_id` (Linux) or `%ProgramData%\opk\auth_id` and `{USERPROFILE}\.opk\auth_id` (Windows) These files contain the policies to determine which identities can assume what linux user accounts. Linux user accounts are typically referred to in SSH as *principals* and we use this terminology. @@ -133,7 +133,7 @@ We support email "wildcard" validation using the `oidc-match-end:email:` prefix. - This matching is **case-insensitive**. - Use with care, as allowing a domain grants access to all users at that domain. -### System authorized identity file `/etc/opk/auth_id` +### System authorized identity file `/etc/opk/auth_id` (Linux) or `%ProgramData%\opk\auth_id` (Windows) This is a server wide policy file. @@ -186,7 +186,7 @@ sudo chmod 640 /etc/opk/auth_id **Note:** The permissions for the system authorized identity file are different than the home authorized identity file. -### Home authorized identity file `/home/{USER}/.opk/auth_id` +### Home authorized identity file `/home/{USER}/.opk/auth_id` (Linux) or `{USERPROFILE}\.opk\auth_id` (Windows) This is user/principal specific permissions. That is, if it is in `/home/alice/.opk/auth_id` it can only specify who can assume the principal `alice` on the server. diff --git a/internal/sysdetails/openssh.go b/internal/sysdetails/openssh.go index 7fa5effe..33b42589 100644 --- a/internal/sysdetails/openssh.go +++ b/internal/sysdetails/openssh.go @@ -70,11 +70,7 @@ func GetOpenSSHVersion() string { } // Try ssh -V (works on most systems) - sshCmd := "ssh" - if osType == OSTypeWindows { - sshCmd = "ssh.exe" - } - cmd := exec.Command(sshCmd, "-V") + cmd := exec.Command("ssh", "-V") output, err := cmd.CombinedOutput() if err == nil && len(strings.TrimSpace(string(output))) > 0 { return strings.TrimSpace(string(output)) @@ -82,11 +78,7 @@ func GetOpenSSHVersion() string { log.Println("Warning: Error executing ssh -V:", err) // Try sshd -V as fallback - sshdCmd := "sshd" - if osType == OSTypeWindows { - sshdCmd = "sshd.exe" - } - cmd = exec.Command(sshdCmd, "-V") + cmd = exec.Command("sshd", "-V") output, err = cmd.CombinedOutput() if err == nil && len(strings.TrimSpace(string(output))) > 0 { return strings.TrimSpace(string(output)) diff --git a/logpath_unix.go b/logpath_unix.go index 63d2d45b..d0ae251c 100644 --- a/logpath_unix.go +++ b/logpath_unix.go @@ -1,7 +1,7 @@ //go:build !windows // +build !windows -// Copyright 2025 OpenPubkey +// Copyright 2026 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/main.go b/main.go index 9d0a2cae..b768ac1f 100644 --- a/main.go +++ b/main.go @@ -51,11 +51,6 @@ var ( Version = "unversioned" ) -// GetLogFilePathServer returns the platform-specific log file path -func GetLogFilePathServer() string { - return GetLogFilePath() -} - func main() { os.Exit(run()) } @@ -317,7 +312,7 @@ Arguments: ctx := context.Background() // Setup logger - logFilePath := GetLogFilePathServer() + logFilePath := GetLogFilePath() logFile, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0660) // Owner and group can read/write if err != nil { fmt.Fprintf(os.Stderr, "Error opening log file: %v\n", err) diff --git a/scripts/windows/test/Install-OpksshServer.Tests.ps1 b/scripts/windows/test/Install-OpksshServer.Tests.ps1 index f9a39f88..3edfdf46 100644 --- a/scripts/windows/test/Install-OpksshServer.Tests.ps1 +++ b/scripts/windows/test/Install-OpksshServer.Tests.ps1 @@ -6,6 +6,8 @@ BeforeAll { } Describe "Set-SshdConfiguration" { + # Tests that Set-SshdConfiguration correctly detects, preserves, and + # updates AuthorizedKeysCommand/AuthorizedKeysCommandUser in sshd_config. It "returns true when sshd_config already matches desired configuration" { $tempPath = Join-Path $env:TEMP "sshd_config.test.$([guid]::NewGuid().ToString())" $binaryPath = "C:\Program Files\opkssh\opkssh.exe" diff --git a/sshcert/sshcert.go b/sshcert/sshcert.go index 9272d368..2b3e7f93 100644 --- a/sshcert/sshcert.go +++ b/sshcert/sshcert.go @@ -82,23 +82,12 @@ func New(pkt *pktoken.PKToken, accessToken []byte, principals []string) (*SshCer } func NewFromAuthorizedKey(certType string, certB64 string) (*SshCertSmuggler, error) { - authorizedKeyStr := certType + " " + certB64 - if certPubkey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(authorizedKeyStr)); err != nil { + if certPubkey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certType + " " + certB64)); err != nil { return nil, err } else { sshCert, ok := certPubkey.(*ssh.Certificate) if !ok { - // Get the actual type for debugging - actualType := fmt.Sprintf("%T", certPubkey) - - // Truncate the base64 data for display (show first 50 chars) - truncatedB64 := certB64 - if len(certB64) > 50 { - truncatedB64 = certB64[:50] + "..." - } - - return nil, fmt.Errorf("parsed SSH authorized_key is not an SSH certificate. Got type: %s, Key type: %s, Base64 (truncated): %s. Expected a certificate (key type should end with '-cert-v01@openssh.com')", - actualType, certType, truncatedB64) + return nil, fmt.Errorf("parsed SSH authorized_key is not an SSH certificate (got type %T, cert type %q)", certPubkey, certType) } opkcert := &SshCertSmuggler{ SshCert: sshCert, From b54a26d092d01cc8432c95716907722ca683f0de Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Tue, 24 Mar 2026 23:07:28 -0300 Subject: [PATCH 03/21] Add Windows ARM64 support to GitHub Actions workflows - ci.yml: Add build-windows-arm64 job (native build + binary test on windows-11-arm) - ci.yml: Add test-windows-arm64 job (unit tests on windows-11-arm) - gha-windows-arm64.yml: New integration test workflow for Windows 11 ARM64 (OpenSSH server install, opkssh setup, and SSH connection test) --- .github/workflows/ci.yml | 44 ++++++++++ .github/workflows/gha-windows-arm64.yml | 103 ++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 .github/workflows/gha-windows-arm64.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b594d6e..ac57d7d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,50 @@ jobs: shell: pwsh run: go test ./... + # Check that binary can be built natively on Windows ARM64 + build-windows-arm64: + name: Build Windows ARM64 + runs-on: windows-11-arm + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - name: Install Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: 'go.mod' + - name: Install dependencies + run: go mod download + - name: Build Windows ARM64 + shell: pwsh + run: go build -v -o opkssh-arm64.exe + - name: Test binary works + shell: pwsh + run: | + .\opkssh-arm64.exe --version + + # Run Windows ARM64 unit tests + test-windows-arm64: + name: 'Windows ARM64 Tests' + runs-on: windows-11-arm + timeout-minutes: 8 + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - name: Install Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: 'go.mod' + - name: Install dependencies + run: go mod download + - name: Run unit tests + shell: pwsh + run: go test ./... + # Run integration tests test: needs: build diff --git a/.github/workflows/gha-windows-arm64.yml b/.github/workflows/gha-windows-arm64.yml new file mode 100644 index 00000000..854f4ad7 --- /dev/null +++ b/.github/workflows/gha-windows-arm64.yml @@ -0,0 +1,103 @@ +name: Test GitHub Provider Windows ARM64 + +on: + push: + +jobs: + build: + name: Test on Windows 11 ARM64 + runs-on: windows-11-arm + permissions: + id-token: write + contents: read + timeout-minutes: 10 + + steps: + - name: Install OpenSSH Server + run: | + Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 + + - name: Create default OpenSSH config files + run: | + Start-Service sshd + Start-Sleep -Seconds 2 + Stop-Service sshd + + - name: Enable OpenSSH Server logs + run: | + $sshdConfig = "$env:ProgramData\ssh\sshd_config" + (Get-Content $sshdConfig -Raw).Replace('#SyslogFacility AUTH', 'SyslogFacility LOCAL0').Replace('#LogLevel INFO', 'LogLevel Debug3') | + Set-Content $sshdConfig + + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Cache Go modules + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Install dependencies + run: go mod download + + - name: Build opkssh + run: go build -v -o opkssh.exe + + - name: Install opkssh with local binary + run: | + powershell -ExecutionPolicy Bypass -File "scripts/windows/Install-OpksshServer.ps1" ` + -InstallFrom "$PWD\opkssh.exe" ` + -NoSshdRestart ` + -Verbose + + - name: Add GitHub provider to opkssh configuration + run: | + $providersPath = 'C:\ProgramData\opk\providers' + if ((Get-Content -Path $providersPath -Raw) -notmatch "`r?`n$") { + Add-Content -Path $providersPath -Value '' + } + Add-Content -Path $providersPath -Value 'https://token.actions.githubusercontent.com github oidc' + + - name: Add current repository to policy + run: | + & 'C:\Program Files\opkssh\opkssh.exe' add runneradmin "repo:${env:GITHUB_REPOSITORY}:ref:${env:GITHUB_REF}" https://token.actions.githubusercontent.com + + - name: Start SSH service + run: Start-Service sshd + + - name: Test SSH connection without opkssh (should fail) + run: | + ssh -o StrictHostKeyChecking=no -o PasswordAuthentication=no runneradmin@localhost dir + continue-on-error: true + + - name: Login with GitHub OIDC + run: | + & 'C:\Program Files\opkssh\opkssh.exe' login github --print-id-token + + - name: Test SSH connection with opkssh (should pass) + run: | + ssh -o StrictHostKeyChecking=no -o PasswordAuthentication=no runneradmin@localhost dir + + - name: Debug - Dump opkssh config + run: | + Get-ChildItem 'C:\ProgramData\opk' -Recurse -ErrorAction Continue + Get-Content 'C:\ProgramData\opk\providers' -ErrorAction Continue + Get-Content 'C:\ProgramData\opk\auth_id' -ErrorAction Continue + if: always() + + - name: Debug - Dump opkssh logs + run: | + Get-Content 'C:\ProgramData\opk\logs\opkssh.log' -ErrorAction Continue + if: always() + + - name: Debug - Dump sshd logs + run: | + Get-Content 'C:\ProgramData\ssh\logs\sshd.log' -ErrorAction Continue + if: always() From 9ce9304445694c789f5632c10bdf066057ce7d86 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Tue, 24 Mar 2026 23:19:53 -0300 Subject: [PATCH 04/21] Fix Windows ARM64 integration test: increase timeout and add setup-go - Increase timeout from 10 to 15 minutes for ARM64 runner - Replace manual cache step with actions/setup-go (handles caching and ensures Go is available on windows-11-arm runner) --- .github/workflows/gha-windows-arm64.yml | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/.github/workflows/gha-windows-arm64.yml b/.github/workflows/gha-windows-arm64.yml index 854f4ad7..857fd38d 100644 --- a/.github/workflows/gha-windows-arm64.yml +++ b/.github/workflows/gha-windows-arm64.yml @@ -10,7 +10,7 @@ jobs: permissions: id-token: write contents: read - timeout-minutes: 10 + timeout-minutes: 15 steps: - name: Install OpenSSH Server @@ -34,15 +34,10 @@ jobs: with: persist-credentials: false - - name: Cache Go modules - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + - name: Install Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- + go-version-file: 'go.mod' - name: Install dependencies run: go mod download From 7d914e6bd25cddd91de0699de0ad5d7cb2e64686 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Wed, 25 Mar 2026 18:20:46 -0300 Subject: [PATCH 05/21] docs: Add Windows Server and Windows 11 ARM64 support to compatibility table in README.md. --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9f9d4741..4a467b0e 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,9 @@ Second, we use the `AuthorizedKeysCommand` configuration option in `sshd_config` | Linux | ✅ | ✅ | Arch Linux | - | | Linux | ✅ | ✅ | opensuse leap 16 | - | | macOS | ❌ | ❌ | - | Likely | -| Windows11 | ❌ | ❌ | - | Likely | +| Windows Server | ✅ | ✅ | Windows Server 2022 | - | +| Windows Server | ✅ | ✅ | Windows Server 2025 | - | +| Windows 11 | ✅ | ✅ | Windows 11 ARM64 | - | ## Server Configuration From 7250ab32a8306b4219adb389caf85b61e42aee76 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Wed, 25 Mar 2026 18:20:55 -0300 Subject: [PATCH 06/21] fix: Update copyright year to 2026 in logpath_windows.go --- logpath_windows.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/logpath_windows.go b/logpath_windows.go index c0f5d99d..b5c30d8d 100644 --- a/logpath_windows.go +++ b/logpath_windows.go @@ -1,7 +1,7 @@ //go:build windows // +build windows -// Copyright 2025 OpenPubkey +// Copyright 2026 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. From c0fb2cd2730cbc8b5b6a1c822f79452d364b4d7c Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Thu, 26 Mar 2026 20:27:08 -0300 Subject: [PATCH 07/21] fix: Address Copilot review comments on PR #480 - main.go: Platform-specific log file error hints (Windows vs Unix) - main.go: Regex fallback already has capturing group (no code change needed) - readhome_windows.go: Fix misleading comment about DOMAIN\user fallback - workflows: Add actions/setup-go to gha-windows.yml and gha-windows-2025.yml - Install-OpksshServer.ps1: Add -Encoding UTF8 for providers file - Install-OpksshServer.ps1: Add -Encoding ascii for sshd_config - Install-OpksshServer.ps1: Guard auto-execution for dot-source safety - Install-OpksshServer.ps1: Preserve PATH order in Add-OpksshToPath - Uninstall-OpksshServer.ps1: Preserve PATH order in Remove-OpksshFromPath - Install-OpksshServer.Tests.ps1: Use AST parsing instead of dot-sourcing - Install-OpksshServer.Tests.ps1: Fix PS string escaping and Pester assertions - .goreleaser.yaml: Add Test-OpksshInstallation.ps1 to release assets --- .github/workflows/gha-windows-2025.yml | 5 ++ .github/workflows/gha-windows.yml | 5 ++ .goreleaser.yaml | 1 + commands/readhome_windows.go | 4 +- main.go | 6 ++- scripts/windows/Install-OpksshServer.ps1 | 49 +++++++++++-------- scripts/windows/Uninstall-OpksshServer.ps1 | 10 ++-- .../test/Install-OpksshServer.Tests.ps1 | 43 ++++++++++++---- 8 files changed, 85 insertions(+), 38 deletions(-) diff --git a/.github/workflows/gha-windows-2025.yml b/.github/workflows/gha-windows-2025.yml index 6f618d8c..80740440 100644 --- a/.github/workflows/gha-windows-2025.yml +++ b/.github/workflows/gha-windows-2025.yml @@ -44,6 +44,11 @@ jobs: restore-keys: | ${{ runner.os }}-go- + - name: Install Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: 'go.mod' + - name: Install dependencies run: go mod download diff --git a/.github/workflows/gha-windows.yml b/.github/workflows/gha-windows.yml index b2e5643c..79d59ab0 100644 --- a/.github/workflows/gha-windows.yml +++ b/.github/workflows/gha-windows.yml @@ -44,6 +44,11 @@ jobs: restore-keys: | ${{ runner.os }}-go- + - name: Install Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: 'go.mod' + - name: Install dependencies run: go mod download diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7e43a22c..12d055b7 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -76,3 +76,4 @@ release: extra_files: - glob: scripts/windows/Install-OpksshServer.ps1 - glob: scripts/windows/Uninstall-OpksshServer.ps1 + - glob: scripts/windows/Test-OpksshInstallation.ps1 diff --git a/commands/readhome_windows.go b/commands/readhome_windows.go index 1d8dd457..5a38ee5e 100644 --- a/commands/readhome_windows.go +++ b/commands/readhome_windows.go @@ -43,8 +43,8 @@ func ReadHome(username string) ([]byte, error) { // Look up the user to get their SID and home directory userObj, err := user.Lookup(username) if err != nil { - // On Windows, user.Lookup may need DOMAIN\user format. Try with - // the bare username first, then fall back if needed. + // On Windows, user.Lookup may need DOMAIN\user format, but we + // only attempt lookup using the provided username string. return nil, fmt.Errorf("failed to find user %s: %w", username, err) } diff --git a/main.go b/main.go index b768ac1f..c79488c0 100644 --- a/main.go +++ b/main.go @@ -317,7 +317,11 @@ Arguments: if err != nil { fmt.Fprintf(os.Stderr, "Error opening log file: %v\n", err) // It could be very difficult to figure out what is going on if the log file was deleted. Hopefully this message saves someone an hour of debugging. - fmt.Fprintf(os.Stderr, "Check if log exists at %v, if it does not create it with permissions: chown root:opksshuser %v; chmod 660 %v\n", logFilePath, logFilePath, logFilePath) + if runtime.GOOS == "windows" { + fmt.Fprintf(os.Stderr, "Check if the log file exists at %v. If it does not, create it and ensure the account running sshd has read/write access (for example, via the file's Security properties or using icacls). The log directory may be under %%ProgramData%%.\n", logFilePath) + } else { + fmt.Fprintf(os.Stderr, "Check if log exists at %v, if it does not create it with permissions: chown root:opksshuser %v; chmod 660 %v\n", logFilePath, logFilePath, logFilePath) + } } else { defer logFile.Close() log.SetOutput(logFile) diff --git a/scripts/windows/Install-OpksshServer.ps1 b/scripts/windows/Install-OpksshServer.ps1 index f1aa6168..bfd9a766 100644 --- a/scripts/windows/Install-OpksshServer.ps1 +++ b/scripts/windows/Install-OpksshServer.ps1 @@ -583,7 +583,7 @@ https://issuer.hello.coop app_xejobTKEsDNSRd5vofKB2iay_2rN 24h "@ if ($PSCmdlet.ShouldProcess($providersPath, "Create providers file")) { - Set-Content -Path $providersPath -Value $providersContent -NoNewline + Set-Content -Path $providersPath -Value $providersContent -NoNewline -Encoding UTF8 Write-Verbose " Created file: providers" } } else { @@ -694,7 +694,7 @@ function Set-SshdConfiguration { # Write new configuration if ($PSCmdlet.ShouldProcess($sshdConfigPath, "Update sshd_config")) { - Set-Content -Path $sshdConfigPath -Value $newConfigLines -Force + Set-Content -Path $sshdConfigPath -Value $newConfigLines -Encoding ascii -Force Write-Verbose " Updated sshd_config" } @@ -801,20 +801,27 @@ function Add-OpksshToPath { } # Add new folder to the current PATH - $newPathFolders = $currentPathFolders + @($normalizedInstallDir) - - # Normalize folders to remove trailing slashes and duplicates - $result = [Collections.Generic.HashSet[string]]::new([StringComparer]::InvariantCultureIgnoreCase) - $newPathFolders | - ForEach-Object { - $normalized = $_.TrimEnd([IO.Path]::DirectorySeparatorChar).Trim() - if ($normalized -ne '') { - $result.Add($normalized) > $null - } + # Preserve original order while removing duplicates (case-insensitive) + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::InvariantCultureIgnoreCase) + $orderedPathFolders = New-Object 'System.Collections.Generic.List[string]' + + foreach ($folder in $currentPathFolders) { + $normalized = $folder.TrimEnd([IO.Path]::DirectorySeparatorChar).Trim() + if ($normalized -ne '' -and $seen.Add($normalized)) { + [void]$orderedPathFolders.Add($normalized) } + } + + # Append install directory if it's not already present + if ($seen.Add($normalizedInstallDir)) { + [void]$orderedPathFolders.Add($normalizedInstallDir) + Write-Verbose "Adding installation directory to PATH" + } else { + Write-Verbose "Installation directory already in PATH" + } # Build new PATH and save it - $newPath = $result -join [IO.Path]::PathSeparator + $newPath = [string]::Join([IO.Path]::PathSeparator, $orderedPathFolders) $key.SetValue('Path', $newPath, 'ExpandString') Write-Log " Added to system PATH: $InstallDir" -Level Success @@ -1094,11 +1101,13 @@ $($_.ScriptStackTrace) #endregion Main Installation Logic -# Execute main installation -try { - Install-OpksshServer -} -catch { - # Final catch to improve error display. - $_.Exception | Write-Host -ForegroundColor Red +# Execute main installation (only when not dot-sourced) +if ($MyInvocation.InvocationName -ne '.') { + try { + Install-OpksshServer + } + catch { + # Final catch to improve error display. + $_.Exception | Write-Host -ForegroundColor Red + } } diff --git a/scripts/windows/Uninstall-OpksshServer.ps1 b/scripts/windows/Uninstall-OpksshServer.ps1 index 51e4e9d3..5ff4c155 100644 --- a/scripts/windows/Uninstall-OpksshServer.ps1 +++ b/scripts/windows/Uninstall-OpksshServer.ps1 @@ -305,17 +305,15 @@ function Remove-OpksshFromPath { return $true } - # Filter out the folder to remove (case-insensitive) - $result = [Collections.Generic.HashSet[string]]::new([StringComparer]::InvariantCultureIgnoreCase) - $currentPathFolders | + # Filter out the folder to remove (case-insensitive), preserving original order + $filteredPathFolders = $currentPathFolders | Where-Object { $normalizedFolder = $_.TrimEnd([IO.Path]::DirectorySeparatorChar) $normalizedFolder -ne $normalizedInstallDir - } | - ForEach-Object { $result.Add($_) > $null } + } # Build new PATH and save it - $newPath = $result -join [IO.Path]::PathSeparator + $newPath = $filteredPathFolders -join [IO.Path]::PathSeparator $key.SetValue('Path', $newPath, 'ExpandString') Write-UninstallLog " Removed from system PATH" -Level Success diff --git a/scripts/windows/test/Install-OpksshServer.Tests.ps1 b/scripts/windows/test/Install-OpksshServer.Tests.ps1 index 3edfdf46..6715649f 100644 --- a/scripts/windows/test/Install-OpksshServer.Tests.ps1 +++ b/scripts/windows/test/Install-OpksshServer.Tests.ps1 @@ -2,7 +2,32 @@ BeforeAll { $scriptPath = Join-Path $PSScriptRoot "..\Install-OpksshServer.ps1" - . $scriptPath + + # Use AST parsing to extract only the functions we need for testing + # without executing the script (which has #Requires -RunAsAdministrator) + $scriptContent = Get-Content -Path $scriptPath -Raw + + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput($scriptContent, [ref]$tokens, [ref]$errors) + + $functionsToLoad = @('Set-SshdConfiguration', 'Write-Log') + foreach ($funcName in $functionsToLoad) { + $funcAst = $ast.Find( + { + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $funcName + }.GetNewClosure(), + $true + ) + + if (-not $funcAst) { + throw "Could not find function '$funcName' in $scriptPath." + } + + . ([scriptblock]::Create($funcAst.Extent.Text)) + } } Describe "Set-SshdConfiguration" { @@ -24,15 +49,15 @@ Describe "Set-SshdConfiguration" { $result | Should -BeTrue $final = Get-Content -Path $tempPath -Raw - $final | Should -Match [regex]::Escape("AuthorizedKeysCommand $quotedBinary verify %u %k %t") - $final | Should -Match [regex]::Escape("AuthorizedKeysCommandUser $authUser") + $final | Should -Match $([regex]::Escape("AuthorizedKeysCommand $quotedBinary verify %u %k %t")) + $final | Should -Match $([regex]::Escape("AuthorizedKeysCommandUser $authUser")) } It "returns false when a different AuthorizedKeysCommand is present and overwrite is not set" { $tempPath = Join-Path $env:TEMP "sshd_config.test.$([guid]::NewGuid().ToString())" @( - "AuthorizedKeysCommand \"C:\\Other\\opkssh.exe\" verify %u %k %t", - "AuthorizedKeysCommandUser otheruser" + 'AuthorizedKeysCommand "C:\Other\opkssh.exe" verify %u %k %t', + 'AuthorizedKeysCommandUser otheruser' ) | Set-Content -Path $tempPath -Force $result = Set-SshdConfiguration -BinaryPath "C:\Program Files\opkssh\opkssh.exe" -AuthCmdUser "opksshuser" -SshdConfigPath $tempPath @@ -42,8 +67,8 @@ Describe "Set-SshdConfiguration" { It "overwrites existing configuration when -OverwriteConfig is set" { $tempPath = Join-Path $env:TEMP "sshd_config.test.$([guid]::NewGuid().ToString())" @( - "AuthorizedKeysCommand \"C:\\Other\\opkssh.exe\" verify %u %k %t", - "AuthorizedKeysCommandUser otheruser" + 'AuthorizedKeysCommand "C:\Other\opkssh.exe" verify %u %k %t', + 'AuthorizedKeysCommandUser otheruser' ) | Set-Content -Path $tempPath -Force $binaryPath = "C:\Program Files\opkssh\opkssh.exe" @@ -54,7 +79,7 @@ Describe "Set-SshdConfiguration" { $result | Should -BeTrue $final = Get-Content -Path $tempPath -Raw - $final | Should -Match [regex]::Escape("AuthorizedKeysCommand $quotedBinary verify %u %k %t") - $final | Should -Match [regex]::Escape("AuthorizedKeysCommandUser $authUser") + $final | Should -Match $([regex]::Escape("AuthorizedKeysCommand $quotedBinary verify %u %k %t")) + $final | Should -Match $([regex]::Escape("AuthorizedKeysCommandUser $authUser")) } } \ No newline at end of file From b6f8c0ecd2b0de3d0f370ae9729cf48c457bfedf Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sat, 28 Mar 2026 09:17:59 -0300 Subject: [PATCH 08/21] docs: Improve README with missing commands, Windows server install, and fixes - Add Windows Server installation section (Install/Uninstall/Test scripts) - Add Windows ARM64 binary to download table - Add missing commands: logout, inspect, audit, permissions - Add CLI reference link to docs/cli/ - Add missing doc links: audit.md, opkssh-and-sssd.md - Update provider list to include all tested providers - Fix typos: 'opk login' -> 'opkssh login', 'that be' -> 'that can be' - Fix grammar: 'the OpenPubkey' -> 'OpenPubkey', plural 'Providers' - Fix formatting: 'Windows11' -> 'Windows 11', 'opensuse' -> 'openSUSE' - Note Windows config path (%ProgramData%\opk\) in Server Configuration --- README.md | 105 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 94 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4a467b0e..56e60c93 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ [![Go Coverage](https://github.com/openpubkey/opkssh/wiki/coverage.svg)](https://raw.githack.com/wiki/openpubkey/opkssh/coverage.html) **opkssh** is a tool which enables ssh to be used with OpenID Connect allowing SSH access to be managed via identities like `alice@example.com` instead of long-lived SSH keys. -It does not replace SSH, but instead generates SSH public keys containing PK Tokens and configures sshd to verify them. These PK Tokens contain standard [OpenID Connect ID Tokens](https://openid.net/specs/openid-connect-core-1_0.html). This protocol builds on the [OpenPubkey](https://github.com/openpubkey/openpubkey/blob/main/README.md) which adds user public keys to OpenID Connect without breaking compatibility with existing OpenID Provider. +It does not replace SSH, but instead generates SSH public keys containing PK Tokens and configures sshd to verify them. These PK Tokens contain standard [OpenID Connect ID Tokens](https://openid.net/specs/openid-connect-core-1_0.html). This protocol builds on [OpenPubkey](https://github.com/openpubkey/openpubkey/blob/main/README.md) which adds user public keys to OpenID Connect without breaking compatibility with existing OpenID Providers. -Currently opkssh is compatible with Google, Microsoft/Azure, Gitlab, hello.dev, and Authelia OpenID Providers (OP). See below for the entire list. If you have a gmail, microsoft or a gitlab account you can ssh with that account. +Currently opkssh is compatible with Google, Microsoft/Azure, GitLab, hello.dev, Authelia, Authentik, Keycloak, Zitadel, PocketID, AWS Cognito, and Kanidm OpenID Providers (OP). See below for the entire list. If you have a Gmail, Microsoft, or a GitLab account you can ssh with that account. To ssh with opkssh you first need to download the opkssh binary and then run: @@ -70,7 +70,8 @@ To install manually, download the opkssh binary and run it: |🐧 Linux (ARM64/aarch64) | [github.com/openpubkey/opkssh/releases/latest/download/opkssh-linux-arm64](https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-linux-arm64) | |🍎 macOS (x86_64) | [github.com/openpubkey/opkssh/releases/latest/download/opkssh-osx-amd64](https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-osx-amd64) | |🍎 macOS (ARM64/aarch64) | [github.com/openpubkey/opkssh/releases/latest/download/opkssh-osx-arm64](https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-osx-arm64) | -| ⊞ Win | [github.com/openpubkey/opkssh/releases/latest/download/opkssh-windows-amd64.exe](https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-windows-amd64.exe) | +| ⊞ Win (x86_64) | [github.com/openpubkey/opkssh/releases/latest/download/opkssh-windows-amd64.exe](https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-windows-amd64.exe) | +| ⊞ Win (ARM64) | [github.com/openpubkey/opkssh/releases/latest/download/opkssh-windows-arm64.exe](https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-windows-arm64.exe) | To install on Windows run: @@ -120,6 +121,20 @@ This works because SSH sends the public key written by opkssh in `~/.ssh/id_ecds sftp root@example.com ``` +### Logging out + +To remove opkssh-generated SSH keys and certificates: + +```cmd +opkssh logout +``` + +This removes the SSH key pair that was generated by `opkssh login`. To remove a specific key: + +```cmd +opkssh logout -i ~/.ssh/opkssh_server_group1 +``` + ### Custom key name
@@ -143,9 +158,9 @@ We recommend specifying `-o "IdentitiesOnly=yes"` as it tells ssh to only use th
-### Installing on a Server +### Installing on a Linux Server -To configure a linux server to use opkssh simply run (with root level privileges): +To configure a Linux server to use opkssh simply run (with root level privileges): ```bash wget -qO- "https://raw.githubusercontent.com/openpubkey/opkssh/main/scripts/install-linux.sh" | sudo bash @@ -153,6 +168,33 @@ wget -qO- "https://raw.githubusercontent.com/openpubkey/opkssh/main/scripts/inst This downloads the opkssh binary, installs it as `/usr/local/bin/opkssh`, and then configures ssh to use opkssh as an additional authentication mechanism. +### Installing on a Windows Server + +To configure a Windows server to use opkssh, download and run the installer script in an elevated PowerShell terminal: + +```powershell +Invoke-WebRequest -Uri "https://github.com/openpubkey/opkssh/releases/latest/download/Install-OpksshServer.ps1" -OutFile Install-OpksshServer.ps1 +.\Install-OpksshServer.ps1 +``` + +This downloads the opkssh binary, configures `sshd_config` for `AuthorizedKeysCommand`, sets up the correct NTFS ACLs on all configuration files, and restarts sshd. + +To uninstall: + +```powershell +Invoke-WebRequest -Uri "https://github.com/openpubkey/opkssh/releases/latest/download/Uninstall-OpksshServer.ps1" -OutFile Uninstall-OpksshServer.ps1 +.\Uninstall-OpksshServer.ps1 +``` + +To validate the installation: + +```powershell +Invoke-WebRequest -Uri "https://github.com/openpubkey/opkssh/releases/latest/download/Test-OpksshInstallation.ps1" -OutFile Test-OpksshInstallation.ps1 +.\Test-OpksshInstallation.ps1 +``` + +On Windows, the configuration files are located at `%ProgramData%\opk\` (typically `C:\ProgramData\opk\`). + To allow a user, `alice@gmail.com`, to ssh to your server as `root`, run: ```bash @@ -189,7 +231,7 @@ Second, we use the `AuthorizedKeysCommand` configuration option in `sshd_config` | --------- | -------- | ------- | ----------------------- | | Linux | ✅ | ✅ | Ubuntu 24.04.1 LTS | | macOS | ✅ | ✅ | macOS 15.3.2 (Sequoia) | -| Windows11 | ✅ | ✅ | Windows 11 | +| Windows 11 | ✅ | ✅ | Windows 11 | ### Server support @@ -198,7 +240,7 @@ Second, we use the `AuthorizedKeysCommand` configuration option in `sshd_config` | Linux | ✅ | ✅ | Ubuntu 24.04.1 LTS | - | | Linux | ✅ | ✅ | Centos 9 | - | | Linux | ✅ | ✅ | Arch Linux | - | -| Linux | ✅ | ✅ | opensuse leap 16 | - | +| Linux | ✅ | ✅ | openSUSE Leap 16 | - | | macOS | ❌ | ❌ | - | Likely | | Windows Server | ✅ | ✅ | Windows Server 2022 | - | | Windows Server | ✅ | ✅ | Windows Server 2025 | - | @@ -207,7 +249,8 @@ Second, we use the `AuthorizedKeysCommand` configuration option in `sshd_config` ## Server Configuration All opkssh configuration files are space delimited and live on the server. -Below we discuss our basic policy system, to read how to configure complex policies rules see our [documentation on our policy plugin system](docs/policyplugins.md). Using the policy plugin system you can enforce any policy rule that be computed on a [Turing Machine](https://en.wikipedia.org/wiki/Turing_machine). +On Linux, configuration files are stored under `/etc/opk/`. On Windows, they are stored under `%ProgramData%\opk\` (typically `C:\ProgramData\opk\`). +Below we discuss our basic policy system, to read how to configure complex policies rules see our [documentation on our policy plugin system](docs/policyplugins.md). Using the policy plugin system you can enforce any policy rule that can be computed on a [Turing Machine](https://en.wikipedia.org/wiki/Turing_machine). ### `/etc/opk/providers` @@ -467,7 +510,7 @@ Instead of using the `opkssh login --provider` flag you can also configure the p The OPKSSH_PROVIDERS variable follow the standard format with `;` delimiting each provider and `,` delimiting fields with a provider for instance: `{alias},{issuer},{client_id},{client_secret},{scope};{alias},{issuer},{client_id},{client_secret},{scope}...` -You can set them in your [`.bashrc` file](https://www.gnu.org/software/bash/manual/html_node/Bash-Startup-Files.html) so you don't have to type custom settings each time you run `opk login`. +You can set them in your [`.bashrc` file](https://www.gnu.org/software/bash/manual/html_node/Bash-Startup-Files.html) so you don't have to type custom settings each time you run `opkssh login`. ```bash export OPKSSH_DEFAULT=WEBCHOOSER @@ -527,6 +570,43 @@ opkssh add root alice@example.com https://authentik.local/application/o/opkssh/ Do not use Confidential/Secret mode **only** client ID is needed. +## Additional Commands + +### Inspect + +To inspect and view details of an opkssh-generated SSH key or certificate: + +```cmd +opkssh inspect ~/.ssh/id_ecdsa.pub +``` + +### Audit + +To validate policy file entries against provider definitions: + +```cmd +opkssh audit +``` + +This checks for inconsistencies between your `auth_id` and `providers` files. See [docs/audit.md](docs/audit.md) for more details. + +### Permissions + +To check and fix filesystem permissions required by opkssh: + +```cmd +opkssh permissions check +opkssh permissions fix +``` + +For non-interactive use (e.g., in scripts), use: + +```cmd +opkssh permissions install +``` + +For full CLI reference, see [docs/cli/](docs/cli/opkssh.md). + ## Developing For a complete developers guide see [CONTRIBUTING.md](CONTRIBUTING.md) @@ -563,14 +643,17 @@ For integration tests run: ## More information ### Documentation +- [docs/cli/](docs/cli/opkssh.md) CLI reference documentation for all opkssh commands. - [docs/config.md](docs/config.md) Documentation of opkssh configuration files. +- [docs/audit.md](docs/audit.md) Documentation of the audit command. - [docs/policyplugins.md](docs/policyplugins.md) Documentation of opkssh policy plugins and how to use them to implement complex policies. - [scripts/installing.md](scripts/installing.md) Documentation of the server install script that opkssh uses to configure an SSH server to accept opkssh SSH certificates. Explains how to manually install opkssh on a server. ### Guides - [CONTRIBUTING.md](https://github.com/openpubkey/opkssh/blob/main/CONTRIBUTING.md) Guide to contributing to opkssh (includes developer help). - [docs/gitlab-selfhosted.md](docs/gitlab-selfhosted.md) Guide on configuring and using a self hosted GitLab instance with opkssh. -- [docs/paramiko.md](docs/paramiko.md) Guide to using the python SSH paramiko library with opkssh. +- [docs/paramiko.md](docs/paramiko.md) Guide to using the Python SSH paramiko library with opkssh. - [docs/putty.md](docs/putty.md) Guide to using PuTTY with opkssh. - [docs/aws-ec2.md](docs/aws-ec2.md) Guide to get opkssh working on AWS EC2. -- [docs/github-actions.md](docs/github-actions.md) Guide to SSHing via GitHub Actions. \ No newline at end of file +- [docs/github-actions.md](docs/github-actions.md) Guide to SSHing via GitHub Actions. +- [docs/opkssh-and-sssd.md](docs/opkssh-and-sssd.md) Guide on using opkssh with SSSD. \ No newline at end of file From 4717edfd2a5a53e2dc703f80895b4b966c7ec082 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sun, 29 Mar 2026 12:56:17 -0300 Subject: [PATCH 09/21] fix: Address PR #480 review feedback - Add Windows to integration test matrix in ci.yml (runs Pester tests) - Fix step numbering in Install-OpksshServer.ps1 ([x/10] -> [x/11]) --- .github/workflows/ci.yml | 13 ++++++++++++- scripts/windows/Install-OpksshServer.ps1 | 12 ++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac57d7d8..c5a692fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,6 +145,9 @@ jobs: exclude: - runs_on: ubuntu-24.04-arm os: arch + include: + - runs_on: windows-latest + os: windows env: OS_TYPE: ${{ matrix.os }} steps: @@ -153,15 +156,23 @@ jobs: with: persist-credentials: false - name: Install Go + if: matrix.os != 'windows' uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version-file: 'go.mod' - name: Install Docker + if: matrix.os != 'windows' uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Install dependencies + if: matrix.os != 'windows' run: go mod download - - name: Run integration tests + - name: Run integration tests (Linux) + if: matrix.os != 'windows' run: go test -tags=integration ./test/integration -timeout=15m -count=1 -parallel=2 -v + - name: Run integration tests (Windows) + if: matrix.os == 'windows' + shell: pwsh + run: Invoke-Pester -Path scripts/windows/test -Output Detailed lint-scripts: name: Shell Scripts Lint & Test runs-on: ubuntu-24.04 diff --git a/scripts/windows/Install-OpksshServer.ps1 b/scripts/windows/Install-OpksshServer.ps1 index bfd9a766..a833aa61 100644 --- a/scripts/windows/Install-OpksshServer.ps1 +++ b/scripts/windows/Install-OpksshServer.ps1 @@ -925,31 +925,31 @@ function Install-OpksshServer { Write-Host "" # Step 1: Verify prerequisites - Write-Host "[1/10] Checking prerequisites..." -ForegroundColor Yellow + Write-Host "[1/11] Checking prerequisites..." -ForegroundColor Yellow Test-Prerequisites | Out-Null Write-Host " Prerequisites OK" -ForegroundColor Green Write-Host "" # Step 2: Get system architecture - Write-Host "[2/10] Detecting system architecture..." -ForegroundColor Yellow + Write-Host "[2/11] Detecting system architecture..." -ForegroundColor Yellow $arch = Get-SystemArchitecture Write-Host " Architecture: $arch" -ForegroundColor Green Write-Host "" # Step 3: Validate version - Write-Host "[3/10] Validating opkssh version..." -ForegroundColor Yellow + Write-Host "[3/11] Validating opkssh version..." -ForegroundColor Yellow Test-OpksshVersion -Version $InstallVersion | Out-Null Write-Host " Version OK: $InstallVersion" -ForegroundColor Green Write-Host "" # Step 4: Create user account (if needed) - Write-Host "[4/10] Configuring authentication user..." -ForegroundColor Yellow + Write-Host "[4/11] Configuring authentication user..." -ForegroundColor Yellow New-OpksshUser -Username $AuthCmdUser | Out-Null Write-Host " Auth user: $AuthCmdUser" -ForegroundColor Green Write-Host "" # Step 5: Install binary - Write-Host "[5/10] Installing opkssh binary..." -ForegroundColor Yellow + Write-Host "[5/11] Installing opkssh binary..." -ForegroundColor Yellow $binaryPath = Install-OpksshBinary -InstallDir $InstallDir ` -LocalFile $InstallFrom ` -Version $InstallVersion ` @@ -959,7 +959,7 @@ function Install-OpksshServer { Write-Host "" # Step 6: Install uninstall script - Write-Host "[6/10] Installing uninstall script..." -ForegroundColor Yellow + Write-Host "[6/11] Installing uninstall script..." -ForegroundColor Yellow $uninstallPath = Install-UninstallScript -InstallDir $InstallDir ` -Version $InstallVersion ` -GitHubRepo $GitHubRepo From 970adf44d28c274f59fa24df552b090a79b4c0d1 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sun, 29 Mar 2026 17:56:51 -0300 Subject: [PATCH 10/21] fix: Improve OpenSSH version detection for LocalSystem account compatibility --- scripts/windows/Install-OpksshServer.ps1 | 34 ++++++++++++++++++------ 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/scripts/windows/Install-OpksshServer.ps1 b/scripts/windows/Install-OpksshServer.ps1 index a833aa61..8eebc62c 100644 --- a/scripts/windows/Install-OpksshServer.ps1 +++ b/scripts/windows/Install-OpksshServer.ps1 @@ -188,16 +188,34 @@ function Test-Prerequisites { # Check OpenSSH version when using System account if ($AuthCmdUser -eq "System") { Write-Verbose " Validating OpenSSH version for LocalSystem account..." - + + # Use 'ssh -V' to detect the OpenSSH version on Windows. + # (Get-Command sshd).Version reads the PE file version resource, which is + # often $null on Windows OpenSSH installations, making the comparison + # always fail. 'ssh -V' reliably returns the actual version string to + # stderr, e.g. "OpenSSH_for_Windows_9.5p2, LibreSSL 3.8.2". try { - $canUseSystemAccount = (Get-Command sshd).Version -ge [version]'8.9' + $sshVersionOutput = & ssh.exe -V 2>&1 + if (-not $sshVersionOutput) { + throw "'ssh -V' returned empty output" + } } catch { - throw "Unexpected: sshd.exe not in PATH?" + throw "Failed to detect OpenSSH version. Is OpenSSH installed and in PATH? Error: $_" } - - $sshdVersion = (Get-Command sshd).Version - Write-Verbose " Detected OpenSSH Server version: $sshdVersion" - + + # Strip the trailing library info to get a clean display string. + $sshdVersion = ($sshVersionOutput -split ',')[0].Trim() + Write-Verbose " Detected OpenSSH version: $sshdVersion" + + # Parse major and minor from "OpenSSH_for_Windows_9.5p2" or "OpenSSH_9.5p2" + if ($sshdVersion -match 'OpenSSH(?:_for_Windows)?_(\d+)\.(\d+)') { + $majorVer = [int]$Matches[1] + $minorVer = [int]$Matches[2] + $canUseSystemAccount = ($majorVer -gt 8) -or ($majorVer -eq 8 -and $minorVer -ge 9) + } else { + throw "Could not parse OpenSSH version number from: '$sshVersionOutput'" + } + if (-not $canUseSystemAccount) { $errorMessage = @" @@ -221,7 +239,7 @@ This will create and use a dedicated 'opksshuser' account instead. "@ throw $errorMessage } - + Write-Verbose " OpenSSH version is compatible with LocalSystem account" } else { Write-Verbose " Using custom user account, no version restriction" From 07c9a3e78c52fd4b381c9c6eebd5132b3cdde439 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sun, 29 Mar 2026 18:13:54 -0300 Subject: [PATCH 11/21] Remove LocalSystem account usage, standardize on opksshuser - Add Group: 'opksshuser' to all Windows system PermInfo entries - Add RequiredACEs/ExpectedACE to ExpectedACL for data-driven ACL enforcement - Update ExpectedACLFromPerm() to populate RequiredACEs on Windows - Rewrite permissions Fix() ACL block to use RequiredACEs loop - Remove SYSTEM:F from Chown() in fileperms_ops_windows_acl.go - Change installer default AuthCmdUser to 'opksshuser' - Remove System from ValidateSet and OpenSSH version check - Remove System early-return in New-OpksshUser - Automate deny-interactive-logon via secedit for opksshuser - Grant opksshuser read ACLs on config and binary directories - Update all 3 CI workflows to explicitly pass -AuthCmdUser 'opksshuser' - Update comments in multipolicyloader, permschecker, fileperms_ops - Replace Windows test assertions: SYSTEM -> opksshuser - Add tests for RequiredACEs, PermInfo, skip-existing-ACEs, no-SYSTEM --- .github/workflows/gha-windows-2025.yml | 1 + .github/workflows/gha-windows-arm64.yml | 1 + commands/permissions.go | 78 ++++++------- commands/permissions_fix_windows_test.go | 89 +++++++++++++-- policy/files/acl.go | 11 ++ policy/files/fileperms_ops_windows_acl.go | 5 +- policy/files/perminfo.go | 16 ++- policy/files/perminfo_windows.go | 10 +- policy/files/perminfo_windows_test.go | 111 +++++++++++++++++++ policy/files/permschecker_windows.go | 2 +- policy/multipolicyloader_windows.go | 5 +- scripts/windows/Install-OpksshServer.ps1 | 129 ++++++++++------------ 12 files changed, 325 insertions(+), 133 deletions(-) create mode 100644 policy/files/perminfo_windows_test.go diff --git a/.github/workflows/gha-windows-2025.yml b/.github/workflows/gha-windows-2025.yml index 80740440..564c139b 100644 --- a/.github/workflows/gha-windows-2025.yml +++ b/.github/workflows/gha-windows-2025.yml @@ -60,6 +60,7 @@ jobs: powershell -ExecutionPolicy Bypass -File "scripts/windows/Install-OpksshServer.ps1" ` -InstallFrom "$PWD\opkssh.exe" ` -NoSshdRestart ` + -AuthCmdUser 'opksshuser' ` -Verbose - name: Add GitHub provider to opkssh configuration diff --git a/.github/workflows/gha-windows-arm64.yml b/.github/workflows/gha-windows-arm64.yml index 857fd38d..41a355ed 100644 --- a/.github/workflows/gha-windows-arm64.yml +++ b/.github/workflows/gha-windows-arm64.yml @@ -50,6 +50,7 @@ jobs: powershell -ExecutionPolicy Bypass -File "scripts/windows/Install-OpksshServer.ps1" ` -InstallFrom "$PWD\opkssh.exe" ` -NoSshdRestart ` + -AuthCmdUser 'opksshuser' ` -Verbose - name: Add GitHub provider to opkssh configuration diff --git a/commands/permissions.go b/commands/permissions.go index e883bbec..88be4da5 100644 --- a/commands/permissions.go +++ b/commands/permissions.go @@ -363,41 +363,27 @@ func (p *PermissionsCmd) Fix() error { // Verify ACLs after changes and apply ACE fixes on Windows if needed if runtime.GOOS == "windows" { - // Pre-resolve commonly used SIDs to avoid repeated lookups and use SID-based trustees - adminSID, _, _ := files.ResolveAccountToSID("Administrators") - systemSID, _, _ := files.ResolveAccountToSID("SYSTEM") - - report, err := p.FileSystem.VerifyACL(systemPolicy, files.ExpectedACLFromPerm(sp)) + expected := files.ExpectedACLFromPerm(sp) + report, err := p.FileSystem.VerifyACL(systemPolicy, expected) if err != nil { errorsFound = append(errorsFound, "acl verify: "+err.Error()) } else { - // Ensure Administrators and SYSTEM have full control; if missing, apply via ApplyACE - needAdmin := true - needSystem := true - for _, a := range report.ACEs { - if a.Principal == "Administrators" && strings.Contains(a.Rights, "GENERIC_ALL") { - needAdmin = false - } - if a.Principal == "SYSTEM" && strings.Contains(a.Rights, "GENERIC_ALL") { - needSystem = false - } - } - if needAdmin { - ace := files.ACE{Principal: "Administrators", Rights: "GENERIC_ALL", Type: "allow"} - if len(adminSID) > 0 { - ace.PrincipalSID = adminSID - } - if err := p.FileSystem.ApplyACE(systemPolicy, ace); err != nil { - errorsFound = append(errorsFound, "apply ACE Administrators:F: "+err.Error()) - } - } - if needSystem { - ace := files.ACE{Principal: "SYSTEM", Rights: "GENERIC_ALL", Type: "allow"} - if len(systemSID) > 0 { - ace.PrincipalSID = systemSID + for _, reqACE := range expected.RequiredACEs { + found := false + for _, a := range report.ACEs { + if a.Principal == reqACE.Principal && strings.Contains(a.Rights, reqACE.Rights) { + found = true + break + } } - if err := p.FileSystem.ApplyACE(systemPolicy, ace); err != nil { - errorsFound = append(errorsFound, "apply ACE SYSTEM:F: "+err.Error()) + if !found { + ace := files.ACE{Principal: reqACE.Principal, Rights: reqACE.Rights, Type: reqACE.Type} + if sid, _, _ := files.ResolveAccountToSID(reqACE.Principal); len(sid) > 0 { + ace.PrincipalSID = sid + } + if err := p.FileSystem.ApplyACE(systemPolicy, ace); err != nil { + errorsFound = append(errorsFound, fmt.Sprintf("apply ACE %s:%s: %s", reqACE.Principal, reqACE.Rights, err.Error())) + } } } } @@ -442,20 +428,24 @@ func (p *PermissionsCmd) Fix() error { } // On Windows, ensure ACLs for plugin files as well if runtime.GOOS == "windows" { - if report, err := p.FileSystem.VerifyACL(path, files.ExpectedACLFromPerm(pf)); err == nil { - needAdmin := true - for _, a := range report.ACEs { - if a.Principal == "Administrators" && strings.Contains(a.Rights, "GENERIC_ALL") { - needAdmin = false - } - } - if needAdmin { - ace := files.ACE{Principal: "Administrators", Rights: "GENERIC_ALL", Type: "allow"} - if adminSID, _, _ := files.ResolveAccountToSID("Administrators"); len(adminSID) > 0 { - ace.PrincipalSID = adminSID + pfExpected := files.ExpectedACLFromPerm(pf) + if report, err := p.FileSystem.VerifyACL(path, pfExpected); err == nil { + for _, reqACE := range pfExpected.RequiredACEs { + found := false + for _, a := range report.ACEs { + if a.Principal == reqACE.Principal && strings.Contains(a.Rights, reqACE.Rights) { + found = true + break + } } - if err := p.FileSystem.ApplyACE(path, ace); err != nil { - errorsFound = append(errorsFound, "apply ACE Administrators:F for "+path+": "+err.Error()) + if !found { + ace := files.ACE{Principal: reqACE.Principal, Rights: reqACE.Rights, Type: reqACE.Type} + if sid, _, _ := files.ResolveAccountToSID(reqACE.Principal); len(sid) > 0 { + ace.PrincipalSID = sid + } + if err := p.FileSystem.ApplyACE(path, ace); err != nil { + errorsFound = append(errorsFound, fmt.Sprintf("apply ACE %s:%s for %s: %s", reqACE.Principal, reqACE.Rights, path, err.Error())) + } } } } else { diff --git a/commands/permissions_fix_windows_test.go b/commands/permissions_fix_windows_test.go index 8a899b21..065852fb 100644 --- a/commands/permissions_fix_windows_test.go +++ b/commands/permissions_fix_windows_test.go @@ -13,7 +13,7 @@ import ( "github.com/spf13/afero" ) -func TestRunPermissionsFix_AppliesAdminACE_Windows(t *testing.T) { +func TestRunPermissionsFix_AppliesRequiredACEs_Windows(t *testing.T) { // Setup in-memory fs with system policy file mem := afero.NewMemMapFs() systemPolicy := policy.SystemDefaultPolicyPath @@ -43,20 +43,95 @@ func TestRunPermissionsFix_AppliesAdminACE_Windows(t *testing.T) { } if len(mfs.Applied) < 2 { - t.Fatalf("expected at least 2 ApplyACE calls for Admin and SYSTEM, got %d", len(mfs.Applied)) + t.Fatalf("expected at least 2 ApplyACE calls for Administrators and opksshuser, got %d", len(mfs.Applied)) } // check principals present - var foundAdmin, foundSystem bool + var foundAdmin, foundOpksshuser bool for _, a := range mfs.Applied { - if a.Principal == "Administrators" { + if a.Principal == "Administrators" && a.Rights == "GENERIC_ALL" { foundAdmin = true } + if a.Principal == "opksshuser" && a.Rights == "GENERIC_READ" { + foundOpksshuser = true + } + } + if !foundAdmin { + t.Fatalf("expected ApplyACE for Administrators:GENERIC_ALL, got: %+v", mfs.Applied) + } + if !foundOpksshuser { + t.Fatalf("expected ApplyACE for opksshuser:GENERIC_READ, got: %+v", mfs.Applied) + } +} + +func TestRunPermissionsFix_SkipsExistingACEs_Windows(t *testing.T) { + // Setup in-memory fs with system policy file, ACEs already present + mem := afero.NewMemMapFs() + systemPolicy := policy.SystemDefaultPolicyPath + afero.WriteFile(mem, systemPolicy, []byte("x"), 0o644) + + mfs := &mockFileSystem{ + fs: mem, + aclReport: files.ACLReport{ + Path: systemPolicy, + Exists: true, + ACEs: []files.ACE{ + {Principal: "Administrators", Rights: "GENERIC_ALL", Type: "allow"}, + {Principal: "opksshuser", Rights: "GENERIC_READ", Type: "allow"}, + }, + }, + } + + p := &PermissionsCmd{ + FileSystem: mfs, + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + IsElevatedFn: func() (bool, error) { return true, nil }, + ConfirmPrompt: func(prompt string, in io.Reader) (bool, error) { return true, nil }, + Yes: true, + } + + err := p.Fix() + if err != nil { + t.Fatalf("Fix failed: %v", err) + } + + // No ACEs should have been applied since they already exist + for _, a := range mfs.Applied { if a.Principal == "SYSTEM" { - foundSystem = true + t.Fatalf("should not apply SYSTEM ACE, but got: %+v", mfs.Applied) } } - if !foundAdmin || !foundSystem { - t.Fatalf("expected ApplyACE for Administrators and SYSTEM, got: %+v", mfs.Applied) +} + +func TestRunPermissionsFix_NoSystemACE_Windows(t *testing.T) { + // Verify that SYSTEM:F ACE is never applied + mem := afero.NewMemMapFs() + systemPolicy := policy.SystemDefaultPolicyPath + afero.WriteFile(mem, systemPolicy, []byte("x"), 0o644) + + mfs := &mockFileSystem{ + fs: mem, + aclReport: files.ACLReport{Path: systemPolicy, Exists: true, ACEs: []files.ACE{}}, + } + + p := &PermissionsCmd{ + FileSystem: mfs, + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + IsElevatedFn: func() (bool, error) { return true, nil }, + ConfirmPrompt: func(prompt string, in io.Reader) (bool, error) { return true, nil }, + Yes: true, + } + + err := p.Fix() + if err != nil { + t.Fatalf("Fix failed: %v", err) + } + + for _, a := range mfs.Applied { + if a.Principal == "SYSTEM" { + t.Fatalf("SYSTEM ACE should not be applied, but got: %+v", mfs.Applied) + } } } diff --git a/policy/files/acl.go b/policy/files/acl.go index 60121f1f..6c136ec8 100644 --- a/policy/files/acl.go +++ b/policy/files/acl.go @@ -22,6 +22,17 @@ type ACE struct { type ExpectedACL struct { Owner string Mode fs.FileMode // expected mode bits; 0 means ignore + + // RequiredACEs lists ACE expectations that must be present. + // Used on Windows to verify that opksshuser has been granted read access. + RequiredACEs []ExpectedACE +} + +// ExpectedACE describes a single required ACE. +type ExpectedACE struct { + Principal string // e.g. "Administrators", "SYSTEM", "opksshuser" + Rights string // e.g. "GENERIC_ALL", "GENERIC_READ" + Type string // "allow" } // ACLReport is the structured result from verifying ACLs/ownership for a path diff --git a/policy/files/fileperms_ops_windows_acl.go b/policy/files/fileperms_ops_windows_acl.go index c69005bf..fbe1cd20 100644 --- a/policy/files/fileperms_ops_windows_acl.go +++ b/policy/files/fileperms_ops_windows_acl.go @@ -89,13 +89,10 @@ func (w *WindowsACLFilePermsOps) Chown(path string, owner string, group string) } } - // Ensure Administrators and SYSTEM have full control via ApplyACE + // Ensure Administrators have full control via ApplyACE if err := w.ApplyACE(path, ACE{Principal: "Administrators", Rights: "GENERIC_ALL", Type: "allow"}); err != nil { return fmt.Errorf("ensure admin ACE failed: %v", err) } - if err := w.ApplyACE(path, ACE{Principal: "SYSTEM", Rights: "GENERIC_ALL", Type: "allow"}); err != nil { - return fmt.Errorf("ensure system ACE failed: %v", err) - } return nil } diff --git a/policy/files/perminfo.go b/policy/files/perminfo.go index 7278c632..6a97367c 100644 --- a/policy/files/perminfo.go +++ b/policy/files/perminfo.go @@ -19,6 +19,7 @@ package files import ( "fmt" "io/fs" + "runtime" ) // PermInfo describes the expected filesystem permissions for a given resource @@ -57,8 +58,21 @@ func (p PermInfo) String() string { // ExpectedACLFromPerm builds an ExpectedACL from a PermInfo. func ExpectedACLFromPerm(pi PermInfo) ExpectedACL { - return ExpectedACL{ + ea := ExpectedACL{ Owner: pi.Owner, Mode: pi.Mode, } + if runtime.GOOS == "windows" { + if pi.Owner != "" { + ea.RequiredACEs = append(ea.RequiredACEs, ExpectedACE{ + Principal: pi.Owner, Rights: "GENERIC_ALL", Type: "allow", + }) + } + if pi.Group != "" { + ea.RequiredACEs = append(ea.RequiredACEs, ExpectedACE{ + Principal: pi.Group, Rights: "GENERIC_READ", Type: "allow", + }) + } + } + return ea } diff --git a/policy/files/perminfo_windows.go b/policy/files/perminfo_windows.go index a5b37b1d..db1b4220 100644 --- a/policy/files/perminfo_windows.go +++ b/policy/files/perminfo_windows.go @@ -44,7 +44,7 @@ var RequiredPerms = struct { SystemPolicy: PermInfo{ Mode: ModeSystemPerms, // 0o640 Owner: "Administrators", - Group: "", + Group: "opksshuser", MustExist: true, }, HomePolicy: PermInfo{ @@ -56,25 +56,25 @@ var RequiredPerms = struct { Providers: PermInfo{ Mode: ModeSystemPerms, // 0o640 Owner: "Administrators", - Group: "", + Group: "opksshuser", MustExist: false, }, Config: PermInfo{ Mode: ModeSystemPerms, // 0o640 Owner: "Administrators", - Group: "", + Group: "opksshuser", MustExist: false, }, PluginsDir: PermInfo{ Mode: 0o750, Owner: "Administrators", - Group: "", + Group: "opksshuser", MustExist: false, }, PluginFile: PermInfo{ Mode: ModeSystemPerms, // 0o640 Owner: "Administrators", - Group: "", + Group: "opksshuser", MustExist: false, }, } diff --git a/policy/files/perminfo_windows_test.go b/policy/files/perminfo_windows_test.go new file mode 100644 index 00000000..94a12a86 --- /dev/null +++ b/policy/files/perminfo_windows_test.go @@ -0,0 +1,111 @@ +//go:build windows +// +build windows + +package files + +import ( + "testing" +) + +func TestExpectedACLFromPerm_PopulatesRequiredACEs(t *testing.T) { + pi := PermInfo{ + Mode: 0o640, + Owner: "Administrators", + Group: "opksshuser", + } + + ea := ExpectedACLFromPerm(pi) + + if ea.Owner != "Administrators" { + t.Fatalf("expected Owner=Administrators, got %q", ea.Owner) + } + if ea.Mode != 0o640 { + t.Fatalf("expected Mode=0o640, got %o", ea.Mode) + } + if len(ea.RequiredACEs) != 2 { + t.Fatalf("expected 2 RequiredACEs, got %d: %+v", len(ea.RequiredACEs), ea.RequiredACEs) + } + + // First ACE: owner gets GENERIC_ALL + if ea.RequiredACEs[0].Principal != "Administrators" { + t.Errorf("RequiredACEs[0].Principal = %q, want Administrators", ea.RequiredACEs[0].Principal) + } + if ea.RequiredACEs[0].Rights != "GENERIC_ALL" { + t.Errorf("RequiredACEs[0].Rights = %q, want GENERIC_ALL", ea.RequiredACEs[0].Rights) + } + if ea.RequiredACEs[0].Type != "allow" { + t.Errorf("RequiredACEs[0].Type = %q, want allow", ea.RequiredACEs[0].Type) + } + + // Second ACE: group gets GENERIC_READ + if ea.RequiredACEs[1].Principal != "opksshuser" { + t.Errorf("RequiredACEs[1].Principal = %q, want opksshuser", ea.RequiredACEs[1].Principal) + } + if ea.RequiredACEs[1].Rights != "GENERIC_READ" { + t.Errorf("RequiredACEs[1].Rights = %q, want GENERIC_READ", ea.RequiredACEs[1].Rights) + } + if ea.RequiredACEs[1].Type != "allow" { + t.Errorf("RequiredACEs[1].Type = %q, want allow", ea.RequiredACEs[1].Type) + } +} + +func TestExpectedACLFromPerm_EmptyGroupNoGroupACE(t *testing.T) { + pi := PermInfo{ + Mode: 0o600, + Owner: "Administrators", + Group: "", + } + + ea := ExpectedACLFromPerm(pi) + + if len(ea.RequiredACEs) != 1 { + t.Fatalf("expected 1 RequiredACE (owner only), got %d: %+v", len(ea.RequiredACEs), ea.RequiredACEs) + } + if ea.RequiredACEs[0].Principal != "Administrators" { + t.Errorf("RequiredACEs[0].Principal = %q, want Administrators", ea.RequiredACEs[0].Principal) + } +} + +func TestExpectedACLFromPerm_EmptyOwnerAndGroup(t *testing.T) { + pi := PermInfo{ + Mode: 0o600, + Owner: "", + Group: "", + } + + ea := ExpectedACLFromPerm(pi) + + if len(ea.RequiredACEs) != 0 { + t.Fatalf("expected 0 RequiredACEs for empty owner/group, got %d: %+v", len(ea.RequiredACEs), ea.RequiredACEs) + } +} + +func TestRequiredPerms_SystemEntriesHaveOpksshuser(t *testing.T) { + entries := []struct { + name string + pi PermInfo + }{ + {"SystemPolicy", RequiredPerms.SystemPolicy}, + {"Providers", RequiredPerms.Providers}, + {"Config", RequiredPerms.Config}, + {"PluginsDir", RequiredPerms.PluginsDir}, + {"PluginFile", RequiredPerms.PluginFile}, + } + + for _, e := range entries { + t.Run(e.name, func(t *testing.T) { + if e.pi.Group != "opksshuser" { + t.Errorf("%s.Group = %q, want opksshuser", e.name, e.pi.Group) + } + if e.pi.Owner != "Administrators" { + t.Errorf("%s.Owner = %q, want Administrators", e.name, e.pi.Owner) + } + }) + } +} + +func TestRequiredPerms_HomePolicyHasNoGroup(t *testing.T) { + if RequiredPerms.HomePolicy.Group != "" { + t.Errorf("HomePolicy.Group = %q, want empty", RequiredPerms.HomePolicy.Group) + } +} diff --git a/policy/files/permschecker_windows.go b/policy/files/permschecker_windows.go index e8c2fa39..972a1218 100644 --- a/policy/files/permschecker_windows.go +++ b/policy/files/permschecker_windows.go @@ -31,7 +31,7 @@ import ( // 3. A file without the read-only attribute will always show as 0666, not 0640 // // For security on Windows, we rely on: -// - NTFS ACLs set by the installer (Administrators and SYSTEM only) +// - NTFS ACLs set by the installer (Administrators full control, opksshuser read) // - File system level security rather than permission bits // // This function validates the file exists and is accessible, but skips diff --git a/policy/multipolicyloader_windows.go b/policy/multipolicyloader_windows.go index 90716418..51845a6f 100644 --- a/policy/multipolicyloader_windows.go +++ b/policy/multipolicyloader_windows.go @@ -24,8 +24,9 @@ import ( ) // ReadWithSudoScript on Windows does not use sudo (which doesn't exist). -// On Windows, the sshd service runs as LocalSystem which has full access to -// read user home directories, so we don't need privilege escalation. +// On Windows, home policy files are not supported. The verify process runs as +// the dedicated opksshuser account, which only has read access to system policy +// files. There is no privilege escalation mechanism on Windows. // This function just returns an error indicating home policy reading failed // and we should rely on the system policy. func ReadWithSudoScript(h *HomePolicyLoader, username string) ([]byte, error) { diff --git a/scripts/windows/Install-OpksshServer.ps1 b/scripts/windows/Install-OpksshServer.ps1 index 8eebc62c..0b717628 100644 --- a/scripts/windows/Install-OpksshServer.ps1 +++ b/scripts/windows/Install-OpksshServer.ps1 @@ -35,8 +35,8 @@ .PARAMETER AuthCmdUser User account that will run the AuthorizedKeysCommand. - Default is "System" (the OpenSSH service account). - You can specify "opksshuser" to create a dedicated local user instead. + Default is "opksshuser" (a dedicated local user account). + Using "System" (LocalSystem) is not supported. .PARAMETER GitHubRepo GitHub repository to download from (format: owner/repo). @@ -97,8 +97,7 @@ param( [string]$ConfigPath = "C:\ProgramData\opk", [Parameter(HelpMessage="User account for AuthorizedKeysCommand")] - [ValidateSet("System", "opksshuser")] - [string]$AuthCmdUser = "System", + [string]$AuthCmdUser = "opksshuser", [Parameter(HelpMessage="GitHub repository (owner/repo)")] [string]$GitHubRepo = "openpubkey/opkssh" @@ -185,64 +184,9 @@ function Test-Prerequisites { } Write-Verbose " sshd service found: $($sshdService.Status)" - # Check OpenSSH version when using System account + # Reject LocalSystem account usage if ($AuthCmdUser -eq "System") { - Write-Verbose " Validating OpenSSH version for LocalSystem account..." - - # Use 'ssh -V' to detect the OpenSSH version on Windows. - # (Get-Command sshd).Version reads the PE file version resource, which is - # often $null on Windows OpenSSH installations, making the comparison - # always fail. 'ssh -V' reliably returns the actual version string to - # stderr, e.g. "OpenSSH_for_Windows_9.5p2, LibreSSL 3.8.2". - try { - $sshVersionOutput = & ssh.exe -V 2>&1 - if (-not $sshVersionOutput) { - throw "'ssh -V' returned empty output" - } - } catch { - throw "Failed to detect OpenSSH version. Is OpenSSH installed and in PATH? Error: $_" - } - - # Strip the trailing library info to get a clean display string. - $sshdVersion = ($sshVersionOutput -split ',')[0].Trim() - Write-Verbose " Detected OpenSSH version: $sshdVersion" - - # Parse major and minor from "OpenSSH_for_Windows_9.5p2" or "OpenSSH_9.5p2" - if ($sshdVersion -match 'OpenSSH(?:_for_Windows)?_(\d+)\.(\d+)') { - $majorVer = [int]$Matches[1] - $minorVer = [int]$Matches[2] - $canUseSystemAccount = ($majorVer -gt 8) -or ($majorVer -eq 8 -and $minorVer -ge 9) - } else { - throw "Could not parse OpenSSH version number from: '$sshVersionOutput'" - } - - if (-not $canUseSystemAccount) { - $errorMessage = @" - -======================================== -ERROR: OpenSSH Version Too Old -======================================== - -Your OpenSSH Server version ($sshdVersion) does not support using 'LocalSystem' -as the AuthorizedKeysCommandUser. - -OpenSSH Server 8.9.0 or higher is required to use the LocalSystem account. - -SOLUTION: -Run the installer with the -AuthCmdUser parameter: - - .\Install-OpksshServer.ps1 -AuthCmdUser "opksshuser" - -This will create and use a dedicated 'opksshuser' account instead. - -======================================== -"@ - throw $errorMessage - } - - Write-Verbose " OpenSSH version is compatible with LocalSystem account" - } else { - Write-Verbose " Using custom user account, no version restriction" + throw "Using LocalSystem as AuthorizedKeysCommandUser is not supported. Use 'opksshuser' instead." } # Verify sshd_config exists @@ -340,11 +284,6 @@ function New-OpksshUser { [string]$Username ) - if ($Username -eq "System") { - Write-Verbose "Using built-in service account: System" - return $true - } - Write-Verbose "Checking if user '$Username' exists..." $existingUser = Get-LocalUser -Name $Username -ErrorAction SilentlyContinue @@ -373,9 +312,31 @@ function New-OpksshUser { Write-Log "Created user: $Username" -Level Success - # Note: Denying interactive logon requires editing local security policy - # This would typically be done via secedit or Group Policy - Write-Warning "Manual step required: Deny interactive logon rights for user '$Username' via Local Security Policy" + # Deny interactive logon for the service account using secedit + try { + $tempCfg = [System.IO.Path]::GetTempFileName() + $tempDb = [System.IO.Path]::GetTempFileName() + try { + secedit /export /cfg $tempCfg | Out-Null + $content = Get-Content $tempCfg -Raw + if ($content -match 'SeDenyInteractiveLogonRight\s*=\s*(.*)') { + $existing = $Matches[1] + $content = $content -replace "SeDenyInteractiveLogonRight\s*=\s*.*", + "SeDenyInteractiveLogonRight = $existing,$Username" + } else { + $content = $content -replace '\[Privilege Rights\]', + "[Privilege Rights]`r`nSeDenyInteractiveLogonRight = $Username" + } + Set-Content $tempCfg $content + secedit /configure /db $tempDb /cfg $tempCfg /areas USER_RIGHTS | Out-Null + Write-Log "Denied interactive logon for user: $Username" -Level Success + } finally { + Remove-Item $tempCfg, $tempDb -ErrorAction SilentlyContinue + } + } catch { + Write-Warning "Could not automatically deny interactive logon for '$Username': $($_.Exception.Message)" + Write-Warning "Manual step required: Deny interactive logon rights for user '$Username' via Local Security Policy" + } } catch { throw "Failed to create user '$Username': $($_.Exception.Message)" @@ -613,6 +574,25 @@ https://issuer.hello.coop app_xejobTKEsDNSRd5vofKB2iay_2rN 24h } } + # Grant opksshuser read access to config directory (inherited by files) + if ($AuthCmdUser -ne "System") { + $configAcl = Get-Acl $ConfigPath + $readRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $AuthCmdUser, "ReadAndExecute", "ContainerInherit,ObjectInherit", "None", "Allow") + $configAcl.AddAccessRule($readRule) + Set-Acl -Path $ConfigPath -AclObject $configAcl + Write-Verbose " Granted $AuthCmdUser read access to $ConfigPath" + + # Grant opksshuser write access to logs directory + $logsDir = Join-Path $ConfigPath "logs" + $logsAcl = Get-Acl $logsDir + $writeRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $AuthCmdUser, "Modify", "ContainerInherit,ObjectInherit", "None", "Allow") + $logsAcl.AddAccessRule($writeRule) + Set-Acl -Path $logsDir -AclObject $logsAcl + Write-Verbose " Granted $AuthCmdUser write access to $logsDir" + } + Write-Log "Configuration created successfully" -Level Success return $true } @@ -974,6 +954,17 @@ function Install-OpksshServer { -Architecture $arch ` -GitHubRepo $GitHubRepo Write-Host " Installed: $binaryPath" -ForegroundColor Green + + # Grant opksshuser read+execute access to the binary directory + if ($AuthCmdUser -ne "System") { + $binAcl = Get-Acl $InstallDir + $execRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $AuthCmdUser, "ReadAndExecute", "ContainerInherit,ObjectInherit", "None", "Allow") + $binAcl.AddAccessRule($execRule) + Set-Acl -Path $InstallDir -AclObject $binAcl + Write-Verbose " Granted $AuthCmdUser read+execute access to $InstallDir" + } + Write-Host "" # Step 6: Install uninstall script From 34c8ba2c238bcadf8e6929430b4217ea8daf0409 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sun, 29 Mar 2026 19:52:17 -0300 Subject: [PATCH 12/21] refactor: Update GitHub Actions workflow for Windows to use matrix strategy --- .github/workflows/gha-windows-2025.yml | 109 ------------------------ .github/workflows/gha-windows-arm64.yml | 99 --------------------- .github/workflows/gha-windows.yml | 26 +++++- 3 files changed, 22 insertions(+), 212 deletions(-) delete mode 100644 .github/workflows/gha-windows-2025.yml delete mode 100644 .github/workflows/gha-windows-arm64.yml diff --git a/.github/workflows/gha-windows-2025.yml b/.github/workflows/gha-windows-2025.yml deleted file mode 100644 index 564c139b..00000000 --- a/.github/workflows/gha-windows-2025.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: Test GitHub Provider Windows Server 2025 - -on: - push: - -jobs: - build: - name: Test on Windows Server 2025 - runs-on: windows-2025 - permissions: - id-token: write - contents: read - timeout-minutes: 10 - - steps: - - name: Install OpenSSH Server - run: | - Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 - - - name: Create default OpenSSH config files - run: | - Start-Service sshd - Start-Sleep -Seconds 2 - Stop-Service sshd - - - name: Enable OpenSSH Server logs - run: | - $sshdConfig = "$env:ProgramData\ssh\sshd_config" - (Get-Content $sshdConfig -Raw).Replace('#SyslogFacility AUTH', 'SyslogFacility LOCAL0').Replace('#LogLevel INFO', 'LogLevel Debug3') | - Set-Content $sshdConfig - - - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - persist-credentials: false - - - name: Cache Go modules - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 - with: - go-version-file: 'go.mod' - - - name: Install dependencies - run: go mod download - - - name: Build opkssh - run: go build -v -o opkssh.exe - - - name: Install opkssh with local binary - run: | - powershell -ExecutionPolicy Bypass -File "scripts/windows/Install-OpksshServer.ps1" ` - -InstallFrom "$PWD\opkssh.exe" ` - -NoSshdRestart ` - -AuthCmdUser 'opksshuser' ` - -Verbose - - - name: Add GitHub provider to opkssh configuration - run: | - $providersPath = 'C:\ProgramData\opk\providers' - if ((Get-Content -Path $providersPath -Raw) -notmatch "`r?`n$") { - Add-Content -Path $providersPath -Value '' - } - Add-Content -Path $providersPath -Value 'https://token.actions.githubusercontent.com github oidc' - - - name: Add current repository to policy - run: | - & 'C:\Program Files\opkssh\opkssh.exe' add runneradmin "repo:${env:GITHUB_REPOSITORY}:ref:${env:GITHUB_REF}" https://token.actions.githubusercontent.com - - - name: Start SSH service - run: Start-Service sshd - - - name: Test SSH connection without opkssh (should fail) - run: | - ssh -o StrictHostKeyChecking=no -o PasswordAuthentication=no runneradmin@localhost dir - continue-on-error: true - - - name: Login with GitHub OIDC - run: | - & 'C:\Program Files\opkssh\opkssh.exe' login github --print-id-token - - - name: Test SSH connection with opkssh (should pass) - run: | - ssh -o StrictHostKeyChecking=no -o PasswordAuthentication=no runneradmin@localhost dir - - - name: Debug - Dump opkssh config - run: | - Get-ChildItem 'C:\ProgramData\opk' -Recurse -ErrorAction Continue - Get-Content 'C:\ProgramData\opk\providers' -ErrorAction Continue - Get-Content 'C:\ProgramData\opk\auth_id' -ErrorAction Continue - if: always() - - - name: Debug - Dump opkssh logs - run: | - Get-Content 'C:\ProgramData\opk\logs\opkssh.log' -ErrorAction Continue - if: always() - - - name: Debug - Dump sshd logs - run: | - Get-Content 'C:\ProgramData\ssh\logs\sshd.log' -ErrorAction Continue - if: always() diff --git a/.github/workflows/gha-windows-arm64.yml b/.github/workflows/gha-windows-arm64.yml deleted file mode 100644 index 41a355ed..00000000 --- a/.github/workflows/gha-windows-arm64.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: Test GitHub Provider Windows ARM64 - -on: - push: - -jobs: - build: - name: Test on Windows 11 ARM64 - runs-on: windows-11-arm - permissions: - id-token: write - contents: read - timeout-minutes: 15 - - steps: - - name: Install OpenSSH Server - run: | - Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 - - - name: Create default OpenSSH config files - run: | - Start-Service sshd - Start-Sleep -Seconds 2 - Stop-Service sshd - - - name: Enable OpenSSH Server logs - run: | - $sshdConfig = "$env:ProgramData\ssh\sshd_config" - (Get-Content $sshdConfig -Raw).Replace('#SyslogFacility AUTH', 'SyslogFacility LOCAL0').Replace('#LogLevel INFO', 'LogLevel Debug3') | - Set-Content $sshdConfig - - - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - persist-credentials: false - - - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 - with: - go-version-file: 'go.mod' - - - name: Install dependencies - run: go mod download - - - name: Build opkssh - run: go build -v -o opkssh.exe - - - name: Install opkssh with local binary - run: | - powershell -ExecutionPolicy Bypass -File "scripts/windows/Install-OpksshServer.ps1" ` - -InstallFrom "$PWD\opkssh.exe" ` - -NoSshdRestart ` - -AuthCmdUser 'opksshuser' ` - -Verbose - - - name: Add GitHub provider to opkssh configuration - run: | - $providersPath = 'C:\ProgramData\opk\providers' - if ((Get-Content -Path $providersPath -Raw) -notmatch "`r?`n$") { - Add-Content -Path $providersPath -Value '' - } - Add-Content -Path $providersPath -Value 'https://token.actions.githubusercontent.com github oidc' - - - name: Add current repository to policy - run: | - & 'C:\Program Files\opkssh\opkssh.exe' add runneradmin "repo:${env:GITHUB_REPOSITORY}:ref:${env:GITHUB_REF}" https://token.actions.githubusercontent.com - - - name: Start SSH service - run: Start-Service sshd - - - name: Test SSH connection without opkssh (should fail) - run: | - ssh -o StrictHostKeyChecking=no -o PasswordAuthentication=no runneradmin@localhost dir - continue-on-error: true - - - name: Login with GitHub OIDC - run: | - & 'C:\Program Files\opkssh\opkssh.exe' login github --print-id-token - - - name: Test SSH connection with opkssh (should pass) - run: | - ssh -o StrictHostKeyChecking=no -o PasswordAuthentication=no runneradmin@localhost dir - - - name: Debug - Dump opkssh config - run: | - Get-ChildItem 'C:\ProgramData\opk' -Recurse -ErrorAction Continue - Get-Content 'C:\ProgramData\opk\providers' -ErrorAction Continue - Get-Content 'C:\ProgramData\opk\auth_id' -ErrorAction Continue - if: always() - - - name: Debug - Dump opkssh logs - run: | - Get-Content 'C:\ProgramData\opk\logs\opkssh.log' -ErrorAction Continue - if: always() - - - name: Debug - Dump sshd logs - run: | - Get-Content 'C:\ProgramData\ssh\logs\sshd.log' -ErrorAction Continue - if: always() diff --git a/.github/workflows/gha-windows.yml b/.github/workflows/gha-windows.yml index 79d59ab0..92882148 100644 --- a/.github/workflows/gha-windows.yml +++ b/.github/workflows/gha-windows.yml @@ -1,16 +1,33 @@ -name: Test GitHub Provider Windows Server 2022 +name: Test GitHub Provider Windows on: push: jobs: build: - name: Test on Windows Server 2022 - runs-on: windows-2022 + name: Test on ${{ matrix.name }} + runs-on: ${{ matrix.runner }} permissions: id-token: write contents: read - timeout-minutes: 10 + timeout-minutes: ${{ matrix.timeout }} + + strategy: + fail-fast: false + matrix: + include: + - name: Windows Server 2022 + runner: windows-2022 + timeout: 10 + cache: true + - name: Windows Server 2025 + runner: windows-2025 + timeout: 10 + cache: true + - name: Windows 11 ARM64 + runner: windows-11-arm + timeout: 15 + cache: false steps: - name: Install OpenSSH Server @@ -35,6 +52,7 @@ jobs: persist-credentials: false - name: Cache Go modules + if: matrix.cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | From 4eefaa33c90cfbfd673c3f79a9406065976266b9 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sun, 29 Mar 2026 20:04:48 -0300 Subject: [PATCH 13/21] gha-windows: add PATH verification for install/uninstall - Verify system PATH contains opkssh install dir after install - Add uninstall step after SSH tests complete - Verify system PATH no longer contains opkssh dir after uninstall --- .github/workflows/gha-windows.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/.github/workflows/gha-windows.yml b/.github/workflows/gha-windows.yml index 92882148..62ba3fb3 100644 --- a/.github/workflows/gha-windows.yml +++ b/.github/workflows/gha-windows.yml @@ -81,6 +81,18 @@ jobs: -AuthCmdUser 'opksshuser' ` -Verbose + - name: Verify system PATH after install + run: | + $installDir = 'C:\Program Files\opkssh' + $regPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' + $systemPath = (Get-ItemProperty -Path $regPath -Name Path).Path + $folders = $systemPath -split [IO.Path]::PathSeparator + $found = $folders | Where-Object { $_.TrimEnd([IO.Path]::DirectorySeparatorChar) -eq $installDir } + if (-not $found) { + throw "FAIL: '$installDir' was NOT found in the system PATH after install" + } + Write-Host "PASS: '$installDir' is in the system PATH after install" + - name: Add GitHub provider to opkssh configuration run: | $providersPath = 'C:\ProgramData\opk\providers' @@ -109,6 +121,25 @@ jobs: run: | ssh -o StrictHostKeyChecking=no -o PasswordAuthentication=no runneradmin@localhost dir + - name: Uninstall opkssh + run: | + powershell -ExecutionPolicy Bypass -File "scripts/windows/Uninstall-OpksshServer.ps1" ` + -Force ` + -NoSshdRestart ` + -Verbose + + - name: Verify system PATH after uninstall + run: | + $installDir = 'C:\Program Files\opkssh' + $regPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' + $systemPath = (Get-ItemProperty -Path $regPath -Name Path).Path + $folders = $systemPath -split [IO.Path]::PathSeparator + $found = $folders | Where-Object { $_.TrimEnd([IO.Path]::DirectorySeparatorChar) -eq $installDir } + if ($found) { + throw "FAIL: '$installDir' is still in the system PATH after uninstall" + } + Write-Host "PASS: '$installDir' was correctly removed from the system PATH after uninstall" + - name: Debug - Dump opkssh config run: | Get-ChildItem 'C:\ProgramData\opk' -Recurse -ErrorAction Continue From fa4c45a54548b3e1aa47f76c3d4612127762b13d Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sun, 29 Mar 2026 20:24:26 -0300 Subject: [PATCH 14/21] gha-windows: fix timeout, update cache action, remove exit code annotation - Move debug dump steps before uninstall to prevent Get-ChildItem from traversing C:\ProgramData\docker\windowsfilter after opk dir is removed (was causing 7+ minute hang and timeout on Windows Server 2022) - Update actions/cache from v4.3.0 to v5.0.4 (Node.js 24 support) - Handle expected SSH failure in-script instead of continue-on-error to eliminate exit code 1 annotation --- .github/workflows/gha-windows.yml | 41 ++++++++++++++++--------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/.github/workflows/gha-windows.yml b/.github/workflows/gha-windows.yml index 62ba3fb3..ed72c2dd 100644 --- a/.github/workflows/gha-windows.yml +++ b/.github/workflows/gha-windows.yml @@ -53,7 +53,7 @@ jobs: - name: Cache Go modules if: matrix.cache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | ~/go/pkg/mod @@ -110,8 +110,9 @@ jobs: - name: Test SSH connection without opkssh (should fail) run: | - ssh -o StrictHostKeyChecking=no -o PasswordAuthentication=no runneradmin@localhost dir - continue-on-error: true + ssh -o StrictHostKeyChecking=no -o PasswordAuthentication=no runneradmin@localhost dir 2>&1 + if ($LASTEXITCODE -eq 0) { throw "SSH should have failed but succeeded" } + Write-Host "SSH correctly rejected without opkssh (exit code: $LASTEXITCODE)" - name: Login with GitHub OIDC run: | @@ -121,6 +122,23 @@ jobs: run: | ssh -o StrictHostKeyChecking=no -o PasswordAuthentication=no runneradmin@localhost dir + - name: Debug - Dump opkssh config + run: | + Get-ChildItem 'C:\ProgramData\opk' -Recurse -ErrorAction Continue + Get-Content 'C:\ProgramData\opk\providers' -ErrorAction Continue + Get-Content 'C:\ProgramData\opk\auth_id' -ErrorAction Continue + if: always() + + - name: Debug - Dump opkssh logs + run: | + Get-Content 'C:\ProgramData\opk\logs\opkssh.log' -ErrorAction Continue + if: always() + + - name: Debug - Dump sshd logs + run: | + Get-Content 'C:\ProgramData\ssh\logs\sshd.log' -ErrorAction Continue + if: always() + - name: Uninstall opkssh run: | powershell -ExecutionPolicy Bypass -File "scripts/windows/Uninstall-OpksshServer.ps1" ` @@ -139,20 +157,3 @@ jobs: throw "FAIL: '$installDir' is still in the system PATH after uninstall" } Write-Host "PASS: '$installDir' was correctly removed from the system PATH after uninstall" - - - name: Debug - Dump opkssh config - run: | - Get-ChildItem 'C:\ProgramData\opk' -Recurse -ErrorAction Continue - Get-Content 'C:\ProgramData\opk\providers' -ErrorAction Continue - Get-Content 'C:\ProgramData\opk\auth_id' -ErrorAction Continue - if: always() - - - name: Debug - Dump opkssh logs - run: | - Get-Content 'C:\ProgramData\opk\logs\opkssh.log' -ErrorAction Continue - if: always() - - - name: Debug - Dump sshd logs - run: | - Get-Content 'C:\ProgramData\ssh\logs\sshd.log' -ErrorAction Continue - if: always() From 7dba4de77a83e808b85a1cebbd2b78e71d4e6425 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sun, 29 Mar 2026 20:37:49 -0300 Subject: [PATCH 15/21] gha-windows: fix expected SSH failure step Reset exit code after checking expected SSH failure, since GitHub Actions pwsh shell wrapper checks LASTEXITCODE at script end. --- .github/workflows/gha-windows.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/gha-windows.yml b/.github/workflows/gha-windows.yml index ed72c2dd..911f12ad 100644 --- a/.github/workflows/gha-windows.yml +++ b/.github/workflows/gha-windows.yml @@ -113,6 +113,7 @@ jobs: ssh -o StrictHostKeyChecking=no -o PasswordAuthentication=no runneradmin@localhost dir 2>&1 if ($LASTEXITCODE -eq 0) { throw "SSH should have failed but succeeded" } Write-Host "SSH correctly rejected without opkssh (exit code: $LASTEXITCODE)" + exit 0 - name: Login with GitHub OIDC run: | From c3a8a36d261ddd85e972af302e6700447cae70df Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sun, 29 Mar 2026 21:22:10 -0300 Subject: [PATCH 16/21] gha-windows: improve PATH handling during OpenSSH Server installation --- scripts/windows/Install-OpksshServer.ps1 | 33 +++++++++++++++++++----- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/scripts/windows/Install-OpksshServer.ps1 b/scripts/windows/Install-OpksshServer.ps1 index 0b717628..b624e513 100644 --- a/scripts/windows/Install-OpksshServer.ps1 +++ b/scripts/windows/Install-OpksshServer.ps1 @@ -822,8 +822,22 @@ function Add-OpksshToPath { $newPath = [string]::Join([IO.Path]::PathSeparator, $orderedPathFolders) $key.SetValue('Path', $newPath, 'ExpandString') + # Broadcast WM_SETTINGCHANGE so that Explorer (and new console windows) pick up the change + if (-not ('Win32.NativeMethods' -as [type])) { + Add-Type -Namespace Win32 -Name NativeMethods -MemberDefinition @' + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] + public static extern IntPtr SendMessageTimeout( + IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, + uint fuFlags, uint uTimeout, out UIntPtr lpdwResult); +'@ + } + $HWND_BROADCAST = [IntPtr]0xffff + $WM_SETTINGCHANGE = 0x1a + $result = [UIntPtr]::Zero + [Win32.NativeMethods]::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result) | Out-Null + Write-Verbose " Broadcast WM_SETTINGCHANGE to notify running processes" + Write-Log " Added to system PATH: $InstallDir" -Level Success - Write-Warning "You may need to restart your PowerShell session for PATH changes to take effect" return $true } finally { if ($null -ne $key) { @@ -1017,8 +1031,17 @@ function Install-OpksshServer { Write-Host " sshd_config updated" -ForegroundColor Green Write-Host "" - # Step 10: Restart sshd service - Write-Host "[10/11] Restarting OpenSSH Server..." -ForegroundColor Yellow + # Step 10: Add to PATH + # Must happen BEFORE restarting sshd so that the service process + # picks up the new PATH. Older OpenSSH versions (8.x, shipped with + # Windows Server 2022) cache the system environment at service start; + # SSH sessions inherit that cached PATH. + Write-Host "[10/11] Adding to system PATH..." -ForegroundColor Yellow + Add-OpksshToPath -InstallDir $InstallDir | Out-Null + Write-Host "" + + # Step 11: Restart sshd service and log + Write-Host "[11/11] Restarting OpenSSH Server..." -ForegroundColor Yellow Restart-SshdService -NoRestart $NoSshdRestart | Out-Null if (-not $NoSshdRestart) { Write-Host " Service restarted" -ForegroundColor Green @@ -1027,10 +1050,6 @@ function Install-OpksshServer { } Write-Host "" - # Step 11: Add to PATH and log - Write-Host "[11/11] Finalizing installation..." -ForegroundColor Yellow - Add-OpksshToPath -InstallDir $InstallDir | Out-Null - $logPath = Join-Path $ConfigPath "logs\opkssh-install.log" Write-InstallationLog -LogPath $logPath ` -BinaryPath $binaryPath ` From d9a0d35f87a56d9a7da784f4713091dd29562afe Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sun, 29 Mar 2026 22:01:45 -0300 Subject: [PATCH 17/21] fix: Handle UTF-8 BOM in config file parsing Windows PowerShell 5.1's Set-Content -Encoding UTF8 writes a UTF-8 BOM (EF BB BF) which caused 'wrong number of arguments' errors when parsing the providers file. Fix 1: Strip UTF-8 BOM in NewTable() for robust config file parsing. Fix 2: Use [System.IO.File]::WriteAllText() in the installer to write UTF-8 without BOM, avoiding the issue at the source. --- policy/files/table.go | 4 ++++ policy/files/table_test.go | 9 +++++++++ scripts/windows/Install-OpksshServer.ps1 | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/policy/files/table.go b/policy/files/table.go index fd071810..efcc0fb8 100644 --- a/policy/files/table.go +++ b/policy/files/table.go @@ -29,6 +29,10 @@ type Table struct { // NewTable creates a new Table from the given content. func NewTable(content []byte) *Table { + // Strip UTF-8 BOM if present + if len(content) >= 3 && content[0] == 0xEF && content[1] == 0xBB && content[2] == 0xBF { + content = content[3:] + } table := [][]string{} rows := strings.Split(string(content), "\n") for _, row := range rows { diff --git a/policy/files/table_test.go b/policy/files/table_test.go index acabb3ef..585cac45 100644 --- a/policy/files/table_test.go +++ b/policy/files/table_test.go @@ -78,6 +78,15 @@ func TestToTable(t *testing.T) { reverse: "https://accounts.google.com 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com 24h\n" + "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096ce0a3-5e72-4da8-9c86-12924b294a01 24h\n", }, + { + name: "input with UTF-8 BOM", + input: "\xEF\xBB\xBF# Issuer Client-ID expiration-policy\n" + + "https://accounts.google.com 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com 24h\n", + output: [][]string{ + {"https://accounts.google.com", "206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com", "24h"}, + }, + reverse: "https://accounts.google.com 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com 24h\n", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/scripts/windows/Install-OpksshServer.ps1 b/scripts/windows/Install-OpksshServer.ps1 index b624e513..1146ee4e 100644 --- a/scripts/windows/Install-OpksshServer.ps1 +++ b/scripts/windows/Install-OpksshServer.ps1 @@ -562,7 +562,7 @@ https://issuer.hello.coop app_xejobTKEsDNSRd5vofKB2iay_2rN 24h "@ if ($PSCmdlet.ShouldProcess($providersPath, "Create providers file")) { - Set-Content -Path $providersPath -Value $providersContent -NoNewline -Encoding UTF8 + [System.IO.File]::WriteAllText($providersPath, $providersContent, [System.Text.UTF8Encoding]::new($false)) Write-Verbose " Created file: providers" } } else { From 8bc5801593c35e35b2bdbdcd43d7e7f4f0fc4a9f Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Mon, 30 Mar 2026 18:18:56 -0300 Subject: [PATCH 18/21] Address Copilot review comments on PR #480 - Remove -AuthCmdUser parameter from installer; hard-code 'opksshuser' to match Go-side permissions model (addresses comments on lines 103, 190) - Fix readhome_windows.go SID error message to use actual resolved SID via ConvertSidToString instead of userObj.Uid (addresses comment on line 92) - Remove per-user Windows policy from docs/config.md since home policy is not yet supported on Windows (addresses comment on line 125) - Add Go integration tests step to Windows CI entry; Docker-based tests skip on Windows with t.Skip (addresses comment on line 175) --- .github/workflows/ci.yml | 8 +-- commands/readhome_windows.go | 5 +- docs/config.md | 6 ++- scripts/windows/Install-OpksshServer.ps1 | 68 +++++++++--------------- test/integration/add_test.go | 5 ++ test/integration/openssh_version_test.go | 5 ++ test/integration/ssh_test.go | 5 ++ 7 files changed, 52 insertions(+), 50 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5a692fc..58729de6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,7 +156,6 @@ jobs: with: persist-credentials: false - name: Install Go - if: matrix.os != 'windows' uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version-file: 'go.mod' @@ -164,12 +163,15 @@ jobs: if: matrix.os != 'windows' uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Install dependencies - if: matrix.os != 'windows' run: go mod download - name: Run integration tests (Linux) if: matrix.os != 'windows' run: go test -tags=integration ./test/integration -timeout=15m -count=1 -parallel=2 -v - - name: Run integration tests (Windows) + - name: Run integration tests (Windows - Go) + if: matrix.os == 'windows' + shell: pwsh + run: go test -tags=integration ./test/integration -timeout=15m -count=1 -parallel=2 -v + - name: Run integration tests (Windows - Pester) if: matrix.os == 'windows' shell: pwsh run: Invoke-Pester -Path scripts/windows/test -Output Detailed diff --git a/commands/readhome_windows.go b/commands/readhome_windows.go index 5a38ee5e..05a0751f 100644 --- a/commands/readhome_windows.go +++ b/commands/readhome_windows.go @@ -79,7 +79,10 @@ func ReadHome(username string) ([]byte, error) { } if !bytes.Equal(report.OwnerSID, expectedSID) { // Convert SIDs to string form for a readable error message - expectedSIDStr := userObj.Uid // user.User.Uid is the SID on Windows + expectedSIDStr, sidErr := files.ConvertSidToString(expectedSID) + if sidErr != nil { + expectedSIDStr = userObj.Uid // fallback to user.User.Uid (SID on Windows) + } actualSIDStr := report.OwnerSIDStr if actualSIDStr == "" { actualSIDStr = "" diff --git a/docs/config.md b/docs/config.md index 81af5771..0a592ace 100644 --- a/docs/config.md +++ b/docs/config.md @@ -121,7 +121,7 @@ https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096c https://gitlab.com 8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923 24h ``` -## Authorized identities files: `/etc/opk/auth_id` and `/home/{USER}/.opk/auth_id` (Linux) or `%ProgramData%\opk\auth_id` and `{USERPROFILE}\.opk\auth_id` (Windows) +## Authorized identities files: `/etc/opk/auth_id` and `/home/{USER}/.opk/auth_id` (Linux) or `%ProgramData%\opk\auth_id` (Windows) These files contain the policies to determine which identities can assume what linux user accounts. Linux user accounts are typically referred to in SSH as *principals* and we use this terminology. @@ -186,7 +186,9 @@ sudo chmod 640 /etc/opk/auth_id **Note:** The permissions for the system authorized identity file are different than the home authorized identity file. -### Home authorized identity file `/home/{USER}/.opk/auth_id` (Linux) or `{USERPROFILE}\.opk\auth_id` (Windows) +### Home authorized identity file `/home/{USER}/.opk/auth_id` (Linux) + +> **Note:** Per-user home policy is not yet supported on Windows. This is user/principal specific permissions. That is, if it is in `/home/alice/.opk/auth_id` it can only specify who can assume the principal `alice` on the server. diff --git a/scripts/windows/Install-OpksshServer.ps1 b/scripts/windows/Install-OpksshServer.ps1 index 1146ee4e..a84198da 100644 --- a/scripts/windows/Install-OpksshServer.ps1 +++ b/scripts/windows/Install-OpksshServer.ps1 @@ -33,11 +33,6 @@ Directory where opkssh configuration files will be created. Default is "C:\ProgramData\opk". -.PARAMETER AuthCmdUser - User account that will run the AuthorizedKeysCommand. - Default is "opksshuser" (a dedicated local user account). - Using "System" (LocalSystem) is not supported. - .PARAMETER GitHubRepo GitHub repository to download from (format: owner/repo). Default is "openpubkey/opkssh". @@ -57,11 +52,6 @@ Install a specific version with verbose output. -.EXAMPLE - .\Install-OpksshServer.ps1 -AuthCmdUser "opksshuser" - - Install using a dedicated local user account instead of System. - .NOTES Author: OpenPubkey Project Requires: Windows Server 2022 (or Windows 10/11 with OpenSSH Server installed) @@ -96,9 +86,6 @@ param( [Parameter(HelpMessage="Configuration directory path")] [string]$ConfigPath = "C:\ProgramData\opk", - [Parameter(HelpMessage="User account for AuthorizedKeysCommand")] - [string]$AuthCmdUser = "opksshuser", - [Parameter(HelpMessage="GitHub repository (owner/repo)")] [string]$GitHubRepo = "openpubkey/opkssh" ) @@ -184,11 +171,6 @@ function Test-Prerequisites { } Write-Verbose " sshd service found: $($sshdService.Status)" - # Reject LocalSystem account usage - if ($AuthCmdUser -eq "System") { - throw "Using LocalSystem as AuthorizedKeysCommandUser is not supported. Use 'opksshuser' instead." - } - # Verify sshd_config exists $sshdConfigPath = "C:\ProgramData\ssh\sshd_config" if (-not (Test-Path $sshdConfigPath)) { @@ -575,23 +557,21 @@ https://issuer.hello.coop app_xejobTKEsDNSRd5vofKB2iay_2rN 24h } # Grant opksshuser read access to config directory (inherited by files) - if ($AuthCmdUser -ne "System") { - $configAcl = Get-Acl $ConfigPath - $readRule = New-Object System.Security.AccessControl.FileSystemAccessRule( - $AuthCmdUser, "ReadAndExecute", "ContainerInherit,ObjectInherit", "None", "Allow") - $configAcl.AddAccessRule($readRule) - Set-Acl -Path $ConfigPath -AclObject $configAcl - Write-Verbose " Granted $AuthCmdUser read access to $ConfigPath" + $configAcl = Get-Acl $ConfigPath + $readRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $AuthCmdUser, "ReadAndExecute", "ContainerInherit,ObjectInherit", "None", "Allow") + $configAcl.AddAccessRule($readRule) + Set-Acl -Path $ConfigPath -AclObject $configAcl + Write-Verbose " Granted $AuthCmdUser read access to $ConfigPath" - # Grant opksshuser write access to logs directory - $logsDir = Join-Path $ConfigPath "logs" - $logsAcl = Get-Acl $logsDir - $writeRule = New-Object System.Security.AccessControl.FileSystemAccessRule( - $AuthCmdUser, "Modify", "ContainerInherit,ObjectInherit", "None", "Allow") - $logsAcl.AddAccessRule($writeRule) - Set-Acl -Path $logsDir -AclObject $logsAcl - Write-Verbose " Granted $AuthCmdUser write access to $logsDir" - } + # Grant opksshuser write access to logs directory + $logsDir = Join-Path $ConfigPath "logs" + $logsAcl = Get-Acl $logsDir + $writeRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $AuthCmdUser, "Modify", "ContainerInherit,ObjectInherit", "None", "Allow") + $logsAcl.AddAccessRule($writeRule) + Set-Acl -Path $logsDir -AclObject $logsAcl + Write-Verbose " Granted $AuthCmdUser write access to $logsDir" Write-Log "Configuration created successfully" -Level Success return $true @@ -897,7 +877,6 @@ Binary Path: $BinaryPath Install Version Parameter: $($InstallParams.InstallVersion) Local Install File: $($InstallParams.InstallFrom) SSH Restarted: $(-not $InstallParams.NoRestart) -Auth Command User: $($InstallParams.AuthCmdUser) Configuration Path: $($InstallParams.ConfigPath) PowerShell Version: $($PSVersionTable.PSVersion) OS Version: $([System.Environment]::OSVersion.VersionString) @@ -928,6 +907,10 @@ function Install-OpksshServer { param() $ErrorActionPreference = 'Stop' + + # The AuthorizedKeysCommand user is always 'opksshuser'. + # This matches the Go-side permissions model which hard-codes this user. + $AuthCmdUser = "opksshuser" try { Write-Host "" @@ -970,14 +953,12 @@ function Install-OpksshServer { Write-Host " Installed: $binaryPath" -ForegroundColor Green # Grant opksshuser read+execute access to the binary directory - if ($AuthCmdUser -ne "System") { - $binAcl = Get-Acl $InstallDir - $execRule = New-Object System.Security.AccessControl.FileSystemAccessRule( - $AuthCmdUser, "ReadAndExecute", "ContainerInherit,ObjectInherit", "None", "Allow") - $binAcl.AddAccessRule($execRule) - Set-Acl -Path $InstallDir -AclObject $binAcl - Write-Verbose " Granted $AuthCmdUser read+execute access to $InstallDir" - } + $binAcl = Get-Acl $InstallDir + $execRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $AuthCmdUser, "ReadAndExecute", "ContainerInherit,ObjectInherit", "None", "Allow") + $binAcl.AddAccessRule($execRule) + Set-Acl -Path $InstallDir -AclObject $binAcl + Write-Verbose " Granted $AuthCmdUser read+execute access to $InstallDir" Write-Host "" @@ -1057,7 +1038,6 @@ function Install-OpksshServer { InstallVersion = $InstallVersion InstallFrom = $InstallFrom NoRestart = $NoSshdRestart - AuthCmdUser = $AuthCmdUser ConfigPath = $ConfigPath } Write-Host " Installation log: $logPath" -ForegroundColor Green diff --git a/test/integration/add_test.go b/test/integration/add_test.go index 9b780866..733863d5 100644 --- a/test/integration/add_test.go +++ b/test/integration/add_test.go @@ -22,6 +22,7 @@ import ( "fmt" "io" "path" + "runtime" "strings" "testing" @@ -84,6 +85,10 @@ func CreateAuthIdFile(t *testing.T, container testcontainers.Container, filePath } func TestAdd(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Docker-based integration tests are not supported on Windows") + } + // Test adding an allowed principal to an opkssh policy issuer := fmt.Sprintf("http://oidc.local:%s/", issuerPort) diff --git a/test/integration/openssh_version_test.go b/test/integration/openssh_version_test.go index 21a537d5..3f96caac 100644 --- a/test/integration/openssh_version_test.go +++ b/test/integration/openssh_version_test.go @@ -21,6 +21,7 @@ package integration import ( "fmt" "io" + "runtime" "strings" "testing" "unicode" @@ -39,6 +40,10 @@ type OpenSSHVersionTest struct { } func TestOpenSSHVersionDetection(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Docker-based integration tests are not supported on Windows") + } + tests := []OpenSSHVersionTest{ { name: "Debian/Ubuntu", diff --git a/test/integration/ssh_test.go b/test/integration/ssh_test.go index 30729627..fb2acdff 100644 --- a/test/integration/ssh_test.go +++ b/test/integration/ssh_test.go @@ -33,6 +33,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strconv" "strings" "testing" @@ -228,6 +229,10 @@ func createZitadelOPKSshProvider(oidcContainerMappedPort int, authCallbackServer // Test cleanup functions are registered to cleanup the containers after the // test finishes. func spawnTestContainers(t *testing.T) (oidcContainer *testprovider.ExampleOpContainer, authCallbackRedirectPort int, serverContainer *ssh_server.SshServerContainer) { + if runtime.GOOS == "windows" { + t.Skip("Docker-based integration tests are not supported on Windows") + } + // Create local Docker network so that the example OIDC container and the // linux container (with SSH) can communicate with each other newNetwork, err := testcontainers.GenericNetwork(TestCtx, testcontainers.GenericNetworkRequest{ From ecfbee4811e52a452321f5459b545efb68e3214d Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Mon, 30 Mar 2026 18:31:21 -0300 Subject: [PATCH 19/21] fix: Remove -AuthCmdUser from GHA workflow invocation The parameter was removed from Install-OpksshServer.ps1 in the previous commit but the gha-windows.yml workflow still passed it. --- .github/workflows/gha-windows.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/gha-windows.yml b/.github/workflows/gha-windows.yml index 911f12ad..76a33cb3 100644 --- a/.github/workflows/gha-windows.yml +++ b/.github/workflows/gha-windows.yml @@ -78,7 +78,6 @@ jobs: powershell -ExecutionPolicy Bypass -File "scripts/windows/Install-OpksshServer.ps1" ` -InstallFrom "$PWD\opkssh.exe" ` -NoSshdRestart ` - -AuthCmdUser 'opksshuser' ` -Verbose - name: Verify system PATH after install From df51adeb552a6863ca68e5b08b1fd82983bdb770 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Mon, 30 Mar 2026 19:44:11 -0300 Subject: [PATCH 20/21] ci: Run only Pester tests for Windows in integration test matrix The Go integration tests are Docker-based and skip on Windows (6 of 8 tests were SKIP). Remove the misleading 'Run integration tests (Windows - Go)' step. The Windows matrix entry now runs only the Pester tests, which are the actual Windows-specific integration tests. Also skip Install Go and Install dependencies for the Windows entry since Pester tests don't need them. --- .github/workflows/ci.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58729de6..c5a692fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,6 +156,7 @@ jobs: with: persist-credentials: false - name: Install Go + if: matrix.os != 'windows' uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version-file: 'go.mod' @@ -163,15 +164,12 @@ jobs: if: matrix.os != 'windows' uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Install dependencies + if: matrix.os != 'windows' run: go mod download - name: Run integration tests (Linux) if: matrix.os != 'windows' run: go test -tags=integration ./test/integration -timeout=15m -count=1 -parallel=2 -v - - name: Run integration tests (Windows - Go) - if: matrix.os == 'windows' - shell: pwsh - run: go test -tags=integration ./test/integration -timeout=15m -count=1 -parallel=2 -v - - name: Run integration tests (Windows - Pester) + - name: Run integration tests (Windows) if: matrix.os == 'windows' shell: pwsh run: Invoke-Pester -Path scripts/windows/test -Output Detailed From 29f3b2af3c7789c2530b22bff3b12d5b23d7cdad Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Tue, 31 Mar 2026 14:29:30 -0300 Subject: [PATCH 21/21] ci: Move Pester tests to test-windows job, clean up integration test matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove Windows from the integration test matrix — the Docker-based integration tests don't apply to Windows, so the matrix entry required if-guards on every step. Instead, add Pester tests as a step in the existing test-windows job. This keeps the integration test matrix clean (no conditionals) and the Windows Pester tests still run in CI under 'Windows Tests'. --- .github/workflows/ci.yml | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5a692fc..dfd53610 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,9 @@ jobs: - name: Run unit tests shell: pwsh run: go test ./... + - name: Run Pester tests + shell: pwsh + run: Invoke-Pester -Path scripts/windows/test -Output Detailed # Check that binary can be built natively on Windows ARM64 build-windows-arm64: @@ -145,9 +148,6 @@ jobs: exclude: - runs_on: ubuntu-24.04-arm os: arch - include: - - runs_on: windows-latest - os: windows env: OS_TYPE: ${{ matrix.os }} steps: @@ -156,23 +156,15 @@ jobs: with: persist-credentials: false - name: Install Go - if: matrix.os != 'windows' uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version-file: 'go.mod' - name: Install Docker - if: matrix.os != 'windows' uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Install dependencies - if: matrix.os != 'windows' run: go mod download - - name: Run integration tests (Linux) - if: matrix.os != 'windows' + - name: Run integration tests run: go test -tags=integration ./test/integration -timeout=15m -count=1 -parallel=2 -v - - name: Run integration tests (Windows) - if: matrix.os == 'windows' - shell: pwsh - run: Invoke-Pester -Path scripts/windows/test -Output Detailed lint-scripts: name: Shell Scripts Lint & Test runs-on: ubuntu-24.04