Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
.PHONY: fmt lint test mocks test_coverage
default: help

help: ## list makefile targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'


.PHONY: help fmt lint test mocks test_coverage

GO_PKGS := $(shell go list -f {{.Dir}} ./...)

fmt:
fmt: ## gofmt all files
@go list -f {{.Dir}} ./... | xargs -I{} gofmt -w -s {}

lint:
lint: ## run golangci-lint
@golangci-lint run

test:
test: ## run tests
@go test -race -v $(GO_FLAGS) -count=1 $(GO_PKGS)

test_coverage:
test_coverage: ## run tests with coverage
@go test -race -v $(GO_FLAGS) -count=1 -coverprofile=coverage.out -covermode=atomic $(GO_PKGS)
@go tool cover -html coverage.out

mocks:
mocks: ## generate mocks
@go generate ./...
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Connect to `ws://localhost:8080/ws` for real-time job updates.
A full-featured example demonstrating 14 different job types is available in the [examples](./examples/) directory:

```bash
cd examples
cd examples/getting_started
go run main.go
```

Expand All @@ -140,6 +140,9 @@ go run main.go

Visit `http://localhost:8080` to see the UI in action.

### Basic Auth
See the [basic_auth](./examples/basic_auth) example for how to secure GoCron-UI using a simple basic auth middleware with the credentials being sourced via environment variables.

## Deployment

### Binary Distribution
Expand Down
116 changes: 116 additions & 0 deletions examples/basic_auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# GoCronUI Basic Auth Example

This is a simple example demonstrating securing gocron-ui with basic auth:

```go
> GOCRON_UI_USERNAME=admin GOCRON_UI_PASSWORD=password go run main.go
```

In another terminal:

**valid credentials**
```bash
curl -v -u "admin:password" 127.0.0.1:8080/api/jobs | jq .
\ % Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0* Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080
* Server auth using Basic with user 'admin'
> GET /api/jobs HTTP/1.1
> Host: 127.0.0.1:8080
> Authorization: Basic YWRtaW46cGFzc3dvcmQ=
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Type: application/json
< Vary: Origin
< Date: Sun, 09 Nov 2025 23:09:13 GMT
< Content-Length: 1187
<
{ [1187 bytes data]
100 1187 100 1187 0 0 1837k 0 --:--:-- --:--:-- --:--:-- 1159k
* Connection #0 to host 127.0.0.1 left intact
[
{
"id": "3681db0c-fd30-48ed-af21-2082f6a9f3d7",
"name": "simple-10s-interval",
"tags": [
"interval",
"simple"
],
"nextRun": "2025-11-10T10:09:07+11:00",
"lastRun": "2025-11-10T10:09:07+11:00",
"nextRuns": [
"2025-11-10T10:09:07+11:00",
"2025-11-10T10:09:17+11:00",
"2025-11-10T10:09:27+11:00",
"2025-11-10T10:09:37+11:00",
"2025-11-10T10:09:47+11:00"
],
"schedule": "Every 10 seconds",
"scheduleDetail": "Duration: 10s"
},
{
"id": "7ff3624c-248e-47f6-90bd-49da5451ddcf",
"name": "simple-20s-interval",
"tags": [
"interval",
"simple"
],
"nextRun": "2025-11-10T10:09:07+11:00",
"lastRun": "2025-11-10T10:09:07+11:00",
"nextRuns": [
"2025-11-10T10:09:07+11:00",
"2025-11-10T10:09:27+11:00",
"2025-11-10T10:09:47+11:00",
"2025-11-10T10:10:07+11:00",
"2025-11-10T10:10:27+11:00"
],
"schedule": "Every 20 seconds",
"scheduleDetail": "Duration: 20s"
},
{
"id": "ad6ddb39-e1a3-4dc4-8539-cf1d9a4667eb",
"name": "simple-5s-interval",
"tags": [
"interval",
"simple"
],
"nextRun": "2025-11-10T10:09:17+11:00",
"lastRun": "2025-11-10T10:09:12+11:00",
"nextRuns": [
"2025-11-10T10:09:17+11:00",
"2025-11-10T10:09:22+11:00",
"2025-11-10T10:09:27+11:00",
"2025-11-10T10:09:32+11:00",
"2025-11-10T10:09:37+11:00"
],
"schedule": "Every 5 seconds",
"scheduleDetail": "Duration: 5s"
}
]
```

**missing or invalid credentials**
```bash
curl -v 127.0.0.1:8080/api/jobs
* Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080
> GET /api/jobs HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 401 Unauthorized
< Content-Type: text/plain; charset=utf-8
< Www-Authenticate: Basic realm="restricted", charset="UTF-8"
< X-Content-Type-Options: nosniff
< Date: Sun, 09 Nov 2025 23:10:02 GMT
< Content-Length: 13
<
Unauthorized
* Connection #0 to host 127.0.0.1 left intact
```
129 changes: 129 additions & 0 deletions examples/basic_auth/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package main

import (
"crypto/sha256"
"crypto/subtle"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"

"github.com/go-co-op/gocron-ui/server"
"github.com/go-co-op/gocron/v2"
)

const (
usernameEnv = "GOCRON_UI_USERNAME"
passwordEnv = "GOCRON_UI_PASSWORD"
)

var jobs = []struct {
name string
definition gocron.JobDefinition
task gocron.Task
options []gocron.JobOption
}{
{
"simple-10s-interval", gocron.DurationJob(10 * time.Second), gocron.NewTask(func() { log.Println("Running 10-second interval job") }), []gocron.JobOption{gocron.WithName("simple-10s-interval"), gocron.WithTags("interval", "simple")},
},
{
"simple-5s-interval", gocron.DurationJob(5 * time.Second), gocron.NewTask(func() { log.Println("Running 5-second interval job") }), []gocron.JobOption{gocron.WithName("simple-5s-interval"), gocron.WithTags("interval", "simple")},
},
{
"simple-20s-interval", gocron.DurationJob(20 * time.Second), gocron.NewTask(func() { log.Println("Running 20-second interval job") }), []gocron.JobOption{gocron.WithName("simple-20s-interval"), gocron.WithTags("interval", "simple")},
},
}

func main() {
username, usernameOK := os.LookupEnv(usernameEnv)
password, passwordOK := os.LookupEnv(passwordEnv)

if (!usernameOK || !passwordOK) || username == "" || password == "" {
log.Fatalf("Environment variables %s and %s must be set for basic authentication", usernameEnv, passwordEnv)
}

port := flag.Int("port", 8080, "Port to run the server on")
title := flag.String("title", "GoCron Scheduler", "Custom title for the UI")
flag.Parse()

// create the gocron scheduler
scheduler, err := gocron.NewScheduler()
if err != nil {
log.Fatalf("Failed to create scheduler: %v", err)
}

// add jobs to the scheduler
for _, job := range jobs {
if _, err := scheduler.NewJob(job.definition, job.task, job.options...); err != nil {
log.Printf("Error creating job: %v", err)
}
}

// start the scheduler
scheduler.Start()
log.Println("Scheduler started with", len(scheduler.Jobs()), "jobs")

// create and start the API server with custom title
srv := server.NewServer(scheduler, *port, server.WithTitle(*title))

// start server in a goroutine
go func() {
addr := fmt.Sprintf(":%d", *port)
log.Println("\n" + strings.Repeat("=", 70))
log.Printf("GoCron UI Server Started with Basic Authentication")
log.Println(strings.Repeat("=", 70))
log.Printf("Web UI: http://localhost%s", addr)
log.Printf("API: http://localhost%s/api", addr)
log.Printf("WebSocket: ws://localhost%s/ws", addr)
log.Printf("Total Jobs: %d", len(scheduler.Jobs()))
log.Println(strings.Repeat("=", 70) + "\n")

if err := http.ListenAndServe(addr, basicAuthMiddleware(srv.Router, username, password)); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}()

// wait for interrupt signal to gracefully shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit

log.Println("\nShutting down server...")

// shutdown scheduler
if err := scheduler.Shutdown(); err != nil {
log.Printf("Error shutting down scheduler: %v", err)
}

log.Println("Server stopped gracefully")
}

// https://www.alexedwards.net/blog/basic-authentication-in-go
func basicAuthMiddleware(next http.Handler, expectedUsername, expectedPassword string) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if ok {
usernameHash := sha256.Sum256([]byte(username))
passwordHash := sha256.Sum256([]byte(password))

expectedUsernameHash := sha256.Sum256([]byte(expectedUsername))
expectedPasswordHash := sha256.Sum256([]byte(expectedPassword))

usernameMatch := (subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1)
passwordMatch := (subtle.ConstantTimeCompare(passwordHash[:], expectedPasswordHash[:]) == 1)

if usernameMatch && passwordMatch {
next.ServeHTTP(w, r)
return
}
}

w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
})
}
File renamed without changes.
Loading