Nebula: separate controller from api
diff --git a/core/nebula/api/Dockerfile b/core/nebula/api/Dockerfile
new file mode 100644
index 0000000..6c5a573
--- /dev/null
+++ b/core/nebula/api/Dockerfile
@@ -0,0 +1,6 @@
+FROM alpine:latest
+
+ARG TARGETARCH
+
+COPY web_${TARGETARCH} /usr/bin/nebula-web
+RUN chmod +x /usr/bin/nebula-web
diff --git a/core/nebula/api/Makefile b/core/nebula/api/Makefile
new file mode 100644
index 0000000..3898183
--- /dev/null
+++ b/core/nebula/api/Makefile
@@ -0,0 +1,21 @@
+clean:
+	rm -f api_*
+
+api_arm64: export CGO_ENABLED=0
+api_arm64: export GO111MODULE=on
+api_arm64: export GOOS=linux
+api_arm64: export GOARCH=arm64
+api_arm64:
+	go1.17.1 build -o api_arm64 main.go
+
+api_amd64: export CGO_ENABLED=0
+api_amd64: export GO111MODULE=on
+api_amd64: export GOOS=linux
+api_amd64: export GOARCH=amd64
+api_amd64:
+	go1.17.1 build -o api_amd64 main.go
+
+api: clean api_arm64 api_amd64
+
+push: api
+	docker buildx build --tag=giolekva/nebula-api:latest . --platform=linux/arm64,linux/amd64 --push
diff --git a/core/nebula/api/go.mod b/core/nebula/api/go.mod
new file mode 100644
index 0000000..67cf6bf
--- /dev/null
+++ b/core/nebula/api/go.mod
@@ -0,0 +1,3 @@
+module github.com/giolekva/pcloud/core/nebula/api
+
+go 1.17
diff --git a/core/nebula/api/main.go b/core/nebula/api/main.go
new file mode 100644
index 0000000..ed09135
--- /dev/null
+++ b/core/nebula/api/main.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/controller/apis/nebula/v1"
+	clientset "github.com/giolekva/pcloud/core/nebula/controller/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))
+}
diff --git a/core/nebula/api/templates/index.html b/core/nebula/api/templates/index.html
new file mode 100644
index 0000000..ac5ae32
--- /dev/null
+++ b/core/nebula/api/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>
+