mirror of
https://github.com/PegaProx/project-pegaprox.git
synced 2026-08-12 15:27:47 +08:00
- webhooks._guard_url now returns (ok, reason, url_to_use); public http(s) targets are IP-pinned via resolve_and_pin_url so a DNS rebind between the guard check and the POST can't swing the request to an internal host. allow_private opt-in skips the pin so an internal ntfy/Gotify keeps working. Both send_to_channel / _post_ntfy callers use the pinned url. (Aikido #469089218) - templates_lib deploy: re-run the SSRF guard on tpl['image_url'] right before the node wgets it. add_custom_template validates on entry, but built-in catalog and DB-stored URLs reached the sink unchecked. (Aikido #469089270) - tests: webhook-guard 3-tuple contract + metadata/loopback block + allow_private no-pin.
635 lines
26 KiB
Python
635 lines
26 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Cloud-Init Template Library — NS May 2026.
|
|
|
|
Curated catalog of common cloud images. PegaProx automates the standard
|
|
"download → import → cloud-init drive → convert to template" workflow so
|
|
admins don't have to ssh into every node and run pvesm/qm by hand.
|
|
|
|
Workflow per deploy:
|
|
1. SSH to a node
|
|
2. wget the cloud .img into /var/lib/vz/template/iso/ (or /tmp)
|
|
3. qm create <vmid> --name <tmpl-name> --memory 2048 --cores 2 \
|
|
--net0 virtio,bridge=vmbr0 --ostype l26 --agent 1 --serial0 socket
|
|
4. qm importdisk <vmid> /tmp/<img> <storage>
|
|
5. qm set <vmid> --scsihw virtio-scsi-pci --scsi0 <storage>:vm-<vmid>-disk-0
|
|
6. qm set <vmid> --ide2 <storage>:cloudinit
|
|
7. qm set <vmid> --boot c --bootdisk scsi0
|
|
8. qm template <vmid>
|
|
|
|
Catalog is a dict of presets — image_url, sha256 (optional), default_user,
|
|
recommended cores/memory. We don't bake heavy verification — Proxmox-side
|
|
qm template either succeeds or doesn't.
|
|
"""
|
|
import os
|
|
import json
|
|
import uuid
|
|
import shlex
|
|
import logging
|
|
import threading
|
|
from datetime import datetime
|
|
from flask import Blueprint, jsonify, request
|
|
|
|
from pegaprox.globals import cluster_managers
|
|
from pegaprox.utils.auth import require_auth
|
|
from pegaprox.api.helpers import check_cluster_access
|
|
from pegaprox.core.db import get_db
|
|
|
|
bp = Blueprint('templates_lib', __name__)
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Catalog
|
|
# Curated list — kept small intentionally. Adding entries: image_url must
|
|
# be a direct .img / .qcow2 URL the node can wget.
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
CATALOG = [
|
|
{
|
|
'id': 'ubuntu-2404',
|
|
'name': 'Ubuntu 24.04 LTS (Noble)',
|
|
'distro': 'ubuntu',
|
|
'version': '24.04',
|
|
'image_url': 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img',
|
|
'default_user': 'ubuntu',
|
|
'cores': 2, 'memory': 2048, 'disk_gb': 10,
|
|
'description': 'Latest LTS — cloud-init ready.',
|
|
'tags': ['lts', 'general', 'recommended'],
|
|
},
|
|
{
|
|
'id': 'ubuntu-2204',
|
|
'name': 'Ubuntu 22.04 LTS (Jammy)',
|
|
'distro': 'ubuntu',
|
|
'version': '22.04',
|
|
'image_url': 'https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img',
|
|
'default_user': 'ubuntu',
|
|
'cores': 2, 'memory': 2048, 'disk_gb': 10,
|
|
'description': 'Previous LTS — broad compatibility.',
|
|
'tags': ['lts', 'general'],
|
|
},
|
|
{
|
|
'id': 'debian-12',
|
|
'name': 'Debian 12 (Bookworm)',
|
|
'distro': 'debian',
|
|
'version': '12',
|
|
'image_url': 'https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2',
|
|
'default_user': 'debian',
|
|
'cores': 1, 'memory': 1024, 'disk_gb': 8,
|
|
'description': 'Lean Debian generic-cloud image.',
|
|
'tags': ['lean', 'recommended'],
|
|
},
|
|
{
|
|
'id': 'debian-11',
|
|
'name': 'Debian 11 (Bullseye)',
|
|
'distro': 'debian',
|
|
'version': '11',
|
|
'image_url': 'https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-amd64.qcow2',
|
|
'default_user': 'debian',
|
|
'cores': 1, 'memory': 1024, 'disk_gb': 8,
|
|
'description': 'Older stable Debian.',
|
|
'tags': ['lean'],
|
|
},
|
|
{
|
|
'id': 'almalinux-9',
|
|
'name': 'AlmaLinux 9',
|
|
'distro': 'almalinux',
|
|
'version': '9',
|
|
'image_url': 'https://repo.almalinux.org/almalinux/9/cloud/x86_64/images/AlmaLinux-9-GenericCloud-latest.x86_64.qcow2',
|
|
'default_user': 'almalinux',
|
|
'cores': 2, 'memory': 2048, 'disk_gb': 10,
|
|
'description': 'RHEL-compatible drop-in replacement.',
|
|
'tags': ['rhel'],
|
|
},
|
|
{
|
|
'id': 'rocky-9',
|
|
'name': 'Rocky Linux 9',
|
|
'distro': 'rocky',
|
|
'version': '9',
|
|
'image_url': 'https://download.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2',
|
|
'default_user': 'rocky',
|
|
'cores': 2, 'memory': 2048, 'disk_gb': 10,
|
|
'description': 'Community RHEL rebuild.',
|
|
'tags': ['rhel'],
|
|
},
|
|
{
|
|
'id': 'fedora-40',
|
|
'name': 'Fedora 40 Cloud',
|
|
'distro': 'fedora',
|
|
'version': '40',
|
|
'image_url': 'https://download.fedoraproject.org/pub/fedora/linux/releases/40/Cloud/x86_64/images/Fedora-Cloud-Base-Generic.x86_64-40-1.14.qcow2',
|
|
'default_user': 'fedora',
|
|
'cores': 2, 'memory': 2048, 'disk_gb': 10,
|
|
'description': 'Cutting-edge Red Hat upstream.',
|
|
'tags': ['cutting-edge'],
|
|
},
|
|
{
|
|
'id': 'alpine-319',
|
|
'name': 'Alpine Linux 3.19',
|
|
'distro': 'alpine',
|
|
'version': '3.19',
|
|
'image_url': 'https://dl-cdn.alpinelinux.org/alpine/v3.19/releases/cloud/nocloud_alpine-3.19.1-x86_64-bios-cloudinit-r0.qcow2',
|
|
'default_user': 'alpine',
|
|
'cores': 1, 'memory': 512, 'disk_gb': 4,
|
|
'description': 'Tiny musl-based — perfect for k3s.',
|
|
'tags': ['minimal', 'container-host'],
|
|
},
|
|
]
|
|
|
|
CATALOG_BY_ID = {t['id']: t for t in CATALOG}
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Custom templates (user-managed, persisted in custom_cloud_templates)
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
def _row_to_template(r):
|
|
"""DB row -> catalog dict shape (matching the built-in CATALOG entries)."""
|
|
tags = (r['tags'] or '').strip()
|
|
tag_list = [t.strip() for t in tags.split(',') if t.strip()] if tags else []
|
|
return {
|
|
'id': r['id'],
|
|
'name': r['name'],
|
|
'distro': r['distro'] or 'custom',
|
|
'version': r['version'] or '',
|
|
'image_url': r['image_url'],
|
|
'default_user': r['default_user'] or 'root',
|
|
'cores': r['cores'] or 2,
|
|
'memory': r['memory'] or 2048,
|
|
'disk_gb': r['disk_gb'] or 10,
|
|
'description': r['description'] or '',
|
|
'tags': tag_list + ['custom'],
|
|
'custom': True,
|
|
'created_by': r['created_by'] or '',
|
|
}
|
|
|
|
|
|
def _load_custom_templates():
|
|
try:
|
|
c = get_db().conn.cursor()
|
|
c.execute('SELECT * FROM custom_cloud_templates ORDER BY created_at DESC')
|
|
return [_row_to_template(r) for r in c.fetchall()]
|
|
except Exception as e:
|
|
logging.warning(f"[templates_lib] custom load failed: {e}")
|
|
return []
|
|
|
|
|
|
def _lookup_template(template_id):
|
|
"""Combined lookup: built-in first, then custom DB-backed."""
|
|
if template_id in CATALOG_BY_ID:
|
|
return CATALOG_BY_ID[template_id]
|
|
try:
|
|
c = get_db().conn.cursor()
|
|
c.execute('SELECT * FROM custom_cloud_templates WHERE id = ?', (template_id,))
|
|
r = c.fetchone()
|
|
if r:
|
|
return _row_to_template(r)
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _current_user():
|
|
"""Return the logged-in username string. PegaProx stores the username
|
|
directly in request.session['user'] (not a dict like default Flask)."""
|
|
try:
|
|
u = request.session.get('user') if hasattr(request, 'session') else ''
|
|
if isinstance(u, dict):
|
|
return u.get('username', '') or ''
|
|
return u or ''
|
|
except Exception:
|
|
return ''
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Helpers
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
def _now_iso():
|
|
return datetime.now().isoformat()
|
|
|
|
|
|
def _update_dep(dep_id, **fields):
|
|
"""Patch one row in cloud_init_deployments. Caller passes any subset of
|
|
status / progress / log (appended) / error / vmid / finished_at."""
|
|
try:
|
|
db = get_db()
|
|
c = db.conn.cursor()
|
|
sets = []
|
|
vals = []
|
|
if 'status' in fields:
|
|
sets.append('status = ?'); vals.append(fields['status'])
|
|
if 'progress' in fields:
|
|
sets.append('progress = ?'); vals.append(fields['progress'])
|
|
if 'log_append' in fields:
|
|
sets.append("log = COALESCE(log,'') || ?")
|
|
vals.append(f"[{_now_iso()}] {fields['log_append']}\n")
|
|
if 'error' in fields:
|
|
sets.append('error = ?'); vals.append(fields['error'])
|
|
if 'vmid' in fields:
|
|
sets.append('vmid = ?'); vals.append(fields['vmid'])
|
|
if 'finished_at' in fields:
|
|
sets.append('finished_at = ?'); vals.append(fields['finished_at'])
|
|
if not sets:
|
|
return
|
|
vals.append(dep_id)
|
|
c.execute(f"UPDATE cloud_init_deployments SET {', '.join(sets)} WHERE id = ?", vals)
|
|
db.conn.commit()
|
|
except Exception as e:
|
|
logging.warning(f"[templates_lib] update_dep failed: {e}")
|
|
|
|
|
|
def _next_free_vmid(mgr):
|
|
"""Ask the cluster for the next free vmid."""
|
|
try:
|
|
resp = mgr._api_get('/cluster/nextid')
|
|
if resp and 'data' in resp:
|
|
return int(resp['data'])
|
|
except Exception:
|
|
pass
|
|
# fallback: pick max+1 from resources
|
|
try:
|
|
used = set()
|
|
for r in (mgr.get_vm_resources() or []):
|
|
try: used.add(int(r.get('vmid') or 0))
|
|
except: pass
|
|
return (max(used) + 1) if used else 9000
|
|
except Exception:
|
|
return 9000
|
|
|
|
|
|
def _run_deploy(dep_id, cluster_id, node, template_id, storage, vmid, vm_name):
|
|
"""Background worker: SSH into node, run the qm pipeline."""
|
|
mgr = cluster_managers.get(cluster_id)
|
|
tpl = _lookup_template(template_id)
|
|
if not mgr or not tpl:
|
|
_update_dep(dep_id, status='failed', error='cluster or template missing',
|
|
finished_at=_now_iso())
|
|
return
|
|
|
|
img_basename = tpl['image_url'].rsplit('/', 1)[-1]
|
|
img_path = f"/tmp/pegaprox-ci-{template_id}-{img_basename}"
|
|
|
|
# use management IP if we have one, otherwise cluster host
|
|
try:
|
|
node_ip = mgr._get_node_ip(node) if hasattr(mgr, '_get_node_ip') else None
|
|
except Exception:
|
|
node_ip = None
|
|
target_host = node_ip or mgr.host or mgr.config.host
|
|
|
|
_update_dep(dep_id, status='running', progress=5,
|
|
log_append=f"deploying {tpl['name']} to {node} ({target_host}) as VMID {vmid}")
|
|
|
|
ssh = None
|
|
try:
|
|
ssh = mgr._ssh_connect(target_host)
|
|
if not ssh:
|
|
_update_dep(dep_id, status='failed', error='SSH connect failed',
|
|
finished_at=_now_iso())
|
|
return
|
|
|
|
def run(cmd, label, weight=10):
|
|
_update_dep(dep_id, log_append=f"$ {cmd}")
|
|
stdin, stdout, stderr = ssh.exec_command(cmd, get_pty=False, timeout=900)
|
|
rc = stdout.channel.recv_exit_status()
|
|
out = stdout.read().decode('utf-8', errors='replace').strip()
|
|
err = stderr.read().decode('utf-8', errors='replace').strip()
|
|
if out:
|
|
_update_dep(dep_id, log_append=out[:1000])
|
|
if err and rc != 0:
|
|
_update_dep(dep_id, log_append=f"stderr: {err[:1000]}")
|
|
if rc != 0:
|
|
raise RuntimeError(f"{label} failed (rc={rc}): {err or out}")
|
|
return out
|
|
|
|
# NS May 2026 — every interpolated string goes through shlex.quote.
|
|
# Even fields we think are safe (vmid is int-cast, storage matches a
|
|
# narrow whitelist on PVE side) are quoted as defense-in-depth so
|
|
# custom-template URLs / names can't break out of the qm command line.
|
|
# NS Aug 2026 (Aikido #469089270) — re-assert the SSRF gate at the wget sink itself.
|
|
# add_custom_template validates on entry, but built-in catalog entries and any image_url
|
|
# loaded straight from the DB reach this wget without that check; gate here so the node
|
|
# never fetches a URL that skipped it. allow_private=True keeps air-gapped mirrors working.
|
|
from pegaprox.utils.url_security import sanitize_outbound_url, SsrfError
|
|
try:
|
|
sanitize_outbound_url(tpl['image_url'], allowed_schemes=('https', 'http'), allow_private=True)
|
|
except SsrfError as _se:
|
|
raise RuntimeError(f"image_url rejected by SSRF guard: {_se}")
|
|
|
|
vmid_i = int(vmid)
|
|
q_img_path = shlex.quote(img_path)
|
|
q_url = shlex.quote(tpl['image_url'])
|
|
q_vm_name = shlex.quote(vm_name)
|
|
q_storage = shlex.quote(storage)
|
|
|
|
# 1. download
|
|
# MK: -nc skip if exists, -q quiet output. show-progress would flood log
|
|
run(f"wget -q -O {q_img_path} {q_url}", 'download')
|
|
_update_dep(dep_id, progress=35, log_append='download done')
|
|
|
|
# 2. qm create skeleton
|
|
cores = int(tpl.get('cores', 2))
|
|
memory = int(tpl.get('memory', 2048))
|
|
create_cmd = (
|
|
f"qm create {vmid_i} --name {q_vm_name} "
|
|
f"--memory {memory} --cores {cores} "
|
|
f"--net0 virtio,bridge=vmbr0 --ostype l26 --agent 1 --serial0 socket --vga serial0"
|
|
)
|
|
run(create_cmd, 'qm create')
|
|
_update_dep(dep_id, progress=50, log_append='VM shell created')
|
|
|
|
# 3. import disk
|
|
run(f"qm importdisk {vmid_i} {q_img_path} {q_storage}", 'qm importdisk')
|
|
_update_dep(dep_id, progress=70, log_append='disk imported')
|
|
|
|
# 4. attach as scsi0 + cloudinit + boot order
|
|
# the volid passed to qm set is built from the (now-quoted) storage
|
|
# plus the vmid integer; quote the whole arg as a unit so a hostile
|
|
# storage value can't escape.
|
|
scsi_arg = shlex.quote(f"{storage}:vm-{vmid_i}-disk-0")
|
|
ide_arg = shlex.quote(f"{storage}:cloudinit")
|
|
run(
|
|
f"qm set {vmid_i} --scsihw virtio-scsi-pci "
|
|
f"--scsi0 {scsi_arg} "
|
|
f"--ide2 {ide_arg} "
|
|
f"--boot c --bootdisk scsi0",
|
|
'qm set'
|
|
)
|
|
_update_dep(dep_id, progress=85, log_append='cloud-init drive attached')
|
|
|
|
# 5. convert to template
|
|
run(f"qm template {vmid_i}", 'qm template')
|
|
_update_dep(dep_id, progress=95, log_append='converted to template')
|
|
|
|
# 6. cleanup downloaded img
|
|
run(f"rm -f {q_img_path}", 'cleanup', weight=2)
|
|
|
|
_update_dep(dep_id, status='completed', progress=100,
|
|
log_append=f"template {vm_name} (vmid {vmid}) ready",
|
|
finished_at=_now_iso())
|
|
except Exception as e:
|
|
logging.exception(f"[templates_lib] deploy {dep_id} failed")
|
|
_update_dep(dep_id, status='failed',
|
|
error=str(e)[:500],
|
|
log_append=f"FAILED: {e}",
|
|
finished_at=_now_iso())
|
|
finally:
|
|
try:
|
|
if ssh: ssh.close()
|
|
except: pass
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Endpoints
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
@bp.route('/api/templates/catalog', methods=['GET'])
|
|
@require_auth()
|
|
def catalog():
|
|
"""Curated catalog + user-defined custom templates."""
|
|
return jsonify({'templates': list(CATALOG) + _load_custom_templates()})
|
|
|
|
|
|
@bp.route('/api/templates/custom', methods=['POST'])
|
|
@require_auth(perms=['vm.create'])
|
|
def add_custom_template():
|
|
"""Persist a user-defined cloud-init template. Body shape mirrors the
|
|
built-in catalog entries: name, image_url required; the rest gets sane
|
|
defaults if omitted."""
|
|
body = request.get_json(silent=True) or {}
|
|
name = (body.get('name') or '').strip()
|
|
image_url = (body.get('image_url') or '').strip()
|
|
|
|
if not name or not image_url:
|
|
return jsonify({'error': 'name and image_url required'}), 400
|
|
# NS May 2026 — parse the URL properly and reject anything that's not a
|
|
# plain http(s) host[:port]/path. Earlier we only checked the prefix, which
|
|
# let strings like "https://x; reboot ;" through and they ended up in a
|
|
# shell command via wget. Now: scheme + netloc + ascii-only chars.
|
|
import urllib.parse
|
|
try:
|
|
u = urllib.parse.urlparse(image_url)
|
|
except Exception:
|
|
u = None
|
|
if (not u or u.scheme not in ('http', 'https') or not u.netloc
|
|
or any(ord(c) < 0x20 or c in (' ', ';', '|', '&', '`', '$', '\n', '\t', '"', "'", '\\') for c in image_url)):
|
|
return jsonify({'error': 'image_url must be a clean http(s) URL'}), 400
|
|
|
|
# NS Jul 2026 (CodeAnt SSRF) — the char check above blocks shell metachars but NOT SSRF;
|
|
# this URL is wget'd on the PVE node. Gate it (allow_private=True keeps internal/air-gapped
|
|
# image mirrors working while cloud-metadata endpoints stay blocked).
|
|
from pegaprox.utils.url_security import sanitize_outbound_url, SsrfError
|
|
try:
|
|
sanitize_outbound_url(image_url, allowed_schemes=('https', 'http'), allow_private=True)
|
|
except SsrfError as _se:
|
|
return jsonify({'error': f'image_url rejected by SSRF guard: {_se}'}), 400
|
|
|
|
# MK: keep IDs predictable + URL-safe so frontend keys don't break
|
|
import re, secrets
|
|
base = re.sub(r'[^a-z0-9-]+', '-', name.lower()).strip('-')[:40] or 'tpl'
|
|
tpl_id = f"custom-{base}-{secrets.token_hex(3)}"
|
|
|
|
try:
|
|
cores = int(body.get('cores') or 2)
|
|
memory = int(body.get('memory') or 2048)
|
|
disk_gb = int(body.get('disk_gb') or 10)
|
|
except Exception:
|
|
return jsonify({'error': 'cores/memory/disk_gb must be integers'}), 400
|
|
|
|
tags = body.get('tags', [])
|
|
if isinstance(tags, list):
|
|
tags = ','.join(str(t) for t in tags)
|
|
elif not isinstance(tags, str):
|
|
tags = ''
|
|
|
|
try:
|
|
c = get_db().conn.cursor()
|
|
c.execute('''
|
|
INSERT INTO custom_cloud_templates
|
|
(id, name, description, distro, version, image_url, default_user,
|
|
cores, memory, disk_gb, tags, created_by, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
''', (
|
|
tpl_id,
|
|
name,
|
|
(body.get('description') or '').strip(),
|
|
(body.get('distro') or 'custom').strip().lower(),
|
|
(body.get('version') or '').strip(),
|
|
image_url,
|
|
(body.get('default_user') or 'root').strip(),
|
|
cores, memory, disk_gb, tags,
|
|
_current_user(),
|
|
datetime.now().isoformat(),
|
|
))
|
|
get_db().conn.commit()
|
|
except Exception as e:
|
|
return jsonify({'error': f'db insert failed: {e}'}), 500
|
|
|
|
# Return the newly-created entry in catalog shape so the UI can splice
|
|
# it into the list without a full reload
|
|
c = get_db().conn.cursor()
|
|
c.execute('SELECT * FROM custom_cloud_templates WHERE id = ?', (tpl_id,))
|
|
row = c.fetchone()
|
|
return jsonify({'template': _row_to_template(row) if row else {'id': tpl_id}})
|
|
|
|
|
|
@bp.route('/api/templates/custom/<tpl_id>', methods=['DELETE'])
|
|
@require_auth(perms=['vm.create'])
|
|
def delete_custom_template(tpl_id):
|
|
"""Remove a user-defined template. Built-in catalog IDs are not touched."""
|
|
if tpl_id in CATALOG_BY_ID:
|
|
return jsonify({'error': 'cannot delete built-in template'}), 400
|
|
try:
|
|
c = get_db().conn.cursor()
|
|
c.execute('DELETE FROM custom_cloud_templates WHERE id = ?', (tpl_id,))
|
|
get_db().conn.commit()
|
|
if c.rowcount == 0:
|
|
return jsonify({'error': 'not found'}), 404
|
|
return jsonify({'ok': True})
|
|
except Exception as e:
|
|
logging.exception('handler error in templates_lib.py'); return jsonify({'error': 'internal error'}), 500
|
|
|
|
|
|
@bp.route('/api/clusters/<cluster_id>/templates/deploy', methods=['POST'])
|
|
@require_auth(perms=['vm.create'])
|
|
def deploy(cluster_id):
|
|
"""Kick off async deployment. Body: {template_id, node, storage, vmid?, name?}."""
|
|
ok, err = check_cluster_access(cluster_id)
|
|
if not ok: return err
|
|
if cluster_id not in cluster_managers:
|
|
return jsonify({'error': 'cluster not found'}), 404
|
|
mgr = cluster_managers[cluster_id]
|
|
|
|
body = request.get_json(silent=True) or {}
|
|
template_id = body.get('template_id')
|
|
node = body.get('node')
|
|
storage = body.get('storage')
|
|
vmid = body.get('vmid')
|
|
name = body.get('name')
|
|
|
|
tpl = _lookup_template(template_id)
|
|
if not tpl:
|
|
return jsonify({'error': 'unknown template_id'}), 400
|
|
if not node or not storage:
|
|
return jsonify({'error': 'node and storage required'}), 400
|
|
|
|
# NS May 2026 — defense-in-depth: shlex.quote in _run_deploy is the seatbelt,
|
|
# this is the airbag. Reject anything that isn't [a-zA-Z0-9_.-] in node/
|
|
# storage/name before it ever touches the worker thread. Proxmox itself
|
|
# already enforces these patterns so we're not breaking real users.
|
|
import re
|
|
_SAFE = re.compile(r'^[A-Za-z0-9._-]+$')
|
|
if not _SAFE.match(str(node)):
|
|
return jsonify({'error': 'invalid node name'}), 400
|
|
if not _SAFE.match(str(storage)):
|
|
return jsonify({'error': 'invalid storage name'}), 400
|
|
if name and not _SAFE.match(str(name)):
|
|
return jsonify({'error': 'invalid VM name (allowed: [A-Za-z0-9._-])'}), 400
|
|
|
|
if not vmid:
|
|
vmid = _next_free_vmid(mgr)
|
|
try:
|
|
vmid = int(vmid)
|
|
except Exception:
|
|
return jsonify({'error': 'vmid must be integer'}), 400
|
|
|
|
if not name:
|
|
name = f"tpl-{tpl['distro']}-{tpl['version']}".replace('.', '')
|
|
|
|
user = _current_user()
|
|
|
|
dep_id = uuid.uuid4().hex[:12]
|
|
try:
|
|
c = get_db().conn.cursor()
|
|
c.execute('''
|
|
INSERT INTO cloud_init_deployments
|
|
(id, cluster_id, node, template_id, template_name, vmid, storage,
|
|
status, progress, log, error, started_by, started_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, '', '', ?, ?)
|
|
''', (dep_id, cluster_id, node, template_id, tpl['name'], vmid, storage,
|
|
user, _now_iso()))
|
|
get_db().conn.commit()
|
|
except Exception as e:
|
|
return jsonify({'error': f'db insert failed: {e}'}), 500
|
|
|
|
t = threading.Thread(
|
|
target=_run_deploy,
|
|
args=(dep_id, cluster_id, node, template_id, storage, vmid, name),
|
|
daemon=True, name=f"ci-deploy-{dep_id}"
|
|
)
|
|
t.start()
|
|
|
|
return jsonify({
|
|
'deployment_id': dep_id,
|
|
'vmid': vmid,
|
|
'name': name,
|
|
'status': 'queued',
|
|
})
|
|
|
|
|
|
@bp.route('/api/clusters/<cluster_id>/templates/deployments', methods=['GET'])
|
|
@require_auth(perms=['cluster.view'])
|
|
def deployments(cluster_id):
|
|
"""List recent deployments for a cluster (newest first)."""
|
|
ok, err = check_cluster_access(cluster_id)
|
|
if not ok: return err
|
|
try:
|
|
c = get_db().conn.cursor()
|
|
c.execute('''
|
|
SELECT id, cluster_id, node, template_id, template_name, vmid, storage,
|
|
status, progress, error, started_by, started_at, finished_at
|
|
FROM cloud_init_deployments
|
|
WHERE cluster_id = ?
|
|
ORDER BY started_at DESC LIMIT 50
|
|
''', (cluster_id,))
|
|
rows = [dict(r) for r in c.fetchall()]
|
|
return jsonify({'deployments': rows})
|
|
except Exception as e:
|
|
logging.exception('handler error in templates_lib.py'); return jsonify({'error': 'internal error'}), 500
|
|
|
|
|
|
@bp.route('/api/templates/deployments/<dep_id>', methods=['GET'])
|
|
@require_auth(perms=['cluster.view'])
|
|
def deployment_status(dep_id):
|
|
"""Detailed view including log tail."""
|
|
try:
|
|
c = get_db().conn.cursor()
|
|
c.execute('SELECT * FROM cloud_init_deployments WHERE id = ?', (dep_id,))
|
|
r = c.fetchone()
|
|
if not r:
|
|
return jsonify({'error': 'not found'}), 404
|
|
d = dict(r)
|
|
# NS May 2026: tenant ACL — dep_id is opaque to clients but a holder of
|
|
# cluster.view on cluster A could enumerate deployments belonging to B.
|
|
# gate the response on the requester's actual access to the originating cluster.
|
|
ok, err = check_cluster_access(d.get('cluster_id'))
|
|
if not ok: return err
|
|
return jsonify(d)
|
|
except Exception as e:
|
|
logging.exception('handler error in templates_lib.py'); return jsonify({'error': 'internal error'}), 500
|
|
|
|
|
|
@bp.route('/api/clusters/<cluster_id>/templates/existing', methods=['GET'])
|
|
@require_auth(perms=['cluster.view'])
|
|
def existing_templates(cluster_id):
|
|
"""All existing VM templates on the cluster (template=1) so the UI
|
|
can show 'already deployed' status."""
|
|
ok, err = check_cluster_access(cluster_id)
|
|
if not ok: return err
|
|
if cluster_id not in cluster_managers:
|
|
return jsonify({'error': 'cluster not found'}), 404
|
|
mgr = cluster_managers[cluster_id]
|
|
out = []
|
|
try:
|
|
for r in (mgr.get_vm_resources() or []):
|
|
if r.get('type') != 'qemu': continue
|
|
if r.get('template') != 1: continue
|
|
out.append({
|
|
'vmid': r.get('vmid'),
|
|
'name': r.get('name'),
|
|
'node': r.get('node'),
|
|
'maxdisk': r.get('maxdisk'),
|
|
'maxmem': r.get('maxmem'),
|
|
})
|
|
except Exception as e:
|
|
logging.debug(f"[templates_lib] existing list failed: {e}")
|
|
return jsonify({'templates': out})
|