Skip to content

Commit 0bfc870

Browse files
committed
client preflight check + tests
1 parent f403394 commit 0bfc870

File tree

5 files changed

+361
-8
lines changed

5 files changed

+361
-8
lines changed

cmd/client-agent/main.go

+282
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
package main
2+
3+
import (
4+
"encoding/base64"
5+
"encoding/json"
6+
"fmt"
7+
"io/ioutil"
8+
"os"
9+
"os/exec"
10+
"path/filepath"
11+
"time"
12+
13+
log "github.com/sirupsen/logrus"
14+
flag "github.com/spf13/pflag"
15+
)
16+
17+
var (
18+
cfg = DefaultConfig()
19+
WGBinary string
20+
WireguardGoBinary string
21+
ControlPlanePrivateKeyPath string
22+
ControlPlaneWGConfigPath string
23+
DataPlanePrivateKeyPath string
24+
DataPlaneWGConfigPath string
25+
)
26+
27+
func init() {
28+
log.SetFormatter(&log.JSONFormatter{})
29+
flag.StringVar(&cfg.APIServer, "apiserver", cfg.APIServer, "hostname to apiserver")
30+
flag.StringVar(&cfg.ConfigDir, "config-dir", cfg.ConfigDir, "path to agent config directory")
31+
flag.StringVar(&cfg.BinaryDir, "binary-dir", cfg.BinaryDir, "path to binary directory")
32+
flag.StringVar(&cfg.ControlPlaneInterface, "control-plane-interface", cfg.ControlPlaneInterface, "name of control plane tunnel interface")
33+
flag.StringVar(&cfg.DataPlaneInterface, "data-plane-interface", cfg.DataPlaneInterface, "name of data plane tunnel interface")
34+
flag.StringVar(&cfg.EnrollmentToken, "enrollment-token", cfg.EnrollmentToken, "enrollment token")
35+
36+
flag.Parse()
37+
38+
WGBinary = filepath.Join(cfg.BinaryDir, "naisdevice-wg")
39+
WireguardGoBinary = filepath.Join(cfg.BinaryDir, "naisdevice-wireguard-go")
40+
ControlPlanePrivateKeyPath = filepath.Join(cfg.ConfigDir, "wgctrl-private.key")
41+
ControlPlaneWGConfigPath = filepath.Join(cfg.ConfigDir, "wgctrl.conf")
42+
DataPlanePrivateKeyPath = filepath.Join(cfg.ConfigDir, "wgdata-private.key")
43+
DataPlaneWGConfigPath = filepath.Join(cfg.ConfigDir, "wgdata.conf")
44+
}
45+
46+
// client-agent is responsible for enabling the end-user to connect to it's permitted gateways.
47+
// To be able to connect, a series of prerequisites must be in place. These will be helped/ensured by client-agent.
48+
//
49+
// 1. A information exchange between end-user and naisdevice administrator/slackbot:
50+
// - The end-user will provide it's generated public key ($(wg pubkey < `ControlPlanePrivateKeyPath`))
51+
// - The end-user will receive the control plane tunnel endpoint and public key.
52+
// The received information will be persisted as `ControlPlaneInfoFile`.
53+
// When client-agent detects `ControlPlaneInfoFile` is present,
54+
// it will generate a WireGuard config file called wgctrl.conf placed in `cfg.ConfigDir`
55+
//
56+
// 2. (When) A valid control plane WireGuard config is present, ensure control plane tunnel is configured and connected:
57+
// - launch wireguard-go with the provided `cfg.ControlPlaneInterface`, and run the following commands:
58+
// - sudo wg setconf "$wgctrl_device" /etc/wireguard/wgctrl.conf
59+
// - sudo ifconfig `cfg.ControlPlaneInterface` inet "`ControlPlaneInfoFile.TunnelIP`/21" "`ControlPlaneInfoFile.TunnelIP`" add
60+
// - sudo ifconfig `cfg.ControlPlaneInterface` mtu 1380
61+
// - sudo ifconfig `cfg.ControlPlaneInterface` up
62+
// - sudo route -q -n add -inet "`ControlPlaneInfoFile.TunnelIP`/21" -interface "$wgctrl_device"
63+
//
64+
// 3.
65+
//
66+
// 'client-agent' binary is packaged as 'naisdevice-agent'
67+
// alongside naisdevice-wg and naisdevice-wireguard-go (for MacOS and Windows)
68+
// Binaries will be reside in /usr/local/bin
69+
// runs as root
70+
//TODO: detect cfg.ConfigDir/wgctrl.conf
71+
//TODO: if missing, notify user ^
72+
//TODO: establish ctrl plane
73+
//TODO: (authenticate user, not part of MVP)
74+
//TODO: get config from apiserver
75+
//TODO: establish data plane (continously monitored, will trigger ^ if it goes down and user wants to connect)
76+
// $$$$$$
77+
func main() {
78+
log.Infof("starting client-agent with config:\n%+v", cfg)
79+
80+
if err := filesExist(WGBinary, WireguardGoBinary); err != nil {
81+
log.Fatalf("verifying if file exists: %v", err)
82+
}
83+
84+
if err := ensureDirectories(cfg.ConfigDir, cfg.BinaryDir); err != nil {
85+
log.Fatalf("ensuring directory exists: %w", err)
86+
}
87+
88+
if err := filesExist(ControlPlaneWGConfigPath); err != nil {
89+
// no wgctrl.conf
90+
91+
if err := enroll(); err != nil {
92+
log.Fatalf("enrolling device: %v", err)
93+
}
94+
95+
}
96+
97+
for range time.NewTicker(10 * time.Second).C {
98+
log.Info("titei")
99+
}
100+
}
101+
102+
type EnrollmentConfig struct {
103+
ClientIP string `json:"clientIP"`
104+
PublicKey string `json:"publicKey"`
105+
Endpoint string `json:"endpoint"`
106+
APIServerIP string `json:"apiServerIP"`
107+
}
108+
109+
func enroll() error {
110+
if err := ensureKey(ControlPlanePrivateKeyPath); err != nil {
111+
return fmt.Errorf("ensuring private key for control plane exists: %v", err)
112+
}
113+
114+
if len(cfg.EnrollmentToken) == 0 {
115+
pubkey, err := generatePublicKey(ControlPlanePrivateKeyPath)
116+
if err != nil {
117+
return fmt.Errorf("generate public key during enroll: %v", err)
118+
}
119+
120+
return fmt.Errorf("no enrollment token present. Send 'Nais Device' this message on slack: 'enroll %v'", string(pubkey))
121+
}
122+
123+
privateKey, err := ioutil.ReadFile(ControlPlanePrivateKeyPath)
124+
if err != nil {
125+
return fmt.Errorf("reading private key: %w", err)
126+
}
127+
128+
enrollmentConfig, err := ParseEnrollmentToken(cfg.EnrollmentToken)
129+
if err != nil {
130+
return fmt.Errorf("parsing enrollment token: %w", err)
131+
}
132+
133+
wgConfigContent := GenerateWGConfig(enrollmentConfig, privateKey)
134+
fmt.Println(wgConfigContent)
135+
136+
if err := ioutil.WriteFile(ControlPlaneWGConfigPath, wgConfigContent, 0600); err != nil {
137+
return fmt.Errorf("writing control plane wireguard config to disk: %w", err)
138+
}
139+
140+
/*
141+
cmd := exec.Command("/usr/bin/wg", "syncconf", "wgdata", "/etc/wireguard/wgdata.conf")
142+
if stdout, err := cmd.Output(); err != nil {
143+
return fmt.Errorf("executing %w: %v", err, string(stdout))
144+
}
145+
146+
return nil
147+
}
148+
*/
149+
150+
// create config file
151+
152+
return nil
153+
}
154+
155+
func ParseEnrollmentToken(enrollmentToken string) (enrollmentConfig *EnrollmentConfig, err error) {
156+
b, err := base64.StdEncoding.DecodeString(enrollmentToken)
157+
if err != nil {
158+
return nil, fmt.Errorf("base64 decoding enrollment token: %w", err)
159+
}
160+
161+
if err := json.Unmarshal(b, &enrollmentConfig); err != nil {
162+
return nil, fmt.Errorf("unmarshalling enrollment token json: %w", err)
163+
}
164+
165+
return
166+
}
167+
168+
func GenerateWGConfig(enrollmentConfig *EnrollmentConfig, privateKey []byte) []byte {
169+
template := `
170+
[Interface]
171+
PrivateKey = %s
172+
173+
[Peer]
174+
PublicKey = %s
175+
AllowedIPs = %s
176+
Endpoint = %s
177+
`
178+
return []byte(fmt.Sprintf(template, privateKey, enrollmentConfig.PublicKey, enrollmentConfig.APIServerIP, enrollmentConfig.Endpoint))
179+
}
180+
181+
func generatePublicKey(privateKeyPath string) ([]byte, error) {
182+
cmd := exec.Command(WGBinary, "pubkey")
183+
184+
stdin, err := cmd.StdinPipe()
185+
if err != nil {
186+
return nil, fmt.Errorf("creating stdin pipe on 'wg pubkey': %w", err)
187+
}
188+
189+
b, err := ioutil.ReadFile(privateKeyPath)
190+
if err != nil {
191+
return nil, fmt.Errorf("reading private key: %w", err)
192+
}
193+
194+
if _, err := stdin.Write(b); err != nil {
195+
return nil, fmt.Errorf("piping private key to 'wg genkey': %w", err)
196+
}
197+
198+
return cmd.Output()
199+
}
200+
201+
func filesExist(files ...string) error {
202+
for _, file := range files {
203+
if err := FileMustExist(file); err != nil {
204+
return err
205+
}
206+
}
207+
208+
return nil
209+
}
210+
211+
func ensureDirectories(dirs ...string) error {
212+
for _, dir := range dirs {
213+
if err := ensureDirectory(dir); err != nil {
214+
return err
215+
}
216+
}
217+
218+
return nil
219+
}
220+
221+
func ensureDirectory(dir string) error {
222+
info, err := os.Stat(dir)
223+
224+
if os.IsNotExist(err) {
225+
return os.MkdirAll(dir, 0700)
226+
}
227+
if err != nil {
228+
return err
229+
}
230+
if !info.IsDir() {
231+
return fmt.Errorf("%v is a file", dir)
232+
}
233+
234+
return nil
235+
}
236+
237+
func ensureKey(keypath string) error {
238+
if err := FileMustExist(keypath); os.IsNotExist(err) {
239+
cmd := exec.Command(WGBinary, "genkey")
240+
stdout, err := cmd.Output()
241+
if err != nil {
242+
return fmt.Errorf("executing %w: %v", err, string(stdout))
243+
}
244+
245+
return ioutil.WriteFile(keypath, stdout, 0600)
246+
} else if err != nil {
247+
return err
248+
}
249+
250+
return nil
251+
}
252+
253+
type Config struct {
254+
APIServer string
255+
DataPlaneInterface string
256+
ControlPlaneInterface string
257+
ConfigDir string
258+
BinaryDir string
259+
EnrollmentToken string
260+
}
261+
262+
func DefaultConfig() Config {
263+
return Config{
264+
APIServer: "http://apiserver.device.nais.io",
265+
DataPlaneInterface: "utun34",
266+
ControlPlaneInterface: "utun35",
267+
ConfigDir: "/usr/local/etc/nais-device",
268+
BinaryDir: "/usr/local/bin",
269+
}
270+
}
271+
272+
func FileMustExist(filepath string) error {
273+
info, err := os.Stat(filepath)
274+
if err != nil {
275+
return err
276+
}
277+
if info.IsDir() {
278+
return fmt.Errorf("%v is a directory", filepath)
279+
}
280+
281+
return nil
282+
}

cmd/client-agent/main_test.go

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package main_test
2+
3+
import (
4+
"testing"
5+
6+
main "github.com/nais/device/cmd/client-agent"
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestParseEnrollmentToken(t *testing.T) {
11+
/*
12+
{
13+
"clientIP": "10.1.1.1",
14+
"publicKey": "PQKmraPOPye5CJq1x7njpl8rRu5RSrIKyHvZXtLvS0E=",
15+
"endpoint": "69.1.1.1:51820",
16+
"apiServerIP": "10.1.1.2"
17+
}
18+
*/
19+
enrollmentToken := "ewogICJpcCI6ICIxMC4xLjEuMSIsCiAgInB1YmxpY0tleSI6ICJQUUttcmFQT1B5ZTVDSnExeDduanBsOHJSdTVSU3JJS3lIdlpYdEx2UzBFPSIsCiAgImVuZHBvaW50IjogIjY5LjEuMS4xOjUxODIwIiwKICAiYXBpU2VydmVySVAiOiAiMTAuMS4xLjIiCn0K"
20+
enrollmentConfig, err := main.ParseEnrollmentToken(enrollmentToken)
21+
assert.NoError(t, err)
22+
assert.Equal(t, "10.1.1.1", enrollmentConfig.ClientIP)
23+
assert.Equal(t, "PQKmraPOPye5CJq1x7njpl8rRu5RSrIKyHvZXtLvS0E=", enrollmentConfig.PublicKey)
24+
assert.Equal(t, "69.1.1.1:51820", enrollmentConfig.Endpoint)
25+
assert.Equal(t, "10.1.1.2", enrollmentConfig.APIServerIP)
26+
}
27+
28+
func TestGenerateWGConfig(t *testing.T) {
29+
enrollmentConfig := &main.EnrollmentConfig{
30+
ClientIP: "10.1.1.1",
31+
PublicKey: "PQKmraPOPye5CJq1x7njpl8rRu5RSrIKyHvZXtLvS0E=",
32+
Endpoint: "69.1.1.1:51820",
33+
APIServerIP: "10.1.1.2",
34+
}
35+
privateKey := []byte("wFTAVe1stJPp0xQ+FE9so56uKh0jaHkPxJ4d2x9jPmU=")
36+
wgConfig := main.GenerateWGConfig(enrollmentConfig, privateKey)
37+
38+
expected := `
39+
[Interface]
40+
PrivateKey = wFTAVe1stJPp0xQ+FE9so56uKh0jaHkPxJ4d2x9jPmU=
41+
42+
[Peer]
43+
PublicKey = PQKmraPOPye5CJq1x7njpl8rRu5RSrIKyHvZXtLvS0E=
44+
AllowedIPs = 10.1.1.2
45+
Endpoint = 69.1.1.1:51820
46+
`
47+
assert.Equal(t, expected, string(wgConfig))
48+
49+
}

cmd/gateway-agent/main.go

+6-6
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ var (
2222
func init() {
2323
log.SetFormatter(&log.JSONFormatter{})
2424
flag.StringVar(&cfg.Apiserver, "apiserver", cfg.Apiserver, "hostname to apiserver")
25-
flag.StringVar(&cfg.Name, "name", cfg.Name, "hostname to apiserver")
25+
flag.StringVar(&cfg.Name, "name", cfg.Name, "gateway name")
2626
flag.StringVar(&cfg.PublicKey, "public-key", cfg.PublicKey, "path to wireguard public key")
2727
flag.StringVar(&cfg.PrivateKey, "private-key", cfg.PrivateKey, "path to wireguard private key")
2828
flag.StringVar(&cfg.TunnelInterfaceName, "interface", cfg.TunnelInterfaceName, "name of tunnel interface")
@@ -35,7 +35,7 @@ func init() {
3535
// is synchronized and enforced by the local wireguard process on the gateway.
3636
//
3737
// Prerequisites:
38-
// - controlplane tunnel is set up/apiserver is reachable at `Config.Apiserver`
38+
// - controlplane tunnel is set up/apiserver is reachable at `Config.APIServer`
3939
//
4040
// Prereqs for MVP (at least):
4141
//
@@ -85,12 +85,12 @@ func configureWireguard(peers []api.Peer, privateKey string) error {
8585
fmt.Println(string(wgConfigContent))
8686
wgConfigFilePath := filepath.Join(cfg.TunnelConfigDir, cfg.TunnelInterfaceName+".conf")
8787
if err := ioutil.WriteFile(wgConfigFilePath, wgConfigContent, 0600); err != nil {
88-
return fmt.Errorf("writing wireguard config to disk: %s", err)
88+
return fmt.Errorf("writing wireguard config to disk: %w", err)
8989
}
9090

9191
cmd := exec.Command("/usr/bin/wg", "syncconf", "wgdata", "/etc/wireguard/wgdata.conf")
9292
if stdout, err := cmd.Output(); err != nil {
93-
return fmt.Errorf("executing %s: %s: %s", cmd, err, string(stdout))
93+
return fmt.Errorf("executing %w: %v", err, string(stdout))
9494
}
9595

9696
return nil
@@ -112,15 +112,15 @@ func generateWGConfig(peers []api.Peer, privateKey string) []byte {
112112
func getPeers(apiserverURL string) (peers []api.Peer, err error) {
113113
resp, err := http.Get(apiserverURL)
114114
if err != nil {
115-
return nil, fmt.Errorf("getting peer config from apiserver: %s", err)
115+
return nil, fmt.Errorf("getting peer config from apiserver: %w", err)
116116
}
117117

118118
defer resp.Body.Close()
119119

120120
err = json.NewDecoder(resp.Body).Decode(&peers)
121121

122122
if err != nil {
123-
return nil, fmt.Errorf("unmarshal json from apiserver: %s", err)
123+
return nil, fmt.Errorf("unmarshal json from apiserver: %w", err)
124124
}
125125

126126
return

go.mod

+3-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ go 1.13
55
require (
66
github.com/go-chi/chi v4.0.4+incompatible
77
github.com/jackc/pgx/v4 v4.5.0
8-
github.com/lib/pq v1.3.0
8+
github.com/lib/pq v1.3.0 // indirect
99
github.com/sirupsen/logrus v1.4.2
1010
github.com/spf13/pflag v1.0.5
11+
github.com/stretchr/testify v1.5.1
12+
golang.zx2c4.com/wireguard v0.0.20200320
1113
)

0 commit comments

Comments
 (0)