summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLeonardo Bishop <me@leonardobishop.net>2026-01-07 23:39:53 +0000
committerLeonardo Bishop <me@leonardobishop.net>2026-01-07 23:39:53 +0000
commit03cd6bdfbd473dba3f3dc50a1b15e389aac5bc70 (patch)
tree5fea2b1840e298aaab953add749fb9226bd4a710
Initial commit
-rw-r--r--.gitignore2
-rw-r--r--Dockerfile25
-rw-r--r--go.mod30
-rw-r--r--go.sum54
-rw-r--r--main.go57
-rw-r--r--pkg/deployer/client.go114
-rw-r--r--pkg/deployer/config.go124
-rw-r--r--pkg/deployer/constants.go17
-rw-r--r--pkg/deployer/deploy.go245
-rw-r--r--pkg/deployer/instance.go163
-rw-r--r--pkg/janitor/janitor.go26
-rw-r--r--pkg/registry/list.go62
-rw-r--r--pkg/session/session.go85
-rw-r--r--web/handler/auth.go89
-rw-r--r--web/handler/deploy.go81
-rw-r--r--web/handler/index.go34
-rw-r--r--web/handler/instance.go95
-rw-r--r--web/middleware/auth.go32
-rw-r--r--web/views/auth.html51
-rw-r--r--web/views/f_auth_error.html3
-rw-r--r--web/views/f_deploy.html32
-rw-r--r--web/views/f_instance.html28
-rw-r--r--web/views/f_instance_result.html4
-rw-r--r--web/views/index.html98
-rw-r--r--web/web.go39
25 files changed, 1590 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..19bf476
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+instancer.tar
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..9da38ea
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,25 @@
+FROM golang:1.25-alpine AS builder
+
+WORKDIR /build
+
+COPY go.mod go.sum ./
+
+RUN go mod download
+
+COPY . .
+
+ARG namespace=net.leonardobishop.instancer
+
+RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X 'git.leonardobishop.net/instancer/pkg/deployer.Namespace=$namespace'" -o .
+
+
+
+FROM alpine
+
+WORKDIR /app
+
+COPY --from=builder /build/instancer .
+
+EXPOSE 8080
+
+CMD ["/app/instancer"]
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..9e150f0
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,30 @@
+module git.leonardobishop.net/instancer
+
+go 1.25.5
+
+require (
+ github.com/Microsoft/go-winio v0.6.2 // indirect
+ github.com/caarlos0/env/v11 v11.3.1 // indirect
+ github.com/containerd/errdefs v1.0.0 // indirect
+ github.com/containerd/errdefs/pkg v0.3.0 // indirect
+ github.com/distribution/reference v0.6.0 // indirect
+ github.com/docker/go-connections v0.6.0 // indirect
+ github.com/docker/go-units v0.5.0 // indirect
+ github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/go-logr/logr v1.4.2 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/moby/docker-image-spec v1.3.1 // indirect
+ github.com/moby/moby/api v1.52.0 // indirect
+ github.com/moby/moby/client v0.2.1 // indirect
+ github.com/opencontainers/go-digest v1.0.0 // indirect
+ github.com/opencontainers/image-spec v1.1.1 // indirect
+ go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
+ go.opentelemetry.io/otel v1.35.0 // indirect
+ go.opentelemetry.io/otel/metric v1.35.0 // indirect
+ go.opentelemetry.io/otel/trace v1.35.0 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/sys v0.38.0 // indirect
+ tailscale.com v1.92.5 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..ae75dfc
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,54 @@
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
+github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
+github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
+github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
+github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
+github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
+github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
+github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
+github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
+github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg=
+github.com/moby/moby/api v1.52.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
+github.com/moby/moby/client v0.2.1 h1:1Grh1552mvv6i+sYOdY+xKKVTvzJegcVMhuXocyDz/k=
+github.com/moby/moby/client v0.2.1/go.mod h1:O+/tw5d4a1Ha/ZA/tPxIZJapJRUS6LNZ1wiVRxYHyUE=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
+github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
+go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
+go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
+go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
+go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
+go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
+go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
+go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
+golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+tailscale.com v1.92.5 h1:h88HqtGk8jd9umF7l6m/8+QK69dY9IVMIlkNK9XYpvQ=
+tailscale.com v1.92.5/go.mod h1:jzTfKDd6XNNSNoX+Q9INIMpMU5rfZ9g8ojcAoMKi5w0=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..bd2e3ae
--- /dev/null
+++ b/main.go
@@ -0,0 +1,57 @@
+package main
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+
+ "git.leonardobishop.net/instancer/pkg/deployer"
+ "git.leonardobishop.net/instancer/pkg/janitor"
+ "git.leonardobishop.net/instancer/pkg/registry"
+ "git.leonardobishop.net/instancer/web"
+ "github.com/caarlos0/env/v11"
+)
+
+type Config struct {
+ ContainerRegistryURL string `env:"CONTAINER_REGISTRY_URL,notEmpty,required"`
+ ContainerRegistryUsername string `env:"CONTAINER_REGISTRY_USERNAME"`
+ ContainerRegistryPassword string `env:"CONTAINER_REGISTRY_PASSWORD"`
+
+ InstancerDomain string `env:"INSTANCER_DOMAIN"`
+
+ ImagePrefix string `env:"IMAGE_PREFIX"`
+ ProxyContainerName string `env:"PROXY_CONTAINER_NAME"`
+}
+
+func main() {
+ var config Config
+
+ if err := env.Parse(&config); err != nil {
+ panic(err)
+ }
+
+ registryClient := registry.RegistryClient{
+ URL: config.ContainerRegistryURL,
+ Username: config.ContainerRegistryUsername,
+ Password: config.ContainerRegistryPassword,
+ }
+
+ dockerDeployer, err := deployer.New(
+ config.ContainerRegistryURL,
+ config.ContainerRegistryUsername,
+ config.ContainerRegistryPassword,
+ config.InstancerDomain,
+ config.ImagePrefix,
+ config.ProxyContainerName,
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ slog.Info("staring janitor job")
+ go janitor.StartJanitor(context.Background(), &dockerDeployer)
+
+ slog.Info("starting http server")
+ err = http.ListenAndServe(":8080", web.NewMux(&registryClient, &dockerDeployer))
+ slog.Error("http server closing", "reason", err.Error())
+}
diff --git a/pkg/deployer/client.go b/pkg/deployer/client.go
new file mode 100644
index 0000000..b240857
--- /dev/null
+++ b/pkg/deployer/client.go
@@ -0,0 +1,114 @@
+package deployer
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "sync"
+
+ "github.com/google/uuid"
+ "github.com/moby/moby/api/types/registry"
+ "github.com/moby/moby/client"
+)
+
+type DockerDeployer struct {
+ client *client.Client
+ deployJobs map[string]*DeployJob
+ deployJobsMutex sync.RWMutex
+
+ containerCreateMutex sync.Mutex
+
+ instancerDomain string
+
+ registryURL string
+ registryUsername string
+ registryPassword string
+
+ proxyContainerName string
+ imagePrefix string
+}
+
+type DeployJob struct {
+ DeployChan chan DeployStatus
+
+ deployKey string
+ challenge string
+ team string
+}
+
+func New(registryURL, registryUsername, registryPassword, instancerDomain, imagePrefix, proxyContainerName string) (DockerDeployer, error) {
+ c, err := client.New(client.FromEnv)
+ if err != nil {
+ return DockerDeployer{}, fmt.Errorf("docker client error: %w", err)
+ }
+
+ return DockerDeployer{
+ client: c,
+ deployJobs: make(map[string]*DeployJob),
+
+ registryURL: registryURL,
+ registryUsername: registryUsername,
+ registryPassword: registryPassword,
+ instancerDomain: instancerDomain,
+ imagePrefix: imagePrefix,
+ proxyContainerName: proxyContainerName,
+ }, nil
+}
+
+func (d *DockerDeployer) GetJob(deployKey string) *DeployJob {
+ d.deployJobsMutex.RLock()
+ defer d.deployJobsMutex.RUnlock()
+
+ return d.deployJobs[deployKey]
+}
+
+func (d *DockerDeployer) registryLogin() string {
+ options := registry.AuthConfig{
+ ServerAddress: d.registryURL,
+ Username: d.registryUsername,
+ Password: d.registryPassword,
+ }
+ encodedJSON, err := json.Marshal(options)
+ if err != nil {
+ panic(err)
+ }
+
+ return base64.StdEncoding.EncodeToString(encodedJSON)
+}
+
+func (d *DockerDeployer) createDeployJob(challenge, team string) (string, *DeployJob) {
+ d.deployJobsMutex.Lock()
+ defer d.deployJobsMutex.Unlock()
+
+ deploymentKey := uuid.New().String()
+ deploymentChan := make(chan DeployStatus)
+
+ job := &DeployJob{
+ DeployChan: deploymentChan,
+
+ deployKey: deploymentKey,
+ challenge: challenge,
+ team: team,
+ }
+ d.deployJobs[deploymentKey] = job
+
+ return deploymentKey, job
+}
+
+func (d *DockerDeployer) writeDeployChannel(deploymentKey string, status DeployStatus) {
+ d.deployJobsMutex.RLock()
+ defer d.deployJobsMutex.RUnlock()
+
+ job := d.deployJobs[deploymentKey]
+ if job == nil || job.DeployChan == nil {
+ return
+ }
+
+ job.DeployChan <- status
+
+ if status.Status == statusSuccess || status.Status == statusError {
+ job.DeployChan <- DeployStatus{Status: "done", Message: "done"}
+ close(job.DeployChan)
+ //TODO cleanup
+ }
+}
diff --git a/pkg/deployer/config.go b/pkg/deployer/config.go
new file mode 100644
index 0000000..f8c2795
--- /dev/null
+++ b/pkg/deployer/config.go
@@ -0,0 +1,124 @@
+package deployer
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+type ImageConfig struct {
+ Port int
+
+ ProxyEnable bool
+ ProxyKind string
+
+ Limits struct {
+ Memory int64
+ CPU int64
+ }
+
+ Security struct {
+ ReadOnlyFS bool
+ SecurityOpt []string
+ CapAdd []string
+ CapDrop []string
+ }
+}
+
+func defaultImageConfig() ImageConfig {
+ cfg := ImageConfig{
+ Port: 0,
+ ProxyEnable: true,
+ ProxyKind: "http",
+ }
+
+ cfg.Limits.Memory = 1024 * 1024 * 1024
+ cfg.Limits.CPU = 1000000000
+
+ cfg.Security.ReadOnlyFS = true
+ cfg.Security.SecurityOpt = []string{"no-new-privileges"}
+ cfg.Security.CapAdd = []string{}
+ cfg.Security.CapDrop = []string{"ALL"}
+
+ return cfg
+}
+
+func extractImageConfig(imageLabels map[string]string) (ImageConfig, error) {
+ var prefix = Namespace + "."
+
+ cfg := defaultImageConfig()
+
+ for k, v := range imageLabels {
+ if !strings.HasPrefix(k, prefix) {
+ continue
+ }
+
+ key := strings.TrimPrefix(k, prefix)
+
+ switch key {
+ case "port":
+ p, err := strconv.Atoi(v)
+ if err != nil {
+ return cfg, fmt.Errorf("invalid port: %q", v)
+ }
+ cfg.Port = p
+
+ case "proxy.enable":
+ cfg.ProxyEnable = v == "true"
+
+ case "proxy.kind":
+ if v != "http" && v != "tcp" {
+ return cfg, fmt.Errorf("invalid proxy.kind: %q", v)
+ }
+ cfg.ProxyKind = v
+
+ case "limits.memory":
+ cfg.Limits.Memory = parseMemory(v)
+
+ case "limits.cpu":
+ cfg.Limits.CPU = parseCPU(v)
+
+ case "security.read-only-fs":
+ cfg.Security.ReadOnlyFS = v == "true"
+
+ case "security.security-opt":
+ cfg.Security.SecurityOpt = strings.Split(v, ",")
+
+ case "security.cap-add":
+ cfg.Security.CapAdd = strings.Split(v, ",")
+
+ case "security.cap-drop":
+ cfg.Security.CapDrop = strings.Split(v, ",")
+ }
+ }
+
+ if cfg.Port == 0 {
+ return cfg, fmt.Errorf("no port given")
+ }
+
+ return cfg, nil
+}
+
+func parseMemory(mem string) int64 {
+ mem = strings.ToUpper(mem)
+
+ switch {
+ case strings.HasSuffix(mem, "G"):
+ v, _ := strconv.ParseInt(strings.TrimSuffix(mem, "G"), 10, 64)
+ return v * 1024 * 1024 * 1024
+ case strings.HasSuffix(mem, "M"):
+ v, _ := strconv.ParseInt(strings.TrimSuffix(mem, "M"), 10, 64)
+ return v * 1024 * 1024
+ default:
+ v, _ := strconv.ParseInt(mem, 10, 64)
+ return v
+ }
+}
+
+func parseCPU(cpu string) int64 {
+ f, err := strconv.ParseFloat(cpu, 64)
+ if err != nil {
+ return 1000000000
+ }
+ return int64(f * 1000000000)
+}
diff --git a/pkg/deployer/constants.go b/pkg/deployer/constants.go
new file mode 100644
index 0000000..9a14b8d
--- /dev/null
+++ b/pkg/deployer/constants.go
@@ -0,0 +1,17 @@
+package deployer
+
+var (
+ Namespace = "net.leonardobishop.instancer"
+)
+
+var (
+ ContainerLabelManaged = Namespace + ".managed"
+ ContainerLabelCreatedAt = Namespace + ".created-at"
+ ContainerLabelExpiresAt = Namespace + ".expires-at"
+ ContainerLabelChallenge = Namespace + ".challenge"
+ ContainerLabelForTeam = Namespace + ".for-team"
+ ContainerLabelRequestedBy = Namespace + ".requested-by"
+ ContainerLabelDeployKey = Namespace + ".deploy-key"
+ ContainerLabelAddress = Namespace + ".address"
+ ContainerLabelAddressFormat = Namespace + ".address-format"
+)
diff --git a/pkg/deployer/deploy.go b/pkg/deployer/deploy.go
new file mode 100644
index 0000000..3e21ce4
--- /dev/null
+++ b/pkg/deployer/deploy.go
@@ -0,0 +1,245 @@
+package deployer
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "maps"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/moby/moby/api/types/container"
+ "github.com/moby/moby/api/types/network"
+ "github.com/moby/moby/client"
+ "tailscale.com/util/truncate"
+)
+
+type DeployStatus struct {
+ Status string
+ Message string
+}
+
+const (
+ statusProgress = "progress"
+ statusError = "error"
+ statusSuccess = "success"
+)
+
+func (d *DockerDeployer) StartDeploy(challenge, team string) string {
+ deploymentKey, job := d.createDeployJob(challenge, team)
+
+ go func() {
+ err := d.doDeployment(deploymentKey, job, context.Background())
+ if err != nil {
+ d.writeDeployChannel(deploymentKey, DeployStatus{Status: statusError, Message: `<div class="alert alert-danger mb-0" role="alert">Aborted: ` + err.Error() + `</div>`})
+ }
+ }()
+
+ return deploymentKey
+}
+
+func (d *DockerDeployer) doDeployment(deployKey string, job *DeployJob, ctx context.Context) error {
+ log := slog.With(
+ "deployment", deployKey,
+ "challenge", job.challenge,
+ "team", job.team,
+ )
+
+ var unlockOnce sync.Once
+ // if !d.containerCreateMutex.TryLock() {
+ // d.writeDeployChannel(deployKey, DeployStatus{statusProgress, "Challenge deployment queued"})
+ // d.containerCreateMutex.Lock()
+ // }
+ d.containerCreateMutex.Lock()
+ defer unlockOnce.Do(d.containerCreateMutex.Unlock)
+
+ log.Info("starting challenge deployment")
+ imageName := fmt.Sprintf("%s%s:latest", d.imagePrefix, job.challenge)
+
+ if err := d.containerExistsForTeam(ctx, log, job); err != nil {
+ return err
+ }
+
+ d.writeDeployChannel(deployKey, DeployStatus{statusProgress, "Pulling image"})
+ if err := d.pullImage(ctx, log, imageName); err != nil {
+ return err
+ }
+
+ d.writeDeployChannel(deployKey, DeployStatus{statusProgress, "Reading challenge configuration"})
+ imageCfg, err := d.readImageMetadata(ctx, log, imageName)
+ if err != nil {
+ return err
+ }
+
+ proxyRouteName := strings.ReplaceAll(deployKey, "-", "")
+ url := truncate.String(job.challenge, 30) + "-" + proxyRouteName + "." + d.instancerDomain
+ expiry := time.Now().Add(90 * time.Minute)
+
+ d.writeDeployChannel(deployKey, DeployStatus{statusProgress, "Configuring network"})
+ networkID, err := d.setupNetwork(ctx, log, job, expiry, proxyRouteName, imageCfg.ProxyEnable)
+ if err != nil {
+ return err
+ }
+
+ log.Info("challenge network created", "networkID", networkID)
+
+ d.writeDeployChannel(deployKey, DeployStatus{statusProgress, "Creating container"})
+ containerID, err := d.createContainer(ctx, log, job, expiry, imageName, proxyRouteName, networkID, url, imageCfg)
+ if err != nil {
+ return err
+ }
+
+ unlockOnce.Do(d.containerCreateMutex.Unlock)
+
+ log.Info("challenge container created", "containerID", containerID)
+
+ d.writeDeployChannel(deployKey, DeployStatus{Status: statusProgress, Message: "Starting container"})
+ if _, err := d.client.ContainerStart(ctx, containerID, client.ContainerStartOptions{}); err != nil {
+ log.Error("could not start challenge container", "cause", err)
+ return fmt.Errorf("error starting container")
+ }
+
+ d.writeDeployChannel(deployKey, DeployStatus{Status: statusSuccess, Message: `<div class="alert alert-success mb-0" role="alert">Challenge created at <a class="alert-link" target="_blank" href="https://` + url + `">https://` + url + `</a></div>`})
+ return nil
+}
+
+func (d *DockerDeployer) containerExistsForTeam(ctx context.Context, log *slog.Logger, job *DeployJob) error {
+ filters := client.Filters{}
+ filters.Add("label", ContainerLabelChallenge+"="+job.challenge)
+ filters.Add("label", ContainerLabelForTeam+"="+job.team)
+
+ containers, err := d.client.ContainerList(ctx, client.ContainerListOptions{
+ All: true,
+ Filters: filters,
+ })
+ if err != nil {
+ log.Error("could not pull list challenges", "cause", err)
+ return fmt.Errorf("error checking uniqueness")
+ }
+
+ if len(containers.Items) > 0 {
+ return fmt.Errorf("instance of challenge already deployed for team")
+ }
+ return nil
+}
+
+func (d *DockerDeployer) pullImage(ctx context.Context, log *slog.Logger, imageName string) error {
+ resp, err := d.client.ImagePull(ctx, imageName, client.ImagePullOptions{
+ RegistryAuth: d.registryLogin(),
+ })
+ if err != nil {
+ log.Error("could not pull challenge image", "cause", err)
+ return fmt.Errorf("error pulling challenge image")
+ }
+ defer resp.Close()
+ resp.Wait(ctx)
+
+ return nil
+}
+
+func (d *DockerDeployer) readImageMetadata(ctx context.Context, log *slog.Logger, imageName string) (*ImageConfig, error) {
+ inspect, err := d.client.ImageInspect(ctx, imageName)
+ if err != nil {
+ log.Error("could not inspect image", "cause", err)
+ return nil, fmt.Errorf("error loading challenge configuration")
+ }
+
+ imageCfg, err := extractImageConfig(inspect.Config.Labels)
+ if err != nil {
+ log.Error("invalid challenge configuration", "cause", err)
+ return nil, fmt.Errorf("error loading challenge configuration")
+ }
+
+ return &imageCfg, nil
+}
+
+func (d *DockerDeployer) setupNetwork(ctx context.Context, log *slog.Logger, job *DeployJob, expiry time.Time, routeName string, proxy bool) (string, error) {
+ resp, err := d.client.NetworkCreate(ctx, "n-"+routeName, client.NetworkCreateOptions{
+ Driver: "bridge",
+ Labels: map[string]string{
+ ContainerLabelManaged: "yes",
+ ContainerLabelCreatedAt: strconv.FormatInt(time.Now().Unix(), 10),
+ ContainerLabelExpiresAt: strconv.FormatInt(expiry.Unix(), 10),
+ ContainerLabelDeployKey: job.deployKey,
+ ContainerLabelChallenge: job.challenge,
+ ContainerLabelForTeam: job.team,
+ },
+ })
+ if err != nil {
+ log.Error("could not create challenge network", "cause", err)
+ return "", fmt.Errorf("could not create challenge network")
+ }
+
+ if proxy {
+ if _, err := d.client.NetworkConnect(ctx, resp.ID, client.NetworkConnectOptions{
+ Container: d.proxyContainerName,
+ }); err != nil {
+ log.Error("could not connect proxy to challenge network", "cause", err)
+ return "", fmt.Errorf("could not connect proxy to challenge network")
+ }
+ }
+
+ return resp.ID, nil
+}
+
+func (d *DockerDeployer) createContainer(ctx context.Context, log *slog.Logger, job *DeployJob, expiry time.Time, imageName, routeName, networkID, url string, imageCfg *ImageConfig) (string, error) {
+ now := time.Now()
+
+ labels := map[string]string{
+ ContainerLabelManaged: "yes",
+ ContainerLabelCreatedAt: strconv.FormatInt(now.Unix(), 10),
+ ContainerLabelExpiresAt: strconv.FormatInt(expiry.Unix(), 10),
+ ContainerLabelDeployKey: job.deployKey,
+ ContainerLabelChallenge: job.challenge,
+ ContainerLabelForTeam: job.team,
+ ContainerLabelAddress: url,
+ }
+
+ if imageCfg.ProxyEnable {
+ //TODO do tcp
+ maps.Copy(labels, map[string]string{
+ ContainerLabelAddressFormat: "https",
+
+ "traefik.enable": "true",
+ "traefik.docker.network": "n-" + routeName,
+
+ "traefik.http.routers." + routeName + ".entrypoints": "websecure",
+ "traefik.http.routers." + routeName + ".rule": "Host(`" + url + "`)",
+ "traefik.http.routers." + routeName + ".tls": "true",
+ "traefik.http.routers." + routeName + ".service": routeName,
+
+ "traefik.http.services." + routeName + ".loadbalancer.server.port": strconv.Itoa(imageCfg.Port),
+ })
+ }
+
+ resp, err := d.client.ContainerCreate(ctx, client.ContainerCreateOptions{
+ Image: imageName,
+ Config: &container.Config{
+ Labels: labels,
+ },
+ Name: "c-" + routeName,
+ HostConfig: &container.HostConfig{
+ Resources: container.Resources{
+ Memory: imageCfg.Limits.Memory,
+ NanoCPUs: imageCfg.Limits.CPU,
+ },
+ ReadonlyRootfs: imageCfg.Security.ReadOnlyFS,
+ SecurityOpt: imageCfg.Security.SecurityOpt,
+ CapAdd: imageCfg.Security.CapAdd,
+ CapDrop: imageCfg.Security.CapDrop,
+ },
+ NetworkingConfig: &network.NetworkingConfig{
+ EndpointsConfig: map[string]*network.EndpointSettings{
+ networkID: {},
+ },
+ },
+ })
+ if err != nil {
+ log.Error("could not create challenge container", "cause", err)
+ return "", fmt.Errorf("error creating container")
+ }
+
+ return resp.ID, nil
+}
diff --git a/pkg/deployer/instance.go b/pkg/deployer/instance.go
new file mode 100644
index 0000000..99171f4
--- /dev/null
+++ b/pkg/deployer/instance.go
@@ -0,0 +1,163 @@
+package deployer
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "strconv"
+ "time"
+
+ "github.com/moby/moby/client"
+)
+
+type Instance struct {
+ ChallengeName string
+ DeployKey string
+ Address string
+ AddressFormat string
+ ExpiresAt time.Time
+}
+
+func (d *DockerDeployer) GetTeamInstances(ctx context.Context, team string) ([]Instance, error) {
+ filters := client.Filters{}
+ filters.Add("label", ContainerLabelForTeam+"="+team)
+
+ containers, err := d.client.ContainerList(ctx, client.ContainerListOptions{
+ All: true,
+ Filters: filters,
+ })
+ if err != nil {
+ return []Instance{}, err
+ }
+
+ var instances []Instance
+ for _, c := range containers.Items {
+ expiresAt, err := strconv.Atoi(c.Labels[ContainerLabelExpiresAt])
+ if err != nil {
+ slog.Error("container has invalid expiry", "container", c.ID, "expiry", c.Labels[ContainerLabelExpiresAt])
+ continue
+ }
+ instances = append(instances, Instance{
+ ChallengeName: c.Labels[ContainerLabelChallenge],
+ DeployKey: c.Labels[ContainerLabelDeployKey],
+ Address: c.Labels[ContainerLabelAddress],
+ AddressFormat: c.Labels[ContainerLabelAddressFormat],
+ ExpiresAt: time.Unix(int64(expiresAt), 0),
+ })
+ }
+ return instances, nil
+}
+
+func (d *DockerDeployer) StopInstance(ctx context.Context, deployKey, team string) error {
+ if deployKey == "" || team == "" {
+ return fmt.Errorf("deploy key/team is invalid")
+ }
+
+ filters := client.Filters{}
+ filters.Add("label", ContainerLabelForTeam+"="+team)
+ filters.Add("label", ContainerLabelDeployKey+"="+deployKey)
+
+ containers, err := d.client.ContainerList(ctx, client.ContainerListOptions{
+ All: true,
+ Filters: filters,
+ })
+ if err != nil {
+ return fmt.Errorf("docker error")
+ }
+
+ if len(containers.Items) == 0 {
+ return fmt.Errorf("no such instance")
+ }
+
+ for _, c := range containers.Items {
+ _, err := d.client.ContainerRemove(ctx, c.ID, client.ContainerRemoveOptions{
+ Force: true,
+ })
+ if err != nil {
+ return fmt.Errorf("docker error")
+ }
+ slog.Info("container removed early", "container", c.ID)
+ }
+
+ networks, err := d.client.NetworkList(ctx, client.NetworkListOptions{})
+ if err != nil {
+ return fmt.Errorf("docker error")
+ }
+ for _, n := range networks.Items {
+ if err = d.forceRemoveNetwork(ctx, n.ID); err != nil {
+ slog.Warn("failed to remove network", "network", n.ID)
+ continue
+ }
+ slog.Info("network removed early", "network", n.ID)
+ }
+
+ return nil
+}
+
+func (d *DockerDeployer) RemoveExpiredResources(ctx context.Context) error {
+ filters := client.Filters{}
+ filters.Add("label", ContainerLabelManaged+"=yes")
+
+ containers, err := d.client.ContainerList(ctx, client.ContainerListOptions{
+ All: true,
+ Filters: filters,
+ })
+ if err != nil {
+ return err
+ }
+ for _, c := range containers.Items {
+ expiry, err := strconv.ParseInt(c.Labels[ContainerLabelExpiresAt], 10, 64)
+ if err != nil {
+ slog.Warn("invalid timestamp on container label", "container", c.ID, "timestamp", c.Labels[ContainerLabelExpiresAt])
+ continue
+ }
+ if expiry > time.Now().Unix() {
+ continue
+ }
+
+ _, err = d.client.ContainerRemove(ctx, c.ID, client.ContainerRemoveOptions{
+ Force: true,
+ })
+ if err != nil {
+ return err
+ }
+ slog.Info("expired container removed", "container", c.ID)
+ }
+
+ networks, err := d.client.NetworkList(ctx, client.NetworkListOptions{
+ Filters: filters,
+ })
+ if err != nil {
+ return err
+ }
+ for _, n := range networks.Items {
+ expiry, err := strconv.ParseInt(n.Labels[ContainerLabelExpiresAt], 10, 64)
+ if err != nil {
+ slog.Warn("invalid timestamp on network label", "network", n.ID, "timestamp", n.Labels[ContainerLabelExpiresAt])
+ continue
+ }
+ if expiry > time.Now().Unix() {
+ continue
+ }
+
+ if err = d.forceRemoveNetwork(ctx, n.ID); err != nil {
+ return err
+ }
+ slog.Info("expired network removed", "network", n.ID)
+ }
+
+ return nil
+}
+
+func (d *DockerDeployer) forceRemoveNetwork(ctx context.Context, networkID string) error {
+ _, _ = d.client.NetworkDisconnect(ctx, networkID, client.NetworkDisconnectOptions{
+ Container: d.proxyContainerName,
+ Force: true,
+ })
+
+ _, err := d.client.NetworkRemove(ctx, networkID, client.NetworkRemoveOptions{})
+ if err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/pkg/janitor/janitor.go b/pkg/janitor/janitor.go
new file mode 100644
index 0000000..b640eed
--- /dev/null
+++ b/pkg/janitor/janitor.go
@@ -0,0 +1,26 @@
+package janitor
+
+import (
+ "context"
+ "log/slog"
+ "time"
+
+ "git.leonardobishop.net/instancer/pkg/deployer"
+)
+
+func StartJanitor(ctx context.Context, deployer *deployer.DockerDeployer) {
+ ticker := time.NewTicker(1 * time.Minute)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ err := deployer.RemoveExpiredResources(ctx)
+ if err != nil {
+ slog.Error("error occurred when removing expired resources", "cause", err)
+ }
+ case <-ctx.Done():
+ return
+ }
+ }
+}
diff --git a/pkg/registry/list.go b/pkg/registry/list.go
new file mode 100644
index 0000000..0c6073f
--- /dev/null
+++ b/pkg/registry/list.go
@@ -0,0 +1,62 @@
+package registry
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+)
+
+type RegistryClient struct {
+ URL string
+ Username string
+ Password string
+}
+
+type CatalogResponse struct {
+ Repositories []string `json:"repositories"`
+}
+
+func (c *RegistryClient) ListRepositories() ([]string, error) {
+ repos := []string{}
+ last := ""
+ pageSize := 100
+
+ for {
+ url := fmt.Sprintf("%s/v2/_catalog?n=%d", c.URL, pageSize)
+ if last != "" {
+ url += "&last=" + last
+ }
+
+ req, _ := http.NewRequest("GET", url, nil)
+ if c.Username != "" {
+ req.SetBasicAuth(c.Username, c.Password)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("registry returned %d: %s", resp.StatusCode, body)
+ }
+
+ var catalog CatalogResponse
+ if err := json.NewDecoder(resp.Body).Decode(&catalog); err != nil {
+ return nil, err
+ }
+
+ repos = append(repos, catalog.Repositories...)
+
+ if len(catalog.Repositories) < pageSize {
+ break
+ }
+
+ last = catalog.Repositories[len(catalog.Repositories)-1]
+ }
+
+ return repos, nil
+}
diff --git a/pkg/session/session.go b/pkg/session/session.go
new file mode 100644
index 0000000..87306ac
--- /dev/null
+++ b/pkg/session/session.go
@@ -0,0 +1,85 @@
+package session
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "fmt"
+ "sync"
+)
+
+type UserSession struct {
+ Token string
+ Team string
+}
+
+// implemtation adapted from
+// https://git.leonardobishop.net/confplanner/tree/pkg/session/memory.go
+
+// TODO add expiry
+type MemoryStore struct {
+ sessions map[string]*UserSession
+ lock sync.RWMutex
+}
+
+func NewMemoryStore() *MemoryStore {
+ return &MemoryStore{
+ sessions: make(map[string]*UserSession),
+ }
+}
+
+func (s *MemoryStore) GetByToken(token string) *UserSession {
+ if token == "" {
+ return nil
+ }
+
+ s.lock.RLock()
+ defer s.lock.RUnlock()
+
+ return s.sessions[token]
+}
+
+func (s *MemoryStore) Create(team string) (*UserSession, error) {
+
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ var token string
+ for {
+ token = generateSessionToken()
+
+ _, tokenExists := s.sessions[token]
+
+ if !tokenExists {
+ break
+ }
+ }
+
+ session := &UserSession{
+ Token: token,
+ Team: team,
+ }
+ s.sessions[token] = session
+
+ return session, nil
+}
+
+func (s *MemoryStore) Destroy(token string) error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ session := s.sessions[token]
+ if session == nil {
+ return fmt.Errorf("session does not exist")
+ }
+
+ delete(s.sessions, token)
+ return nil
+}
+
+func generateSessionToken() string {
+ b := make([]byte, 100)
+ if _, err := rand.Read(b); err != nil {
+ return ""
+ }
+ return base64.StdEncoding.EncodeToString(b)
+}
diff --git a/web/handler/auth.go b/web/handler/auth.go
new file mode 100644
index 0000000..38b87b6
--- /dev/null
+++ b/web/handler/auth.go
@@ -0,0 +1,89 @@
+package handler
+
+import (
+ "html/template"
+ "log/slog"
+ "net/http"
+ "strconv"
+ "time"
+
+ "git.leonardobishop.net/instancer/pkg/session"
+)
+
+func GetAuth(tmpl *template.Template) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ tmpl.ExecuteTemplate(w, "auth.html", nil)
+ }
+}
+
+func PostAuth(tmpl *template.Template, session *session.MemoryStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ tmpl.ExecuteTemplate(w, "f_auth_error.html", struct {
+ Message string
+ }{
+ Message: "Invalid form data",
+ })
+ return
+ }
+
+ team := r.FormValue("team")
+ if team == "" {
+ tmpl.ExecuteTemplate(w, "f_auth_error.html", struct {
+ Message string
+ }{
+ Message: "No team entered",
+ })
+ return
+ }
+
+ if _, err := strconv.Atoi(team); err != nil {
+ tmpl.ExecuteTemplate(w, "f_auth_error.html", struct {
+ Message string
+ }{
+ Message: "Team ID must be number",
+ })
+ return
+ }
+
+ session, err := session.Create(team)
+ if err != nil {
+ slog.Error("could not create session", "cause", err)
+ tmpl.ExecuteTemplate(w, "f_auth_error.html", struct {
+ Message string
+ }{
+ Message: "Could not create session",
+ })
+ return
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "session",
+ Value: session.Token,
+
+ Path: "/",
+ Secure: true,
+ SameSite: http.SameSiteStrictMode,
+ HttpOnly: true,
+ })
+ w.Header().Add("HX-Redirect", "/")
+ }
+}
+
+func GetLogout(session *session.MemoryStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ //TODO expire session here
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "session",
+ Value: "",
+ Expires: time.Unix(0, 0),
+
+ Path: "/",
+ Secure: true,
+ SameSite: http.SameSiteStrictMode,
+ HttpOnly: true,
+ })
+ http.Redirect(w, r, "/auth", http.StatusFound)
+ }
+}
diff --git a/web/handler/deploy.go b/web/handler/deploy.go
new file mode 100644
index 0000000..24e0f95
--- /dev/null
+++ b/web/handler/deploy.go
@@ -0,0 +1,81 @@
+package handler
+
+import (
+ "fmt"
+ "html/template"
+ "net/http"
+
+ "git.leonardobishop.net/instancer/pkg/deployer"
+ "git.leonardobishop.net/instancer/pkg/registry"
+ "git.leonardobishop.net/instancer/pkg/session"
+)
+
+func PostDeploy(tmpl *template.Template, dockerDeployer *deployer.DockerDeployer) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "Invalid form data", http.StatusBadRequest)
+ return
+ }
+
+ challenge := r.FormValue("challenge")
+ if challenge == "" {
+ http.Error(w, "No challenge selected", http.StatusBadRequest)
+ return
+ }
+
+ session := r.Context().Value("session").(*session.UserSession)
+ deployKey := dockerDeployer.StartDeploy(challenge, session.Team)
+
+ tmpl.ExecuteTemplate(w, "f_deploy.html", struct {
+ DeployKey string
+ Challenge string
+ }{
+ DeployKey: deployKey,
+ Challenge: challenge,
+ })
+ }
+}
+
+func DeploySSE(tmpl *template.Template, registryClient *registry.RegistryClient, dockerDeployer *deployer.DockerDeployer) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
+ return
+ }
+
+ deployKey := r.URL.Query().Get("deploy")
+ if deployKey == "" {
+ http.Error(w, "No deployment specified", http.StatusBadRequest)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+
+ clientGone := r.Context().Done()
+ job := dockerDeployer.GetJob(deployKey)
+
+ if job == nil || job.DeployChan == nil {
+ fmt.Fprintf(w, "event: %s\ndata: %s\n\n", "close", "Invalid deployment key")
+ flusher.Flush()
+ return
+ }
+
+ for {
+ select {
+ case <-clientGone:
+ return
+ case update, ok := <-job.DeployChan:
+ if !ok {
+ return
+ }
+
+ fmt.Fprintf(w, "event: %s\ndata: %s\n\n", update.Status, update.Message)
+ flusher.Flush()
+ }
+ }
+
+ }
+}
diff --git a/web/handler/index.go b/web/handler/index.go
new file mode 100644
index 0000000..5cf44cf
--- /dev/null
+++ b/web/handler/index.go
@@ -0,0 +1,34 @@
+package handler
+
+import (
+ "html/template"
+ "log"
+ "net/http"
+
+ "git.leonardobishop.net/instancer/pkg/registry"
+ "git.leonardobishop.net/instancer/pkg/session"
+)
+
+func GetIndex(tmpl *template.Template, registryClient *registry.RegistryClient) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ challenges, err := registryClient.ListRepositories()
+ if err != nil {
+ log.Printf("Could not list repositories: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ session := r.Context().Value("session").(*session.UserSession)
+
+ if err := tmpl.ExecuteTemplate(w, "index.html", struct {
+ Challenges []string
+ Team string
+ }{
+ Challenges: challenges,
+ Team: session.Team,
+ }); err != nil {
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+ }
+}
diff --git a/web/handler/instance.go b/web/handler/instance.go
new file mode 100644
index 0000000..0222a42
--- /dev/null
+++ b/web/handler/instance.go
@@ -0,0 +1,95 @@
+package handler
+
+import (
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net/http"
+ "time"
+
+ "git.leonardobishop.net/instancer/pkg/deployer"
+ "git.leonardobishop.net/instancer/pkg/session"
+)
+
+func GetInstances(tmpl *template.Template, dockerDeployer *deployer.DockerDeployer) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ session := r.Context().Value("session").(*session.UserSession)
+
+ instances, err := dockerDeployer.GetTeamInstances(r.Context(), session.Team)
+ if err != nil {
+ slog.Error("could not get team instances", "team", session.Team, "error", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ type instanceView struct {
+ ChallengeName string
+ DeployKey string
+ Address template.HTML
+ ExpiresIn string
+ }
+ var instanceViews []instanceView
+ for _, instance := range instances {
+ var expiresIn string
+ timeLeft := time.Until(instance.ExpiresAt)
+ if timeLeft <= 0 {
+ expiresIn = "now"
+ } else {
+ expiresIn = fmt.Sprintf("%dm %ds", int(timeLeft.Minutes()), int(timeLeft.Seconds())%60)
+ }
+
+ var address string
+
+ if instance.AddressFormat == "http" || instance.AddressFormat == "https" {
+ address = `<a target="_blank" href="` + instance.AddressFormat + "://" + instance.Address + `">` + instance.Address + `</a>`
+ } else {
+ address = `<code>` + instance.Address + `</code>`
+ }
+
+ instanceViews = append(instanceViews, instanceView{
+ ChallengeName: instance.ChallengeName,
+ DeployKey: instance.DeployKey,
+ Address: template.HTML(address),
+ ExpiresIn: expiresIn,
+ })
+ }
+
+ if err := tmpl.ExecuteTemplate(w, "f_instance.html", struct {
+ Instances []instanceView
+ }{
+ Instances: instanceViews,
+ }); err != nil {
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+ }
+}
+
+func PostStopInstance(tmpl *template.Template, dockerDeployer *deployer.DockerDeployer) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ session := r.Context().Value("session").(*session.UserSession)
+ deployKey := r.PathValue("deployKey")
+
+ err := dockerDeployer.StopInstance(r.Context(), deployKey, session.Team)
+ if err != nil {
+ tmpl.ExecuteTemplate(w, "f_instance_result.html", struct {
+ State string
+ Message string
+ }{
+ State: "danger",
+ Message: "Could not stop instance" + err.Error(),
+ })
+ return
+ }
+
+ w.Header().Set("HX-Trigger", "poll-instances-now")
+
+ tmpl.ExecuteTemplate(w, "f_deploy_result.html", struct {
+ State string
+ Message string
+ }{
+ State: "success",
+ Message: "Instance " + deployKey + " stopped",
+ })
+ }
+}
diff --git a/web/middleware/auth.go b/web/middleware/auth.go
new file mode 100644
index 0000000..fcba3b7
--- /dev/null
+++ b/web/middleware/auth.go
@@ -0,0 +1,32 @@
+package middleware
+
+import (
+ "context"
+ "net/http"
+
+ "git.leonardobishop.net/instancer/pkg/session"
+)
+
+func MustAuthenticate(store *session.MemoryStore) func(http.HandlerFunc) http.HandlerFunc {
+ return func(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ sessionCookie, err := r.Cookie("session")
+ if err != nil {
+ w.Header().Add("HX-Redirect", "/auth")
+ http.Redirect(w, r, "/auth", http.StatusFound)
+ return
+ }
+
+ s := store.GetByToken(sessionCookie.Value)
+ if s == nil {
+ w.Header().Add("HX-Redirect", "/auth")
+ http.Redirect(w, r, "/auth", http.StatusFound)
+ return
+ }
+
+ ctx := context.WithValue(r.Context(), "session", s)
+
+ next(w, r.WithContext(ctx))
+ }
+ }
+}
diff --git a/web/views/auth.html b/web/views/auth.html
new file mode 100644
index 0000000..995815a
--- /dev/null
+++ b/web/views/auth.html
@@ -0,0 +1,51 @@
+
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Challenge Instancer</title>
+
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
+ <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js" integrity="sha384-IQsoLXl5PILFhosVNubq5LC7Qb9DXgDA9i+tQ8Zj3iwWAwPtgFTxbJ8NT4GN1R8p" crossorigin="anonymous"></script>
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.min.js" integrity="sha384-cVKIPhGWiC2Al4u+LWgxfKTRIcfu0JTxR+EQDz/bgldoEyl4H0zUF0QKbrJ0EcQF" crossorigin="anonymous"></script>
+ <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script>
+ <script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.4" integrity="sha384-A986SAtodyH8eg8x8irJnYUk7i9inVQqYigD6qZ9evobksGNIXfeFvDwLSHcp31N" crossorigin="anonymous"></script>
+</head>
+<body class="bg-light">
+
+<div class="container py-5">
+ <div class="row justify-content-center">
+ <div class="col-12 col-md-6 col-lg-4">
+ <div class="card mx-auto">
+ <div class="card-body">
+ <h4 class="card-title mb-3">Enter a team ID</h4>
+
+ <div id="auth-response"></div>
+
+ <form hx-post="/auth" hx-target="#auth-response" hx-swap="innerHTML">
+ <div class="input-group">
+ <span class="input-group-text">ID</span>
+ <input id="team" name="team" class="form-control" type="number" required>
+ </input>
+ <button id="deploy-btn" type="submit" class="btn btn-primary">
+ Continue
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+
+ <div class="card mt-4 mx-auto">
+ <div class="card-body">
+ <p class="card-text">
+ <b>Attacking this platform is out of scope of the CTF and is forbidden.</b>
+ If there are any issues, please speak to an organiser.
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+
+</body>
+</html>
diff --git a/web/views/f_auth_error.html b/web/views/f_auth_error.html
new file mode 100644
index 0000000..4ebdac3
--- /dev/null
+++ b/web/views/f_auth_error.html
@@ -0,0 +1,3 @@
+<div class="alert alert-danger" role="alert">
+ {{.Message}}
+</div>
diff --git a/web/views/f_deploy.html b/web/views/f_deploy.html
new file mode 100644
index 0000000..52edd5f
--- /dev/null
+++ b/web/views/f_deploy.html
@@ -0,0 +1,32 @@
+<div
+ id="deploy-{{.DeployKey}}"
+ class="card mt-4"
+ hx-ext="sse"
+ hx-on::sse-close="document.getElementById('progress-{{.DeployKey}}').remove(); document.getElementById('close-deploy-{{.DeployKey}}').classList.remove('d-none');"
+ sse-connect="/deploy/stream?deploy={{.DeployKey}}"
+ sse-close="done">
+
+ <div class="card-body">
+
+ <div class="d-flex justify-content-between align-items-start">
+ <h5 class="card-title mb-3">Deploying {{.Challenge}}</h5>
+ <button id="close-deploy-{{.DeployKey}}" type="button" class="btn-close d-none" aria-label="Close" hx-on:click="document.getElementById('deploy-{{.DeployKey}}').remove()"></button>
+ </div>
+
+ <div class="d-flex gap-3 flex-column">
+ <div id="progress-{{.DeployKey}}" class="d-flex align-items-center gap-2">
+ <div class="spinner-grow spinner-grow-sm" role="status">
+ </div>
+ <div id="status-log" sse-swap="progress">
+ Deployment requested
+ </div>
+ </div>
+
+ <div id="deployment-result-{{.DeployKey}}" class="d-none"></div>
+
+ <div>Instance <code>{{.DeployKey}}</code></div>
+ </div>
+ <span sse-swap="success" hx-target="#deployment-result-{{.DeployKey}}" hx-swap="outerHTML"></span>
+ <span sse-swap="error" hx-target="#deployment-result-{{.DeployKey}}" hx-swap="outerHTML"></span>
+ </div>
+</div>
diff --git a/web/views/f_instance.html b/web/views/f_instance.html
new file mode 100644
index 0000000..1518ee2
--- /dev/null
+++ b/web/views/f_instance.html
@@ -0,0 +1,28 @@
+{{range .Instances}}
+<tr>
+<td>
+ <div class="d-flex flex-column gap-2 justify-content-between">
+ <b>{{.Address}}</b>
+
+ <span>Expires in <code>{{.ExpiresIn}}</code></span>
+
+ <small>Instance <code>{{.DeployKey}}</code> of <i>{{.ChallengeName}}</i></small>
+ </div>
+</td>
+<td>
+ <button
+ hx-post="/instances/{{.DeployKey}}/stop"
+ hx-target="#instance-action-result"
+ hx-swap="beforeend"
+ class="btn btn-danger">
+ Stop
+ </button>
+</td>
+</tr>
+{{else}}
+<tr>
+<td colspan="2">
+<i class="text-center text-muted">Your team does not have any instances</i>
+</td>
+</tr>
+{{end}}
diff --git a/web/views/f_instance_result.html b/web/views/f_instance_result.html
new file mode 100644
index 0000000..85bd4eb
--- /dev/null
+++ b/web/views/f_instance_result.html
@@ -0,0 +1,4 @@
+<div class="alert alert-{{.State}} alert-dismissible fade show" role="alert">
+ {{.Message}}
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+</div>
diff --git a/web/views/index.html b/web/views/index.html
new file mode 100644
index 0000000..97edc88
--- /dev/null
+++ b/web/views/index.html
@@ -0,0 +1,98 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Challenge Instancer</title>
+
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
+ <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js" integrity="sha384-IQsoLXl5PILFhosVNubq5LC7Qb9DXgDA9i+tQ8Zj3iwWAwPtgFTxbJ8NT4GN1R8p" crossorigin="anonymous"></script>
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.min.js" integrity="sha384-cVKIPhGWiC2Al4u+LWgxfKTRIcfu0JTxR+EQDz/bgldoEyl4H0zUF0QKbrJ0EcQF" crossorigin="anonymous"></script>
+ <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script>
+ <script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.4" integrity="sha384-A986SAtodyH8eg8x8irJnYUk7i9inVQqYigD6qZ9evobksGNIXfeFvDwLSHcp31N" crossorigin="anonymous"></script>
+</head>
+<body class="bg-light">
+
+<div class="container py-5">
+ <div class="row justify-content-center">
+ <div class="col-9">
+ <div class="card">
+ <div class="card-body">
+ <h4 class="card-title mb-3">Deploy challenge</h4>
+
+ <form hx-post="/deploy" hx-target="#deployment-area" hx-swap="afterbegin">
+ <div class="input-group">
+ <span class="input-group-text">Challenge</span>
+ <select id="challenge" name="challenge" class="form-select" required>
+ <option value="" disabled selected>Choose a challenge</option>
+ {{range .Challenges}}
+ <option value="{{.}}">
+ {{.}}
+ </option>
+ {{end}}
+ </select>
+ <button id="deploy-btn" type="submit" class="btn btn-primary">
+ Deploy
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+
+ <div
+ id="instances"
+ class="card mt-4">
+ <div class="card-body">
+ <div class="d-flex justify-content-between align-items-start">
+ <h5 class="card-title mb-3">Instances</h5>
+ <span class="badge bg-primary">Refreshing</span>
+ </div>
+
+ <div id="instance-action-result"></div>
+
+ <table class="table align-middle">
+ <thead>
+ <tr>
+ <th scope="col">Instance</th>
+ <th scope="col">Controls</th>
+ </tr>
+ </thead>
+ <tbody id="instances-tbody" hx-get="/instances" hx-trigger="load, every 5s, poll-instances-now from:body">
+
+ </tbody>
+ </table>
+ </div>
+ </div>
+
+ <div id="deployment-area"></div>
+
+ </div>
+ <div class="col">
+ <div class="card">
+ <div class="card-body">
+ <h5 class="card-title mb-3">What is this?</h5>
+ <p class="card-text">
+ This platform allows you to spawn an instance of a challenge for your team.
+ Each instance is shared across your team and can be stopped at any time.
+ </p>
+ <p>
+ Instances will automatically stop after a while; if more time is needed then
+ you can stop the instance and deploy a new one.
+ </p>
+ <p class="card-text">
+ <b>Attacking this platform is out of scope of the CTF and is forbidden.</b>
+ If there are any issues, please speak to an organiser.
+ </p>
+ </div>
+ </div>
+ <div class="mt-2 px-3">
+ <small class="text-muted">
+ Logged in as <b>{{.Team}}</b>.
+ <a href="/logout">Not you</a>?
+ </small>
+ </div>
+ </div>
+ </div>
+</div>
+
+</body>
+</html>
diff --git a/web/web.go b/web/web.go
new file mode 100644
index 0000000..eaf03e8
--- /dev/null
+++ b/web/web.go
@@ -0,0 +1,39 @@
+package web
+
+import (
+ "embed"
+ "html/template"
+ "net/http"
+
+ "git.leonardobishop.net/instancer/pkg/deployer"
+ "git.leonardobishop.net/instancer/pkg/registry"
+ "git.leonardobishop.net/instancer/pkg/session"
+ "git.leonardobishop.net/instancer/web/handler"
+ "git.leonardobishop.net/instancer/web/middleware"
+)
+
+//go:embed views
+var views embed.FS
+
+func NewMux(registryClient *registry.RegistryClient, dockerDeployer *deployer.DockerDeployer) *http.ServeMux {
+ tmpl, err := template.ParseFS(views, "views/*.html")
+ if err != nil {
+ panic(err)
+ }
+
+ mux := http.NewServeMux()
+ store := session.NewMemoryStore()
+ mustAuthenticate := middleware.MustAuthenticate(store)
+
+ mux.HandleFunc("GET /auth", handler.GetAuth(tmpl))
+ mux.HandleFunc("POST /auth", handler.PostAuth(tmpl, store))
+ mux.HandleFunc("GET /logout", handler.GetLogout(store))
+
+ mux.HandleFunc("GET /", mustAuthenticate(handler.GetIndex(tmpl, registryClient)))
+ mux.HandleFunc("POST /deploy", mustAuthenticate(handler.PostDeploy(tmpl, dockerDeployer)))
+ mux.HandleFunc("GET /deploy/stream", mustAuthenticate(handler.DeploySSE(tmpl, registryClient, dockerDeployer)))
+ mux.HandleFunc("GET /instances", mustAuthenticate(handler.GetInstances(tmpl, dockerDeployer)))
+ mux.HandleFunc("POST /instances/{deployKey}/stop", mustAuthenticate(handler.PostStopInstance(tmpl, dockerDeployer)))
+
+ return mux
+}