Skip to content

Commit

Permalink
feat: ssh AuthorizedKey type (#398)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tinyblargon authored Jan 28, 2025
1 parent 4e26f5d commit 69a4086
Show file tree
Hide file tree
Showing 9 changed files with 461 additions and 107 deletions.
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ require (
github.com/joho/godotenv v1.5.1
github.com/spf13/cobra v1.8.0
github.com/stretchr/testify v1.8.2
golang.org/x/crypto v0.32.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/sys v0.29.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Expand Down
38 changes: 1 addition & 37 deletions proxmox/config_qemu_cloudinit.go
Original file line number Diff line number Diff line change
@@ -1,57 +1,21 @@
package proxmox

import (
"crypto"
"errors"
"net"
"net/netip"
"net/url"
"regexp"
"strconv"
"strings"

"github.com/Telmate/proxmox-api-go/internal/util"
)

var regexMultipleNewlineEncoded = regexp.MustCompile(`(%0A)+`)
var regexMultipleSpaces = regexp.MustCompile(`\s+`)
var regexMultipleSpacesEncoded = regexp.MustCompile(`(%20)+`)

// URL encodes the ssh keys
func sshKeyUrlDecode(encodedKeys string) (keys []crypto.PublicKey) {
encodedKeys = regexMultipleSpacesEncoded.ReplaceAllString(encodedKeys, "%20")
encodedKeys = strings.TrimSuffix(encodedKeys, "%0A")
encodedKeys = regexMultipleNewlineEncoded.ReplaceAllString(encodedKeys, "%0A")
encodedKeys = strings.ReplaceAll(encodedKeys, "%2B", "+")
encodedKeys = strings.ReplaceAll(encodedKeys, "%40", "@")
encodedKeys = strings.ReplaceAll(encodedKeys, "%3D", "=")
encodedKeys = strings.ReplaceAll(encodedKeys, "%3A", ":")
encodedKeys = strings.ReplaceAll(encodedKeys, "%20", " ")
encodedKeys = strings.ReplaceAll(encodedKeys, "%2F", "/")
for _, key := range strings.Split(encodedKeys, "%0A") {
keys = append(keys, key)
}
return
}

// URL encodes the ssh keys
func sshKeyUrlEncode(keys []crypto.PublicKey) (encodedKeys string) {
for _, key := range keys {
tmpKey := regexMultipleSpaces.ReplaceAllString(key.(string), " ")
tmpKey = url.PathEscape(tmpKey + "\n")
tmpKey = strings.ReplaceAll(tmpKey, "+", "%2B")
tmpKey = strings.ReplaceAll(tmpKey, "@", "%40")
tmpKey = strings.ReplaceAll(tmpKey, "=", "%3D")
encodedKeys += strings.ReplaceAll(tmpKey, ":", "%3A")
}
return
}

type CloudInit struct {
Custom *CloudInitCustom `json:"cicustom,omitempty"`
DNS *GuestDNS `json:"dns,omitempty"`
NetworkInterfaces CloudInitNetworkInterfaces `json:"ipconfig,omitempty"`
PublicSSHkeys *[]crypto.PublicKey `json:"sshkeys,omitempty"`
PublicSSHkeys *[]AuthorizedKey `json:"sshkeys,omitempty"`
UpgradePackages *bool `json:"ciupgrade,omitempty"`
UserPassword *string `json:"userpassword,omitempty"` // TODO custom type
Username *string `json:"username,omitempty"` // TODO custom type
Expand Down
36 changes: 0 additions & 36 deletions proxmox/config_qemu_cloudinit_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package proxmox

import (
"crypto"
"errors"
"testing"

Expand All @@ -10,41 +9,6 @@ import (
"github.com/stretchr/testify/require"
)

func Test_sshKeyUrlDecode(t *testing.T) {
tests := []struct {
name string
input string
output []crypto.PublicKey
}{
{name: "Decode",
input: test_data_qemu.PublicKey_Encoded_Input(),
output: test_data_qemu.PublicKey_Decoded_Output()},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require.Equal(t, test.output, sshKeyUrlDecode(test.input))
})
}
}

// Test the encoding logic to encode the ssh keys
func Test_sshKeyUrlEncode(t *testing.T) {
tests := []struct {
name string
input []crypto.PublicKey
output string
}{
{name: "Encode",
input: test_data_qemu.PublicKey_Decoded_Input(),
output: test_data_qemu.PublicKey_Encoded_Output()},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require.Equal(t, test.output, sshKeyUrlEncode(test.input))
})
}
}

func Test_CloudInit_Validate(t *testing.T) {
tests := []struct {
name string
Expand Down
54 changes: 38 additions & 16 deletions proxmox/config_qemu_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package proxmox

import (
"crypto"
"errors"
"net"
"net/netip"
Expand Down Expand Up @@ -39,13 +38,28 @@ func Test_ConfigQemu_mapToAPI(t *testing.T) {
Address: util.Pointer(IPv6CIDR("2001:0db8:abcd::/48")),
Gateway: util.Pointer(IPv6Address("2001:0db8:abcd::1"))}}
}
parseIP := func(rawIP string) (ip netip.Addr) {
ip, _ = netip.ParseAddr(rawIP)
return
parseIP := func(rawIP string) netip.Addr {
ip, err := netip.ParseAddr(rawIP)
failPanic(err)
return ip
}
parseMAC := func(rawMAC string) (mac net.HardwareAddr) {
mac, _ = net.ParseMAC(rawMAC)
return
parseMAC := func(rawMAC string) net.HardwareAddr {
mac, err := net.ParseMAC(rawMAC)
failPanic(err)
return mac
}
parsePublicKey := func(rawKey string) AuthorizedKey {
key := &AuthorizedKey{}
failError(key.Parse([]byte(rawKey)))
return *key
}
publicKeys := func() *[]AuthorizedKey {
data := test_data_qemu.PublicKey_Decoded_Input()
keys := make([]AuthorizedKey, len(data))
for i := range data {
keys[i] = AuthorizedKey{Options: data[i].Options, PublicKey: data[i].PublicKey, Comment: data[i].Comment}
}
return &keys
}
networkInterface := func() QemuNetworkInterface {
return QemuNetworkInterface{
Expand Down Expand Up @@ -462,8 +476,8 @@ func Test_ConfigQemu_mapToAPI(t *testing.T) {
currentConfig: ConfigQemu{CloudInit: &CloudInit{DNS: &GuestDNS{SearchDomain: util.Pointer("example.org")}}},
output: map[string]interface{}{"searchdomain": "example.com"}},
{name: `CloudInit PublicSSHkeys`,
config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Input())}},
currentConfig: ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+0roY6F4yzq5RfA6V2+8gOgKlLOg9RtB1uGyTYvOMU6wxWUXVZP44+XozNxXZK4/MfPjCZLomqv78RlAedIQbqU8l6J9fdrrsRt6NknusE36UqD4HGPLX3Wn7svjSyNRfrjlk5BrBQ26rglLGlRSeD/xWvQ+5jLzzdo5NczszGkE9IQtrmKye7Gq7NQeGkHb1h0yGH7nMQ48WJ6ZKv1JG+GzFb8n4Qoei3zK9zpWxF+0AzF5u/zzCRZ4yU7FtfHgGRBDPze8oe3nVe+aO8MBH2dy8G/BRMXBdjWrSkaT9ZyeaT0k9SMjsCr9DQzUtVSOeqZZokpNU1dVglI+HU0vN test-key"})}},
config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: publicKeys()}},
currentConfig: ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]AuthorizedKey{parsePublicKey("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+0roY6F4yzq5RfA6V2+8gOgKlLOg9RtB1uGyTYvOMU6wxWUXVZP44+XozNxXZK4/MfPjCZLomqv78RlAedIQbqU8l6J9fdrrsRt6NknusE36UqD4HGPLX3Wn7svjSyNRfrjlk5BrBQ26rglLGlRSeD/xWvQ+5jLzzdo5NczszGkE9IQtrmKye7Gq7NQeGkHb1h0yGH7nMQ48WJ6ZKv1JG+GzFb8n4Qoei3zK9zpWxF+0AzF5u/zzCRZ4yU7FtfHgGRBDPze8oe3nVe+aO8MBH2dy8G/BRMXBdjWrSkaT9ZyeaT0k9SMjsCr9DQzUtVSOeqZZokpNU1dVglI+HU0vN test-key")})}},
output: map[string]interface{}{"sshkeys": test_data_qemu.PublicKey_Encoded_Output()}},
{name: `CloudInit UpgradePackages v7`,
version: Version{Major: 7, Minor: 255, Patch: 255},
Expand Down Expand Up @@ -511,7 +525,7 @@ func Test_ConfigQemu_mapToAPI(t *testing.T) {
QemuNetworkInterfaceID19: CloudInitNetworkConfig{},
QemuNetworkInterfaceID31: CloudInitNetworkConfig{
IPv4: &CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR("10.20.4.7/22"))}}},
PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Input()),
PublicSSHkeys: publicKeys(),
UpgradePackages: util.Pointer(false),
UserPassword: util.Pointer("Enter123!"),
Username: util.Pointer("root")}},
Expand Down Expand Up @@ -550,7 +564,7 @@ func Test_ConfigQemu_mapToAPI(t *testing.T) {
QemuNetworkInterfaceID19: CloudInitNetworkConfig{},
QemuNetworkInterfaceID31: CloudInitNetworkConfig{
IPv4: &CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR("10.20.4.7/22"))}}},
PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Input()),
PublicSSHkeys: publicKeys(),
UpgradePackages: util.Pointer(true),
UserPassword: util.Pointer("Enter123!"),
Username: util.Pointer("root")}},
Expand Down Expand Up @@ -607,7 +621,7 @@ func Test_ConfigQemu_mapToAPI(t *testing.T) {
"ipconfig1": "ip=dhcp,ip6=dhcp",
"ipconfig30": "ip=10.20.4.7/22"}},
{name: `CloudInit PublicSSHkeys empty`,
config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{})}},
config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]AuthorizedKey{})}},
output: map[string]interface{}{}},
{name: `CloudInit Username empty`,
config: &ConfigQemu{CloudInit: &CloudInit{Username: util.Pointer("")}},
Expand Down Expand Up @@ -878,8 +892,8 @@ func Test_ConfigQemu_mapToAPI(t *testing.T) {
"ipconfig13": "ip=192.168.56.30/24,gw=192.168.56.1,ip6=auto",
"delete": "ipconfig14"}},
{name: `CloudInit PublicSSHkeys empty`,
config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{})}},
currentConfig: ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]crypto.PublicKey{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+0roY6F4yzq5RfA6V2+8gOgKlLOg9RtB1uGyTYvOMU6wxWUXVZP44+XozNxXZK4/MfPjCZLomqv78RlAedIQbqU8l6J9fdrrsRt6NknusE36UqD4HGPLX3Wn7svjSyNRfrjlk5BrBQ26rglLGlRSeD/xWvQ+5jLzzdo5NczszGkE9IQtrmKye7Gq7NQeGkHb1h0yGH7nMQ48WJ6ZKv1JG+GzFb8n4Qoei3zK9zpWxF+0AzF5u/zzCRZ4yU7FtfHgGRBDPze8oe3nVe+aO8MBH2dy8G/BRMXBdjWrSkaT9ZyeaT0k9SMjsCr9DQzUtVSOeqZZokpNU1dVglI+HU0vN test-key"})}},
config: &ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]AuthorizedKey{})}},
currentConfig: ConfigQemu{CloudInit: &CloudInit{PublicSSHkeys: util.Pointer([]AuthorizedKey{parsePublicKey("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+0roY6F4yzq5RfA6V2+8gOgKlLOg9RtB1uGyTYvOMU6wxWUXVZP44+XozNxXZK4/MfPjCZLomqv78RlAedIQbqU8l6J9fdrrsRt6NknusE36UqD4HGPLX3Wn7svjSyNRfrjlk5BrBQ26rglLGlRSeD/xWvQ+5jLzzdo5NczszGkE9IQtrmKye7Gq7NQeGkHb1h0yGH7nMQ48WJ6ZKv1JG+GzFb8n4Qoei3zK9zpWxF+0AzF5u/zzCRZ4yU7FtfHgGRBDPze8oe3nVe+aO8MBH2dy8G/BRMXBdjWrSkaT9ZyeaT0k9SMjsCr9DQzUtVSOeqZZokpNU1dVglI+HU0vN test-key")})}},
output: map[string]interface{}{"delete": "sshkeys"}},
{name: `CloudInit Username empty`,
config: &ConfigQemu{CloudInit: &CloudInit{Username: util.Pointer("")}},
Expand Down Expand Up @@ -4113,6 +4127,14 @@ func Test_ConfigQemu_mapToStruct(t *testing.T) {
mac, _ = net.ParseMAC(rawMAC)
return
}
publicKeys := func() *[]AuthorizedKey {
rawOutput := test_data_qemu.PublicKey_Decoded_Output()
output := make([]AuthorizedKey, len(rawOutput))
for i := range rawOutput {
output[i] = AuthorizedKey{Options: rawOutput[i].Options, PublicKey: rawOutput[i].PublicKey, Comment: rawOutput[i].Comment}
}
return &output
}
uint1 := uint(1)
uint2 := uint(2)
uint31 := uint(31)
Expand Down Expand Up @@ -4341,7 +4363,7 @@ func Test_ConfigQemu_mapToStruct(t *testing.T) {
IPv6: &CloudInitIPv6Config{DHCP: true}},
QemuNetworkInterfaceID31: CloudInitNetworkConfig{
IPv4: &CloudInitIPv4Config{Address: util.Pointer(IPv4CIDR("10.20.4.7/22"))}}},
PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Output()),
PublicSSHkeys: publicKeys(),
UpgradePackages: util.Pointer(true),
UserPassword: util.Pointer("Enter123!"),
Username: util.Pointer("root")}})},
Expand Down Expand Up @@ -4411,7 +4433,7 @@ func Test_ConfigQemu_mapToStruct(t *testing.T) {
input: map[string]interface{}{"sshkeys": test_data_qemu.PublicKey_Encoded_Input()},
output: baseConfig(ConfigQemu{CloudInit: &CloudInit{
NetworkInterfaces: CloudInitNetworkInterfaces{},
PublicSSHkeys: util.Pointer(test_data_qemu.PublicKey_Decoded_Output())}})},
PublicSSHkeys: publicKeys()}})},
{name: `UpgradePackages`,
input: map[string]interface{}{"ciupgrade": float64(0)},
output: baseConfig(ConfigQemu{CloudInit: &CloudInit{
Expand Down
114 changes: 114 additions & 0 deletions proxmox/type_authorizedkey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package proxmox

import (
"encoding/json"
"errors"
"net/url"
"regexp"
"strconv"
"strings"

"golang.org/x/crypto/ssh"
)

type AuthorizedKey struct {
Options []string
PublicKey ssh.PublicKey
Comment string
}

const (
AuthorizedKey_Error_NilPointer = "authorizedKey pointer is nil"
AuthorizedKey_Error_Invalid = "invalid value for AuthorizedKey"
)

// Parse parses a public key from an authorized_keys file used in OpenSSH according to the sshd(8) manual page.
func (key *AuthorizedKey) Parse(rawKey []byte) error {
if key == nil {
return errors.New(AuthorizedKey_Error_NilPointer)
}
return key.parse_unsafe(rawKey)
}

// Parse the raw key into the AuthorizedKey struct
func (key *AuthorizedKey) parse_unsafe(rawKey []byte) (err error) {
key.PublicKey, key.Comment, key.Options, _, err = ssh.ParseAuthorizedKey(rawKey)
return err
}

// Custom MarshalJSON for AuthorizedKey
func (key AuthorizedKey) MarshalJSON() ([]byte, error) {
// Convert the AuthorizedKey to the OpenSSH format and return it as a JSON string
return json.Marshal(key.String())
}

func (key AuthorizedKey) String() string {
if key.PublicKey == nil {
return ""
}
var options string
if len(key.Options) > 0 {
options = strings.Join(key.Options, ",") + " "
}
tmpKey := string(ssh.MarshalAuthorizedKey(key.PublicKey))
if key.Comment == "" {
return options + tmpKey[:len(tmpKey)-1]
}
comment := regexMultipleSpaces.ReplaceAllString(key.Comment, " ")
if comment == " " {
return options + tmpKey[:len(tmpKey)-1]
}
return options + tmpKey[:len(tmpKey)-1] + " " + comment
}

// Custom UnmarshalJSON for AuthorizedKey
func (key *AuthorizedKey) UnmarshalJSON(data []byte) error {
if len(data) < 2 {
return errors.New(AuthorizedKey_Error_Invalid)
}
// Decode JSON string and handle unescaping
rawString := string(data[1 : len(data)-1]) // Strip surrounding quotes
unescapedString, _ := strconv.Unquote(`"` + rawString + `"`)
return key.parse_unsafe([]byte(unescapedString))
}

const newlineEncoded = "%0A"
const spaceEncoded = "%20"

var regexMultipleNewlineEncoded = regexp.MustCompile(`(%0A)+`)
var regexMultipleSpaces = regexp.MustCompile(`( )+`)
var regexMultipleSpacesEncoded = regexp.MustCompile(`(%20)+`)

// URL encodes the ssh keys
func sshKeyUrlDecode(encodedKeys string) (keys []AuthorizedKey) {
encodedKeys = regexMultipleSpacesEncoded.ReplaceAllString(encodedKeys, spaceEncoded)
encodedKeys = strings.TrimSuffix(encodedKeys, newlineEncoded)
encodedKeys = regexMultipleNewlineEncoded.ReplaceAllString(encodedKeys, newlineEncoded)
encodedKeys = strings.ReplaceAll(encodedKeys, `%2B`, `+`)
encodedKeys = strings.ReplaceAll(encodedKeys, `%40`, `@`)
encodedKeys = strings.ReplaceAll(encodedKeys, `%3D`, `=`)
encodedKeys = strings.ReplaceAll(encodedKeys, `%3A`, `:`)
encodedKeys = strings.ReplaceAll(encodedKeys, `%20`, ` `)
encodedKeys = strings.ReplaceAll(encodedKeys, `%2F`, `/`)
encodedKeys = strings.ReplaceAll(encodedKeys, `%2C`, `,`)
encodedKeys = strings.ReplaceAll(encodedKeys, `%22`, `"`)
rawKeys := strings.Split(encodedKeys, newlineEncoded)
keys = make([]AuthorizedKey, len(rawKeys))
for i := range rawKeys {
keys[i].PublicKey, keys[i].Comment, keys[i].Options, _, _ = ssh.ParseAuthorizedKey([]byte(rawKeys[i]))
}
return
}

// URL encodes the ssh keys
func sshKeyUrlEncode(keys []AuthorizedKey) string {
encodedKeys := make([]string, len(keys))
for i := range keys {
tmpKey := url.PathEscape(keys[i].String())
tmpKey = strings.ReplaceAll(tmpKey, "+", "%2B")
tmpKey = strings.ReplaceAll(tmpKey, "@", "%40")
tmpKey = strings.ReplaceAll(tmpKey, "=", "%3D")
encodedKeys[i] = strings.ReplaceAll(tmpKey, ":", "%3A") + newlineEncoded
}
return strings.Join(encodedKeys, "")
}
Loading

0 comments on commit 69a4086

Please sign in to comment.