[ci skip] more codex 5.3 improvements

This commit is contained in:
2026-02-06 15:17:38 +11:00
parent dc96431f06
commit dfbaacb6f3
16 changed files with 297 additions and 75 deletions

View File

@@ -58,10 +58,12 @@ vctp -settings /path/to/vctp.yml -db-cleanup
```
## Database Configuration
By default the app uses SQLite and creates/opens `db.sqlite3`. You can opt into PostgreSQL
by updating the settings file:
By default the app uses SQLite and creates/opens `db.sqlite3`.
- `settings.database_driver`: `sqlite` (default) or `postgres`
PostgreSQL support is currently **experimental** and not a production target. To enable it,
set `VCTP_ENABLE_EXPERIMENTAL_POSTGRES=1` and update the settings file:
- `settings.database_driver`: `sqlite` (default) or `postgres` (experimental)
- `settings.database_url`: SQLite file path/DSN or PostgreSQL DSN
Examples:
@@ -104,14 +106,19 @@ HTTP/TLS:
vCenter:
- `settings.vcenter_username`: vCenter username
- `settings.vcenter_password`: vCenter password (encrypted at startup)
- `settings.vcenter_password`: vCenter password (auto-encrypted on startup if plaintext length > 2)
- `settings.vcenter_insecure`: `true` to skip TLS verification
- `settings.vcenter_event_polling_seconds`: event polling interval (0 disables)
- `settings.vcenter_inventory_polling_seconds`: inventory polling interval (0 disables)
- `settings.vcenter_event_polling_seconds`: deprecated and ignored
- `settings.vcenter_inventory_polling_seconds`: deprecated and ignored
- `settings.vcenter_inventory_snapshot_seconds`: hourly snapshot cadence (seconds)
- `settings.vcenter_inventory_aggregate_seconds`: daily aggregation cadence (seconds)
- `settings.vcenter_addresses`: list of vCenter SDK URLs to monitor
Credential encryption:
- `VCTP_ENCRYPTION_KEY`: optional environment variable used to derive the encryption key.
If unset, vCTP derives a host key from hardware/host identity.
- New encrypted values are written with `enc:v1:` prefix.
Snapshots:
- `settings.hourly_snapshot_concurrency`: max concurrent vCenter snapshots (0 = unlimited)
- `settings.hourly_snapshot_max_age_days`: retention for hourly tables

View File

@@ -0,0 +1,27 @@
package secrets
import (
"encoding/base64"
"io"
"log/slog"
"strings"
"testing"
)
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func TestDecryptRejectsShortCiphertext(t *testing.T) {
key := []byte("0123456789abcdef0123456789abcdef")
s := New(testLogger(), key)
encoded := base64.StdEncoding.EncodeToString([]byte{1, 2, 3})
_, err := s.Decrypt(encoded)
if err == nil {
t.Fatal("expected error for short ciphertext, got nil")
}
if !strings.Contains(err.Error(), "ciphertext is too short") {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -6,6 +6,7 @@ import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"log"
"math/big"
"net"
@@ -14,7 +15,7 @@ import (
"time"
)
func GenerateCerts(tlsCert string, tlsKey string) {
func GenerateCerts(tlsCert string, tlsKey string) error {
// @see https://shaneutt.com/blog/golang-ca-and-signed-cert-go/
// @see https://golang.org/src/crypto/tls/generate_cert.go
validFrom := ""
@@ -24,7 +25,7 @@ func GenerateCerts(tlsCert string, tlsKey string) {
// Get the hostname
hostname, err := os.Hostname()
if err != nil {
panic(err)
return fmt.Errorf("failed to lookup hostname: %w", err)
}
// Check that the directory exists
@@ -33,13 +34,15 @@ func GenerateCerts(tlsCert string, tlsKey string) {
_, err = os.Stat(relativePath)
if os.IsNotExist(err) {
log.Printf("Certificate path does not exist, creating %s before generating certificate\n", relativePath)
os.MkdirAll(relativePath, os.ModePerm)
if mkErr := os.MkdirAll(relativePath, os.ModePerm); mkErr != nil {
return fmt.Errorf("failed to create certificate directory %s: %w", relativePath, mkErr)
}
}
// Generate a private key
priv, err := rsa.GenerateKey(rand.Reader, rsaBits)
if err != nil {
log.Fatalf("Failed to generate private key: %v", err)
return fmt.Errorf("failed to generate private key: %w", err)
}
var notBefore time.Time
@@ -48,7 +51,7 @@ func GenerateCerts(tlsCert string, tlsKey string) {
} else {
notBefore, err = time.Parse("Jan 2 15:04:05 2006", validFrom)
if err != nil {
log.Fatalf("Failed to parse creation date: %v", err)
return fmt.Errorf("failed to parse creation date: %w", err)
}
}
@@ -57,7 +60,7 @@ func GenerateCerts(tlsCert string, tlsKey string) {
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
log.Fatalf("Failed to generate serial number: %v", err)
return fmt.Errorf("failed to generate serial number: %w", err)
}
template := x509.Certificate{
@@ -105,35 +108,38 @@ func GenerateCerts(tlsCert string, tlsKey string) {
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
log.Fatalf("Failed to create certificate: %v", err)
return fmt.Errorf("failed to create certificate: %w", err)
}
certOut, err := os.Create(tlsCert)
if err != nil {
log.Fatalf("Failed to open %s for writing: %v", tlsCert, err)
return fmt.Errorf("failed to open %s for writing: %w", tlsCert, err)
}
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
log.Fatalf("Failed to write data to %s: %v", tlsCert, err)
_ = certOut.Close()
return fmt.Errorf("failed to write certificate data to %s: %w", tlsCert, err)
}
if err := certOut.Close(); err != nil {
log.Fatalf("Error closing %s: %v", tlsCert, err)
return fmt.Errorf("failed to close certificate file %s: %w", tlsCert, err)
}
log.Printf("wrote %s\n", tlsCert)
keyOut, err := os.OpenFile(tlsKey, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
log.Fatalf("Failed to open %s for writing: %v", tlsKey, err)
return
return fmt.Errorf("failed to open %s for writing: %w", tlsKey, err)
}
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
log.Fatalf("Unable to marshal private key: %v", err)
_ = keyOut.Close()
return fmt.Errorf("unable to marshal private key: %w", err)
}
if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil {
log.Fatalf("Failed to write data to %s: %v", tlsKey, err)
_ = keyOut.Close()
return fmt.Errorf("failed to write private key data to %s: %w", tlsKey, err)
}
if err := keyOut.Close(); err != nil {
log.Fatalf("Error closing %s: %v", tlsKey, err)
return fmt.Errorf("failed to close private key file %s: %w", tlsKey, err)
}
log.Printf("wrote %s\n", tlsKey)
return nil
}

24
main.go
View File

@@ -29,8 +29,6 @@ var (
bindDisableTls bool
sha1ver string // sha1 revision used to build the program
buildTime string // when the executable was built
cronFrequency time.Duration
cronInvFrequency time.Duration
cronSnapshotFrequency time.Duration
cronAggregateFrequency time.Duration
)
@@ -66,6 +64,7 @@ func main() {
s.Logger = logger
logger.Info("vCTP starting", "build_time", buildTime, "sha1_version", sha1ver, "go_version", runtime.Version(), "settings_file", *settingsPath)
warnDeprecatedPollingSettings(logger, s.Values)
// Configure database
dbDriver := strings.TrimSpace(s.Values.Settings.DatabaseDriver)
@@ -137,7 +136,10 @@ func main() {
// Generate certificate if required
if !(utils.FileExists(tlsCertFilename) && utils.FileExists(tlsKeyFilename)) {
logger.Warn("Specified TLS certificate or private key do not exist", "certificate", tlsCertFilename, "tls-key", tlsKeyFilename)
utils.GenerateCerts(tlsCertFilename, tlsKeyFilename)
if err := utils.GenerateCerts(tlsCertFilename, tlsKeyFilename); err != nil {
logger.Error("failed to generate TLS cert/key", "error", err)
os.Exit(1)
}
}
// Load vcenter credentials from settings, decrypt if required.
@@ -338,6 +340,22 @@ func alignStart(now time.Time, freq time.Duration) time.Time {
return now.Add(freq)
}
func warnDeprecatedPollingSettings(logger *slog.Logger, cfg *settings.SettingsYML) {
if cfg == nil {
return
}
if cfg.Settings.VcenterEventPollingSeconds > 0 {
logger.Warn("vcenter_event_polling_seconds is deprecated and ignored; snapshot lifecycle processing is used instead",
"value", cfg.Settings.VcenterEventPollingSeconds,
)
}
if cfg.Settings.VcenterInventoryPollingSeconds > 0 {
logger.Warn("vcenter_inventory_polling_seconds is deprecated and ignored; hourly snapshot jobs are used instead",
"value", cfg.Settings.VcenterInventoryPollingSeconds,
)
}
}
func durationFromSeconds(value int, fallback int) time.Duration {
if value <= 0 {
return time.Second * time.Duration(fallback)

112
main_test.go Normal file
View File

@@ -0,0 +1,112 @@
package main
import (
"io"
"log/slog"
"strings"
"testing"
"vctp/internal/secrets"
)
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func mustEncrypt(t *testing.T, s *secrets.Secrets, plain string) string {
t.Helper()
enc, err := s.Encrypt([]byte(plain))
if err != nil {
t.Fatalf("encrypt failed: %v", err)
}
return enc
}
func TestResolveVcenterPasswordPlaintextRewrite(t *testing.T) {
logger := testLogger()
key := []byte("0123456789abcdef0123456789abcdef")
cipher := secrets.New(logger, key)
pass, rewritten, err := resolveVcenterPassword(logger, cipher, nil, "my-password")
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if string(pass) != "my-password" {
t.Fatalf("unexpected plaintext returned: %q", string(pass))
}
if !strings.HasPrefix(rewritten, encryptedVcenterPasswordPrefix) {
t.Fatalf("expected rewritten prefixed credential, got: %q", rewritten)
}
}
func TestResolveVcenterPasswordUnprefixedLegacyCiphertextRewrite(t *testing.T) {
logger := testLogger()
activeKey := []byte("0123456789abcdef0123456789abcdef")
legacyKey := []byte("abcdef0123456789abcdef0123456789")
activeCipher := secrets.New(logger, activeKey)
legacyCipher := secrets.New(logger, legacyKey)
legacyCiphertext := mustEncrypt(t, legacyCipher, "legacy-secret")
pass, rewritten, err := resolveVcenterPassword(logger, activeCipher, [][]byte{legacyKey}, legacyCiphertext)
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if string(pass) != "legacy-secret" {
t.Fatalf("unexpected plaintext returned: %q", string(pass))
}
if !strings.HasPrefix(rewritten, encryptedVcenterPasswordPrefix) {
t.Fatalf("expected rewritten prefixed credential, got: %q", rewritten)
}
decoded, err := activeCipher.Decrypt(strings.TrimPrefix(rewritten, encryptedVcenterPasswordPrefix))
if err != nil {
t.Fatalf("rewritten ciphertext did not decrypt with active key: %v", err)
}
if string(decoded) != "legacy-secret" {
t.Fatalf("unexpected rewritten decrypt value: %q", string(decoded))
}
}
func TestResolveVcenterPasswordPrefixedLegacyCiphertextRewrite(t *testing.T) {
logger := testLogger()
activeKey := []byte("0123456789abcdef0123456789abcdef")
legacyKey := []byte("abcdef0123456789abcdef0123456789")
activeCipher := secrets.New(logger, activeKey)
legacyCipher := secrets.New(logger, legacyKey)
legacyCiphertext := mustEncrypt(t, legacyCipher, "legacy-prefixed-secret")
raw := encryptedVcenterPasswordPrefix + legacyCiphertext
pass, rewritten, err := resolveVcenterPassword(logger, activeCipher, [][]byte{legacyKey}, raw)
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if string(pass) != "legacy-prefixed-secret" {
t.Fatalf("unexpected plaintext returned: %q", string(pass))
}
if !strings.HasPrefix(rewritten, encryptedVcenterPasswordPrefix) {
t.Fatalf("expected rewritten prefixed credential, got: %q", rewritten)
}
decoded, err := activeCipher.Decrypt(strings.TrimPrefix(rewritten, encryptedVcenterPasswordPrefix))
if err != nil {
t.Fatalf("rewritten ciphertext did not decrypt with active key: %v", err)
}
if string(decoded) != "legacy-prefixed-secret" {
t.Fatalf("unexpected rewritten decrypt value: %q", string(decoded))
}
}
func TestResolveVcenterPasswordShortPlaintextRejected(t *testing.T) {
logger := testLogger()
key := []byte("0123456789abcdef0123456789abcdef")
cipher := secrets.New(logger, key)
_, _, err := resolveVcenterPassword(logger, cipher, nil, "ab")
if err == nil {
t.Fatal("expected short plaintext error, got nil")
}
if !strings.Contains(err.Error(), "too short") {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -2,11 +2,18 @@ package handler
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
const encryptedValuePrefixV1 = "enc:v1:"
type encryptRequest struct {
Plaintext string `json:"plaintext"`
Value string `json:"value"`
Message string `json:"message"`
}
// EncryptData encrypts a plaintext value and returns the ciphertext.
// @Summary Encrypt data
// @Description Encrypts a plaintext value and returns the ciphertext.
@@ -15,57 +22,47 @@ import (
// @Produce json
// @Param payload body map[string]string true "Plaintext payload"
// @Success 200 {object} models.StatusMessageResponse "Ciphertext response"
// @Failure 400 {object} models.ErrorResponse "Invalid request"
// @Failure 500 {object} models.ErrorResponse "Server error"
// @Router /api/encrypt [post]
func (h *Handler) EncryptData(w http.ResponseWriter, r *http.Request) {
//ctx := context.Background()
var cipherText string
reqBody, err := io.ReadAll(r.Body)
if err != nil {
h.Logger.Error("Invalid data received", "error", err)
fmt.Fprintf(w, "Invalid data received")
w.WriteHeader(http.StatusInternalServerError)
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
} else {
h.Logger.Debug("received input data", "length", len(reqBody))
}
// get the json input
var input map[string]string
if err := json.Unmarshal(reqBody, &input); err != nil {
h.Logger.Error("unable to unmarshal json", "error", err)
prettyPrint(reqBody)
var req encryptRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
h.Logger.Error("unable to decode encrypt request", "error", err)
writeJSONError(w, http.StatusBadRequest, "invalid JSON body")
return
}
plaintext := strings.TrimSpace(req.Plaintext)
if plaintext == "" {
plaintext = strings.TrimSpace(req.Value)
}
if plaintext == "" {
plaintext = strings.TrimSpace(req.Message)
}
if plaintext == "" {
writeJSONError(w, http.StatusBadRequest, "plaintext is required (accepted keys: plaintext, value, message)")
return
}
cipherText, err := h.Secret.Encrypt([]byte(plaintext))
if err != nil {
h.Logger.Error("unable to encrypt payload", "error", err)
writeJSONError(w, http.StatusInternalServerError, "encryption failed")
return
}
h.Logger.Debug("encrypted plaintext payload", "input_length", len(plaintext))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"status": "ERROR",
"message": fmt.Sprintf("Unable to unmarshal JSON in request body: '%s'", err),
})
return
} else {
h.Logger.Debug("successfully decoded JSON")
//prettyPrint(input)
}
//cipher, err := h.Secret.Encrypt()
for k := range input {
//h.Logger.Debug("foo", "key", k, "value", input[k])
cipherText, err = h.Secret.Encrypt([]byte(input[k]))
if err != nil {
h.Logger.Error("Unable to encrypt", "error", err)
} else {
h.Logger.Debug("Encrypted plaintext", "length", len(input[k]), "ciphertext", cipherText)
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "OK",
"message": cipherText,
"prefixed": encryptedValuePrefixV1 + cipherText,
"ciphertext": cipherText,
})
return
}
}
// return the result
}

View File

@@ -0,0 +1,23 @@
package handler
import (
"fmt"
"net/http"
"os"
"strings"
)
const legacyAPIEnvVar = "VCTP_ENABLE_LEGACY_API"
func legacyAPIEnabled() bool {
return strings.TrimSpace(os.Getenv(legacyAPIEnvVar)) == "1"
}
func (h *Handler) denyLegacyAPI(w http.ResponseWriter, endpoint string) bool {
if legacyAPIEnabled() {
return false
}
h.Logger.Warn("legacy endpoint request blocked", "endpoint", endpoint, "env_var", legacyAPIEnvVar)
writeJSONError(w, http.StatusGone, fmt.Sprintf("%s is deprecated and disabled; set %s=1 to temporarily re-enable", endpoint, legacyAPIEnvVar))
return true
}

View File

@@ -16,6 +16,10 @@ import (
// @Failure 500 {string} string "Server error"
// @Router /api/cleanup/updates [delete]
func (h *Handler) UpdateCleanup(w http.ResponseWriter, r *http.Request) {
if h.denyLegacyAPI(w, "/api/cleanup/updates") {
return
}
/*
// Get the current time
now := time.Now()

View File

@@ -20,6 +20,10 @@ import (
// @Failure 400 {object} models.ErrorResponse "Invalid request"
// @Router /api/cleanup/vcenter [delete]
func (h *Handler) VcCleanup(w http.ResponseWriter, r *http.Request) {
if h.denyLegacyAPI(w, "/api/cleanup/vcenter") {
return
}
ctx := context.Background()
// Get the parameters

View File

@@ -27,6 +27,10 @@ import (
// @Failure 500 {string} string "Server error"
// @Router /api/event/vm/create [post]
func (h *Handler) VmCreateEvent(w http.ResponseWriter, r *http.Request) {
if h.denyLegacyAPI(w, "/api/event/vm/create") {
return
}
var (
unixTimestamp int64
//numVcpus int32

View File

@@ -25,6 +25,10 @@ import (
// @Failure 500 {string} string "Server error"
// @Router /api/event/vm/delete [post]
func (h *Handler) VmDeleteEvent(w http.ResponseWriter, r *http.Request) {
if h.denyLegacyAPI(w, "/api/event/vm/delete") {
return
}
var (
deletedTimestamp int64
)

View File

@@ -32,6 +32,10 @@ import (
// @Failure 500 {object} models.ErrorResponse "Server error"
// @Router /api/event/vm/modify [post]
func (h *Handler) VmModifyEvent(w http.ResponseWriter, r *http.Request) {
if h.denyLegacyAPI(w, "/api/event/vm/modify") {
return
}
var configChanges []map[string]string
params := queries.CreateUpdateParams{}
var unixTimestamp int64

View File

@@ -27,6 +27,10 @@ import (
// @Failure 500 {object} models.ErrorResponse "Server error"
// @Router /api/event/vm/move [post]
func (h *Handler) VmMoveEvent(w http.ResponseWriter, r *http.Request) {
if h.denyLegacyAPI(w, "/api/event/vm/move") {
return
}
params := queries.CreateUpdateParams{}
var unixTimestamp int64

View File

@@ -55,7 +55,7 @@ func New(logger *slog.Logger, database db.Database, buildTime string, sha1ver st
// add missing data to VMs
//mux.HandleFunc("/api/inventory/vm/update", h.VmUpdateDetails)
// temporary endpoint
// Legacy/maintenance endpoints are gated by VCTP_ENABLE_LEGACY_API.
mux.HandleFunc("/api/cleanup/updates", h.UpdateCleanup)
//mux.HandleFunc("/api/cleanup/vcenter", h.VcCleanup)

View File

@@ -1,3 +1,9 @@
CPE_OPTS='-settings /etc/dtms/vctp.yml'
MONTHLY_AGG_GO=0
DAILY_AGG_GO=0
# Optional explicit encryption key source (recommended for stable credential decryption across host changes):
# VCTP_ENCRYPTION_KEY=''
# PostgreSQL is experimental and disabled by default:
# VCTP_ENABLE_EXPERIMENTAL_POSTGRES=0
# Deprecated API endpoints are disabled by default:
# VCTP_ENABLE_LEGACY_API=0

View File

@@ -12,8 +12,10 @@ settings:
vcenter_username: ""
vcenter_password: ""
vcenter_insecure: false
vcenter_event_polling_seconds: 60
vcenter_inventory_polling_seconds: 7200
# Deprecated (ignored): legacy event poller
vcenter_event_polling_seconds: 0
# Deprecated (ignored): legacy inventory poller
vcenter_inventory_polling_seconds: 0
vcenter_inventory_snapshot_seconds: 3600
vcenter_inventory_aggregate_seconds: 86400
hourly_snapshot_concurrency: 0