nebula management web interface
diff --git a/core/nebula/Makefile b/core/nebula/Makefile
index e184886..3f7a57e 100644
--- a/core/nebula/Makefile
+++ b/core/nebula/Makefile
@@ -1,14 +1,18 @@
 clean:
-	rm -f controller
+	rm -f controller web
 
 generate:
 	rm -rf generated
 	./hack/generate.sh
 
-build: clean
+controller: clean
 	go1.16 mod tidy
 	go1.16 mod vendor
-	go1.16 build -o controller .
+	go1.16 build -o controller main.go
+
+web: clean
+	go1.16 build -o web web.go
+
 
 # image: clean build
 # 	docker build --tag=giolekva/rpuppy-arm .
diff --git a/core/nebula/apis/nebula/v1/types.go b/core/nebula/apis/nebula/v1/types.go
index 1a33752..0a295b7 100644
--- a/core/nebula/apis/nebula/v1/types.go
+++ b/core/nebula/apis/nebula/v1/types.go
@@ -16,7 +16,6 @@
 }
 
 type NebulaCASpec struct {
-	CAName     string `json:"caName"`
 	SecretName string `json:"secretName"`
 }
 
@@ -52,10 +51,11 @@
 }
 
 type NebulaNodeSpec struct {
-	CAName     string `json:"caName"`
-	NodeName   string `json:"nodeName"`
-	IPCidr     string `json:"ipCidr"`
-	SecretName string `json:"secretName"`
+	CAName      string `json:"caName"`
+	CANamespace string `json:"caNamespace"`
+	IPCidr      string `json:"ipCidr"`
+	PubKey      string `json:"pubKey"`
+	SecretName  string `json:"secretName"`
 }
 
 type NebulaNodeStatus struct {
diff --git a/core/nebula/controllers/ca.go b/core/nebula/controllers/ca.go
index c848571..d31b6cd 100644
--- a/core/nebula/controllers/ca.go
+++ b/core/nebula/controllers/ca.go
@@ -11,8 +11,6 @@
 
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/labels"
-	"k8s.io/apimachinery/pkg/selection"
 	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
 	"k8s.io/apimachinery/pkg/util/wait"
 	corev1informers "k8s.io/client-go/informers/core/v1"
@@ -181,7 +179,7 @@
 		fmt.Printf("%s CA is already in Ready state\n", ca.Name)
 		return nil
 	}
-	keyDir, err := generateCAKey(ca.Spec.CAName, c.nebulaCert)
+	keyDir, err := generateCAKey(ca.Name, c.nebulaCert)
 	if err != nil {
 		panic(err)
 	}
@@ -216,7 +214,7 @@
 		fmt.Printf("%s Node is already in Ready state\n", node.Name)
 		return nil
 	}
-	ca, err := c.getCA(namespace, node.Spec.CAName)
+	ca, err := c.getCA(node.Spec.CANamespace, node.Spec.CAName)
 	if ca.Status.State != nebulav1.NebulaCAStateReady {
 		return fmt.Errorf("Referenced CA %s is not ready yet.", node.Spec.CAName)
 	}
@@ -228,8 +226,14 @@
 	if err != nil {
 		panic(err)
 	}
-	if err := generateNodeKey(node.Spec.NodeName, node.Spec.IPCidr, dir, c.nebulaCert); err != nil {
-		panic(err)
+	if node.Spec.PubKey == "" {
+		if err := generateNodeKey(node.Name, node.Spec.IPCidr, dir, c.nebulaCert); err != nil {
+			panic(err)
+		}
+	} else {
+		if err := generateNodeKeyFromPub(node.Name, node.Spec.IPCidr, node.Spec.PubKey, dir, c.nebulaCert); err != nil {
+			panic(err)
+		}
 	}
 	defer os.RemoveAll(dir)
 	if err := os.Remove(filepath.Join(dir, "ca.key")); err != nil {
@@ -316,12 +320,32 @@
 		"-out-key", filepath.Join(tmp, "ca.key"),
 		"-out-crt", filepath.Join(tmp, "ca.crt"),
 		"-out-qr", filepath.Join(tmp, "ca.png"))
-	if err := cmd.Run(); err != nil {
-		return "", err
+	if d, err := cmd.CombinedOutput(); err != nil {
+		return "", fmt.Errorf(string(d))
 	}
 	return tmp, nil
 }
 
+func generateNodeKeyFromPub(name, ip, pubKey, dir, nebulaCert string) error {
+	hostPub := filepath.Join(dir, "host.pub")
+	if err := ioutil.WriteFile(hostPub, []byte(pubKey), 0644); err != nil {
+		return err
+	}
+	defer os.Remove(hostPub)
+	cmd := exec.Command(nebulaCert, "sign",
+		"-ca-crt", filepath.Join(dir, "ca.crt"),
+		"-ca-key", filepath.Join(dir, "ca.key"),
+		"-name", name,
+		"-ip", ip,
+		"-in-pub", hostPub,
+		"-out-crt", filepath.Join(dir, "host.crt"),
+		"-out-qr", filepath.Join(dir, "host.png"))
+	if d, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf(string(d))
+	}
+	return nil
+}
+
 func generateNodeKey(name, ip, dir, nebulaCert string) error {
 	cmd := exec.Command(nebulaCert, "sign",
 		"-ca-crt", filepath.Join(dir, "ca.crt"),
@@ -338,45 +362,47 @@
 }
 
 func (c *NebulaController) getCA(namespace, name string) (*nebulav1.NebulaCA, error) {
-	s := labels.NewSelector()
-	r, err := labels.NewRequirement("metadata.namespace", selection.Equals, []string{namespace})
-	if err != nil {
-		panic(err)
-	}
-	r1, err := labels.NewRequirement("metadata.name", selection.Equals, []string{name})
-	if err != nil {
-		panic(err)
-	}
-	s.Add(*r, *r1)
-	ncas, err := c.caLister.List(s)
-	if err != nil {
-		panic(err)
-	}
-	if len(ncas) != 1 {
-		panic("err")
-	}
-	return ncas[0], nil
+	return c.caLister.NebulaCAs(namespace).Get(name)
+	// s := labels.NewSelector()
+	// r, err := labels.NewRequirement("metadata.namespace", selection.Equals, []string{namespace})
+	// if err != nil {
+	// 	panic(err)
+	// }
+	// r1, err := labels.NewRequirement("metadata.name", selection.Equals, []string{name})
+	// if err != nil {
+	// 	panic(err)
+	// }
+	// s.Add(*r, *r1)
+	// ncas, err := c.caLister.List(s)
+	// if err != nil {
+	// 	panic(err)
+	// }
+	// if len(ncas) != 1 {
+	// 	panic("err")
+	// }
+	// return ncas[0], nil
 }
 
 func (c *NebulaController) getNode(namespace, name string) (*nebulav1.NebulaNode, error) {
-	s := labels.NewSelector()
-	r, err := labels.NewRequirement("metadata.namespace", selection.Equals, []string{namespace})
-	if err != nil {
-		panic(err)
-	}
-	r1, err := labels.NewRequirement("metadata.name", selection.Equals, []string{name})
-	if err != nil {
-		panic(err)
-	}
-	s.Add(*r, *r1)
-	nodes, err := c.nodeLister.List(s)
-	if err != nil {
-		panic(err)
-	}
-	if len(nodes) != 1 {
-		panic("err")
-	}
-	return nodes[0], nil
+	return c.nodeLister.NebulaNodes(namespace).Get(name)
+	// s := labels.NewSelector()
+	// r, err := labels.NewRequirement("metadata.namespace", selection.Equals, []string{namespace})
+	// if err != nil {
+	// 	panic(err)
+	// }
+	// r1, err := labels.NewRequirement("metadata.name", selection.Equals, []string{name})
+	// if err != nil {
+	// 	panic(err)
+	// }
+	// s.Add(*r, *r1)
+	// nodes, err := c.nodeLister.List(s)
+	// if err != nil {
+	// 	panic(err)
+	// }
+	// if len(nodes) != 1 {
+	// 	panic("err")
+	// }
+	// return nodes[0], nil
 }
 
 func (c *NebulaController) getSecret(namespace, name string) (*corev1.Secret, error) {
diff --git a/core/nebula/crds/nebula.crds.yaml b/core/nebula/crds/nebula.crds.yaml
index d75cac0..c8de194 100644
--- a/core/nebula/crds/nebula.crds.yaml
+++ b/core/nebula/crds/nebula.crds.yaml
@@ -26,8 +26,6 @@
             spec:
               type: object
               properties:
-                caName:
-                  type: string
                 secretName:
                   type: string
             status:
@@ -68,10 +66,12 @@
               properties:
                 caName:
                   type: string
-                nodeName:
+                caNamespace:
                   type: string
                 ipCidr:
                   type: string
+                pubKey:
+                  type: string
                 secretName:
                   type: string
             status:
diff --git a/core/nebula/crds/test-ca.yaml b/core/nebula/crds/test-ca.yaml
deleted file mode 100644
index 19dcdab..0000000
--- a/core/nebula/crds/test-ca.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-apiVersion: lekva.me/v1
-kind: NebulaCA
-metadata:
-  name: test
-  namespace: test-nebula
-spec:
-  caName: lekva-pcloud
-  secretName: lekva-pcloud-ca
diff --git a/core/nebula/crds/test-node.yaml b/core/nebula/crds/test-node.yaml
deleted file mode 100644
index bcabecf..0000000
--- a/core/nebula/crds/test-node.yaml
+++ /dev/null
@@ -1,10 +0,0 @@
-apiVersion: lekva.me/v1
-kind: NebulaNode
-metadata:
-  name: test-host
-  namespace: test-nebula
-spec:
-  caName: lekva-pcloud
-  nodeName: lighthouse
-  ipCidr: "111.0.0.1/24"
-  secretName: node-lighthouse
diff --git a/core/nebula/go.mod b/core/nebula/go.mod
index 032bd23..ea547d2 100644
--- a/core/nebula/go.mod
+++ b/core/nebula/go.mod
@@ -3,6 +3,7 @@
 go 1.16
 
 require (
+	github.com/gorilla/mux v1.8.0
 	k8s.io/api v0.22.2
 	k8s.io/apimachinery v0.22.2
 	k8s.io/client-go v0.22.2
diff --git a/core/nebula/go.sum b/core/nebula/go.sum
index 8cf33e5..0bfb743 100644
--- a/core/nebula/go.sum
+++ b/core/nebula/go.sum
@@ -129,6 +129,8 @@
 github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU=
 github.com/googleapis/gnostic v0.5.5 h1:9fHAtK0uDfpveeqqo1hkEZJcFvYXAiCN3UutL8F9xHw=
 github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA=
+github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
+github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
 github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
diff --git a/core/nebula/templates/index.html b/core/nebula/templates/index.html
new file mode 100644
index 0000000..ac5ae32
--- /dev/null
+++ b/core/nebula/templates/index.html
@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8" />
+    <title>Nebula Mesh VPN Manager</title>
+</head>
+<body>
+    <form action="/sign-node" method="POST">
+	<label for="ca-name">CA Name:</label><br />
+	<input type="text" name="ca-name" /><br />
+	<label for="ca-namespace">CA Namespace:</label><br />
+	<input type="text" name="ca-namespace" /><br />
+	<label for="node-name">Node Name:</label><br />
+	<input type="text" name="node-name" /><br />
+	<label for="node-namespace">Node Namespace:</label><br />
+	<input type="text" name="node-namespace" /><br />
+	<label for="ip-cidr">IP/CIDR:</label><br />
+	<input type="text" name="ip-cidr" /><br />
+	<label for="pub-key">Public Key:</label><br />
+	<textarea name="pub-key">Put node public key here</textarea><br />
+	<input type="submit" value="Sign node key" />
+    </form>
+    {{range .}}
+    <a href="/ca/{{.Namespace}}/{{.Name}}"><h1>{{.Name}}</h1></a>
+    <table>
+	<tr>
+	    <th>Node</th>
+	    <th>IP</th>
+	</tr>
+	{{range .Nodes}}
+	<tr>
+	    <td>
+		<a href="/node/{{.Namespace}}/{{.Name}}">{{.Name}}</a>
+	    </td>
+	    <td>
+		{{.IP}}
+	    </td>
+	</tr>
+	{{end}}
+    </table>
+    {{end}}
+</body>
+</html>
+
diff --git a/core/nebula/web.go b/core/nebula/web.go
new file mode 100644
index 0000000..8738cd4
--- /dev/null
+++ b/core/nebula/web.go
@@ -0,0 +1,225 @@
+package main
+
+import (
+	"context"
+	"embed"
+	"flag"
+	"fmt"
+	"html/template"
+	"log"
+	"net/http"
+
+	"github.com/gorilla/mux"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/tools/clientcmd"
+
+	nebulav1 "github.com/giolekva/pcloud/core/nebula/apis/nebula/v1"
+	clientset "github.com/giolekva/pcloud/core/nebula/generated/clientset/versioned"
+)
+
+var port = flag.Int("port", 8080, "Port to listen on.")
+var kubeConfig = flag.String("kubeconfig", "", "Path to a kubeconfig. Only required if out-of-cluster.")
+var masterURL = flag.String("master", "", "The address of the Kubernetes API server. Overrides any value in kubeconfig. Only required if out-of-cluster.")
+
+//go:embed templates/*
+var tmpls embed.FS
+
+type Templates struct {
+	Index *template.Template
+}
+
+func ParseTemplates(fs embed.FS) (*Templates, error) {
+	index, err := template.ParseFS(fs, "templates/index.html")
+	if err != nil {
+		return nil, err
+	}
+	return &Templates{index}, nil
+}
+
+type nebulaCA struct {
+	Name      string
+	Namespace string
+	Nodes     []nebulaNode
+}
+
+type nebulaNode struct {
+	Name      string
+	Namespace string
+	IP        string
+}
+
+type Manager struct {
+	kubeClient   kubernetes.Interface
+	nebulaClient clientset.Interface
+}
+
+func (m *Manager) ListAll() ([]*nebulaCA, error) {
+	ret := make([]*nebulaCA, 0)
+	cas, err := m.nebulaClient.LekvaV1().NebulaCAs("").List(context.TODO(), metav1.ListOptions{})
+	if err != nil {
+		return nil, err
+	}
+	for _, ca := range cas.Items {
+		ret = append(ret, &nebulaCA{
+			Name:      ca.Name,
+			Namespace: ca.Namespace,
+			Nodes:     make([]nebulaNode, 0),
+		})
+	}
+	nodes, err := m.nebulaClient.LekvaV1().NebulaNodes("").List(context.TODO(), metav1.ListOptions{})
+	if err != nil {
+		return nil, err
+	}
+	for _, node := range nodes.Items {
+		for _, ca := range ret {
+			if ca.Name == node.Spec.CAName {
+				ca.Nodes = append(ca.Nodes, nebulaNode{
+					Name:      node.Name,
+					Namespace: node.Namespace,
+					IP:        node.Spec.IPCidr,
+				})
+			}
+		}
+	}
+	return ret, nil
+}
+
+func (m *Manager) createNode(namespace, name, caNamespace, caName, ipCidr, pubKey string) (string, string, error) {
+	node := &nebulav1.NebulaNode{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: namespace,
+		},
+		Spec: nebulav1.NebulaNodeSpec{
+			CAName:      caName,
+			CANamespace: caNamespace,
+			IPCidr:      ipCidr,
+			PubKey:      pubKey,
+			SecretName:  fmt.Sprintf("%s-cert", name),
+		},
+	}
+	node, err := m.nebulaClient.LekvaV1().NebulaNodes(namespace).Create(context.TODO(), node, metav1.CreateOptions{})
+	if err != nil {
+		return "", "", err
+	}
+	return node.Namespace, node.Name, nil
+}
+
+func (m *Manager) getNodeCertQR(namespace, name string) ([]byte, error) {
+	node, err := m.nebulaClient.LekvaV1().NebulaNodes(namespace).Get(context.TODO(), name, metav1.GetOptions{})
+	if err != nil {
+		return nil, err
+	}
+	secret, err := m.kubeClient.CoreV1().Secrets(namespace).Get(context.TODO(), node.Spec.SecretName, metav1.GetOptions{})
+	if err != nil {
+		return nil, err
+	}
+	return secret.Data["host.png"], nil
+}
+
+func (m *Manager) getCACertQR(namespace, name string) ([]byte, error) {
+	ca, err := m.nebulaClient.LekvaV1().NebulaCAs(namespace).Get(context.TODO(), name, metav1.GetOptions{})
+	if err != nil {
+		return nil, err
+	}
+	secret, err := m.kubeClient.CoreV1().Secrets(namespace).Get(context.TODO(), ca.Spec.SecretName, metav1.GetOptions{})
+	if err != nil {
+		return nil, err
+	}
+	return secret.Data["ca.png"], nil
+}
+
+type Handler struct {
+	mgr   Manager
+	tmpls *Templates
+}
+
+func (h *Handler) handleIndex(w http.ResponseWriter, r *http.Request) {
+	cas, err := h.mgr.ListAll()
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := h.tmpls.Index.Execute(w, cas); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+	}
+}
+
+func (h *Handler) handleNode(w http.ResponseWriter, r *http.Request) {
+	vars := mux.Vars(r)
+	namespace := vars["namespace"]
+	name := vars["name"]
+	qr, err := h.mgr.getNodeCertQR(namespace, name)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+	}
+	w.Header().Set("Content-Type", "img/png")
+	w.Write(qr)
+}
+
+func (h *Handler) handleCA(w http.ResponseWriter, r *http.Request) {
+	vars := mux.Vars(r)
+	namespace := vars["namespace"]
+	name := vars["name"]
+	qr, err := h.mgr.getCACertQR(namespace, name)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+	}
+	w.Header().Set("Content-Type", "img/png")
+	w.Write(qr)
+}
+
+func (h *Handler) handleSignNode(w http.ResponseWriter, r *http.Request) {
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	_, _, err := h.mgr.createNode(
+		r.FormValue("node-namespace"),
+		r.FormValue("node-name"),
+		r.FormValue("ca-namespace"),
+		r.FormValue("ca-name"),
+		r.FormValue("ip-cidr"),
+		r.FormValue("pub-key"),
+	)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+func main() {
+	flag.Parse()
+	cfg, err := clientcmd.BuildConfigFromFlags(*masterURL, *kubeConfig)
+	if err != nil {
+		panic(err)
+	}
+	kubeClient, err := kubernetes.NewForConfig(cfg)
+	if err != nil {
+		panic(err)
+	}
+	nebulaClient := clientset.NewForConfigOrDie(cfg)
+	t, err := ParseTemplates(tmpls)
+	if err != nil {
+		log.Fatal(err)
+	}
+	mgr := Manager{
+		kubeClient:   kubeClient,
+		nebulaClient: nebulaClient,
+	}
+	handler := Handler{
+		mgr:   mgr,
+		tmpls: t,
+	}
+	r := mux.NewRouter()
+	r.HandleFunc("/node/{namespace:[a-zA-z0-9-]+}/{name:[a-zA-z0-9-]+}", handler.handleNode)
+	r.HandleFunc("/ca/{namespace:[a-zA-z0-9-]+}/{name:[a-zA-z0-9-]+}", handler.handleCA)
+	r.HandleFunc("/sign-node", handler.handleSignNode)
+	r.HandleFunc("/", handler.handleIndex)
+	http.Handle("/", r)
+	fmt.Printf("Starting HTTP server on port: %d\n", *port)
+	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
+}