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 @@ -3,6 +3,7 @@ config.toml

# Ignore database files
*.db
*.db-journal

# Ignore dependency directories and lock files
node_modules
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ This project modernizes the original [proxmox KOTH](https://github.com/UNHCSC/pr
- Go 1.20+ (or later) for the server binaries.
- Node.js 20+ / npm for building the dashboard assets.
- A valid `config.toml` next to the repository root to describe the database, Proxmox, and LDAP settings.
- A service user on your Proxmox cluster set up with `VM.Audit, VM.Console` permissions on Proxmox. (You can create a custom role with these permissions and assign only that role to the service user for better security.)
- Container setup and scoring are performed via the Proxmox console (raw exec), not SSH. If your setup scripts rely on SSH access, be sure to install and enable an OpenSSH server inside the container.
- Some container templates do not ship an SSH server; add `openssh-server` (or your distro equivalent) in your setup scripts if you require SSH.

## Installation

Expand Down
80 changes: 80 additions & 0 deletions app/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,10 @@ func apiCreateCompetition(c *fiber.Ctx) (err error) {
compReq.EnableAdvancedLogging = enableAdvancedLogging
ctx.logf("advanced logging: %t", enableAdvancedLogging)

if err = validateCompetitionTemplates(&compReq); err != nil {
return ctx.fail(c, fiber.StatusBadRequest, "invalid container configuration", err)
}

var packageRecord *db.CompetitionPackage
if packageRecord, err = persistCompetitionPackage(&compReq, configData, fHeader.Filename); err != nil {
return ctx.fail(c, fiber.StatusInternalServerError, "failed to store competition package", err)
Expand Down Expand Up @@ -1490,6 +1494,82 @@ func persistCompetitionPackage(req *db.CreateCompetitionRequest, configBytes []b
return record, nil
}

func validateCompetitionTemplates(req *db.CreateCompetitionRequest) error {
if req == nil {
return fmt.Errorf("competition request is nil")
}

var lookup map[string]db.ContainerSpecTemplate
var err error
if lookup, err = koth.BuildContainerSpecTemplateIndex(req.ContainerSpecsTemplates); err != nil {
return err
}
req.TemplateLookup = lookup

restrictions := config.Config.ContainerRestrictions
for name, spec := range lookup {
if strings.TrimSpace(spec.TemplatePath) == "" {
return fmt.Errorf("template %q missing templatePath", name)
}
if strings.TrimSpace(spec.StoragePool) == "" {
return fmt.Errorf("template %q missing storagePool", name)
}
if strings.TrimSpace(spec.RootPassword) == "" {
return fmt.Errorf("template %q missing rootPassword", name)
}
if spec.StorageSizeGB <= 0 {
return fmt.Errorf("template %q invalid storageSizeGB (%d)", name, spec.StorageSizeGB)
}
if spec.MemoryMB <= 0 {
return fmt.Errorf("template %q invalid memoryMB (%d)", name, spec.MemoryMB)
}
if spec.Cores <= 0 {
return fmt.Errorf("template %q invalid cores (%d)", name, spec.Cores)
}

if len(restrictions.AllowedLXCTemplates) > 0 && !containsString(restrictions.AllowedLXCTemplates, spec.TemplatePath) {
return fmt.Errorf("template %q uses disallowed template path %q", name, spec.TemplatePath)
}
if len(restrictions.AllowedStoragePools) > 0 && !containsString(restrictions.AllowedStoragePools, spec.StoragePool) {
return fmt.Errorf("template %q uses disallowed storage pool %q", name, spec.StoragePool)
}

if restrictions.MaxDiskMB > 0 {
storageMB := int64(spec.StorageSizeGB) * 1024
if storageMB > int64(restrictions.MaxDiskMB) {
return fmt.Errorf("template %q requests %d MB which exceeds maxDiskMB (%d)", name, storageMB, restrictions.MaxDiskMB)
}
}
if restrictions.MaxMemoryMB > 0 && spec.MemoryMB > restrictions.MaxMemoryMB {
return fmt.Errorf("template %q requests %d MB of RAM which exceeds maxMemoryMB (%d)", name, spec.MemoryMB, restrictions.MaxMemoryMB)
}
if restrictions.MaxCPUCores > 0 && spec.Cores > restrictions.MaxCPUCores {
return fmt.Errorf("template %q requests %d cores which exceeds maxCPUCores (%d)", name, spec.Cores, restrictions.MaxCPUCores)
}
}

for _, cfg := range req.TeamContainerConfigs {
if strings.TrimSpace(cfg.ContainerSpecsTemplate) == "" {
return fmt.Errorf("team container %s missing containerSpecsTemplate", cfg.Name)
}
if _, err = koth.ResolveContainerSpecTemplate(lookup, cfg.ContainerSpecsTemplate); err != nil {
return fmt.Errorf("team container %s references invalid template %q: %w", cfg.Name, cfg.ContainerSpecsTemplate, err)
}
}

return nil
}

func containsString(list []string, value string) bool {
value = strings.TrimSpace(value)
for _, entry := range list {
if strings.EqualFold(strings.TrimSpace(entry), value) {
return true
}
}
return false
}

func sanitizeIdentifier(value string) string {
var cleanValue = strings.ToLower(strings.TrimSpace(value))
if cleanValue == "" {
Expand Down
81 changes: 77 additions & 4 deletions app/views.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package app

import (
"github.com/UNHCSC/pve-koth/auth"
"github.com/UNHCSC/pve-koth/config"
"github.com/UNHCSC/pve-koth/db"
"github.com/gofiber/fiber/v2"
"strings"
)

func showLanding(c *fiber.Ctx) (err error) {
Expand Down Expand Up @@ -98,14 +100,85 @@ func showDashboard(c *fiber.Ctx) (err error) {
displayName = "Guest"
}

comps, compsErr := db.Competitions.SelectAll()
if compsErr != nil {
appLog.Errorf("failed to load competitions for dashboard resources: %v\n", compsErr)
comps = nil
}

return c.Render("dashboard", bindWithLocals(c, fiber.Map{
"Title": "Dashboard",
"User": displayName,
"LoggedIn": user != nil,
"CanManage": canManage,
"Title": "Dashboard",
"User": displayName,
"LoggedIn": user != nil,
"CanManage": canManage,
"ResourceInfo": fiber.Map{"Restrictions": config.Config.ContainerRestrictions, "Network": buildNetworkResourceStats(comps)},
}), "layout")
}

func buildNetworkResourceStats(comps []*db.Competition) fiber.Map {
network := config.Config.Network
info := fiber.Map{
"PoolCIDR": network.PoolCIDR,
"CompetitionPrefix": network.CompetitionSubnetPrefix,
"TeamPrefix": network.TeamSubnetPrefix,
"ContainerCIDR": network.ContainerCIDR,
"Gateway": network.ContainerGateway,
"Nameserver": network.ContainerNameserver,
"SearchDomain": network.ContainerSearchDomain,
"TotalSubnets": 0,
"UsedSubnets": 0,
"FreeSubnets": 0,
"UsagePercent": 0.0,
}

pool := network.ParsedPool()
if pool == nil {
return info
}

maskOnes, _ := pool.Mask.Size()
diff := network.CompetitionSubnetPrefix - maskOnes
total := 0
if diff >= 0 && diff < 63 {
total = 1 << diff
}

seen := map[string]struct{}{}
for _, comp := range comps {
if comp == nil {
continue
}
cidr := strings.TrimSpace(comp.NetworkCIDR)
if cidr == "" {
continue
}
if _, ok := seen[cidr]; ok {
continue
}
seen[cidr] = struct{}{}
}

used := len(seen)
free := total - used
if free < 0 {
free = 0
}

usage := 0.0
if total > 0 {
usage = (float64(used) / float64(total)) * 100
if usage > 100 {
usage = 100
}
}

info["TotalSubnets"] = total
info["UsedSubnets"] = used
info["FreeSubnets"] = free
info["UsagePercent"] = usage
return info
}

func showUnauthorized(c *fiber.Ctx) error {
var user *auth.AuthUser = auth.IsAuthenticated(c, jwtSigningKey)

Expand Down
16 changes: 15 additions & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ type Configuration struct {
Port string `toml:"port" default:"8006" validate:"required"` // Proxmox VE API port (usually "8006")
TokenID string `toml:"token_id" default:"" validate:"required"` // Proxmox VE API token ID (e.g. "laas-api-token-id")
Secret string `toml:"secret" default:"" validate:"required"` // Proxmox VE API token secret
Username string `toml:"username" default:""` // Proxmox VE username (with realm) for ticket-based console auth, e.g. "root@pam"
Password string `toml:"password" default:""` // Proxmox VE password for ticket-based console auth
Testing struct {
Enabled bool `toml:"enabled" default:"false"` // Enable Proxmox VE integration testing mode
SubnetCIDR string `toml:"subnet_cidr" default:"10.255.0.0/16"` // Subnet CIDR to use for testing VMs
Expand All @@ -56,7 +58,8 @@ type Configuration struct {
BasePath string `toml:"base_path" default:"./koth_live_data" validate:"required"` // Root directory where uploaded competition packages are stored
} `toml:"storage"`

Network NetworkConfig `toml:"network"`
Network NetworkConfig `toml:"network"`
ContainerRestrictions ContainerRestrictionsConfig `toml:"container_restrictions"`
}

var Config Configuration
Expand All @@ -66,10 +69,21 @@ type NetworkConfig struct {
CompetitionSubnetPrefix int `toml:"competition_subnet_prefix" default:"16" validate:"min=8,max=30"`
TeamSubnetPrefix int `toml:"team_subnet_prefix" default:"24" validate:"min=8,max=30"`
ContainerCIDR int `toml:"container_cidr" default:"8" validate:"min=1,max=30"`
ContainerGateway string `toml:"container_gateway" default:"10.0.0.1" validate:"required,ipv4"`
ContainerNameserver string `toml:"container_nameserver" default:"10.0.0.2" validate:"required,ipv4"`
ContainerSearchDomain string `toml:"container_search_domain" default:"cyber.lab" validate:"required"`

parsedPool *net.IPNet `toml:"-"`
}

type ContainerRestrictionsConfig struct {
AllowedLXCTemplates []string `toml:"allowed_lxc_templates" default:"[]"`
AllowedStoragePools []string `toml:"allowed_storage_pools" default:"[]"`
MaxCPUCores int `toml:"max_cpu_cores" default:"4" validate:"min=1"`
MaxMemoryMB int `toml:"max_memory_mb" default:"8192" validate:"min=1"`
MaxDiskMB int `toml:"max_disk_mb" default:"32768" validate:"min=1"`
}

func (n *NetworkConfig) initialize() error {
if n == nil {
return fmt.Errorf("network configuration missing")
Expand Down
42 changes: 21 additions & 21 deletions db/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,22 @@ type ScoringCheck struct {
FailPoints int `json:"failPoints"`
}

type ContainerSpecTemplate struct {
TemplatePath string `json:"templatePath"`
StoragePool string `json:"storagePool"`
RootPassword string `json:"rootPassword"`
StorageSizeGB int `json:"storageSizeGB"`
MemoryMB int `json:"memoryMB"`
Cores int `json:"cores"`
}

type TeamContainerConfig struct {
Name string `json:"name"`
LastOctetValue int `json:"lastOctetValue"`
SetupScript []string `json:"setupScript"`
ScoringScript []string `json:"scoringScript"`
ScoringSchema []ScoringCheck `json:"scoringSchema"`
Name string `json:"name"`
LastOctetValue int `json:"lastOctetValue"`
SetupScript []string `json:"setupScript"`
ScoringScript []string `json:"scoringScript"`
ScoringSchema []ScoringCheck `json:"scoringSchema"`
ContainerSpecsTemplate string `json:"containerSpecsTemplate"`
}

type CreateCompetitionRequest struct {
Expand All @@ -183,22 +193,12 @@ type CreateCompetitionRequest struct {
Public bool `json:"public"`
LDAPAllowedGroupsFilter flexibleStringList `json:"ldapAllowedGroupsFilter"`
} `json:"privacy"`
ContainerSpecs struct {
TemplatePath string `json:"templatePath"`
StoragePool string `json:"storagePool"`
RootPassword string `json:"rootPassword"`
StorageSizeGB int `json:"storageSizeGB"`
MemoryMB int `json:"memoryMB"`
Cores int `json:"cores"`
GatewayIPv4 string `json:"gatewayIPv4"`
CIDRBlock flexibleInt `json:"cidrBlock"`
NameServerIPv4 string `json:"nameServerIPv4"`
SearchDomain string `json:"searchDomain"`
} `json:"containerSpecs"`
TeamContainerConfigs []TeamContainerConfig `json:"teamContainerConfigs"`
SetupPublicFolder string `json:"setupPublicFolder"`
WriteupFilePath string `json:"writeupFilePath"`
AttachedFiles []struct {
ContainerSpecsTemplates map[string]ContainerSpecTemplate `json:"containerSpecsTemplates"`
TeamContainerConfigs []TeamContainerConfig `json:"teamContainerConfigs"`
TemplateLookup map[string]ContainerSpecTemplate `json:"-"`
SetupPublicFolder string `json:"setupPublicFolder"`
WriteupFilePath string `json:"writeupFilePath"`
AttachedFiles []struct {
SourceFilePath string `json:"sourceFilePath"`
FileContent []byte `json:"fileContent"`
} `json:"attachedFiles"`
Expand Down
5 changes: 4 additions & 1 deletion docs/creating_your_first_competition.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,18 @@ Use the sample `config.json` as a template. Important keys:
- `competitionID`, `competitionName`, `competitionDescription`, and `competitionHost` describe the competition itself.
- `numTeams` controls how many team slots are created.
- `privacy.public` toggles visibility; `ldapAllowedGroupsFilter` can limit access to specific groups.
- `containerSpecs` defines defaults for every container (template, storage pool, resources, gateway, DNS, etc.).
- `containerSpecsTemplates` maps a name to the resource definition every container may use (template path, storage pool, root password, disk/memory/CPU limits, etc.).
- `teamContainerConfigs` contains an array of container definitions with:
- `name` (human label used in the dashboard),
- `lastOctetValue` (the octet offset used when allocating IPs in the competition block),
- `containerSpecsTemplate` (the template name defined above that the container should be built from),
- `setupScript`/`scoringScript` arrays that reference files inside `scripts/`,
- `scoringSchema`, the checks the scoring loops execute.
- `setupPublicFolder` points to a subdirectory (like `public`) that will be served to containers when they download static assets.
- `writeupFilePath` can reference a Markdown or PDF file to share with participants after provisioning.

The new network defaults (gateway, DNS, search domain, constraint CIDRs) now live under `config.toml`'s `[network]` section so individual competition configs stop repeating them, and `[container_restrictions]` lets operators whitelist specific templates/pools and cap CPU/memory/disk usage for uploaded packages.

When you're ready to upload, zip the folder so that `config.json` is at the archive root and upload via the dashboard's create competition modal.

### Available Environment Variables
Expand Down
Binary file modified examples/competition_config.zip
Binary file not shown.
4 changes: 3 additions & 1 deletion examples/competition_config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ This is where the admins information will go.

Stuff to hand out to the participants goes in writeup.md/writeup.pdf

Make sure to explain scoring and setup scripts here.
Make sure to explain scoring and setup scripts here.

Note: Provisioning and scoring run via the Proxmox console (raw exec), not SSH. If your container images do not include an SSH server and you want SSH access, ensure your setup scripts install and enable OpenSSH (or your distro equivalent).
37 changes: 23 additions & 14 deletions examples/competition_config/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,30 @@
"public": true,
"ldapAllowedGroupsFilter": []
},
"containerSpecs": {
"templatePath": "isos-ct_templates:vztmpl/ubuntu-25.04-standard_25.04-1.1_amd64.tar.zst",
"storagePool": "team",
"rootPassword": "password123",
"storageSizeGB": 8,
"memoryMB": 1024,
"cores": 1,
"gatewayIPv4": "10.0.0.1",
"cidrBlock": 8,
"nameServerIPv4": "10.0.0.2",
"searchDomain": "cyber.lab"
"containerSpecsTemplates": {
"ubuntu-light": {
"templatePath": "isos-ct_templates:vztmpl/ubuntu-25.04-standard_25.04-1.1_amd64.tar.zst",
"storagePool": "team",
"rootPassword": "password123",
"storageSizeGB": 8,
"memoryMB": 512,
"cores": 1
},
"ubuntu-heavy": {
"templatePath": "isos-ct_templates:vztmpl/ubuntu-25.04-standard_25.04-1.1_amd64.tar.zst",
"storagePool": "team",
"rootPassword": "password123",
"storageSizeGB": 16,
"memoryMB": 2048,
"cores": 2
}
},
"teamContainerConfigs": [
{
"name": "website",
"lastOctetValue": 1,
"setupScript": ["scripts/setup_global.sh", "scripts/setup_website.sh"],
"containerSpecsTemplate": "ubuntu-heavy",
"setupScript": ["scripts/setup_global_apt.sh", "scripts/setup_website.sh"],
"scoringScript": ["scripts/score_global.sh", "scripts/score_website.sh"],
"scoringSchema": [
{
Expand Down Expand Up @@ -52,10 +59,12 @@
"failPoints": -2
}
]
}, {
},
{
"name": "grafana",
"lastOctetValue": 2,
"setupScript": ["scripts/setup_global.sh", "scripts/setup_grafana.sh"],
"containerSpecsTemplate": "ubuntu-light",
"setupScript": ["scripts/setup_global_apt.sh", "scripts/setup_grafana.sh"],
"scoringScript": ["scripts/score_global.sh", "scripts/score_grafana.sh"],
"scoringSchema": [
{
Expand Down
Loading