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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ dist-ssr
.nitro
.tanstack
bin
.claude/settings.local.json
31 changes: 24 additions & 7 deletions backend/Justfile
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
set dotenv-load := true
set windows-shell := ["pwsh", "-NoLogo", "-NoProfile", "-Command"]

VERSION := `git describe --tags --always --dirty 2>/dev/null || echo "dev"`
COMMIT := `git rev-parse --short HEAD 2>/dev/null || echo "none"`
DATE := `date -u +%Y-%m-%dT%H:%M:%SZ`
VERSION := if os_family() == 'windows' {
`git describe --tags --always --dirty 2>$null; if (-not $?) { "dev" }`
} else {
`git describe --tags --always --dirty 2>/dev/null || echo "dev"`
}
COMMIT := if os_family() == 'windows' {
`git rev-parse --short HEAD 2>$null; if (-not $?) { "none" }`
} else {
`git rev-parse --short HEAD 2>/dev/null || echo "none"`
}
DATE := if os_family() == 'windows' {
`(Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')`
} else {
`date -u +%Y-%m-%dT%H:%M:%SZ`
}
LDFLAGS := "-s -w -X github.com/OrcaCD/orca-cd/internal/version.Version=" + VERSION + " -X github.com/OrcaCD/orca-cd/internal/version.Commit=" + COMMIT + " -X github.com/OrcaCD/orca-cd/internal/version.BuildDate=" + DATE
defaultCommand := ""

Expand All @@ -25,27 +38,31 @@ test:

# Build the hub binary to bin/hub
[group('hub')]
[env("CGO_ENABLED", "1")]
build-hub:
CGO_ENABLED=1 go build -ldflags '{{ LDFLAGS }}' -o bin/hub ./cmd/hub
go build -ldflags '{{ LDFLAGS }}' -o bin/hub ./cmd/hub

# Build the agent binary to bin/agent
[group('agent')]
[env("CGO_ENABLED", "1")]
build-agent:
CGO_ENABLED=1 go build -ldflags '{{ LDFLAGS }}' -o bin/agent ./cmd/agent
go build -ldflags '{{ LDFLAGS }}' -o bin/agent ./cmd/agent

# Build both hub and agent binaries in parallel
[parallel]
build: build-hub build-agent

# Run the hub (use the help command to get a list of commands)
[group('hub')]
[env("CGO_ENABLED", "1")]
run-hub cmd=defaultCommand:
CGO_ENABLED=1 go run -ldflags '{{ LDFLAGS }}' ./cmd/hub {{ cmd }}
go run -ldflags '{{ LDFLAGS }}' ./cmd/hub {{ cmd }}

# Run the agent (use the help command to get a list of commands)
[group('agent')]
[env("CGO_ENABLED", "1")]
run-agent cmd=defaultCommand:
CGO_ENABLED=1 go run -ldflags '{{ LDFLAGS }}' ./cmd/agent {{ cmd }}
go run -ldflags '{{ LDFLAGS }}' ./cmd/agent {{ cmd }}

# Run both hub and agent in parallel
[parallel]
Expand Down
12 changes: 10 additions & 2 deletions backend/cmd/hub/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ func main() {
Short: "Orca Hub",
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
return hub.Run(hub.DefaultConfig())
cfg, err := hub.DefaultConfig()
if err != nil {
return err
}
return hub.Run(cfg)
},
}

Expand All @@ -32,7 +36,11 @@ func main() {
Use: "healthcheck",
Short: "Check the health of the hub",
Run: func(cmd *cobra.Command, args []string) {
cfg := hub.DefaultConfig()
cfg, err := hub.DefaultConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "invalid configuration: %v\n", err)
os.Exit(1)
}

//nolint:gosec
resp, err := http.Get("http://localhost:" + cfg.Port + "/api/v1/health")
Expand Down
38 changes: 38 additions & 0 deletions backend/internal/hub/middleware/csrf.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package middleware

import (
"net/http"
"strings"

"github.com/gin-gonic/gin"
)

var safeMethods = map[string]bool{
http.MethodGet: true,
http.MethodHead: true,
http.MethodOptions: true,
}

func ValidateOrigin(appURL string) gin.HandlerFunc {
return func(c *gin.Context) {
if safeMethods[c.Request.Method] {
c.Next()
return
}

origin := strings.TrimSuffix(c.GetHeader("Origin"), "/")
if origin == "" {
c.String(http.StatusForbidden, "403 forbidden: missing origin header")
c.Abort()
return
}

if origin != appURL {
c.String(http.StatusForbidden, "403 forbidden: invalid origin")
c.Abort()
return
}

c.Next()
}
}
18 changes: 18 additions & 0 deletions backend/internal/hub/middleware/security.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package middleware

import "github.com/gin-gonic/gin"

func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("X-Frame-Options", "DENY")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Referrer-Policy", "same-origin")
c.Header("X-Robots-Tag", "noindex, nofollow, noarchive")
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; object-src 'none'")
c.Header("Cross-Origin-Opener-Policy", "same-origin")
c.Header("Cross-Origin-Resource-Policy", "same-origin")
c.Header("Cross-Origin-Embedder-Policy", "require-corp")

c.Next()
}
}
50 changes: 40 additions & 10 deletions backend/internal/hub/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,27 @@ package hub

import (
"os"
"strings"
"time"

"github.com/OrcaCD/orca-cd/internal/hub/middleware"
"github.com/OrcaCD/orca-cd/internal/version"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
)

type Config struct {
Debug bool
Port string
LogLevel zerolog.Level
Debug bool
Host string
Port string
LogLevel zerolog.Level
TrustedProxies []string
AppURL string
}

func DefaultConfig() Config {
func DefaultConfig() (Config, error) {
debug := os.Getenv("DEBUG")
host := os.Getenv("HOST")
port := os.Getenv("PORT")
logLevelStr := os.Getenv("LOG_LEVEL")

Expand All @@ -29,11 +35,26 @@ func DefaultConfig() Config {
logLevel = zerolog.InfoLevel
}

return Config{
Debug: debug == "true",
Port: port,
LogLevel: logLevel,
var trustedProxies []string
for entry := range strings.SplitSeq(os.Getenv("TRUSTED_PROXIES"), ",") {
if s := strings.TrimSpace(entry); s != "" {
trustedProxies = append(trustedProxies, s)
}
}

appURL, err := parseAppURL(os.Getenv("APP_URL"))
if err != nil {
return Config{}, err
}

return Config{
Debug: debug == "true",
Host: host,
Port: port,
LogLevel: logLevel,
TrustedProxies: trustedProxies,
AppURL: appURL,
}, nil
}

var Log = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).
Expand All @@ -47,9 +68,18 @@ func Run(cfg Config) error {
Log = Log.Level(cfg.LogLevel)

router := gin.Default()
if err := router.SetTrustedProxies(cfg.TrustedProxies); err != nil {
return err
}
if !cfg.Debug && len(cfg.TrustedProxies) == 0 {
Log.Warn().Msg("no trusted proxies configured; in production the server should always run behind a reverse proxy")
}
router.Use(middleware.SecurityHeaders())
router.Use(middleware.ValidateOrigin(cfg.AppURL))

RegisterRoutes(router, cfg)

Log.Info().Str("port", cfg.Port).Str("version", version.Version).Msg("hub started")
return router.Run(":" + cfg.Port)
addr := cfg.Host + ":" + cfg.Port
Log.Info().Str("addr", addr).Str("version", version.Version).Msg("hub started")
return router.Run(addr)
}
50 changes: 50 additions & 0 deletions backend/internal/hub/url.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package hub

import (
"errors"
"fmt"
"net/url"
"strings"
)

var defaultPorts = map[string]string{
"http": "80",
"https": "443",
}

// parseAppURL validates and normalises the APP_URL value.
// It must contain a scheme and host, must not contain a path (other than a
// single leading slash which is stripped), and must not contain a query or
// fragment.
func parseAppURL(raw string) (string, error) {
if raw == "" {
return "", errors.New("APP_URL is required")
}

u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("APP_URL is not a valid URL: %w", err)
}

if u.Scheme == "" || u.Host == "" {
return "", errors.New("APP_URL must include a scheme and host (e.g. https://example.com)")
}

path := strings.TrimPrefix(u.Path, "/")
if path != "" {
return "", errors.New("APP_URL must not include a path")
}

if u.RawQuery != "" || u.Fragment != "" {
return "", errors.New("APP_URL must not include a query string or fragment")
}

scheme := strings.ToLower(u.Scheme)
host := strings.ToLower(u.Hostname())
port := u.Port()
if port != "" && port != defaultPorts[scheme] {
host = host + ":" + port
}

return scheme + "://" + host, nil
}
2 changes: 2 additions & 0 deletions frontend/public/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
Loading