diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/handler/auth.go | 89 | ||||
| -rw-r--r-- | web/handler/deploy.go | 81 | ||||
| -rw-r--r-- | web/handler/index.go | 34 | ||||
| -rw-r--r-- | web/handler/instance.go | 95 | ||||
| -rw-r--r-- | web/middleware/auth.go | 32 | ||||
| -rw-r--r-- | web/views/auth.html | 51 | ||||
| -rw-r--r-- | web/views/f_auth_error.html | 3 | ||||
| -rw-r--r-- | web/views/f_deploy.html | 32 | ||||
| -rw-r--r-- | web/views/f_instance.html | 28 | ||||
| -rw-r--r-- | web/views/f_instance_result.html | 4 | ||||
| -rw-r--r-- | web/views/index.html | 98 | ||||
| -rw-r--r-- | web/web.go | 39 |
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 +} |
