summaryrefslogtreecommitdiffstats
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-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
12 files changed, 586 insertions, 0 deletions
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
+}