DNS: run separate CoreDNS instance for each PCloud env.

Previously shared CoreDNS instance was used to handle all domains. This has multiple downsides, most important which is security. For example DNS-Sec keys of all domains were persisted on the same shared volume. Also key itself was generated by PCloud env-manager as part of bootstrapping new env. Which is counter to the main aspirations of PCloud, that environment internal private data must not leak outside of the environment.

With new approach implemented in this change, environment starts up it’s own CoreDNS and DNS record manager servers. Manager generates dns-sec keys internally and only exposes public information to the outside world. PCloud infrastructure runes another instance of CoreDNS which acts as a proxy service forwarding requests to individual environments based an requested domain.

This simplifies DNS based TLS challenge solvers, as private certificate issuer of each env will point directly to the DNS record manager of the same environment.

Change-Id: Ifb0f36d2a133e3b53da22030cc7d6b9099136b3d
diff --git a/core/installer/tasks/activate.go b/core/installer/tasks/activate.go
index ccbdbb8..6916262 100644
--- a/core/installer/tasks/activate.go
+++ b/core/installer/tasks/activate.go
@@ -10,12 +10,13 @@
 	"text/template"
 
 	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
 //go:embed env-tmpl
 var filesTmpls embed.FS
 
-func NewActivateEnvTask(env Env, st *state) Task {
+func NewActivateEnvTask(env installer.EnvConfig, st *state) Task {
 	return newSequentialParentTask(
 		"Activate GitOps",
 		false,
@@ -24,19 +25,19 @@
 	)
 }
 
-func AddNewEnvTask(env Env, st *state) Task {
+func AddNewEnvTask(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Commit initial configuration", func() error {
 		ssPublicKeys, err := st.ssClient.GetPublicKeys()
 		if err != nil {
 			return err
 		}
-		repoHost := strings.Split(st.ssClient.Addr, ":")[0]
-		return st.repo.Do(func(r installer.RepoFS) (string, error) {
-			kust, err := installer.ReadKustomization(r, "environments/kustomization.yaml")
+		repoHost := strings.Split(st.ssClient.Address(), ":")[0]
+		return st.repo.Do(func(r soft.RepoFS) (string, error) {
+			kust, err := soft.ReadKustomization(r, "environments/kustomization.yaml")
 			if err != nil {
 				return "", err
 			}
-			kust.AddResources(env.Name)
+			kust.AddResources(env.Id)
 			tmpls, err := template.ParseFS(filesTmpls, "env-tmpl/*.yaml")
 			if err != nil {
 				return "", err
@@ -46,14 +47,14 @@
 				fmt.Fprintf(&knownHosts, "%s %s\n", repoHost, key)
 			}
 			for _, tmpl := range tmpls.Templates() { // TODO(gio): migrate to cue
-				dstPath := path.Join("environments", env.Name, tmpl.Name())
+				dstPath := path.Join("environments", env.Id, tmpl.Name())
 				dst, err := r.Writer(dstPath)
 				if err != nil {
 					return "", err
 				}
 				defer dst.Close()
 				if err := tmpl.Execute(dst, map[string]string{
-					"Name":       env.Name,
+					"Name":       env.Id,
 					"PrivateKey": base64.StdEncoding.EncodeToString(st.keys.RawPrivateKey()),
 					"PublicKey":  base64.StdEncoding.EncodeToString(st.keys.RawAuthorizedKey()),
 					"RepoHost":   repoHost,
@@ -63,10 +64,10 @@
 					return "", err
 				}
 			}
-			if err := installer.WriteYaml(r, "environments/kustomization.yaml", kust); err != nil {
+			if err := soft.WriteYaml(r, "environments/kustomization.yaml", kust); err != nil {
 				return "", err
 			}
-			return fmt.Sprintf("%s: initialize environment", env.Name), nil
+			return fmt.Sprintf("%s: initialize environment", env.Id), nil
 		})
 	})
 	return &t
diff --git a/core/installer/tasks/dns.go b/core/installer/tasks/dns.go
index 27e044d..51b066a 100644
--- a/core/installer/tasks/dns.go
+++ b/core/installer/tasks/dns.go
@@ -2,24 +2,24 @@
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"net"
-	"text/template"
+	"strings"
 	"time"
 
-	"github.com/Masterminds/sprig/v3"
-
 	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/dns"
 )
 
 type Check func(ch Check) error
 
-func SetupZoneTask(env Env, ingressIP net.IP, st *state) Task {
+func SetupZoneTask(env installer.EnvConfig, mgr *installer.InfraAppManager, st *state) Task {
 	ret := newSequentialParentTask(
 		"Configure DNS",
 		true,
-		CreateZoneRecords(env.Domain, st.publicIPs, ingressIP, env, st),
-		WaitToPropagate(env.Domain, st.publicIPs),
+		SetupDNSServer(env, st),
+		WaitToPropagate(st.dnsClient, env.Domain, env.PublicIP),
 	)
 	ret.beforeStart = func() {
 		st.infoListener(fmt.Sprintf("Generating DNS zone records for %s", env.Domain))
@@ -30,100 +30,86 @@
 	return ret
 }
 
-func CreateZoneRecords(
-	name string,
-	expected []net.IP,
-	ingressIP net.IP,
-	env Env,
-	st *state,
-) Task {
-	t := newLeafTask("Generate and publish DNS records", func() error {
-		key, err := newDNSSecKey(env.Domain)
-		if err != nil {
-			return err
-		}
-		repo, err := st.ssClient.GetRepo("config")
-		if err != nil {
-			return err
-		}
-		r, err := installer.NewRepoIO(repo, st.ssClient.Signer)
-		if err != nil {
-			return err
-		}
-		return r.Do(func(r installer.RepoFS) (string, error) {
-			{
-				out, err := r.Writer("dns-zone.yaml")
-				if err != nil {
-					return "", err
-				}
-				defer out.Close()
-				dnsZoneTmpl, err := template.New("config").Funcs(sprig.TxtFuncMap()).Parse(`
-apiVersion: dodo.cloud.dodo.cloud/v1
-kind: DNSZone
-metadata:
-  name: dns-zone
-  namespace: {{ .namespace }}
-spec:
-  zone: {{ .zone }}
-  privateIP: {{ .ingressIP }}
-  publicIPs:
-{{ range .publicIPs }}
-  - {{ .String }}
-{{ end }}
-  nameservers:
-{{ range .publicIPs }}
-  - {{ .String }}
-{{ end }}
-  dnssec:
-    enabled: true
-    secretName: dnssec-key
----
-apiVersion: v1
-kind: Secret
-metadata:
-  name: dnssec-key
-  namespace: {{ .namespace }}
-type: Opaque
-data:
-  basename: {{ .dnssec.Basename | b64enc }}
-  key: {{ .dnssec.Key | toString | b64enc }}
-  private: {{ .dnssec.Private | toString | b64enc }}
-  ds: {{ .dnssec.DS | toString | b64enc }}
-`)
-				if err != nil {
-					return "", err
-				}
-				if err := dnsZoneTmpl.Execute(out, map[string]any{
-					"namespace": env.Name,
-					"zone":      env.Domain,
-					"dnssec":    key,
-					"publicIPs": st.publicIPs,
-					"ingressIP": ingressIP.String(),
-				}); err != nil {
-					return "", err
-				}
-				rootKust, err := installer.ReadKustomization(r, "kustomization.yaml")
-				if err != nil {
-					return "", err
-				}
-				rootKust.AddResources("dns-zone.yaml")
-				if err := installer.WriteYaml(r, "kustomization.yaml", rootKust); err != nil {
-					return "", err
-				}
-				return "configure dns zone", nil
+func join[T fmt.Stringer](items []T, sep string) string {
+	var tmp []string
+	for _, i := range items {
+		tmp = append(tmp, i.String())
+	}
+	return strings.Join(tmp, ",")
+}
+
+func SetupDNSServer(env installer.EnvConfig, st *state) Task {
+	t := newLeafTask("Start up DNS server", func() error {
+		addressPool := fmt.Sprintf("%s-dns", env.Id)
+		{
+			app, err := installer.FindEnvApp(st.appsRepo, "env-dns")
+			if err != nil {
+				return err
 			}
-		})
+			instanceId := app.Name()
+			appDir := fmt.Sprintf("/apps/%s", instanceId)
+			namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
+			if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
+				"addressPool":  addressPool,
+				"inClusterIP":  env.Network.DNSInClusterIP.String(),
+				"publicIP":     join(env.PublicIP, ","),
+				"privateIP":    env.Network.Ingress.String(),
+				"nameserverIP": join(env.NameserverIP, ","),
+			}); err != nil {
+				return err
+			}
+		}
+		{
+			app, err := installer.FindInfraApp(st.appsRepo, "dns-gateway")
+			if err != nil {
+				return err
+			}
+			cfg, err := st.infraAppManager.FindInstance("dns-gateway")
+			if err != nil {
+				return err
+			}
+			serversJSON, ok := cfg.Values["servers"]
+			if !ok {
+				serversJSON = []installer.EnvDNS{}
+			}
+			serversTmp, err := json.Marshal(serversJSON)
+			if err != nil {
+				return err
+			}
+			servers := []installer.EnvDNS{}
+			if err := json.Unmarshal(serversTmp, &servers); err != nil {
+				return err
+			}
+			servers = append(servers, installer.EnvDNS{
+				env.Domain,
+				env.Network.DNSInClusterIP.String(),
+			})
+			if err := st.infraAppManager.Update(app, "dns-gateway", map[string]any{
+				"servers": servers,
+			}); err != nil {
+				return err
+			}
+		}
+		{
+			for {
+				if _, err := st.dnsFetcher.Fetch(fmt.Sprintf("http://dns-api.%sdns.svc.cluster.local/records-to-publish", env.NamespacePrefix)); err != nil {
+					time.Sleep(5 * time.Second)
+				} else {
+					break
+				}
+			}
+		}
+		return nil
 	})
 	return &t
 }
 
 func WaitToPropagate(
+	client dns.Client,
 	name string,
 	expected []net.IP,
 ) Task {
 	t := newLeafTask("Wait to propagate", func() error {
-		time.Sleep(2 * time.Minute)
-		return nil
 		ctx := context.TODO()
 		gotExpectedIPs := func(actual []net.IP) bool {
 			for _, a := range actual {
@@ -141,7 +127,7 @@
 			return true
 		}
 		check := func(check Check) error {
-			addrs, err := net.LookupIP(name)
+			addrs, err := client.Lookup(name)
 			fmt.Printf("DNS LOOKUP: %+v\n", addrs)
 			if err == nil && gotExpectedIPs(addrs) {
 				return err
diff --git a/core/installer/tasks/env.go b/core/installer/tasks/env.go
index 68fbe57..a38e5b1 100644
--- a/core/installer/tasks/env.go
+++ b/core/installer/tasks/env.go
@@ -3,21 +3,25 @@
 import (
 	"context"
 	"fmt"
-	"net"
 
 	"github.com/charmbracelet/keygen"
 
 	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/dns"
+	"github.com/giolekva/pcloud/core/installer/http"
 	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
 type state struct {
 	infoListener    EnvInfoListener
-	publicIPs       []net.IP
 	nsCreator       installer.NamespaceCreator
-	repo            installer.RepoIO
+	dnsFetcher      installer.ZoneStatusFetcher
+	httpClient      http.Client
+	dnsClient       dns.Client
+	repo            soft.RepoIO
+	repoClient      soft.ClientGetter
 	ssAdminKeys     *keygen.KeyPair
-	ssClient        *soft.Client
+	ssClient        soft.Client
 	fluxUserName    string
 	keys            *keygen.KeyPair
 	appManager      *installer.AppManager
@@ -25,44 +29,35 @@
 	infraAppManager *installer.InfraAppManager
 }
 
-type Env struct {
-	PCloudEnvName   string
-	Name            string
-	ContactEmail    string
-	Domain          string
-	AdminPublicKey  string
-	NamespacePrefix string
-}
-
 type EnvInfoListener func(string)
 
-type DNSZoneRef struct {
-	Name      string
-	Namespace string
-}
-
 func NewCreateEnvTask(
-	env Env,
-	publicIPs []net.IP,
-	startIP net.IP,
+	env installer.EnvConfig,
 	nsCreator installer.NamespaceCreator,
-	repo installer.RepoIO,
+	dnsFetcher installer.ZoneStatusFetcher,
+	httpClient http.Client,
+	dnsClient dns.Client,
+	repo soft.RepoIO,
+	repoClient soft.ClientGetter,
 	mgr *installer.InfraAppManager,
 	infoListener EnvInfoListener,
-) (Task, DNSZoneRef) {
+) (Task, installer.EnvDNS) {
 	st := state{
 		infoListener:    infoListener,
-		publicIPs:       publicIPs,
 		nsCreator:       nsCreator,
+		dnsFetcher:      dnsFetcher,
+		httpClient:      httpClient,
+		dnsClient:       dnsClient,
 		repo:            repo,
+		repoClient:      repoClient,
 		infraAppManager: mgr,
 	}
 	t := newSequentialParentTask(
 		"Create env",
 		true,
 		SetupConfigRepoTask(env, &st),
-		SetupZoneTask(env, startIP, &st),
-		SetupInfra(env, startIP, &st),
+		SetupZoneTask(env, mgr, &st),
+		SetupInfra(env, &st),
 	)
 	t.afterDone = func() {
 		infoListener(fmt.Sprintf("dodo environment for %s has been provisioned successfully. Visit [https://welcome.%s](https://welcome.%s) to create administrative account and log into the system.", env.Domain, env.Domain, env.Domain))
@@ -73,13 +68,16 @@
 	})
 	pr := NewFluxcdReconciler( // TODO(gio): make reconciler address a flag
 		"http://fluxcd-reconciler.dodo-fluxcd-reconciler.svc.cluster.local",
-		fmt.Sprintf("%s-flux", env.PCloudEnvName),
+		fmt.Sprintf("%s-flux", env.InfraName),
 	)
 	er := NewFluxcdReconciler(
 		"http://fluxcd-reconciler.dodo-fluxcd-reconciler.svc.cluster.local",
-		env.Name,
+		env.Id,
 	)
 	go pr.Reconcile(rctx)
 	go er.Reconcile(rctx)
-	return t, DNSZoneRef{"dns-zone", env.Name}
+	return t, installer.EnvDNS{
+		Zone:    env.Domain,
+		Address: fmt.Sprintf("http://dns-api.%sdns.svc.cluster.local/records-to-publish", env.NamespacePrefix),
+	}
 }
diff --git a/core/installer/tasks/infra.go b/core/installer/tasks/infra.go
index 987ff9c..0327e4c 100644
--- a/core/installer/tasks/infra.go
+++ b/core/installer/tasks/infra.go
@@ -2,24 +2,19 @@
 
 import (
 	"fmt"
-	"net"
-	"net/netip"
 	"strings"
 
 	"github.com/miekg/dns"
 
 	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
 var initGroups = []string{"admin"}
 
-func CreateRepoClient(env Env, st *state) Task {
+func CreateRepoClient(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Create repo client", func() error {
-		repo, err := st.ssClient.GetRepo("config")
-		if err != nil {
-			return err
-		}
-		r, err := installer.NewRepoIO(repo, st.ssClient.Signer)
+		r, err := st.ssClient.GetRepo("config")
 		if err != nil {
 			return err
 		}
@@ -37,46 +32,30 @@
 	return &t
 }
 
-func SetupInfra(env Env, startIP net.IP, st *state) Task {
+func SetupInfra(env installer.EnvConfig, st *state) Task {
 	return newConcurrentParentTask(
 		"Setup core services",
 		true,
-		SetupNetwork(env, startIP, st),
+		SetupNetwork(env, st),
 		SetupCertificateIssuers(env, st),
 		SetupAuth(env, st),
 		SetupGroupMemberships(env, st),
-		SetupHeadscale(env, startIP, st),
+		SetupHeadscale(env, st),
 		SetupWelcome(env, st),
 		SetupAppStore(env, st),
 		SetupLauncher(env, st),
 	)
 }
 
-func CommitEnvironmentConfiguration(env Env, st *state) Task {
+func CommitEnvironmentConfiguration(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("commit config", func() error {
-		repo, err := st.ssClient.GetRepo("config")
+		r, err := st.ssClient.GetRepo("config")
 		if err != nil {
 			return err
 		}
-		r, err := installer.NewRepoIO(repo, st.ssClient.Signer)
-		if err != nil {
-			return err
-		}
-		r.Do(func(r installer.RepoFS) (string, error) {
-			{
-				// TODO(giolekva): private domain can be configurable as well
-				config := installer.AppEnvConfig{
-					Id:              env.Name,
-					InfraName:       env.PCloudEnvName,
-					Domain:          env.Domain,
-					PrivateDomain:   fmt.Sprintf("p.%s", env.Domain),
-					ContactEmail:    env.ContactEmail,
-					PublicIP:        st.publicIPs,
-					NamespacePrefix: fmt.Sprintf("%s-", env.Name),
-				}
-				if err := installer.WriteYaml(r, "config.yaml", config); err != nil {
-					return "", err
-				}
+		r.Do(func(r soft.RepoFS) (string, error) {
+			if err := soft.WriteYaml(r, "config.yaml", env); err != nil {
+				return "", err
 			}
 			out, err := r.Writer("pcloud-charts.yaml")
 			if err != nil {
@@ -94,16 +73,16 @@
   url: https://github.com/giolekva/pcloud
   ref:
     branch: main
-`, env.Name)
+`, env.Id)
 			if err != nil {
 				return "", err
 			}
-			rootKust, err := installer.ReadKustomization(r, "kustomization.yaml")
+			rootKust, err := soft.ReadKustomization(r, "kustomization.yaml")
 			if err != nil {
 				return "", err
 			}
 			rootKust.AddResources("pcloud-charts.yaml")
-			if err := installer.WriteYaml(r, "kustomization.yaml", rootKust); err != nil {
+			if err := soft.WriteYaml(r, "kustomization.yaml", rootKust); err != nil {
 				return "", err
 			}
 			return "configure charts repo", nil
@@ -118,19 +97,15 @@
 	Groups  []string `json:"groups"`
 }
 
-func ConfigureFirstAccount(env Env, st *state) Task {
+func ConfigureFirstAccount(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Configure first account settings", func() error {
-		repo, err := st.ssClient.GetRepo("config")
+		r, err := st.ssClient.GetRepo("config")
 		if err != nil {
 			return err
 		}
-		r, err := installer.NewRepoIO(repo, st.ssClient.Signer)
-		if err != nil {
-			return err
-		}
-		return r.Do(func(r installer.RepoFS) (string, error) {
+		return r.Do(func(r soft.RepoFS) (string, error) {
 			fa := firstAccount{false, initGroups}
-			if err := installer.WriteYaml(r, "first-account.yaml", fa); err != nil {
+			if err := soft.WriteYaml(r, "first-account.yaml", fa); err != nil {
 				return "", err
 			}
 			return "first account membership configuration", nil
@@ -139,32 +114,9 @@
 	return &t
 }
 
-func SetupNetwork(env Env, startIP net.IP, st *state) Task {
+func SetupNetwork(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Setup private and public networks", func() error {
-		startAddr, err := netip.ParseAddr(startIP.String())
-		if err != nil {
-			return err
-		}
-		if !startAddr.Is4() {
-			return fmt.Errorf("Expected IPv4, got %s instead", startAddr)
-		}
-		addr := startAddr.AsSlice()
-		if addr[3] != 0 {
-			return fmt.Errorf("Expected last byte to be zero, got %d instead", addr[3])
-		}
-		addr[3] = 10
-		fromIP, ok := netip.AddrFromSlice(addr)
-		if !ok {
-			return fmt.Errorf("Must not reach")
-		}
-		addr[3] = 254
-		toIP, ok := netip.AddrFromSlice(addr)
-		if !ok {
-			return fmt.Errorf("Must not reach")
-		}
 		{
-			ingressPrivateIP := startAddr
-			headscaleIP := ingressPrivateIP.Next()
 			app, err := installer.FindEnvApp(st.appsRepo, "metallb-ipaddresspool")
 			if err != nil {
 				return err
@@ -174,9 +126,9 @@
 				appDir := fmt.Sprintf("/apps/%s", instanceId)
 				namespace := fmt.Sprintf("%s%s-ingress-private", env.NamespacePrefix, app.Namespace())
 				if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
-					"name":       fmt.Sprintf("%s-ingress-private", env.Name),
-					"from":       ingressPrivateIP.String(),
-					"to":         ingressPrivateIP.String(),
+					"name":       fmt.Sprintf("%s-ingress-private", env.Id),
+					"from":       env.Network.Ingress.String(),
+					"to":         env.Network.Ingress.String(),
 					"autoAssign": false,
 					"namespace":  "metallb-system",
 				}); err != nil {
@@ -188,9 +140,9 @@
 				appDir := fmt.Sprintf("/apps/%s", instanceId)
 				namespace := fmt.Sprintf("%s%s-ingress-private", env.NamespacePrefix, app.Namespace())
 				if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
-					"name":       fmt.Sprintf("%s-headscale", env.Name),
-					"from":       headscaleIP.String(),
-					"to":         headscaleIP.String(),
+					"name":       fmt.Sprintf("%s-headscale", env.Id),
+					"from":       env.Network.Headscale.String(),
+					"to":         env.Network.Headscale.String(),
 					"autoAssign": false,
 					"namespace":  "metallb-system",
 				}); err != nil {
@@ -202,9 +154,9 @@
 				appDir := fmt.Sprintf("/apps/%s", instanceId)
 				namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
 				if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
-					"name":       env.Name,
-					"from":       fromIP.String(),
-					"to":         toIP.String(),
+					"name":       env.Id,
+					"from":       env.Network.ServicesFrom.String(),
+					"to":         env.Network.ServicesTo.String(),
 					"autoAssign": false,
 					"namespace":  "metallb-system",
 				}); err != nil {
@@ -217,7 +169,7 @@
 			if err != nil {
 				return err
 			}
-			user := fmt.Sprintf("%s-port-allocator", env.Name)
+			user := fmt.Sprintf("%s-port-allocator", env.Id)
 			if err := st.ssClient.AddUser(user, keys.AuthorizedKey()); err != nil {
 				return err
 			}
@@ -235,7 +187,7 @@
 				"privateNetwork": map[string]any{
 					"hostname": "private-network-proxy",
 					"username": "private-network-proxy",
-					"ipSubnet": fmt.Sprintf("%s/24", startIP.String()),
+					"ipSubnet": fmt.Sprintf("%s.0/24", strings.Join(strings.Split(env.Network.DNS.String(), ".")[:3], ".")),
 				},
 				"sshPrivateKey": string(keys.RawPrivateKey()),
 			}); err != nil {
@@ -247,7 +199,7 @@
 	return &t
 }
 
-func SetupCertificateIssuers(env Env, st *state) Task {
+func SetupCertificateIssuers(env installer.EnvConfig, st *state) Task {
 	pub := newLeafTask(fmt.Sprintf("Public %s", env.Domain), func() error {
 		app, err := installer.FindEnvApp(st.appsRepo, "certificate-issuer-public")
 		if err != nil {
@@ -269,12 +221,7 @@
 		instanceId := app.Name()
 		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
-			"apiConfigMap": map[string]any{
-				"name":      "api-config", // TODO(gio): take from global pcloud config
-				"namespace": fmt.Sprintf("%s-dns-zone-manager", env.PCloudEnvName),
-			},
-		}); err != nil {
+		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{}); err != nil {
 			return err
 		}
 		return nil
@@ -282,7 +229,7 @@
 	return newSequentialParentTask("Configure TLS certificate issuers", false, &pub, &priv)
 }
 
-func SetupAuth(env Env, st *state) Task {
+func SetupAuth(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Setup", func() error {
 		app, err := installer.FindEnvApp(st.appsRepo, "core-auth")
 		if err != nil {
@@ -302,11 +249,11 @@
 		"Authentication services",
 		false,
 		&t,
-		waitForAddr(fmt.Sprintf("https://accounts-ui.%s", env.Domain)),
+		waitForAddr(st.httpClient, fmt.Sprintf("https://accounts-ui.%s", env.Domain)),
 	)
 }
 
-func SetupGroupMemberships(env Env, st *state) Task {
+func SetupGroupMemberships(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Setup", func() error {
 		app, err := installer.FindEnvApp(st.appsRepo, "memberships")
 		if err != nil {
@@ -326,11 +273,11 @@
 		"Group membership",
 		false,
 		&t,
-		waitForAddr(fmt.Sprintf("https://memberships.p.%s", env.Domain)),
+		waitForAddr(st.httpClient, fmt.Sprintf("https://memberships.p.%s", env.Domain)),
 	)
 }
 
-func SetupHeadscale(env Env, startIP net.IP, st *state) Task {
+func SetupHeadscale(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Setup", func() error {
 		app, err := installer.FindEnvApp(st.appsRepo, "headscale")
 		if err != nil {
@@ -341,7 +288,7 @@
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
 		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 			"subdomain": "headscale",
-			"ipSubnet":  fmt.Sprintf("%s/24", startIP),
+			"ipSubnet":  fmt.Sprintf("%s/24", env.Network.DNS.String()),
 		}); err != nil {
 			return err
 		}
@@ -351,17 +298,17 @@
 		"Setup mesh VPN",
 		false,
 		&t,
-		waitForAddr(fmt.Sprintf("https://headscale.%s/apple", env.Domain)),
+		waitForAddr(st.httpClient, fmt.Sprintf("https://headscale.%s/apple", env.Domain)),
 	)
 }
 
-func SetupWelcome(env Env, st *state) Task {
+func SetupWelcome(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Setup", func() error {
 		keys, err := installer.NewSSHKeyPair("welcome")
 		if err != nil {
 			return err
 		}
-		user := fmt.Sprintf("%s-welcome", env.Name)
+		user := fmt.Sprintf("%s-welcome", env.Id)
 		if err := st.ssClient.AddUser(user, keys.AuthorizedKey()); err != nil {
 			return err
 		}
@@ -387,13 +334,13 @@
 		"Welcome service",
 		false,
 		&t,
-		waitForAddr(fmt.Sprintf("https://welcome.%s", env.Domain)),
+		waitForAddr(st.httpClient, fmt.Sprintf("https://welcome.%s", env.Domain)),
 	)
 }
 
-func SetupAppStore(env Env, st *state) Task {
+func SetupAppStore(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Application marketplace", func() error {
-		user := fmt.Sprintf("%s-appmanager", env.Name)
+		user := fmt.Sprintf("%s-appmanager", env.Id)
 		keys, err := installer.NewSSHKeyPair(user)
 		if err != nil {
 			return err
@@ -423,9 +370,9 @@
 	return &t
 }
 
-func SetupLauncher(env Env, st *state) Task {
+func SetupLauncher(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Application Launcher", func() error {
-		user := fmt.Sprintf("%s-launcher", env.Name)
+		user := fmt.Sprintf("%s-launcher", env.Id)
 		keys, err := installer.NewSSHKeyPair(user)
 		if err != nil {
 			return err
@@ -454,6 +401,7 @@
 	return &t
 }
 
+// TODO(gio-dns): remove
 type DNSSecKey struct {
 	Basename string `json:"basename,omitempty"`
 	Key      []byte `json:"key,omitempty"`
diff --git a/core/installer/tasks/init.go b/core/installer/tasks/init.go
index 4e676cd..20e428d 100644
--- a/core/installer/tasks/init.go
+++ b/core/installer/tasks/init.go
@@ -6,10 +6,11 @@
 	"path/filepath"
 
 	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/io"
 	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
-func SetupConfigRepoTask(env Env, st *state) Task {
+func SetupConfigRepoTask(env installer.EnvConfig, st *state) Task {
 	ret := newSequentialParentTask(
 		"Configure Git repository",
 		true,
@@ -35,24 +36,24 @@
 	return ret
 }
 
-func NewCreateConfigRepoTask(env Env, st *state) Task {
+func NewCreateConfigRepoTask(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Install Git server", func() error {
 		appsRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
 		app, err := installer.FindInfraApp(appsRepo, "config-repo")
 		if err != nil {
 			return err
 		}
-		adminKeys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-admin-keys", env.Name))
+		adminKeys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-admin-keys", env.Id))
 		if err != nil {
 			return err
 		}
 		st.ssAdminKeys = adminKeys
-		keys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-keys", env.Name))
+		keys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-keys", env.Id))
 		if err != nil {
 			return err
 		}
-		appDir := filepath.Join("/environments", env.Name, "config-repo")
-		return st.infraAppManager.Install(app, appDir, env.Name, map[string]any{
+		appDir := filepath.Join("/environments", env.Id, "config-repo")
+		return st.infraAppManager.Install(app, appDir, env.Id, map[string]any{
 			"privateKey": string(keys.RawPrivateKey()),
 			"publicKey":  string(keys.RawAuthorizedKey()),
 			"adminKey":   string(adminKeys.RawAuthorizedKey()),
@@ -61,10 +62,10 @@
 	return &t
 }
 
-func CreateGitClientTask(env Env, st *state) Task {
+func CreateGitClientTask(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Wait git server to come up", func() error {
-		ssClient, err := soft.WaitForClient(
-			fmt.Sprintf("soft-serve.%s.svc.cluster.local:%d", env.Name, 22),
+		ssClient, err := st.repoClient.Get(
+			fmt.Sprintf("soft-serve.%s.svc.cluster.local:%d", env.Id, 22),
 			st.ssAdminKeys.RawPrivateKey(),
 			log.Default())
 		if err != nil {
@@ -85,9 +86,9 @@
 	return &t
 }
 
-func NewInitConfigRepoTask(env Env, st *state) Task {
+func NewInitConfigRepoTask(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Configure access control lists", func() error {
-		st.fluxUserName = fmt.Sprintf("flux-%s", env.Name)
+		st.fluxUserName = fmt.Sprintf("flux-%s", env.Id)
 		keys, err := installer.NewSSHKeyPair(st.fluxUserName)
 		if err != nil {
 			return err
@@ -96,24 +97,20 @@
 		if err := st.ssClient.AddRepository("config"); err != nil {
 			return err
 		}
-		repo, err := st.ssClient.GetRepo("config")
+		repoIO, err := st.ssClient.GetRepo("config")
 		if err != nil {
 			return err
 		}
-		repoIO, err := installer.NewRepoIO(repo, st.ssClient.Signer)
-		if err != nil {
-			return err
-		}
-		if err := repoIO.Do(func(r installer.RepoFS) (string, error) {
+		if err := repoIO.Do(func(r soft.RepoFS) (string, error) {
 			w, err := r.Writer("README.md")
 			if err != nil {
 				return "", err
 			}
 			defer w.Close()
-			if _, err := fmt.Fprintf(w, "# %s PCloud environment", env.Name); err != nil {
+			if _, err := fmt.Fprintf(w, "# %s PCloud environment", env.Id); err != nil {
 				return "", err
 			}
-			if err := installer.WriteYaml(r, "kustomization.yaml", installer.NewKustomization()); err != nil {
+			if err := soft.WriteYaml(r, "kustomization.yaml", io.NewKustomization()); err != nil {
 				return "", err
 			}
 			return "init", nil
diff --git a/core/installer/tasks/tasks.go b/core/installer/tasks/tasks.go
index 1cc053b..3db7042 100644
--- a/core/installer/tasks/tasks.go
+++ b/core/installer/tasks/tasks.go
@@ -1,5 +1,9 @@
 package tasks
 
+import (
+	"fmt"
+)
+
 type Status int
 
 const (
@@ -55,6 +59,9 @@
 }
 
 func (b *basicTask) callDoneListeners(err error) {
+	if err != nil {
+		fmt.Printf("%s %s\n", b.title, err.Error())
+	}
 	for _, l := range b.listeners {
 		go l(err)
 	}
diff --git a/core/installer/tasks/web.go b/core/installer/tasks/web.go
index 5136287..c2625a5 100644
--- a/core/installer/tasks/web.go
+++ b/core/installer/tasks/web.go
@@ -4,12 +4,14 @@
 	"fmt"
 	"net/http"
 	"time"
+
+	phttp "github.com/giolekva/pcloud/core/installer/http"
 )
 
-func waitForAddr(addr string) Task {
+func waitForAddr(client phttp.Client, addr string) Task {
 	t := newLeafTask(fmt.Sprintf("Wait for %s to come up", addr), func() error {
 		for {
-			if resp, err := http.Get(addr); err != nil || resp.StatusCode != http.StatusOK {
+			if resp, err := client.Get(addr); err != nil || resp.StatusCode != http.StatusOK {
 				time.Sleep(2 * time.Second)
 			} else {
 				return nil