Files
glpi-ai-agent/internal/config/config.go
T
jbergner 40a98e0f57
release-tag / release-image (push) Successful in 1m37s
Update am Dashboard
2026-07-28 05:33:10 +02:00

521 lines
18 KiB
Go

package config
import (
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
HTTPAddr string
DataDir string
DryRun bool
LogLevel string
WebUsername string
WebPassword string
WebAllowAnonymous bool
WebhookSecret string
GLPIURL string
GLPIAPIVersion string
GLPIClientID string
GLPIClientSecret string
GLPIUsername string
GLPIPassword string
GLPIPollInterval time.Duration
GLPIPollLimit int
GLPITicketFilter string
GLPITimeout time.Duration
GLPIAgentUserID int64
GLPIAllowInsecureHTTP bool
GLPIAllowedStatusIDs []int64
OllamaURL string
OllamaModel string
OllamaEmbeddingModel string
OllamaTimeout time.Duration
OllamaNumPredict int
OllamaKeepAlive time.Duration
OllamaThink bool
OllamaMaxConcurrent int
OllamaJSONRetries int
KnowledgeDir string
RAGEnabled bool
KnowledgeTopK int
CategoryPromptLimit int
KnowledgeAllowedSources []string
KnowledgeAutoReplySources []string
KnowledgeWebEditEnabled bool
LearningEnabled bool
LearningMaxExamples int
LearningExamplesPerCategory int
CommunicationLanguage string
CommunicationStyle string
CommunicationSalutation string
CommunicationClosing string
CommunicationSignature string
AutoCategory bool
AutoReply bool
CategoryConfidence float64
ReplyConfidence float64
KnowledgeMinScore float64
ContextEnabled bool
ContextTimeout time.Duration
ContextRelevanceMinScore float64
ContextBlockReplyOnError bool
ContextBlockReplyOnIncident bool
ChangeCalendarEnabled bool
GLPIChangePath string
GLPIChangeFilter string
GLPIChangeLimit int
ChangeLookback time.Duration
ChangeLookahead time.Duration
MajorIncidentsEnabled bool
GLPIMajorIncidentFilter string
GLPIMajorIncidentLimit int
UserDeviceContextEnabled bool
GLPIUserDevicePaths []string
GLPIUserDeviceFilterTemplate string
GLPIUserDeviceLimit int
UptimeKumaEnabled bool
UptimeKumaURL string
UptimeKumaMode string
UptimeKumaAPIKey string
UptimeKumaStatusPages []string
UptimeKumaTimeout time.Duration
UptimeKumaMaxIssues int
UptimeKumaIncludeMaintenance bool
QueueSize int
Workers int
}
func Load() (Config, error) {
c := Config{
HTTPAddr: env("HTTP_ADDR", ":8080"),
DataDir: env("DATA_DIR", "./data"),
DryRun: envBool("DRY_RUN", true),
LogLevel: env("LOG_LEVEL", "info"),
WebUsername: os.Getenv("WEB_USERNAME"),
WebPassword: os.Getenv("WEB_PASSWORD"),
WebAllowAnonymous: envBool("WEB_ALLOW_ANONYMOUS", false),
WebhookSecret: os.Getenv("WEBHOOK_SECRET"),
GLPIURL: strings.TrimRight(os.Getenv("GLPI_URL"), "/"),
GLPIAPIVersion: env("GLPI_API_VERSION", "v2.3"),
GLPIClientID: os.Getenv("GLPI_CLIENT_ID"),
GLPIClientSecret: os.Getenv("GLPI_CLIENT_SECRET"),
GLPIUsername: os.Getenv("GLPI_USERNAME"),
GLPIPassword: os.Getenv("GLPI_PASSWORD"),
GLPIPollInterval: envDuration("GLPI_POLL_INTERVAL", 30*time.Second),
GLPIPollLimit: envInt("GLPI_POLL_LIMIT", 50),
GLPITicketFilter: os.Getenv("GLPI_TICKET_FILTER"),
GLPITimeout: envDuration("GLPI_TIMEOUT", 20*time.Second),
GLPIAgentUserID: envInt64("GLPI_AGENT_USER_ID", 0),
GLPIAllowInsecureHTTP: envBool("GLPI_ALLOW_INSECURE_HTTP", false),
GLPIAllowedStatusIDs: envInt64List("GLPI_ALLOWED_STATUS_IDS", "1"),
OllamaURL: strings.TrimRight(env("OLLAMA_URL", "http://ollama:11434"), "/"),
OllamaModel: env("OLLAMA_MODEL", "qwen3:8b"),
OllamaEmbeddingModel: env("OLLAMA_EMBEDDING_MODEL", "embeddinggemma"),
OllamaTimeout: envDuration("OLLAMA_TIMEOUT", 10*time.Minute),
OllamaNumPredict: envInt("OLLAMA_NUM_PREDICT", 768),
OllamaKeepAlive: envDuration("OLLAMA_KEEP_ALIVE", 10*time.Minute),
OllamaThink: envBool("OLLAMA_THINK", false),
OllamaMaxConcurrent: envInt("OLLAMA_MAX_CONCURRENT", 1),
OllamaJSONRetries: envInt("OLLAMA_JSON_RETRIES", 1),
KnowledgeDir: env("KNOWLEDGE_DIR", "./knowledge"),
RAGEnabled: envBool("RAG_ENABLED", true),
KnowledgeTopK: envInt("KNOWLEDGE_TOP_K", 3),
CategoryPromptLimit: envInt("CATEGORY_PROMPT_LIMIT", 80),
KnowledgeAllowedSources: envStringList("KNOWLEDGE_ALLOWED_SOURCES", "internal-kb"),
KnowledgeAutoReplySources: envStringList("KNOWLEDGE_AUTO_REPLY_SOURCES", "internal-kb"),
KnowledgeWebEditEnabled: envBool("KNOWLEDGE_WEB_EDIT_ENABLED", false),
LearningEnabled: envBool("LEARNING_ENABLED", true),
LearningMaxExamples: envInt("LEARNING_MAX_EXAMPLES", 500),
LearningExamplesPerCategory: envInt("LEARNING_EXAMPLES_PER_CATEGORY", 5),
CommunicationLanguage: env("COMMUNICATION_LANGUAGE", "de-DE"),
CommunicationStyle: strings.ToLower(env("COMMUNICATION_STYLE", "formal")),
CommunicationSalutation: env("COMMUNICATION_SALUTATION", "Guten Tag,"),
CommunicationClosing: env("COMMUNICATION_CLOSING", "Mit freundlichen Grüßen"),
CommunicationSignature: env("COMMUNICATION_SIGNATURE", "IT-Service"),
AutoCategory: envBool("AUTO_CATEGORY", true),
AutoReply: envBool("AUTO_REPLY", false),
CategoryConfidence: envFloat("CATEGORY_CONFIDENCE", 0.90),
ReplyConfidence: envFloat("REPLY_CONFIDENCE", 0.97),
KnowledgeMinScore: envFloat("KNOWLEDGE_MIN_SCORE", 0.88),
ContextEnabled: envBool("CONTEXT_ENABLED", true),
ContextTimeout: envDuration("CONTEXT_TIMEOUT", 12*time.Second),
ContextRelevanceMinScore: envFloat("CONTEXT_RELEVANCE_MIN_SCORE", 0.20),
ContextBlockReplyOnError: envBool("CONTEXT_BLOCK_AUTO_REPLY_ON_ERRORS", true),
ContextBlockReplyOnIncident: envBool("CONTEXT_BLOCK_AUTO_REPLY_ON_INCIDENT", true),
ChangeCalendarEnabled: envBool("CHANGE_CALENDAR_ENABLED", true),
GLPIChangePath: env("GLPI_CHANGE_PATH", "/Assistance/Change"),
GLPIChangeFilter: os.Getenv("GLPI_CHANGE_FILTER"),
GLPIChangeLimit: envInt("GLPI_CHANGE_LIMIT", 100),
ChangeLookback: envDuration("CHANGE_LOOKBACK", 48*time.Hour),
ChangeLookahead: envDuration("CHANGE_LOOKAHEAD", 24*time.Hour),
MajorIncidentsEnabled: envBool("MAJOR_INCIDENTS_ENABLED", false),
GLPIMajorIncidentFilter: strings.TrimSpace(os.Getenv("GLPI_MAJOR_INCIDENT_FILTER")),
GLPIMajorIncidentLimit: envInt("GLPI_MAJOR_INCIDENT_LIMIT", 20),
UserDeviceContextEnabled: envBool("USER_DEVICE_CONTEXT_ENABLED", true),
GLPIUserDevicePaths: envPathList("GLPI_USER_DEVICE_PATHS", "/Assets/Computer"),
GLPIUserDeviceFilterTemplate: env("GLPI_USER_DEVICE_FILTER_TEMPLATE", "user.id=={{user_id}}"),
GLPIUserDeviceLimit: envInt("GLPI_USER_DEVICE_LIMIT", 20),
UptimeKumaEnabled: envBool("UPTIME_KUMA_ENABLED", false),
UptimeKumaURL: strings.TrimRight(os.Getenv("UPTIME_KUMA_URL"), "/"),
UptimeKumaMode: strings.ToLower(env("UPTIME_KUMA_MODE", "metrics")),
UptimeKumaAPIKey: os.Getenv("UPTIME_KUMA_API_KEY"),
UptimeKumaStatusPages: envStringListPreserveCase("UPTIME_KUMA_STATUS_PAGES", ""),
UptimeKumaTimeout: envDuration("UPTIME_KUMA_TIMEOUT", 10*time.Second),
UptimeKumaMaxIssues: envInt("UPTIME_KUMA_MAX_ISSUES", 20),
UptimeKumaIncludeMaintenance: envBool("UPTIME_KUMA_INCLUDE_MAINTENANCE", true),
QueueSize: envInt("QUEUE_SIZE", 256),
Workers: envInt("WORKERS", 2),
}
return c, c.Validate()
}
func (c Config) Validate() error {
var missing []string
for name, value := range map[string]string{
"GLPI_URL": c.GLPIURL,
"GLPI_CLIENT_ID": c.GLPIClientID,
"GLPI_CLIENT_SECRET": c.GLPIClientSecret,
"GLPI_USERNAME": c.GLPIUsername,
"GLPI_PASSWORD": c.GLPIPassword,
} {
if strings.TrimSpace(value) == "" {
missing = append(missing, name)
}
}
if len(missing) > 0 {
return fmt.Errorf("missing required environment variables: %s", strings.Join(missing, ", "))
}
if !c.WebAllowAnonymous && (c.WebUsername == "" || c.WebPassword == "") {
return errors.New("WEB_USERNAME and WEB_PASSWORD are required unless WEB_ALLOW_ANONYMOUS=true")
}
if !c.WebAllowAnonymous && len(c.WebPassword) < 12 {
return errors.New("WEB_PASSWORD must contain at least 12 characters")
}
if !c.WebAllowAnonymous && isPlaceholder(c.WebPassword) {
return errors.New("WEB_PASSWORD still contains a CHANGE_ME placeholder")
}
if c.WebhookSecret != "" && len(c.WebhookSecret) < 24 {
return errors.New("WEBHOOK_SECRET must contain at least 24 characters when enabled")
}
if c.WebhookSecret != "" && isPlaceholder(c.WebhookSecret) {
return errors.New("WEBHOOK_SECRET still contains a CHANGE_ME placeholder")
}
for name, value := range map[string]string{"GLPI_CLIENT_ID": c.GLPIClientID, "GLPI_CLIENT_SECRET": c.GLPIClientSecret, "GLPI_PASSWORD": c.GLPIPassword} {
if isPlaceholder(value) {
return fmt.Errorf("%s still contains a CHANGE_ME placeholder", name)
}
}
u, err := url.Parse(c.GLPIURL)
if err != nil || u.Host == "" {
return errors.New("GLPI_URL must be a valid absolute URL")
}
if u.Scheme != "https" && u.Scheme != "http" {
return errors.New("GLPI_URL scheme must be http or https")
}
if u.Scheme == "http" && !c.GLPIAllowInsecureHTTP {
return errors.New("GLPI_URL must use https unless GLPI_ALLOW_INSECURE_HTTP=true")
}
if c.OllamaTimeout <= 0 {
return errors.New("OLLAMA_TIMEOUT must be > 0")
}
if c.OllamaNumPredict <= 0 || c.OllamaNumPredict > 4096 {
return errors.New("OLLAMA_NUM_PREDICT must be between 1 and 4096")
}
if c.OllamaKeepAlive < 0 {
return errors.New("OLLAMA_KEEP_ALIVE must be >= 0")
}
if c.OllamaMaxConcurrent <= 0 || c.OllamaMaxConcurrent > 32 {
return errors.New("OLLAMA_MAX_CONCURRENT must be between 1 and 32")
}
if c.OllamaJSONRetries < 0 || c.OllamaJSONRetries > 3 {
return errors.New("OLLAMA_JSON_RETRIES must be between 0 and 3")
}
if c.KnowledgeWebEditEnabled && c.WebAllowAnonymous {
return errors.New("KNOWLEDGE_WEB_EDIT_ENABLED requires authenticated dashboard access; WEB_ALLOW_ANONYMOUS must be false")
}
if c.LearningEnabled {
if c.LearningMaxExamples < 1 || c.LearningMaxExamples > 10000 {
return errors.New("LEARNING_MAX_EXAMPLES must be between 1 and 10000")
}
if c.LearningExamplesPerCategory < 1 || c.LearningExamplesPerCategory > 20 {
return errors.New("LEARNING_EXAMPLES_PER_CATEGORY must be between 1 and 20")
}
}
if len(c.GLPIAllowedStatusIDs) == 0 {
return errors.New("GLPI_ALLOWED_STATUS_IDS must contain at least one positive status id")
}
for _, id := range c.GLPIAllowedStatusIDs {
if id <= 0 {
return errors.New("GLPI_ALLOWED_STATUS_IDS may contain only positive integers")
}
}
if c.AutoReply && c.GLPIAgentUserID <= 0 {
return errors.New("GLPI_AGENT_USER_ID must be set when AUTO_REPLY=true")
}
if strings.TrimSpace(c.CommunicationLanguage) == "" {
return errors.New("COMMUNICATION_LANGUAGE must not be empty")
}
switch c.CommunicationStyle {
case "formal", "neutral", "informal":
default:
return errors.New("COMMUNICATION_STYLE must be one of: formal, neutral, informal")
}
if len(c.KnowledgeAllowedSources) == 0 {
return errors.New("KNOWLEDGE_ALLOWED_SOURCES must contain at least one source")
}
allowedSources := make(map[string]struct{}, len(c.KnowledgeAllowedSources))
for _, source := range c.KnowledgeAllowedSources {
if strings.TrimSpace(source) == "" {
return errors.New("KNOWLEDGE_ALLOWED_SOURCES may not contain empty source names")
}
allowedSources[strings.ToLower(source)] = struct{}{}
}
for _, source := range c.KnowledgeAutoReplySources {
if _, ok := allowedSources[strings.ToLower(source)]; !ok {
return fmt.Errorf("KNOWLEDGE_AUTO_REPLY_SOURCES source %q is not present in KNOWLEDGE_ALLOWED_SOURCES", source)
}
}
if c.AutoReply && len(c.KnowledgeAutoReplySources) == 0 {
return errors.New("KNOWLEDGE_AUTO_REPLY_SOURCES must contain at least one source when AUTO_REPLY=true")
}
if c.Workers < 1 || c.QueueSize < 1 {
return errors.New("WORKERS and QUEUE_SIZE must be >= 1")
}
if c.CategoryConfidence < 0 || c.CategoryConfidence > 1 || c.ReplyConfidence < 0 || c.ReplyConfidence > 1 || c.KnowledgeMinScore < 0 || c.KnowledgeMinScore > 1 || c.ContextRelevanceMinScore < 0 || c.ContextRelevanceMinScore > 1 {
return errors.New("confidence/score thresholds must be between 0 and 1")
}
if c.ContextEnabled {
if c.ContextTimeout <= 0 {
return errors.New("CONTEXT_TIMEOUT must be > 0")
}
if c.ChangeCalendarEnabled {
if !validAPIPath(c.GLPIChangePath) {
return errors.New("GLPI_CHANGE_PATH must be an absolute API path such as /Assistance/Change")
}
if c.GLPIChangeLimit < 1 || c.GLPIChangeLimit > 1000 {
return errors.New("GLPI_CHANGE_LIMIT must be between 1 and 1000")
}
}
if c.MajorIncidentsEnabled {
if c.GLPIMajorIncidentFilter == "" {
return errors.New("GLPI_MAJOR_INCIDENT_FILTER is required when MAJOR_INCIDENTS_ENABLED=true")
}
if c.GLPIMajorIncidentLimit < 1 || c.GLPIMajorIncidentLimit > 500 {
return errors.New("GLPI_MAJOR_INCIDENT_LIMIT must be between 1 and 500")
}
}
if c.UserDeviceContextEnabled {
if len(c.GLPIUserDevicePaths) == 0 {
return errors.New("GLPI_USER_DEVICE_PATHS must contain at least one API path when USER_DEVICE_CONTEXT_ENABLED=true")
}
for _, p := range c.GLPIUserDevicePaths {
if !validAPIPath(p) {
return fmt.Errorf("invalid GLPI user-device API path %q", p)
}
}
if !strings.Contains(c.GLPIUserDeviceFilterTemplate, "{{user_id}}") {
return errors.New("GLPI_USER_DEVICE_FILTER_TEMPLATE must contain {{user_id}}")
}
if c.GLPIUserDeviceLimit < 1 || c.GLPIUserDeviceLimit > 500 {
return errors.New("GLPI_USER_DEVICE_LIMIT must be between 1 and 500")
}
}
if c.UptimeKumaEnabled {
if c.UptimeKumaURL == "" {
return errors.New("UPTIME_KUMA_URL is required when UPTIME_KUMA_ENABLED=true")
}
u, err := url.Parse(c.UptimeKumaURL)
if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") {
return errors.New("UPTIME_KUMA_URL must be an absolute http/https URL")
}
switch c.UptimeKumaMode {
case "metrics":
if strings.TrimSpace(c.UptimeKumaAPIKey) == "" {
return errors.New("UPTIME_KUMA_API_KEY is required for UPTIME_KUMA_MODE=metrics")
}
case "status_page":
if len(c.UptimeKumaStatusPages) == 0 {
return errors.New("UPTIME_KUMA_STATUS_PAGES is required for UPTIME_KUMA_MODE=status_page")
}
default:
return errors.New("UPTIME_KUMA_MODE must be metrics or status_page")
}
if c.UptimeKumaMaxIssues < 1 || c.UptimeKumaMaxIssues > 200 {
return errors.New("UPTIME_KUMA_MAX_ISSUES must be between 1 and 200")
}
}
}
return nil
}
func validAPIPath(v string) bool {
v = strings.TrimSpace(v)
return strings.HasPrefix(v, "/") && !strings.Contains(v, "..") && !strings.ContainsAny(v, "\r\n")
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func isPlaceholder(v string) bool {
return strings.Contains(strings.ToUpper(strings.TrimSpace(v)), "CHANGE_ME")
}
func envInt64List(key, def string) []int64 {
raw := os.Getenv(key)
if strings.TrimSpace(raw) == "" {
raw = def
}
parts := strings.Split(raw, ",")
out := make([]int64, 0, len(parts))
seen := make(map[int64]struct{}, len(parts))
for _, part := range parts {
n, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
if err != nil || n <= 0 {
return nil
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
out = append(out, n)
}
return out
}
func envStringList(key, def string) []string {
raw, ok := os.LookupEnv(key)
if !ok {
raw = def
}
raw = strings.TrimSpace(raw)
if raw == "" || strings.EqualFold(raw, "none") {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
v := strings.ToLower(strings.TrimSpace(part))
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
func envStringListPreserveCase(key, def string) []string {
raw, ok := os.LookupEnv(key)
if !ok {
raw = def
}
raw = strings.TrimSpace(raw)
if raw == "" || strings.EqualFold(raw, "none") {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
v := strings.TrimSpace(part)
if v == "" {
continue
}
key := strings.ToLower(v)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, v)
}
return out
}
func envPathList(key, def string) []string {
vals := envStringListPreserveCase(key, def)
for i := range vals {
vals[i] = "/" + strings.TrimLeft(strings.TrimSpace(vals[i]), "/")
}
return vals
}
func envBool(key string, def bool) bool {
v := os.Getenv(key)
if v == "" {
return def
}
b, e := strconv.ParseBool(v)
if e != nil {
return def
}
return b
}
func envInt(key string, def int) int {
v := os.Getenv(key)
if v == "" {
return def
}
n, e := strconv.Atoi(v)
if e != nil {
return def
}
return n
}
func envInt64(key string, def int64) int64 {
v := os.Getenv(key)
if v == "" {
return def
}
n, e := strconv.ParseInt(v, 10, 64)
if e != nil {
return def
}
return n
}
func envFloat(key string, def float64) float64 {
v := os.Getenv(key)
if v == "" {
return def
}
n, e := strconv.ParseFloat(v, 64)
if e != nil {
return def
}
return n
}
func envDuration(key string, def time.Duration) time.Duration {
v := os.Getenv(key)
if v == "" {
return def
}
d, e := time.ParseDuration(v)
if e != nil {
return def
}
return d
}