Skip to content

Commit 55a8b17

Browse files
authored
Use firewalls (#5)
* use firewalls Signed-off-by: Flipez <[email protected]> * add delete and clear Signed-off-by: Flipez <[email protected]> * update readme Signed-off-by: Flipez <[email protected]> --------- Signed-off-by: Flipez <[email protected]>
1 parent 4a9a574 commit 55a8b17

5 files changed

Lines changed: 235 additions & 80 deletions

File tree

README.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
# key/value store based on Hetzner Cloud Labels
2-
Implements a simple key/value store by utilizing the ssh-key resource and its labels.
3-
As per API spec it supports keys and values with a max lenght of 63 chars.
1+
# Slow key/value store based on Hetzner Cloud Metadata
2+
Implements a simple key/value store by utilizing the firewall resource and its rule descriptions.
3+
As per API spec it supports up to 500 rules with 255 bytes description each.
4+
The data is encoded using ![gob](https://pkg.go.dev/encoding/gob) and compressed using ![zstd](https://pkg.go.dev/github.com/klauspost/compress/zstd).
5+
The actual number of keys and size of values therefore depends on how well they can be compressed.
46
hetzner-kv supports multiple "databases" provided by a flag.
57

68
It uses the token specified via env variable `HCLOUD_TOKEN`
@@ -15,13 +17,16 @@ USAGE:
1517
hcloud-kv [global options] command [command options] [arguments...]
1618
1719
COMMANDS:
18-
init, i initializes a new database
19-
set, s sets a key
20-
get, g get a value from given key
21-
list, l list all keys
22-
help, h Shows a list of commands or help for one command
20+
init, i initializes a new database
21+
set, s sets a key
22+
get, g get a value from given key
23+
list, l list all keys
24+
delete, d delete a given key
25+
clear, c delete all keys
26+
help, h Shows a list of commands or help for one command
2327
2428
GLOBAL OPTIONS:
2529
--db value database to use (default: "0")
30+
--no-info Do not print db usage information (default: false)
2631
--help, -h show help
2732
```

database.go

Lines changed: 181 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,89 @@
11
package main
22

33
import (
4+
"bytes"
45
"context"
5-
"encoding/json"
6+
"encoding/base64"
7+
"encoding/gob"
8+
"fmt"
69
"log"
10+
"net"
11+
"strings"
712

813
"github.com/hetznercloud/hcloud-go/hcloud"
14+
"github.com/klauspost/compress/zstd"
915
)
1016

1117
type Database struct {
12-
Store map[string]string
13-
Name string
14-
Client *hcloud.Client
15-
Context context.Context
16-
Self *hcloud.SSHKey
17-
NoInfo bool
18+
Store map[string]string
19+
Name string
20+
Client *hcloud.Client
21+
Context context.Context
22+
Self *hcloud.Firewall
23+
NoInfo bool
24+
LastEncodedSize int
1825
}
1926

2027
func (d *Database) Init() {
21-
keyPlaceholder := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAUqWKFtn3P3p0tOWXMgkfc7aTc5Z17+LSlf50X/ep/Z"
28+
_, response, err := d.Client.Firewall.Create(d.Context, hcloud.FirewallCreateOpts{Name: d.Name})
2229

23-
database, response, err := d.Client.SSHKey.Create(d.Context, hcloud.SSHKeyCreateOpts{Name: d.Name, PublicKey: keyPlaceholder})
24-
if err != nil && response.StatusCode == 409 {
25-
log.Fatalf("database %s already exists", d.Name)
26-
} else if err != nil {
27-
log.Fatalf("unhandled error: %s\n", err)
30+
if err != nil {
31+
if response != nil && response.StatusCode == 409 {
32+
log.Printf("database %s already exists", d.Name)
33+
} else {
34+
log.Fatalf("unhandled error: %s\n", err)
35+
}
36+
} else {
37+
log.Printf("created new database: %s", d.Name)
2838
}
2939

30-
d.Store = database.Labels
40+
d.Fetch()
3141
}
3242

33-
func (d *Database) Fetch() *hcloud.SSHKey {
34-
db, _, err := d.Client.SSHKey.Get(d.Context, d.Name)
35-
d.Self = db
36-
43+
func (d *Database) Fetch() *hcloud.Firewall {
44+
db, _, err := d.Client.Firewall.Get(d.Context, d.Name)
3745
if err != nil {
3846
log.Fatalf("error retrieving database: %s\n", err)
3947
}
48+
d.Self = db
4049

4150
if d.Self != nil {
42-
d.Store = d.Self.Labels
43-
} else {
44-
log.Fatalf("database %s not found", d.Name)
51+
store, size, err := rulesToMap(d.Self.Rules)
52+
if err != nil {
53+
log.Printf("could not parse rules (db might be empty or old format): %s", err)
54+
if d.Store == nil {
55+
d.Store = make(map[string]string)
56+
}
57+
} else {
58+
d.Store = store
59+
d.LastEncodedSize = size
60+
}
4561
}
4662

47-
checkSize(d)
48-
63+
checkSize(d, d.LastEncodedSize)
4964
return d.Self
5065
}
5166

5267
func (d *Database) Set(key, value string) bool {
5368
d.Fetch()
69+
70+
if d.Store == nil {
71+
d.Store = make(map[string]string)
72+
}
73+
5474
d.Store[key] = value
5575

56-
database, _, err := d.Client.SSHKey.Update(d.Context, d.Self, hcloud.SSHKeyUpdateOpts{Labels: d.Store})
76+
firewallRules, encodedLength, err := mapToRules(d.Store)
77+
78+
_, _, err = d.Client.Firewall.SetRules(d.Context, d.Self, hcloud.FirewallSetRulesOpts{Rules: firewallRules})
5779

5880
if err != nil {
5981
log.Fatalf("error updating db: %s\n", err)
6082
}
6183

62-
d.Store = database.Labels
63-
84+
d.LastEncodedSize = encodedLength
6485
log.Println("OK")
65-
checkSize(d)
86+
checkSize(d, encodedLength)
6687

6788
return true
6889
}
@@ -75,21 +96,146 @@ func (d *Database) Get(key string) string {
7596
func (d *Database) List() []string {
7697
d.Fetch()
7798

78-
keys := make([]string, len(d.Store))
99+
keys := make([]string, 0, len(d.Store))
79100

80-
i := 0
81101
for k := range d.Store {
82-
keys[i] = k
83-
i++
102+
keys = append(keys, k)
84103
}
85104

86105
return keys
87106
}
88107

89-
func checkSize(db *Database) {
90-
if !db.NoInfo {
91-
jsonStr, _ := json.Marshal(db.Store)
92-
log.Printf("[Info] Database usage: %.2f%%", float64(len(jsonStr))/float64(maxDBBytes)*100)
108+
func (d *Database) Delete(key string) bool {
109+
d.Fetch()
110+
111+
if _, exists := d.Store[key]; !exists {
112+
log.Printf("Key '%s' not found, nothing to delete.", key)
113+
return false
93114
}
115+
116+
delete(d.Store, key)
117+
118+
firewallRules, encodedLength, err := mapToRules(d.Store)
119+
if err != nil {
120+
log.Fatalf("Error encoding data after delete: %s", err)
121+
}
122+
123+
_, _, err = d.Client.Firewall.SetRules(d.Context, d.Self, hcloud.FirewallSetRulesOpts{
124+
Rules: firewallRules,
125+
})
126+
127+
if err != nil {
128+
log.Fatalf("error updating db during delete: %s\n", err)
129+
}
130+
131+
d.LastEncodedSize = encodedLength
132+
log.Println("Deleted key:", key)
133+
checkSize(d, encodedLength)
134+
135+
return true
94136
}
95137

138+
func (d *Database) Clear() {
139+
d.Fetch()
140+
141+
if d.Self == nil {
142+
log.Fatal("Could not clear database: Firewall not found or not initialized.")
143+
}
144+
145+
d.Store = make(map[string]string)
146+
147+
_, _, err := d.Client.Firewall.SetRules(d.Context, d.Self, hcloud.FirewallSetRulesOpts{
148+
Rules: []hcloud.FirewallRule{},
149+
})
150+
151+
if err != nil {
152+
log.Fatalf("Failed to clear database: %s", err)
153+
}
154+
155+
d.LastEncodedSize = 0
156+
log.Println("Database cleared successfully.")
157+
}
158+
159+
func checkSize(db *Database, currentLength int) {
160+
if db.NoInfo {
161+
return
162+
}
163+
164+
maxChars := 500 * 255
165+
usage := (float64(currentLength) / float64(maxChars)) * 100
166+
167+
log.Printf("[Info] Storage: %d/%d chars (%.2f%% used)", currentLength, maxChars, usage)
168+
}
169+
170+
func rulesToMap(rules []hcloud.FirewallRule) (map[string]string, int, error) {
171+
store := map[string]string{}
172+
var sb strings.Builder
173+
sb.Grow(len(rules) * 255)
174+
175+
for _, rule := range rules {
176+
if rule.Description != nil {
177+
sb.WriteString(*rule.Description)
178+
}
179+
}
180+
tempString := sb.String()
181+
totalLength := len(tempString)
182+
if totalLength == 0 {
183+
return store, 0, nil
184+
}
185+
186+
decodedBytes, err := base64.StdEncoding.DecodeString(tempString)
187+
if err != nil {
188+
return store, totalLength, err
189+
}
190+
191+
zr, err := zstd.NewReader(bytes.NewReader(decodedBytes))
192+
if err != nil {
193+
return store, totalLength, err
194+
}
195+
defer zr.Close()
196+
197+
err = gob.NewDecoder(zr).Decode(&store)
198+
return store, totalLength, err
199+
}
200+
201+
func mapToRules(store map[string]string) ([]hcloud.FirewallRule, int, error) {
202+
var gobBuf bytes.Buffer
203+
if err := gob.NewEncoder(&gobBuf).Encode(store); err != nil {
204+
return nil, 0, err
205+
}
206+
207+
var compressedBuf bytes.Buffer
208+
zw, _ := zstd.NewWriter(&compressedBuf)
209+
zw.Write(gobBuf.Bytes())
210+
zw.Close()
211+
212+
encodedString := base64.StdEncoding.EncodeToString(compressedBuf.Bytes())
213+
totalLength := len(encodedString)
214+
215+
var rules []hcloud.FirewallRule
216+
limit := 255
217+
_, dummyNet, _ := net.ParseCIDR("0.0.0.0/32")
218+
219+
for i := 0; i < len(encodedString); i += limit {
220+
end := i + limit
221+
if end > len(encodedString) {
222+
end = len(encodedString)
223+
}
224+
225+
chunk := encodedString[i:end] // Direct slice is safer for ASCII Base64
226+
227+
rules = append(rules, hcloud.FirewallRule{
228+
Description: hcloud.Ptr(chunk),
229+
Direction: hcloud.FirewallRuleDirectionIn,
230+
Protocol: hcloud.FirewallRuleProtocolTCP,
231+
Port: hcloud.Ptr("80"),
232+
SourceIPs: []net.IPNet{*dummyNet},
233+
})
234+
}
235+
236+
for i, r := range rules {
237+
fmt.Printf("Rule %d: Desc Length: %d, Protocol: %s\n", i, len(*r.Description), r.Protocol)
238+
}
239+
240+
return rules, totalLength, nil
241+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ go 1.24.0
44

55
require (
66
github.com/hetznercloud/hcloud-go v1.59.2
7+
github.com/klauspost/compress v1.18.2
78
github.com/urfave/cli/v2 v2.27.7
89
)
910

1011
require (
1112
github.com/beorn7/perks v1.0.1 // indirect
1213
github.com/cespare/xxhash/v2 v2.3.0 // indirect
1314
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
14-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
1515
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
1616
github.com/prometheus/client_golang v1.23.2 // indirect
1717
github.com/prometheus/client_model v0.6.2 // indirect

0 commit comments

Comments
 (0)