Skip to content

Commit c676e32

Browse files
committed
Hot Reload of Credentials (Still Work in Progress)
Fixes #1139
1 parent e165984 commit c676e32

5 files changed

Lines changed: 234 additions & 10 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ hcloud-cloud-controller-manager
88
*.tgz
99
hack/.*
1010
coverage/
11+
.vscode

hcloud/cloud.go

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ package hcloud
1919
import (
2020
"context"
2121
"fmt"
22-
"net/http"
2322
"os"
2423
"strings"
2524
"time"
@@ -71,13 +70,8 @@ func NewCloud(cidr string) (cloudprovider.Interface, error) {
7170
}
7271

7372
opts := []hcloud.ClientOption{
74-
hcloud.WithToken(cfg.HCloudClient.Token),
7573
hcloud.WithApplication("hcloud-cloud-controller", providerVersion),
76-
hcloud.WithHTTPClient(
77-
&http.Client{
78-
Timeout: apiClientTimeout,
79-
},
80-
),
74+
hcloud.WithHTTPClient(newHCloudHTTPClient(apiClientTimeout)),
8175
}
8276

8377
// start metrics server if enabled (enabled by default)
@@ -100,9 +94,7 @@ func NewCloud(cidr string) (cloudprovider.Interface, error) {
10094
c := hrobot.NewBasicAuthClientWithCustomHttpClient(
10195
cfg.Robot.User,
10296
cfg.Robot.Password,
103-
&http.Client{
104-
Timeout: apiClientTimeout,
105-
},
97+
newRobotHTTPClient(apiClientTimeout),
10698
)
10799

108100
robotClient = robot.NewRateLimitedClient(

hcloud/runtime_credentials.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package hcloud
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"time"
7+
8+
"golang.org/x/net/http/httpguts"
9+
10+
"github.com/hetznercloud/hcloud-cloud-controller-manager/internal/config"
11+
)
12+
13+
const invalidAuthorizationTokenError = "authorization token contains invalid characters"
14+
15+
type roundTripperFunc func(*http.Request) (*http.Response, error)
16+
17+
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
18+
return f(req)
19+
}
20+
21+
func newHCloudHTTPClient(timeout time.Duration) *http.Client {
22+
return &http.Client{
23+
Timeout: timeout,
24+
Transport: newHCloudCredentialReloader(nil),
25+
}
26+
}
27+
28+
func newRobotHTTPClient(timeout time.Duration) *http.Client {
29+
return &http.Client{
30+
Timeout: timeout,
31+
Transport: newRobotCredentialReloader(nil),
32+
}
33+
}
34+
35+
func newHCloudCredentialReloader(next http.RoundTripper) http.RoundTripper {
36+
next = transportOrDefault(next)
37+
38+
return roundTripperFunc(func(req *http.Request) (*http.Response, error) {
39+
token, err := config.LookupHCloudToken()
40+
if err != nil {
41+
return nil, err
42+
}
43+
if token != "" && !httpguts.ValidHeaderFieldValue(token) {
44+
return nil, fmt.Errorf(invalidAuthorizationTokenError)
45+
}
46+
47+
cloned := cloneRequest(req)
48+
if token == "" {
49+
cloned.Header.Del("Authorization")
50+
} else {
51+
cloned.Header.Set("Authorization", "Bearer "+token)
52+
}
53+
return next.RoundTrip(cloned)
54+
})
55+
}
56+
57+
func newRobotCredentialReloader(next http.RoundTripper) http.RoundTripper {
58+
next = transportOrDefault(next)
59+
60+
return roundTripperFunc(func(req *http.Request) (*http.Response, error) {
61+
user, password, err := config.LookupRobotCredentials()
62+
if err != nil {
63+
return nil, err
64+
}
65+
66+
cloned := cloneRequest(req)
67+
if user == "" && password == "" {
68+
cloned.Header.Del("Authorization")
69+
} else {
70+
cloned.SetBasicAuth(user, password)
71+
}
72+
return next.RoundTrip(cloned)
73+
})
74+
}
75+
76+
func cloneRequest(req *http.Request) *http.Request {
77+
cloned := req.Clone(req.Context())
78+
cloned.Header = req.Header.Clone()
79+
return cloned
80+
}
81+
82+
func transportOrDefault(next http.RoundTripper) http.RoundTripper {
83+
if next != nil {
84+
return next
85+
}
86+
return http.DefaultTransport
87+
}

hcloud/runtime_credentials_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package hcloud
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
hrobot "github.com/syself/hrobot-go"
13+
14+
"github.com/hetznercloud/hcloud-cloud-controller-manager/internal/testsupport"
15+
"github.com/hetznercloud/hcloud-go/v2/hcloud"
16+
"github.com/hetznercloud/hcloud-go/v2/hcloud/schema"
17+
)
18+
19+
func TestHCloudClientReloadsTokenFromFile(t *testing.T) {
20+
defer unsetEnv(t, "HCLOUD_TOKEN")()
21+
22+
var authorizations []string
23+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
24+
authorizations = append(authorizations, r.Header.Get("Authorization"))
25+
assert.NoError(t, json.NewEncoder(w).Encode(schema.LocationListResponse{Locations: []schema.Location{}}))
26+
}))
27+
defer server.Close()
28+
29+
tokenFile := filepath.Join(t.TempDir(), "hcloud-token")
30+
assert.NoError(t, os.WriteFile(tokenFile, []byte("token-1"), 0o600))
31+
32+
resetEnv := testsupport.Setenv(t, "HCLOUD_TOKEN_FILE", tokenFile)
33+
defer resetEnv()
34+
35+
client := hcloud.NewClient(
36+
hcloud.WithEndpoint(server.URL),
37+
hcloud.WithHTTPClient(newHCloudHTTPClient(0)),
38+
hcloud.WithPollOpts(hcloud.PollOpts{BackoffFunc: hcloud.ConstantBackoff(0)}),
39+
hcloud.WithRetryOpts(hcloud.RetryOpts{BackoffFunc: hcloud.ConstantBackoff(0)}),
40+
)
41+
42+
_, _, err := client.Location.List(t.Context(), hcloud.LocationListOpts{})
43+
assert.NoError(t, err)
44+
45+
assert.NoError(t, os.WriteFile(tokenFile, []byte("token-2"), 0o600))
46+
47+
_, _, err = client.Location.List(t.Context(), hcloud.LocationListOpts{})
48+
assert.NoError(t, err)
49+
50+
assert.Equal(t, []string{"Bearer token-1", "Bearer token-2"}, authorizations)
51+
}
52+
53+
func TestRobotClientReloadsCredentialsFromFile(t *testing.T) {
54+
defer unsetEnv(t, "ROBOT_USER")()
55+
defer unsetEnv(t, "ROBOT_PASSWORD")()
56+
57+
var users []string
58+
var passwords []string
59+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60+
user, password, ok := r.BasicAuth()
61+
assert.True(t, ok)
62+
users = append(users, user)
63+
passwords = append(passwords, password)
64+
assert.NoError(t, json.NewEncoder(w).Encode([]map[string]any{
65+
{
66+
"server": map[string]any{
67+
"server_number": 1,
68+
"server_name": "node-1",
69+
"server_ip": "192.0.2.1",
70+
},
71+
},
72+
}))
73+
}))
74+
defer server.Close()
75+
76+
dir := t.TempDir()
77+
userFile := filepath.Join(dir, "robot-user")
78+
passwordFile := filepath.Join(dir, "robot-password")
79+
assert.NoError(t, os.WriteFile(userFile, []byte("robot-user-1"), 0o600))
80+
assert.NoError(t, os.WriteFile(passwordFile, []byte("robot-password-1"), 0o600))
81+
82+
resetEnv := testsupport.Setenv(t,
83+
"ROBOT_USER_FILE", userFile,
84+
"ROBOT_PASSWORD_FILE", passwordFile,
85+
)
86+
defer resetEnv()
87+
88+
client := hrobot.NewBasicAuthClientWithCustomHttpClient("stale-user", "stale-password", newRobotHTTPClient(0))
89+
client.SetBaseURL(server.URL)
90+
91+
_, err := client.ServerGetList()
92+
assert.NoError(t, err)
93+
94+
assert.NoError(t, os.WriteFile(userFile, []byte("robot-user-2"), 0o600))
95+
assert.NoError(t, os.WriteFile(passwordFile, []byte("robot-password-2"), 0o600))
96+
97+
_, err = client.ServerGetList()
98+
assert.NoError(t, err)
99+
100+
assert.Equal(t, []string{"robot-user-1", "robot-user-2"}, users)
101+
assert.Equal(t, []string{"robot-password-1", "robot-password-2"}, passwords)
102+
}
103+
104+
func unsetEnv(t *testing.T, key string) func() {
105+
t.Helper()
106+
107+
value, ok := os.LookupEnv(key)
108+
assert.NoError(t, os.Unsetenv(key))
109+
110+
return func() {
111+
if !ok {
112+
assert.NoError(t, os.Unsetenv(key))
113+
return
114+
}
115+
assert.NoError(t, os.Setenv(key, value))
116+
}
117+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package config
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
7+
"github.com/hetznercloud/hcloud-go/v2/hcloud/exp/kit/envutil"
8+
)
9+
10+
// LookupHCloudToken reads the current HCLOUD_TOKEN / HCLOUD_TOKEN_FILE value.
11+
func LookupHCloudToken() (string, error) {
12+
return envutil.LookupEnvWithFile(hcloudToken)
13+
}
14+
15+
// LookupRobotCredentials reads the current ROBOT_USER / ROBOT_USER_FILE and
16+
// ROBOT_PASSWORD / ROBOT_PASSWORD_FILE values.
17+
func LookupRobotCredentials() (string, string, error) {
18+
user, userErr := envutil.LookupEnvWithFile(robotUser)
19+
password, passwordErr := envutil.LookupEnvWithFile(robotPassword)
20+
if userErr != nil || passwordErr != nil {
21+
return "", "", errors.Join(userErr, passwordErr)
22+
}
23+
if (user == "") != (password == "") {
24+
return "", "", fmt.Errorf("both %q and %q must be provided, or neither", robotUser, robotPassword)
25+
}
26+
return user, password, nil
27+
}

0 commit comments

Comments
 (0)