11 Commits

Author SHA1 Message Date
43827d0d9e docs: add Matrix community chat links
All checks were successful
PR Tests / Lint, Build & Test (pull_request) Successful in 5m14s
Security Scan / Go Vulnerability Check (pull_request) Successful in 4m48s
2026-04-05 18:28:26 +02:00
1d60ba2999 fix: add nodejs to security-scan container for checkout action
Some checks failed
PR Tests / Lint, Build & Test (pull_request) Has been cancelled
Security Scan / Go Vulnerability Check (pull_request) Has been cancelled
2026-04-05 18:25:46 +02:00
268955732a fix: use net.JoinHostPort for IPv6-compatible address formatting
Some checks failed
PR Tests / Lint, Build & Test (pull_request) Successful in 5m19s
Security Scan / Go Vulnerability Check (pull_request) Failing after 17s
2026-04-05 18:18:26 +02:00
1083b54fb9 fix: add nodejs to alpine container for actions/checkout
Some checks failed
PR Tests / Lint, Build & Test (pull_request) Failing after 4m29s
Security Scan / Go Vulnerability Check (pull_request) Failing after 13s
2026-04-05 18:07:56 +02:00
45baaf8db8 docs: add secure key generation guide for session and encryption keys
Some checks failed
PR Tests / Lint, Build & Test (pull_request) Failing after 45s
Security Scan / Go Vulnerability Check (pull_request) Failing after 14s
2026-04-05 17:58:02 +02:00
fbff33d201 docs: update feedback link to GitHub Issues 2026-04-05 17:53:59 +02:00
e994f13526 refactor: rename KEYWARDEN_ADMIN_USER/EMAIL env vars to KEYWARDEN_OWNER_USER/EMAIL
- Rename environment variables to match the owner role
- Add backward compatibility: legacy ADMIN vars still accepted with deprecation warning
- Update .env.example, docs and quickstart accordingly
2026-04-05 17:45:43 +02:00
775186038e feat: use prebuilt image, bind mount and custom network in docker-compose 2026-04-05 17:41:06 +02:00
6cbcb272d0 fix(ci): handle empty tag in workflow_dispatch trigger
- Fallback to latest git tag when github.event.release.tag_name is empty
- Add fetch-depth: 0 to checkout step so git tags are available
- Fail with clear error if no tag exists at all
2026-04-05 17:19:05 +02:00
91e4758bb8 ci: add workflow_dispatch trigger to release-docker workflow 2026-04-05 17:15:09 +02:00
7a448034e4 fix(ci): remove protocol prefix from Docker image tags 2026-04-05 17:13:30 +02:00
16 changed files with 197 additions and 86 deletions

View File

@@ -10,8 +10,8 @@
# --- Application ---
KEYWARDEN_PORT=8080
KEYWARDEN_ADMIN_USER=admin
KEYWARDEN_ADMIN_EMAIL=admin@keywarden.local
KEYWARDEN_OWNER_USER=admin
KEYWARDEN_OWNER_EMAIL=admin@keywarden.local
KEYWARDEN_SESSION_KEY=change-me-to-a-random-string
KEYWARDEN_ENCRYPTION_KEY=change-me-encryption-key-32chars

View File

@@ -15,7 +15,7 @@ jobs:
steps:
- name: Install build dependencies
run: apk add --no-cache gcc musl-dev sqlite-dev git
run: apk add --no-cache gcc musl-dev sqlite-dev git nodejs
- name: Checkout code
uses: actions/checkout@v4

View File

@@ -6,6 +6,7 @@ name: Release Docker Image
on:
release:
types: [published]
workflow_dispatch:
env:
IMAGE_NAME: keywarden
@@ -18,16 +19,30 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract version from tag
id: version
run: |
# Release tag is e.g. v0.1.0
# Release tag from release event, or latest git tag for workflow_dispatch
TAG="${{ github.event.release.tag_name }}"
if [ -z "$TAG" ]; then
TAG="$(git describe --tags --abbrev=0 2>/dev/null || echo '')"
fi
if [ -z "$TAG" ]; then
echo "::error::No tag found. Please create a release or tag first."
exit 1
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
# Strip 'v' prefix for docker tag if needed
VERSION="${TAG#v}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
# Strip protocol (https://) from REGISTRY_URL for Docker tags
REGISTRY="${{ vars.REGISTRY_URL }}"
REGISTRY="${REGISTRY#https://}"
REGISTRY="${REGISTRY#http://}"
echo "registry=${REGISTRY}" >> "$GITHUB_OUTPUT"
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
@@ -45,8 +60,8 @@ jobs:
context: .
push: true
tags: |
${{ vars.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/${{ env.IMAGE_NAME }}:latest
${{ vars.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.tag }}
${{ steps.version.outputs.registry }}/${{ secrets.REGISTRY_USER }}/${{ env.IMAGE_NAME }}:latest
${{ steps.version.outputs.registry }}/${{ secrets.REGISTRY_USER }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.tag }}
labels: |
org.opencontainers.image.title=Keywarden
org.opencontainers.image.description=Centralized SSH Key Management and Deployment

View File

@@ -15,7 +15,7 @@ jobs:
steps:
- name: Install dependencies
run: apk add --no-cache git gcc musl-dev sqlite-dev
run: apk add --no-cache git gcc musl-dev sqlite-dev nodejs
- name: Checkout code
uses: actions/checkout@v4

3
.gitignore vendored
View File

@@ -21,6 +21,9 @@ vendor/
*.swo
*~
# AI workspace
.ki-workspace/
# OS
.DS_Store
Thumbs.db

View File

@@ -10,7 +10,7 @@
>
> - **Do NOT expose this application directly to the public internet.** Use it only in trusted, private networks.
> - The software may contain bugs, incomplete features, or security issues.
> - **Your feedback is valuable!** If you discover bugs or have suggestions, please report them at [git.techniverse.net/scriptos/keywarden](https://git.techniverse.net/scriptos/keywarden). Every report helps improve the project.
> - **Your feedback is valuable!** If you discover bugs or have suggestions, please open an [Issue on GitHub](https://github.com/pscriptos/keywarden/issues). Every report helps improve the project.
---
@@ -44,14 +44,19 @@ git clone https://git.techniverse.net/scriptos/keywarden.git
cd keywarden
```
Create a `.env` file:
Create a `.env` file and generate two separate cryptographically secure keys:
```env
KEYWARDEN_SESSION_KEY=your-random-session-key-at-least-32-characters
KEYWARDEN_ENCRYPTION_KEY=your-random-encryption-key-at-least-32-chars
```bash
# Generate keys (run twice, once per key):
openssl rand -base64 48
```
> **Important:** Change both keys to unique random strings. The encryption key protects all stored SSH private keys — if lost, they cannot be recovered.
```env
KEYWARDEN_SESSION_KEY=<first generated string>
KEYWARDEN_ENCRYPTION_KEY=<second generated string>
```
> **Important:** Change both keys to unique random strings. The encryption key protects all stored SSH private keys — if lost, they cannot be recovered. See the [Quick Start Guide](docs/quickstart.md) for more options to generate secure keys.
### 2. Start
@@ -113,4 +118,27 @@ For detailed documentation, see the [docs/](docs/README.md) folder:
Keywarden is licensed under the [GNU Affero General Public License v3.0 (AGPL-3.0-or-later)](LICENSE).
© 2026 Patrick Asmus ([scriptos](https://git.techniverse.net/scriptos))
© 2026 Patrick Asmus ([scriptos](https://git.techniverse.net/scriptos))
---
## Community
Join the **Keywarden Matrix chat** to discuss the project, ask questions, or share feedback:
[![Matrix](https://img.shields.io/badge/Matrix-%23keywarden%3Atechniverse.net-blue?logo=matrix)](https://matrix.to/#/#keywarden:techniverse.net)
➡️ [#keywarden:techniverse.net](https://matrix.to/#/#keywarden:techniverse.net)
---
## Repository & Mirror
| | URL |
|---|---|
| **Primary (Gitea)** | [git.techniverse.net/scriptos/keywarden](https://git.techniverse.net/scriptos/keywarden) |
| **Mirror (GitHub)** | [github.com/pscriptos/keywarden](https://github.com/pscriptos/keywarden) |
The **primary repository** is hosted on Gitea. The GitHub repository is a read-only mirror.
**Bug reports & feature requests:** Please open an [Issue on GitHub](https://github.com/pscriptos/keywarden/issues) — registration on the Gitea instance is currently closed.

View File

@@ -60,17 +60,18 @@ func main() {
mailSvc := mail.NewService(cfg)
// Create default owner if no users exist (password is auto-generated)
adminUser := getEnv("KEYWARDEN_ADMIN_USER", "admin")
adminEmail := getEnv("KEYWARDEN_ADMIN_EMAIL", "admin@keywarden.local")
// Support legacy KEYWARDEN_ADMIN_USER / KEYWARDEN_ADMIN_EMAIL for existing installations
ownerUser := getEnvWithLegacy("KEYWARDEN_OWNER_USER", "KEYWARDEN_ADMIN_USER", "admin")
ownerEmail := getEnvWithLegacy("KEYWARDEN_OWNER_EMAIL", "KEYWARDEN_ADMIN_EMAIL", "admin@keywarden.local")
created, generatedPass, err := authSvc.EnsureAdmin(adminUser, adminEmail)
created, generatedPass, err := authSvc.EnsureAdmin(ownerUser, ownerEmail)
if err != nil {
logging.Fatal("Failed to create admin user: %v", err)
logging.Fatal("Failed to create owner user: %v", err)
}
if created {
logging.Info("════════════════════════════════════════════════════════════")
logging.Info(" Initial owner account created")
logging.Info(" Username: %s", adminUser)
logging.Info(" Username: %s", ownerUser)
logging.Info(" Password: %s", generatedPass)
logging.Info(" Please change this password after first login!")
logging.Info("════════════════════════════════════════════════════════════")
@@ -137,3 +138,17 @@ func getEnv(key, fallback string) string {
}
return fallback
}
// getEnvWithLegacy checks the primary key first, then falls back to the
// legacy (deprecated) key, and finally to the default value. This ensures
// existing installations that still use the old variable name keep working.
func getEnvWithLegacy(primary, legacy, fallback string) string {
if val, ok := os.LookupEnv(primary); ok {
return val
}
if val, ok := os.LookupEnv(legacy); ok {
logging.Warn("Environment variable %s is deprecated, please rename to %s", legacy, primary)
return val
}
return fallback
}

View File

@@ -1,14 +1,17 @@
services:
keywarden:
build: .
image: git.techniverse.net/scriptos/keywarden:latest
container_name: keywarden
restart: unless-stopped
ports:
- "${KEYWARDEN_PORT:-8080}:${KEYWARDEN_PORT:-8080}"
volumes:
- keywarden_data:/data
- ./data:/data
env_file:
- .env
networks:
keywarden_net:
ipv4_address: 172.23.64.10
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${KEYWARDEN_PORT:-8080}/api/health"]
interval: 30s
@@ -16,6 +19,12 @@ services:
start_period: 10s
retries: 3
volumes:
keywarden_data:
driver: local
networks:
keywarden_net:
name: keywarden.dockernetwork.local
driver: bridge
ipam:
config:
- subnet: 172.23.64.0/24
gateway: 172.23.64.1
ip_range: 172.23.64.128/25

View File

@@ -44,6 +44,14 @@ Keywarden provides a clean web UI to generate, import, and securely store SSH ke
---
## Community
Have questions, ideas, or feedback? Join the Keywarden Matrix chat room:
➡️ [#keywarden:techniverse.net](https://matrix.to/#/#keywarden:techniverse.net)
---
## License
Keywarden is licensed under the [GNU Affero General Public License v3.0 (AGPL-3.0-or-later)](../LICENSE).

View File

@@ -82,12 +82,11 @@ In addition to the application-level backup, you can also back up the Docker vol
# Stop the container
docker compose down
# Backup the volume
docker run --rm -v keywarden_keywarden_data:/data -v $(pwd):/backup \
alpine tar czf /backup/keywarden-volume-backup.tar.gz /data
# Backup the data directory
tar czf keywarden-volume-backup.tar.gz ./data
# Start the container
docker compose up -d
```
This captures the raw SQLite database file and all data files. Note that this backup is **not encrypted** — protect it accordingly.
Since data is stored in the `./data` bind mount on the host, you can back it up directly without needing a helper container. Note that this backup is **not encrypted** — protect it accordingly.

View File

@@ -144,6 +144,14 @@ Test files are co-located with their packages (e.g., `auth_test.go`, `config_tes
- Error messages should be lowercase
- Log messages use the structured logging package (`logging.Info`, `logging.Debug`, etc.)
## Community & Communication
For questions, discussions, and coordination with other contributors, join the Matrix chat:
➡️ [#keywarden:techniverse.net](https://matrix.to/#/#keywarden:techniverse.net)
---
## License
All contributions must be compatible with the [AGPL-3.0-or-later](../LICENSE) license.

View File

@@ -34,15 +34,18 @@ A complete `docker-compose.yml`:
```yaml
services:
keywarden:
build: .
image: git.techniverse.net/scriptos/keywarden:latest
container_name: keywarden
restart: unless-stopped
ports:
- "${KEYWARDEN_PORT:-8080}:${KEYWARDEN_PORT:-8080}"
volumes:
- keywarden_data:/data
- ./data:/data
env_file:
- .env
networks:
keywarden_net:
ipv4_address: 172.23.64.10
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${KEYWARDEN_PORT:-8080}/api/health"]
interval: 30s
@@ -50,9 +53,15 @@ services:
start_period: 10s
retries: 3
volumes:
keywarden_data:
driver: local
networks:
keywarden_net:
name: keywarden.dockernetwork.local
driver: bridge
ipam:
config:
- subnet: 172.23.64.0/24
gateway: 172.23.64.1
ip_range: 172.23.64.128/25
```
### Environment File (.env)
@@ -68,9 +77,9 @@ KEYWARDEN_ENCRYPTION_KEY=generate-another-random-string-32-chars
KEYWARDEN_PORT=8080
KEYWARDEN_LOG_LEVEL=INFO
# Initial admin (only used on first startup)
KEYWARDEN_ADMIN_USER=admin
KEYWARDEN_ADMIN_EMAIL=admin@example.com
# Initial owner (only used on first startup)
KEYWARDEN_OWNER_USER=admin
KEYWARDEN_OWNER_EMAIL=admin@example.com
# HTTPS / Reverse Proxy
KEYWARDEN_BASE_URL=https://keywarden.example.com
@@ -180,12 +189,9 @@ The Docker HEALTHCHECK is configured automatically in the Dockerfile.
To update Keywarden:
```bash
# Pull latest changes (if building from source)
git pull
# Rebuild and restart
# Pull latest image and restart
docker compose pull
docker compose down
docker compose build --no-cache
docker compose up -d
```

View File

@@ -25,14 +25,16 @@ Complete reference of all configuration options for Keywarden. All settings are
| `KEYWARDEN_RATE_LIMIT_LOGIN` | `10` | Maximum login POST attempts per IP per minute. Set to `0` to disable. |
| `KEYWARDEN_MAX_REQUEST_SIZE` | `10485760` | Maximum request body size in bytes (default: 10 MB). Set to `0` for no limit. |
## Initial Admin Account
## Initial Owner Account
These variables are only used on first startup when no users exist in the database:
| Variable | Default | Description |
|---|---|---|
| `KEYWARDEN_ADMIN_USER` | `admin` | Username for the initial owner account |
| `KEYWARDEN_ADMIN_EMAIL` | `admin@keywarden.local` | Email for the initial owner account |
| `KEYWARDEN_OWNER_USER` | `admin` | Username for the initial owner account |
| `KEYWARDEN_OWNER_EMAIL` | `admin@keywarden.local` | Email for the initial owner account |
> **Note:** The previous variable names `KEYWARDEN_ADMIN_USER` and `KEYWARDEN_ADMIN_EMAIL` are still accepted for backward compatibility but are deprecated. Please update your `.env` file to use the new names.
The initial password is auto-generated (20 characters, alphanumeric) and printed to the startup log. It must be changed on first login.
@@ -74,9 +76,9 @@ KEYWARDEN_ENCRYPTION_KEY=mX9nP2qR4sT6uV8wY0zA1bC3dE5fG7hI
KEYWARDEN_PORT=8080
KEYWARDEN_LOG_LEVEL=INFO
# Initial admin (only used on first startup)
KEYWARDEN_ADMIN_USER=admin
KEYWARDEN_ADMIN_EMAIL=admin@example.com
# Initial owner (only used on first startup)
KEYWARDEN_OWNER_USER=admin
KEYWARDEN_OWNER_EMAIL=admin@example.com
# Reverse proxy / HTTPS
KEYWARDEN_BASE_URL=https://keywarden.example.com

View File

@@ -17,14 +17,29 @@ mkdir keywarden && cd keywarden
Create a `.env` file with at minimum these settings:
Generate two separate, cryptographically secure random strings (minimum 32 characters each):
```bash
# Linux / macOS
openssl rand -base64 48
# Alternative without OpenSSL
head -c 48 /dev/urandom | base64
# Windows (PowerShell)
[Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Max 256 }) -as [byte[]])
```
Each command produces a 64-character Base64 string. Run it **twice** — once for each key — and paste the values below:
```env
# REQUIRED: Change these for security!
KEYWARDEN_SESSION_KEY=your-random-session-key-at-least-32-characters
KEYWARDEN_ENCRYPTION_KEY=your-random-encryption-key-at-least-32-chars
KEYWARDEN_SESSION_KEY=<first generated string>
KEYWARDEN_ENCRYPTION_KEY=<second generated string>
# Optional: Admin credentials (defaults: admin / auto-generated password)
KEYWARDEN_ADMIN_USER=admin
KEYWARDEN_ADMIN_EMAIL=admin@example.com
# Optional: Owner credentials (defaults: admin / auto-generated password)
KEYWARDEN_OWNER_USER=admin
KEYWARDEN_OWNER_EMAIL=admin@example.com
# Optional: Port (default: 8080)
KEYWARDEN_PORT=8080
@@ -43,33 +58,28 @@ services:
ports:
- "${KEYWARDEN_PORT:-8080}:${KEYWARDEN_PORT:-8080}"
volumes:
- keywarden_data:/data
- ./data:/data
env_file:
- .env
networks:
keywarden_net:
ipv4_address: 172.23.64.10
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${KEYWARDEN_PORT:-8080}/api/health"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
volumes:
keywarden_data:
driver: local
```
Or, to build from source:
```yaml
services:
keywarden:
build: .
container_name: keywarden
restart: unless-stopped
ports:
- "${KEYWARDEN_PORT:-8080}:${KEYWARDEN_PORT:-8080}"
volumes:
- keywarden_data:/data
env_file:
- .env
volumes:
keywarden_data:
driver: local
networks:
keywarden_net:
name: keywarden.dockernetwork.local
driver: bridge
ipam:
config:
- subnet: 172.23.64.0/24
gateway: 172.23.64.1
ip_range: 172.23.64.128/25
```
## 4. Start Keywarden

View File

@@ -213,3 +213,11 @@ Every HTTP request is logged with:
- Response time
- Client IP address
- Username (if authenticated)
---
## Still Need Help?
If your issue isn't covered here, join the community Matrix chat to ask for help:
➡️ [#keywarden:techniverse.net](https://matrix.to/#/#keywarden:techniverse.net)

View File

@@ -46,7 +46,7 @@ func (s *Service) DeployKey(key *models.SSHKey, server *models.Server, authPriva
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
addr := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", server.Port))
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("connection failed: %v", err))
@@ -92,7 +92,7 @@ func (s *Service) DeployKeyWithPassword(key *models.SSHKey, server *models.Serve
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
addr := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", server.Port))
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("connection failed: %v", err))
@@ -140,7 +140,7 @@ func (s *Service) RemoveKey(key *models.SSHKey, server *models.Server, authPriva
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
addr := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", server.Port))
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return fmt.Errorf("failed to connect to server for key removal: %w", err)
@@ -193,7 +193,7 @@ func (s *Service) DeployKeyToUser(key *models.SSHKey, server *models.Server, aut
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
addr := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", server.Port))
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("connection failed: %v", err))
@@ -296,7 +296,7 @@ func (s *Service) DeployKeyToUserWithPassword(key *models.SSHKey, server *models
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
addr := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", server.Port))
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("connection failed: %v", err))
@@ -401,7 +401,7 @@ func (s *Service) RemoveKeyFromUser(key *models.SSHKey, server *models.Server, a
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
addr := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", server.Port))
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return fmt.Errorf("failed to connect to server for key removal: %w", err)
@@ -456,7 +456,7 @@ func (s *Service) RemoveSystemUser(key *models.SSHKey, server *models.Server, au
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
addr := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", server.Port))
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return fmt.Errorf("failed to connect to server for user removal: %w", err)
@@ -526,7 +526,7 @@ func (s *Service) DisableSystemUser(key *models.SSHKey, server *models.Server, a
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
addr := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", server.Port))
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return fmt.Errorf("failed to connect to server for user disable: %w", err)
@@ -577,7 +577,7 @@ func (s *Service) DisableSystemUser(key *models.SSHKey, server *models.Server, a
// TestConnection tests TCP connectivity to a server (port reachable)
func (s *Service) TestConnection(hostname string, port int) error {
logging.Debug("Testing TCP connection to %s:%d", hostname, port)
addr := fmt.Sprintf("%s:%d", hostname, port)
addr := net.JoinHostPort(hostname, fmt.Sprintf("%d", port))
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
return fmt.Errorf("cannot reach %s: %w", addr, err)
@@ -604,7 +604,7 @@ func (s *Service) TestSSHAuth(hostname string, port int, username string, privat
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", hostname, port)
addr := net.JoinHostPort(hostname, fmt.Sprintf("%d", port))
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return fmt.Errorf("SSH authentication failed: %w", err)