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
2 changes: 2 additions & 0 deletions .github/filters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ shared-frontend:
- packages/frontend-shared/**
cloud-shared:
- cloud/shared/**
hosted-pi-manager:
- cloud/hosted-pi-manager/**
cloud-openapi:
- cloud/frontend/openapi.json
- cloud/frontend/src/types/api.generated.ts
Expand Down
27 changes: 27 additions & 0 deletions .github/workflows/backend-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: [main, master]
paths:
- "cloud/backend/**"
- "cloud/hosted-pi-manager/**"
- "pi/backend/**"
- "packages/vendiqo_shared/**"
- "cloud/shared/**"
Expand All @@ -13,6 +14,7 @@ on:
pull_request:
paths:
- "cloud/backend/**"
- "cloud/hosted-pi-manager/**"
- "pi/backend/**"
- "packages/vendiqo_shared/**"
- "cloud/shared/**"
Expand All @@ -29,6 +31,7 @@ jobs:
outputs:
cloud: ${{ steps.filter.outputs.cloud-backend == 'true' || steps.filter.outputs.shared-python == 'true' || steps.filter.outputs.cloud-shared == 'true' }}
pi: ${{ steps.filter.outputs.pi-backend == 'true' || steps.filter.outputs.shared-python == 'true' }}
hosted-pi-manager: ${{ steps.filter.outputs.hosted-pi-manager == 'true' }}
steps:
- uses: actions/checkout@v7

Expand Down Expand Up @@ -102,3 +105,27 @@ jobs:
with:
name: coverage-pi
path: pi/backend/coverage.xml

test-hosted-pi-manager:
needs: changes
if: needs.changes.outputs.hosted-pi-manager == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"

- name: Install dependencies (hosted-pi-manager)
working-directory: cloud/hosted-pi-manager
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-dev.txt

- name: Run tests (hosted-pi-manager)
working-directory: cloud/hosted-pi-manager
env:
PYTHONPATH: ${{ github.workspace }}/cloud/hosted-pi-manager
run: python -m pytest tests/ -v
3 changes: 3 additions & 0 deletions .github/workflows/pr-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ on:
pull_request:
branches: [main, master]

permissions:
contents: read

jobs:
gate:
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.2.3
1.2.4
6 changes: 1 addition & 5 deletions cloud/frontend/src/components/EventConfigLayoutsSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ import { useI18n } from 'vue-i18n'
import { apiJson } from '../api'
import { textColorForBackground } from '../utils/colorContrast.js'
import { layoutCellHasContent } from '../utils/eventConfigLayoutsPayload'
import { newUuid } from '@/utils/newUuid'
import type { ColorPaletteEntry, EventConfigurationRead } from '@/types/api'
import type {
EventCellEditState,
Expand Down Expand Up @@ -284,11 +285,6 @@ function filterTreeNodes(nodes: TreeViewNode[], query: string): TreeViewNode[] {
return out
}

function newUuid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
return `local-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}

function cellVoucherUuids(c: EventLayoutCellLocal | null | undefined): string[] {
const list = c?.voucher_definition_uuids
if (Array.isArray(list) && list.length) return list.map(String)
Expand Down
6 changes: 1 addition & 5 deletions cloud/frontend/src/components/EventConfigVouchersSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
<script setup lang="ts">
import FormLabel from './FormLabel.vue'
import { rules } from '../utils/formRules.js'
import { newUuid } from '@/utils/newUuid'
import type { ArticleSelectOption, EventVoucherDefinitionLocal, SelectOption } from '@/types/ui'

withDefaults(
Expand All @@ -97,11 +98,6 @@ const emit = defineEmits<{
}>()
const vouchers = defineModel<EventVoucherDefinitionLocal[]>({ required: true })

function newUuid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
return `local-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}

function addVoucher() {
vouchers.value.push({
uuid: newUuid(),
Expand Down
6 changes: 1 addition & 5 deletions cloud/frontend/src/components/EventConfiguration.vue
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ import EventBookkeepingTab from './EventBookkeepingTab.vue'
import ReceiptPrintingSection from './ReceiptPrintingSection.vue'
import { organisationAccountsEnabled } from '../utils/orgScope.js'
import { resolveAppLayoutsForPut } from '../utils/eventConfigLayoutsPayload'
import { newUuid } from '@/utils/newUuid'
import { SESSION_CONTEXT_KEY } from '../sessionContext'
import type {
ArticleRead,
Expand Down Expand Up @@ -416,11 +417,6 @@ function onLayoutRemoved({ removedUuid, fallbackUuid }: LayoutRemovedPayload) {
})
}

function newUuid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
return `local-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}

function normalizePickupPrefix(value: string | null | undefined): string {
return String(value || '').toUpperCase().replace(/[^A-Z]/g, '').slice(0, 3)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import { apiJson } from '../api'
import type { ColorPaletteEntry, ColorPaletteRead } from '@/types/api'
import { getErrorMessage } from '@/types/api'
import type { DataTableHeader } from '@/types/vuetify'
import { newUuid } from '@/utils/newUuid'
import VqDataTable from './VqDataTable.vue'
import HelpLink from './HelpLink.vue'

Expand Down Expand Up @@ -122,7 +123,7 @@ const colorHeaders = computed((): DataTableHeader[] => [
])

function nextKey(): string {
return `color-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
return `color-${newUuid()}`
}

function mapRows(entries: ColorPaletteEntry[]): PaletteRow[] {
Expand Down
25 changes: 25 additions & 0 deletions cloud/frontend/src/utils/newUuid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isUuid, newUuid } from './newUuid'

describe('newUuid', () => {
afterEach(() => {
vi.unstubAllGlobals()
})

it('returns a UUID v4 string', () => {
expect(isUuid(newUuid())).toBe(true)
})

it('uses getRandomValues when randomUUID is unavailable', () => {
const getRandomValues = vi.fn((array: Uint8Array) => {
for (let index = 0; index < array.length; index += 1) {
array[index] = index
}
return array
})
vi.stubGlobal('crypto', { getRandomValues })

expect(isUuid(newUuid())).toBe(true)
expect(getRandomValues).toHaveBeenCalled()
})
})
25 changes: 25 additions & 0 deletions cloud/frontend/src/utils/newUuid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i

function randomUuidV4(): string {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}

export function newUuid(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
return randomUuidV4()
}
throw new Error('crypto is not available')
}

export function isUuid(value: string): boolean {
return UUID_RE.test(value)
}
4 changes: 3 additions & 1 deletion cloud/hosted-pi-manager/app/caddy.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
import subprocess

from .config import CADDY_CONTAINER, CADDY_SNIPPETS_DIR, HOSTED_PI_BASE_DOMAIN
from .slug import safe_path_under, validate_slug

log = logging.getLogger(__name__)


def snippet_path(slug: str):
return CADDY_SNIPPETS_DIR / f"{slug}.caddy"
validate_slug(slug)
return safe_path_under(CADDY_SNIPPETS_DIR, f"{slug}.caddy")


def write_snippet(slug: str, frontend_container: str) -> None:
Expand Down
6 changes: 5 additions & 1 deletion cloud/hosted-pi-manager/app/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
PI_BACKEND_IMAGE,
PI_FRONTEND_IMAGE,
)
from .slug import safe_path_under, validate_slug

log = logging.getLogger(__name__)

Expand All @@ -25,7 +26,8 @@ def _project_name(slug: str) -> str:


def _instance_dir(slug: str) -> Path:
return INSTANCES_DIR / slug
validate_slug(slug)
return safe_path_under(INSTANCES_DIR, slug)


def _frontend_container(slug: str) -> str:
Expand Down Expand Up @@ -148,6 +150,8 @@ def provision(slug: str, *, cloud_base_url: str, edge_client_id: str, edge_secre
directory = _instance_dir(slug)
directory.mkdir(parents=True, exist_ok=True)
compose_file = directory / "docker-compose.yml"
# codeql[py/clear-text-storage-sensitive-data]
# Intentional: instance compose file is manager-internal; edge secret is required for Pi sync.
compose_file.write_text(
_compose_yaml(slug, cloud_base_url=cloud_base_url, edge_client_id=edge_client_id, edge_secret=edge_secret),
encoding="utf-8",
Expand Down
19 changes: 15 additions & 4 deletions cloud/hosted-pi-manager/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,27 @@
import logging
import os

from fastapi import Depends, FastAPI, Header, HTTPException, status
from pydantic import BaseModel, Field
from fastapi import Depends, FastAPI, Header, HTTPException, Path, status
from pydantic import BaseModel, Field, field_validator

from .compose import destroy, provision
from .slug import validate_slug

log = logging.getLogger(__name__)
app = FastAPI(title=os.getenv("APP_NAME", "hosted-pi-manager"))


class ProvisionRequest(BaseModel):
slug: str = Field(..., min_length=6, max_length=32)
slug: str = Field(..., min_length=12, max_length=12)
cloud_base_url: str
edge_client_id: str
edge_secret: str

@field_validator("slug")
@classmethod
def _validate_slug(cls, value: str) -> str:
return validate_slug(value)


def _verify_secret(x_manager_secret: str | None = Header(default=None, alias="X-Manager-Secret")) -> None:
expected = os.getenv("HOSTED_PI_MANAGER_SECRET", "")
Expand All @@ -39,16 +45,21 @@ def create_instance(body: ProvisionRequest):
edge_client_id=body.edge_client_id,
edge_secret=body.edge_secret,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
except Exception as exc:
log.exception("provision failed for %s", body.slug)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)[:2000]) from exc
return {"status": "running", "slug": body.slug}


@app.delete("/internal/instances/{slug}", dependencies=[Depends(_verify_secret)])
def delete_instance(slug: str):
def delete_instance(slug: str = Path(..., min_length=12, max_length=12)):
try:
validate_slug(slug)
destroy(slug)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
except Exception as exc:
log.exception("destroy failed for %s", slug)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)[:2000]) from exc
Expand Down
22 changes: 22 additions & 0 deletions cloud/hosted-pi-manager/app/slug.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Validation helpers for hosted Pi instance slugs."""

from __future__ import annotations

import re
from pathlib import Path

HOSTED_PI_SLUG_RE = re.compile(r"^[a-f0-9]{12}$")


def validate_slug(slug: str) -> str:
if not HOSTED_PI_SLUG_RE.fullmatch(slug):
raise ValueError("invalid hosted Pi slug")
return slug


def safe_path_under(root: Path, *parts: str) -> Path:
candidate = root.joinpath(*parts).resolve()
root_resolved = root.resolve()
if not candidate.is_relative_to(root_resolved):
raise ValueError("path escapes hosted Pi root directory")
return candidate
1 change: 1 addition & 0 deletions cloud/hosted-pi-manager/requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pytest>=8.0.0
43 changes: 43 additions & 0 deletions cloud/hosted-pi-manager/tests/test_slug.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Tests for hosted Pi slug validation and safe path resolution."""

from pathlib import Path

import pytest
from app.slug import HOSTED_PI_SLUG_RE, safe_path_under, validate_slug


def test_hosted_pi_slug_pattern_matches_generated_tokens():
assert HOSTED_PI_SLUG_RE.fullmatch("a1b2c3d4e5f6")
assert HOSTED_PI_SLUG_RE.fullmatch("0123456789ab")


@pytest.mark.parametrize(
"slug",
[
"../etc",
"..",
"/etc/passwd",
"UPPERCASE12",
"short",
"a" * 13,
"zzzzzzzzzzzz",
"abc/def",
],
)
def test_validate_slug_rejects_unsafe_values(slug: str):
with pytest.raises(ValueError, match="invalid hosted Pi slug"):
validate_slug(slug)


def test_safe_path_under_allows_child_directory(tmp_path: Path):
root = tmp_path / "instances"
root.mkdir()
path = safe_path_under(root, "a1b2c3d4e5f6")
assert path == (root / "a1b2c3d4e5f6").resolve()


def test_safe_path_under_rejects_traversal(tmp_path: Path):
root = tmp_path / "instances"
root.mkdir()
with pytest.raises(ValueError, match="path escapes"):
safe_path_under(root, "..", "outside")
2 changes: 2 additions & 0 deletions pi/backend/app/edge_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ def write_edge_config(*, cloud_base_url: str, edge_client_id: str, edge_secret:
"",
],
)
# codeql[py/clear-text-storage-sensitive-data]
# Intentional: edge credentials must persist locally for Pi sync; file is chmod 0o600.
EDGE_CONFIG_FILE.write_text(body, encoding="utf-8")
try:
EDGE_CONFIG_FILE.chmod(0o600)
Expand Down
Loading