Skip to content

Commit 821cf55

Browse files
committed
feat: allow using existing SSH key instead of creating a new one
Add existing_ssh_key_name config option to networking.ssh that lets users specify the name of an existing SSH key in the Hetzner project. When set, the key is validated (exists in Hetzner, fingerprint matches local public key) and used instead of creating a new one. The key is also not deleted on cluster deletion. Bump version to 2.5.0.
1 parent 4cf28a2 commit 821cf55

12 files changed

Lines changed: 84 additions & 21 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ brew install vitobotta/tap/hetzner_k3s
147147

148148
**Linux binary (amd64):**
149149
```bash
150-
wget https://github.com/vitobotta/hetzner-k3s/releases/download/v2.4.9/hetzner-k3s-linux-amd64
150+
wget https://github.com/vitobotta/hetzner-k3s/releases/download/v2.5.0/hetzner-k3s-linux-amd64
151151
chmod +x hetzner-k3s-linux-amd64
152152
sudo mv hetzner-k3s-linux-amd64 /usr/local/bin/hetzner-k3s
153153
```

docs/Creating_a_cluster.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ networking:
2222
use_private_ip: false # set to true to connect to nodes via their private IPs
2323
public_key_path: "~/.ssh/id_ed25519.pub"
2424
private_key_path: "~/.ssh/id_ed25519"
25+
# existing_ssh_key_name: "my-existing-key" # optional: use an existing SSH key in Hetzner instead of creating a new one
2526
allowed_networks:
2627
ssh:
2728
- 0.0.0.0/0
@@ -369,7 +370,7 @@ The `create` command can be run multiple times with the same configuration witho
369370
- The `networking`.`allowed_networks`.`api` setting specifies which networks can access the Kubernetes API. This works with both single-master and multi-master clusters, but only when `create_load_balancer_for_the_kubernetes_api` is disabled. If the API load balancer is enabled, Hetzner's firewalls do not yet support load balancers, so the API would be exposed to the public internet regardless of the allowed networks configuration.
370371
- If you enable autoscaling for a nodepool, avoid changing this setting later, as it can cause issues with the autoscaler.
371372
- Autoscaling is only supported with Ubuntu or other default images, not snapshots.
372-
- If you already have SSH keys in your Hetzner project, it's best to use a different key for your cluster—unless there's already a key with the same name and fingerprint as the one in your config file. Hetzner doesn't allow two keys with the same fingerprint in one project. So if you've already added the key from your config but under a different name, Hetzner won't let you add it again. In that case, hetzner-k3s will skip creating the key and won't inject any SSH key into the cluster nodes. Without an SSH key, Hetzner sets up the nodes with password login instead. That means you'll get an email for each node with its root password. Managing several nodes this way can get tricky. To avoid this, just use a new keypair for your cluster—unless the project already has a key that matches both the name and fingerprint in your config.
373+
- If you already have SSH keys in your Hetzner project, it's best to use a different key for your cluster—unless there's already a key with the same name and fingerprint as the one in your config file. Hetzner doesn't allow two keys with the same fingerprint in one project. So if you've already added the key from your config but under a different name, Hetzner won't let you add it again. In that case, hetzner-k3s will skip creating the key and won't inject any SSH key into the cluster nodes. Without an SSH key, Hetzner sets up the nodes with password login instead. That means you'll get an email for each node with its root password. Managing several nodes this way can get tricky. To avoid this, you can either use a new keypair for your cluster, or set `existing_ssh_key_name` in the SSH config to reference the existing key by name. When using `existing_ssh_key_name`, hetzner-k3s will use the specified key instead of creating a new one, and it won't delete the key when you delete the cluster.
373374
- SSH keys with passphrases can only be used if you set `networking`.`ssh`.`use_ssh_agent` to `true` and use an SSH agent to access your key. For example, on macOS, you can start an agent like this:
374375

375376
```bash

docs/Installation.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,14 @@ If you prefer not to use Homebrew, install the required dependencies first:
4040

4141
**Apple Silicon:**
4242
```bash
43-
wget https://github.com/vitobotta/hetzner-k3s/releases/download/v2.4.9/hetzner-k3s-macos-arm64
43+
wget https://github.com/vitobotta/hetzner-k3s/releases/download/v2.5.0/hetzner-k3s-macos-arm64
4444
chmod +x hetzner-k3s-macos-arm64
4545
sudo mv hetzner-k3s-macos-arm64 /usr/local/bin/hetzner-k3s
4646
```
4747

4848
**Intel:**
4949
```bash
50-
wget https://github.com/vitobotta/hetzner-k3s/releases/download/v2.4.9/hetzner-k3s-macos-amd64
50+
wget https://github.com/vitobotta/hetzner-k3s/releases/download/v2.5.0/hetzner-k3s-macos-amd64
5151
chmod +x hetzner-k3s-macos-amd64
5252
sudo mv hetzner-k3s-macos-amd64 /usr/local/bin/hetzner-k3s
5353
```
@@ -67,15 +67,15 @@ brew install vitobotta/tap/hetzner_k3s
6767
### amd64 (x86_64)
6868

6969
```bash
70-
wget https://github.com/vitobotta/hetzner-k3s/releases/download/v2.4.9/hetzner-k3s-linux-amd64
70+
wget https://github.com/vitobotta/hetzner-k3s/releases/download/v2.5.0/hetzner-k3s-linux-amd64
7171
chmod +x hetzner-k3s-linux-amd64
7272
sudo mv hetzner-k3s-linux-amd64 /usr/local/bin/hetzner-k3s
7373
```
7474

7575
### arm64 (ARM)
7676

7777
```bash
78-
wget https://github.com/vitobotta/hetzner-k3s/releases/download/v2.4.9/hetzner-k3s-linux-arm64
78+
wget https://github.com/vitobotta/hetzner-k3s/releases/download/v2.5.0/hetzner-k3s-linux-arm64
7979
chmod +x hetzner-k3s-linux-arm64
8080
sudo mv hetzner-k3s-linux-arm64 /usr/local/bin/hetzner-k3s
8181
```

src/cluster/delete.cr

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,9 @@ class Cluster::Delete
118118
private def delete_ssh_key
119119
Hetzner::SSHKey::Delete.new(
120120
hetzner_client: hetzner_client,
121-
ssh_key_name: settings.cluster_name,
122-
public_ssh_key_path: settings.networking.ssh.public_key_path
121+
ssh_key_name: settings.networking.ssh.ssh_key_name(settings.cluster_name),
122+
public_ssh_key_path: settings.networking.ssh.public_key_path,
123+
using_existing_ssh_key: settings.networking.ssh.using_existing_ssh_key?
123124
).run
124125
end
125126

src/configuration/models/networking_config/ssh.cr

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class Configuration::Models::NetworkingConfig::SSH
77
getter use_private_ip : Bool = false
88
getter private_key_path : String = "~/.ssh/id_rsa"
99
getter public_key_path : String = "~/.ssh/id_rsa.pub"
10+
getter existing_ssh_key_name : String = ""
1011

1112
def initialize
1213
end
@@ -19,6 +20,14 @@ class Configuration::Models::NetworkingConfig::SSH
1920
absolute_path(@public_key_path)
2021
end
2122

23+
def ssh_key_name(cluster_name : String) : String
24+
@existing_ssh_key_name.empty? ? cluster_name : @existing_ssh_key_name
25+
end
26+
27+
def using_existing_ssh_key? : Bool
28+
!@existing_ssh_key_name.empty?
29+
end
30+
2231
private def absolute_path(path)
2332
home_dir = ENV["HOME"]? || raise "HOME environment variable not set"
2433
File.expand_path(path.sub("~/", "#{home_dir}/"))

src/configuration/validators/autoscaler_ssh_key.cr

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,19 @@ class Configuration::Validators::AutoscalerSSHKey
1313
def validate
1414
return unless autoscaling_in_use?
1515

16+
ssh_key_name = settings.networking.ssh.ssh_key_name(settings.cluster_name)
17+
1618
begin
17-
existing_ssh_key = Hetzner::SSHKey::Find.new(hetzner_client, settings.cluster_name, settings.networking.ssh.public_key_path).run
19+
existing_ssh_key = Hetzner::SSHKey::Find.new(hetzner_client, ssh_key_name, settings.networking.ssh.public_key_path).run
1820
rescue ex
1921
errors << "Unable to verify SSH key for autoscaler: #{ex.message}"
2022
return
2123
end
2224

2325
return unless existing_ssh_key
24-
return if existing_ssh_key.name == settings.cluster_name
26+
return if existing_ssh_key.name == ssh_key_name
2527

26-
errors << "Cluster autoscaler requires an SSH key named '#{settings.cluster_name}' in Hetzner. A key with the same fingerprint exists as '#{existing_ssh_key.name}', so hetzner-k3s will not create '#{settings.cluster_name}'. Autoscaled nodes will be created without SSH keys. Rename or delete the existing key, or change cluster_name."
28+
errors << "Cluster autoscaler requires an SSH key named '#{ssh_key_name}' in Hetzner. A key with the same fingerprint exists as '#{existing_ssh_key.name}', so hetzner-k3s will not create '#{ssh_key_name}'. Autoscaled nodes will be created without SSH keys. Rename or delete the existing key, or use the existing_ssh_key_name setting to reference it."
2729
end
2830

2931
private def autoscaling_in_use?

src/configuration/validators/networking_config/ssh.cr

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ class Configuration::Validators::NetworkingConfig::SSH
1515
def validate
1616
validate_path(errors, ssh.private_key_path, "private")
1717
validate_path(errors, ssh.public_key_path, "public")
18-
validate_ssh_key_fingerprint(errors, hetzner_client, cluster_name)
18+
19+
if ssh.using_existing_ssh_key?
20+
validate_existing_ssh_key(errors, hetzner_client)
21+
else
22+
validate_ssh_key_fingerprint(errors, hetzner_client, cluster_name)
23+
end
1924
end
2025

2126
private def validate_path(errors, path, key_type)
@@ -36,13 +41,41 @@ class Configuration::Validators::NetworkingConfig::SSH
3641
end
3742
end
3843

44+
private def validate_existing_ssh_key(errors, hetzner_client)
45+
unless File.exists?(ssh.public_key_path)
46+
errors << "public_key_path is required to validate the fingerprint of existing_ssh_key_name '#{ssh.existing_ssh_key_name}'"
47+
return
48+
end
49+
50+
begin
51+
existing_ssh_key = Hetzner::SSHKey::Find.new(hetzner_client.not_nil!, ssh.existing_ssh_key_name, ssh.public_key_path).run
52+
rescue e
53+
errors << "Unable to verify existing SSH key '#{ssh.existing_ssh_key_name}': #{e.message}"
54+
return
55+
end
56+
57+
unless existing_ssh_key
58+
errors << "The SSH key '#{ssh.existing_ssh_key_name}' specified in existing_ssh_key_name does not exist in Hetzner"
59+
return
60+
end
61+
62+
unless existing_ssh_key.name == ssh.existing_ssh_key_name
63+
errors << "SSH key mismatch: the key '#{ssh.existing_ssh_key_name}' was not found by name in Hetzner. A key with the same fingerprint exists as '#{existing_ssh_key.name}', but existing_ssh_key_name must match the key name exactly."
64+
return
65+
end
66+
67+
config_fingerprint = Util::SSH.calculate_fingerprint(ssh.public_key_path)
68+
69+
if existing_ssh_key.fingerprint != config_fingerprint
70+
errors << "SSH key fingerprint mismatch: the existing key '#{ssh.existing_ssh_key_name}' in Hetzner has a different fingerprint than the local public key at '#{ssh.public_key_path}'. Please ensure the public_key_path points to the corresponding public key."
71+
end
72+
end
73+
3974
private def find_existing_ssh_key(hetzner_client, cluster_name)
4075
return nil unless hetzner_client && cluster_name
4176
ssh_key_finder = Hetzner::SSHKey::Find.new(hetzner_client, cluster_name, ssh.public_key_path)
4277
ssh_key_finder.run
4378
rescue e
44-
# If we can't fetch existing SSH keys, we'll skip validation
45-
# This allows the create command to proceed and handle any API errors
4679
nil
4780
end
4881
end

src/hetzner-k3s.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ require "./cluster/run"
1010

1111
module Hetzner::K3s
1212
class CLI < Admiral::Command
13-
VERSION = "2.4.9"
13+
VERSION = "2.5.0"
1414

1515
def self.print_banner
1616
puts "╭─────────────────────────────────────────╮".colorize(:green)

src/hetzner/ssh_key/create.cr

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class Hetzner::SSHKey::Create
1212
getter ssh_key_finder : Hetzner::SSHKey::Find
1313

1414
def initialize(@hetzner_client, @settings)
15-
@ssh_key_name = settings.cluster_name
15+
@ssh_key_name = settings.networking.ssh.ssh_key_name(settings.cluster_name)
1616
@public_ssh_key_path = settings.networking.ssh.public_key_path
1717
@ssh_key_finder = Hetzner::SSHKey::Find.new(hetzner_client, ssh_key_name, public_ssh_key_path)
1818
end
@@ -22,6 +22,11 @@ class Hetzner::SSHKey::Create
2222

2323
return ssh_key if ssh_key
2424

25+
if settings.networking.ssh.using_existing_ssh_key?
26+
STDERR.puts "[#{default_log_prefix}] Existing SSH key '#{ssh_key_name}' not found in Hetzner"
27+
exit 1
28+
end
29+
2530
log_line "Creating SSH key..."
2631
create_ssh_key
2732
log_line "...SSH key created"

src/hetzner/ssh_key/delete.cr

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ class Hetzner::SSHKey::Delete
88
getter hetzner_client : Hetzner::Client
99
getter ssh_key_name : String
1010
getter ssh_key_finder : Hetzner::SSHKey::Find
11+
getter using_existing_ssh_key : Bool
1112

12-
def initialize(@hetzner_client, @ssh_key_name, public_ssh_key_path)
13+
def initialize(@hetzner_client, @ssh_key_name, public_ssh_key_path, @using_existing_ssh_key = false)
1314
@ssh_key_finder = Hetzner::SSHKey::Find.new(hetzner_client, ssh_key_name, public_ssh_key_path)
1415
end
1516

1617
def run
18+
return handle_existing_ssh_key_skip if using_existing_ssh_key
19+
1720
ssh_key = ssh_key_finder.run
1821

1922
return handle_no_ssh_key unless ssh_key
@@ -23,6 +26,11 @@ class Hetzner::SSHKey::Delete
2326
ssh_key_name
2427
end
2528

29+
private def handle_existing_ssh_key_skip
30+
log_line "Using existing SSH key '#{ssh_key_name}', skipping delete"
31+
ssh_key_name
32+
end
33+
2634
private def handle_no_ssh_key
2735
log_line "SSH key does not exist, skipping delete"
2836
ssh_key_name

0 commit comments

Comments
 (0)