From 3aa1e90d7f9b40be78e9b5aa047010d1558f7eb7 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 27 Jan 2026 10:12:31 +0100 Subject: [PATCH] feat: add vikunja doctor command for diagnostic checks (#2165) Add a new `vikunja doctor` CLI command that performs diagnostic checks. Checks performed: - **System**: Version, Go version, OS/arch, running user, working directory - **Configuration**: Config file path, public URL, JWT secret, CORS origins - **Database**: Connection test, server version (SQLite/MySQL/PostgreSQL) - **Files**: Storage path, writability, disk space (Unix only) - **Optional services** (when enabled): - Redis: Connection ping - Typesense: Health endpoint - Mailer: SMTP connection - LDAP: Bind authentication test - OpenID Connect: Discovery endpoint for each configured provider --- go.mod | 2 +- go.sum | 2 - pkg/cmd/doctor.go | 68 ++++++ pkg/doctor/config.go | 120 +++++++++++ pkg/doctor/database.go | 124 +++++++++++ pkg/doctor/doctor.go | 33 +++ pkg/doctor/files.go | 132 ++++++++++++ pkg/doctor/files_unix.go | 46 ++++ pkg/doctor/files_windows.go | 27 +++ pkg/doctor/output.go | 75 +++++++ pkg/doctor/services.go | 406 +++++++++++++++++++++++++++++++++++ pkg/doctor/system.go | 82 +++++++ pkg/doctor/system_unix.go | 40 ++++ pkg/doctor/system_windows.go | 37 ++++ pkg/doctor/types.go | 32 +++ 15 files changed, 1223 insertions(+), 3 deletions(-) create mode 100644 pkg/cmd/doctor.go create mode 100644 pkg/doctor/config.go create mode 100644 pkg/doctor/database.go create mode 100644 pkg/doctor/doctor.go create mode 100644 pkg/doctor/files.go create mode 100644 pkg/doctor/files_unix.go create mode 100644 pkg/doctor/files_windows.go create mode 100644 pkg/doctor/output.go create mode 100644 pkg/doctor/services.go create mode 100644 pkg/doctor/system.go create mode 100644 pkg/doctor/system_unix.go create mode 100644 pkg/doctor/system_windows.go create mode 100644 pkg/doctor/types.go diff --git a/go.mod b/go.mod index d41b8e3f6..5cb9f70df 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/d4l3k/messagediff v1.2.1 github.com/disintegration/imaging v1.6.2 github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b + github.com/fatih/color v1.18.0 github.com/fclairamb/afero-s3 v0.4.0 github.com/gabriel-vasile/mimetype v1.4.12 github.com/ganigeorgiev/fexpr v0.5.0 @@ -121,7 +122,6 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-chi/chi/v5 v5.2.2 // indirect diff --git a/go.sum b/go.sum index 84bb12f36..0cbcbd482 100644 --- a/go.sum +++ b/go.sum @@ -423,8 +423,6 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= -github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= -github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4= github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= diff --git a/pkg/cmd/doctor.go b/pkg/cmd/doctor.go new file mode 100644 index 000000000..ac2b72230 --- /dev/null +++ b/pkg/cmd/doctor.go @@ -0,0 +1,68 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "fmt" + "os" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/doctor" + "code.vikunja.io/api/pkg/log" + + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(doctorCmd) +} + +var doctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Run diagnostic checks on your Vikunja installation", + Long: `The doctor command runs a series of diagnostic checks to help troubleshoot +issues with your Vikunja installation. It checks: + +- System information (version, user, working directory) +- Configuration (config file, public URL, JWT secret, CORS) +- Database connectivity and version +- File storage (local or S3) +- Optional services (Redis, Typesense, Mailer, LDAP, OpenID) + +Exit codes: + 0 - All checks passed + 1 - One or more checks failed`, + PreRun: func(_ *cobra.Command, _ []string) { + // Minimal init - just config and logger + // Each check will initialize and test its own components + log.InitLogger() + config.InitConfig() + }, + Run: func(_ *cobra.Command, _ []string) { + results := doctor.Run() + + doctor.PrintResults(os.Stdout, results) + + failed := doctor.CountFailed(results) + if failed > 0 { + fmt.Printf("%d check(s) failed\n", failed) + os.Exit(1) + } + + fmt.Println("All checks passed") + }, +} diff --git a/pkg/doctor/config.go b/pkg/doctor/config.go new file mode 100644 index 000000000..b9114d757 --- /dev/null +++ b/pkg/doctor/config.go @@ -0,0 +1,120 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package doctor + +import ( + "code.vikunja.io/api/pkg/config" + + "github.com/spf13/viper" +) + +// CheckConfig returns configuration checks. +func CheckConfig() CheckGroup { + results := []CheckResult{ + checkConfigFile(), + checkPublicURL(), + checkJWTSecret(), + } + + // Only show CORS details if CORS is enabled + if config.CorsEnable.GetBool() { + results = append(results, checkCORS()) + } + + return CheckGroup{ + Name: "Configuration", + Results: results, + } +} + +func checkConfigFile() CheckResult { + configFile := viper.ConfigFileUsed() + if configFile == "" { + return CheckResult{ + Name: "Config file", + Passed: true, + Value: "none (using defaults/environment)", + } + } + + return CheckResult{ + Name: "Config file", + Passed: true, + Value: configFile, + } +} + +func checkPublicURL() CheckResult { + publicURL := config.ServicePublicURL.GetString() + if publicURL == "" { + return CheckResult{ + Name: "Public URL", + Passed: false, + Error: "not configured (required for many features)", + } + } + + return CheckResult{ + Name: "Public URL", + Passed: true, + Value: publicURL, + } +} + +func checkJWTSecret() CheckResult { + // We can't check the actual value, but we can check if it's the default length + // which would indicate it was auto-generated + secret := config.ServiceJWTSecret.GetString() + + // Auto-generated secrets are 64 hex characters (32 bytes) + if len(secret) == 64 { + return CheckResult{ + Name: "JWT secret", + Passed: true, + Value: "configured (auto-generated)", + } + } + + return CheckResult{ + Name: "JWT secret", + Passed: true, + Value: "configured", + } +} + +func checkCORS() CheckResult { + origins := config.CorsOrigins.GetStringSlice() + + result := CheckResult{ + Name: "CORS origins", + Passed: true, + Value: "", + } + + if len(origins) == 0 { + result.Value = "none configured" + return result + } + + // Show first origin in the value, rest as additional lines + result.Value = origins[0] + if len(origins) > 1 { + result.Lines = append(result.Lines, origins[1:]...) + } + + return result +} diff --git a/pkg/doctor/database.go b/pkg/doctor/database.go new file mode 100644 index 000000000..071b36230 --- /dev/null +++ b/pkg/doctor/database.go @@ -0,0 +1,124 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package doctor + +import ( + "fmt" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" +) + +// CheckDatabase returns database connectivity checks. +func CheckDatabase() CheckGroup { + dbType := config.DatabaseType.GetString() + + // Initialize database engine + _, err := db.CreateDBEngine() + if err != nil { + return CheckGroup{ + Name: fmt.Sprintf("Database (%s)", dbType), + Results: []CheckResult{ + { + Name: "Connection", + Passed: false, + Error: err.Error(), + }, + }, + } + } + + results := []CheckResult{ + checkDatabaseConnection(), + checkDatabaseVersion(dbType), + } + + return CheckGroup{ + Name: fmt.Sprintf("Database (%s)", dbType), + Results: results, + } +} + +func checkDatabaseConnection() CheckResult { + s := db.NewSession() + defer s.Close() + + if err := s.Ping(); err != nil { + return CheckResult{ + Name: "Connection", + Passed: false, + Error: err.Error(), + } + } + + return CheckResult{ + Name: "Connection", + Passed: true, + Value: "OK", + } +} + +func checkDatabaseVersion(dbType string) CheckResult { + s := db.NewSession() + defer s.Close() + + var versionQuery string + switch dbType { + case "sqlite": + versionQuery = "SELECT sqlite_version()" + case "mysql": + versionQuery = "SELECT version()" + case "postgres": + versionQuery = "SELECT version()" + default: + return CheckResult{ + Name: "Server version", + Passed: false, + Error: fmt.Sprintf("unknown database type: %s", dbType), + } + } + + results, err := s.QueryString(versionQuery) + if err != nil { + return CheckResult{ + Name: "Server version", + Passed: false, + Error: err.Error(), + } + } + + if len(results) == 0 || len(results[0]) == 0 { + return CheckResult{ + Name: "Server version", + Passed: false, + Error: "could not retrieve version", + } + } + + // Get the first value from the result map + var version string + for _, v := range results[0] { + version = v + break + } + + return CheckResult{ + Name: "Server version", + Passed: true, + Value: version, + } +} diff --git a/pkg/doctor/doctor.go b/pkg/doctor/doctor.go new file mode 100644 index 000000000..3ad076a1c --- /dev/null +++ b/pkg/doctor/doctor.go @@ -0,0 +1,33 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package doctor provides diagnostic checks for Vikunja installations. +package doctor + +// Run executes all diagnostic checks and returns the results. +func Run() []CheckGroup { + groups := []CheckGroup{ + CheckSystem(), + CheckConfig(), + CheckDatabase(), + CheckFiles(), + } + + // Add optional service checks + groups = append(groups, CheckOptionalServices()...) + + return groups +} diff --git a/pkg/doctor/files.go b/pkg/doctor/files.go new file mode 100644 index 000000000..7e1f43533 --- /dev/null +++ b/pkg/doctor/files.go @@ -0,0 +1,132 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package doctor + +import ( + "fmt" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/files" +) + +// CheckFiles returns file storage checks. +func CheckFiles() CheckGroup { + fileType := config.FilesType.GetString() + + // Initialize file handler + if err := files.InitFileHandler(); err != nil { + return CheckGroup{ + Name: fmt.Sprintf("Files (%s)", fileType), + Results: []CheckResult{ + { + Name: "Initialization", + Passed: false, + Error: err.Error(), + }, + }, + } + } + + var results []CheckResult + + switch fileType { + case "local": + results = checkLocalStorage() + case "s3": + results = checkS3Storage() + default: + results = []CheckResult{ + { + Name: "Type", + Passed: false, + Error: fmt.Sprintf("unknown storage type: %s", fileType), + }, + } + } + + return CheckGroup{ + Name: fmt.Sprintf("Files (%s)", fileType), + Results: results, + } +} + +func checkLocalStorage() []CheckResult { + basePath := config.FilesBasePath.GetString() + + results := []CheckResult{ + { + Name: "Path", + Passed: true, + Value: basePath, + }, + } + + // Check writable using the existing ValidateFileStorage function + if err := files.ValidateFileStorage(); err != nil { + results = append(results, CheckResult{ + Name: "Writable", + Passed: false, + Error: err.Error(), + }) + } else { + results = append(results, CheckResult{ + Name: "Writable", + Passed: true, + Value: "yes", + }) + } + + // Check disk space (platform-specific) + results = append(results, checkDiskSpace(basePath)) + + return results +} + +func checkS3Storage() []CheckResult { + endpoint := config.FilesS3Endpoint.GetString() + bucket := config.FilesS3Bucket.GetString() + + results := []CheckResult{ + { + Name: "Endpoint", + Passed: true, + Value: endpoint, + }, + { + Name: "Bucket", + Passed: true, + Value: bucket, + }, + } + + // Check writable using the existing ValidateFileStorage function + if err := files.ValidateFileStorage(); err != nil { + results = append(results, CheckResult{ + Name: "Writable", + Passed: false, + Error: err.Error(), + }) + } else { + results = append(results, CheckResult{ + Name: "Writable", + Passed: true, + Value: "yes", + }) + } + + return results +} diff --git a/pkg/doctor/files_unix.go b/pkg/doctor/files_unix.go new file mode 100644 index 000000000..d7a4b0062 --- /dev/null +++ b/pkg/doctor/files_unix.go @@ -0,0 +1,46 @@ +//go:build !windows + +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package doctor + +import ( + "fmt" + + "golang.org/x/sys/unix" +) + +func checkDiskSpace(path string) CheckResult { + var stat unix.Statfs_t + if err := unix.Statfs(path, &stat); err != nil { + return CheckResult{ + Name: "Disk space", + Passed: false, + Error: err.Error(), + } + } + + // Available space in bytes + availableBytes := stat.Bavail * uint64(stat.Bsize) + availableGB := float64(availableBytes) / (1024 * 1024 * 1024) + + return CheckResult{ + Name: "Disk space", + Passed: true, + Value: fmt.Sprintf("%.1f GB available", availableGB), + } +} diff --git a/pkg/doctor/files_windows.go b/pkg/doctor/files_windows.go new file mode 100644 index 000000000..ce954944a --- /dev/null +++ b/pkg/doctor/files_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package doctor + +func checkDiskSpace(_ string) CheckResult { + return CheckResult{ + Name: "Disk space", + Passed: true, + Value: "check not available on Windows", + } +} diff --git a/pkg/doctor/output.go b/pkg/doctor/output.go new file mode 100644 index 000000000..3a4759915 --- /dev/null +++ b/pkg/doctor/output.go @@ -0,0 +1,75 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package doctor + +import ( + "fmt" + "io" + + "github.com/fatih/color" +) + +var ( + greenCheck = color.New(color.FgGreen).SprintFunc() + redCross = color.New(color.FgRed).SprintFunc() + bold = color.New(color.Bold).SprintFunc() +) + +// PrintResults writes all check groups to the given writer with colored output. +func PrintResults(w io.Writer, groups []CheckGroup) { + fmt.Fprintln(w, bold("Vikunja Doctor")) + fmt.Fprintln(w, "==============") + fmt.Fprintln(w) + + for _, group := range groups { + fmt.Fprintln(w, bold(group.Name)) + for _, result := range group.Results { + printResult(w, result) + } + fmt.Fprintln(w) + } +} + +func printResult(w io.Writer, result CheckResult) { + marker := greenCheck("✓") + value := result.Value + if !result.Passed { + marker = redCross("✗") + if result.Error != "" { + value = result.Error + } + } + + fmt.Fprintf(w, " %s %s: %s\n", marker, result.Name, value) + + for _, line := range result.Lines { + fmt.Fprintf(w, " %s\n", line) + } +} + +// CountFailed returns the number of failed checks across all groups. +func CountFailed(groups []CheckGroup) int { + count := 0 + for _, group := range groups { + for _, result := range group.Results { + if !result.Passed { + count++ + } + } + } + return count +} diff --git a/pkg/doctor/services.go b/pkg/doctor/services.go new file mode 100644 index 000000000..082f61baa --- /dev/null +++ b/pkg/doctor/services.go @@ -0,0 +1,406 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package doctor + +import ( + "context" + "fmt" + "net" + "net/http" + "time" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/modules/auth/ldap" + "code.vikunja.io/api/pkg/red" +) + +// CheckOptionalServices returns check groups for all enabled optional services. +func CheckOptionalServices() []CheckGroup { + var groups []CheckGroup + + if config.RedisEnabled.GetBool() { + groups = append(groups, checkRedis()) + } + + if config.TypesenseEnabled.GetBool() { + groups = append(groups, checkTypesense()) + } + + if config.MailerEnabled.GetBool() { + groups = append(groups, checkMailer()) + } + + if config.AuthLdapEnabled.GetBool() { + groups = append(groups, checkLDAP()) + } + + if config.AuthOpenIDEnabled.GetBool() { + groups = append(groups, checkOpenID()) + } + + return groups +} + +func checkRedis() CheckGroup { + // Initialize Redis + red.InitRedis() + + r := red.GetRedis() + if r == nil { + return CheckGroup{ + Name: "Redis", + Results: []CheckResult{ + { + Name: "Connection", + Passed: false, + Error: "Redis client not initialized", + }, + }, + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := r.Ping(ctx).Err(); err != nil { + return CheckGroup{ + Name: "Redis", + Results: []CheckResult{ + { + Name: "Connection", + Passed: false, + Error: err.Error(), + }, + }, + } + } + + return CheckGroup{ + Name: "Redis", + Results: []CheckResult{ + { + Name: "Connection", + Passed: true, + Value: fmt.Sprintf("OK (%s)", config.RedisHost.GetString()), + }, + }, + } +} + +func checkTypesense() CheckGroup { + url := config.TypesenseURL.GetString() + healthURL := url + "/health" + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) + if err != nil { + return CheckGroup{ + Name: "Typesense", + Results: []CheckResult{ + { + Name: "Connection", + Passed: false, + Error: err.Error(), + }, + }, + } + } + + req.Header.Set("X-TYPESENSE-API-KEY", config.TypesenseAPIKey.GetString()) + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return CheckGroup{ + Name: "Typesense", + Results: []CheckResult{ + { + Name: "Connection", + Passed: false, + Error: err.Error(), + }, + }, + } + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return CheckGroup{ + Name: "Typesense", + Results: []CheckResult{ + { + Name: "Connection", + Passed: false, + Error: fmt.Sprintf("health check returned status %d", resp.StatusCode), + }, + }, + } + } + + return CheckGroup{ + Name: "Typesense", + Results: []CheckResult{ + { + Name: "Connection", + Passed: true, + Value: fmt.Sprintf("OK (%s)", url), + }, + }, + } +} + +func checkMailer() CheckGroup { + host := config.MailerHost.GetString() + port := config.MailerPort.GetInt() + + if host == "" { + return CheckGroup{ + Name: "Mailer", + Results: []CheckResult{ + { + Name: "SMTP connection", + Passed: false, + Error: "mailer host not configured", + }, + }, + } + } + + address := net.JoinHostPort(host, fmt.Sprintf("%d", port)) + + // Simple TCP dial test with timeout + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + dialer := &net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", address) + if err != nil { + return CheckGroup{ + Name: "Mailer", + Results: []CheckResult{ + { + Name: "SMTP connection", + Passed: false, + Error: err.Error(), + }, + }, + } + } + defer conn.Close() + + return CheckGroup{ + Name: "Mailer", + Results: []CheckResult{ + { + Name: "SMTP connection", + Passed: true, + Value: fmt.Sprintf("OK (%s)", address), + }, + }, + } +} + +func checkLDAP() CheckGroup { + host := config.AuthLdapHost.GetString() + port := config.AuthLdapPort.GetInt() + useTLS := config.AuthLdapUseTLS.GetBool() + + address := net.JoinHostPort(host, fmt.Sprintf("%d", port)) + + protocol := "ldap" + if useTLS { + protocol = "ldaps" + } + + // Use the actual LDAP connection function which tests bind credentials + l, err := ldap.ConnectAndBindToLDAPDirectory() + if err != nil { + return CheckGroup{ + Name: "LDAP", + Results: []CheckResult{ + { + Name: "Connection", + Passed: false, + Error: err.Error(), + }, + }, + } + } + defer l.Close() + + return CheckGroup{ + Name: "LDAP", + Results: []CheckResult{ + { + Name: "Connection", + Passed: true, + Value: fmt.Sprintf("OK (%s://%s)", protocol, address), + }, + }, + } +} + +func checkOpenID() CheckGroup { + // Parse raw config to get all providers (including ones that fail to connect) + rawProviders := config.AuthOpenIDProviders.Get() + if rawProviders == nil { + return CheckGroup{ + Name: "OpenID Connect", + Results: []CheckResult{ + { + Name: "Providers", + Passed: true, + Value: "none configured", + }, + }, + } + } + + // Convert to map[string]interface{} + var providerMap map[string]interface{} + switch p := rawProviders.(type) { + case map[string]interface{}: + providerMap = p + case map[interface{}]interface{}: + providerMap = make(map[string]interface{}, len(p)) + for k, v := range p { + if key, ok := k.(string); ok { + providerMap[key] = v + } + } + default: + return CheckGroup{ + Name: "OpenID Connect", + Results: []CheckResult{ + { + Name: "Configuration", + Passed: false, + Error: "invalid provider configuration format", + }, + }, + } + } + + if len(providerMap) == 0 { + return CheckGroup{ + Name: "OpenID Connect", + Results: []CheckResult{ + { + Name: "Providers", + Passed: true, + Value: "none configured", + }, + }, + } + } + + var results []CheckResult + for key, p := range providerMap { + result := checkOpenIDProvider(key, p) + results = append(results, result) + } + + return CheckGroup{ + Name: "OpenID Connect", + Results: results, + } +} + +func checkOpenIDProvider(key string, rawProvider interface{}) CheckResult { + // Extract provider config + var pi map[string]interface{} + switch p := rawProvider.(type) { + case map[string]interface{}: + pi = p + case map[interface{}]interface{}: + pi = make(map[string]interface{}, len(p)) + for k, v := range p { + if kStr, ok := k.(string); ok { + pi[kStr] = v + } + } + default: + return CheckResult{ + Name: fmt.Sprintf("Provider: %s", key), + Passed: false, + Error: "invalid configuration format", + } + } + + // Get provider name + name := key + if n, ok := pi["name"].(string); ok { + name = n + } + + // Get auth URL + authURL, ok := pi["authurl"].(string) + if !ok || authURL == "" { + return CheckResult{ + Name: fmt.Sprintf("Provider: %s", name), + Passed: false, + Error: "authurl not configured", + } + } + + // Check if the provider's discovery endpoint is reachable + // OpenID Connect discovery is at /.well-known/openid-configuration + discoveryURL := authURL + if authURL[len(authURL)-1] != '/' { + discoveryURL += "/" + } + discoveryURL += ".well-known/openid-configuration" + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil) + if err != nil { + return CheckResult{ + Name: fmt.Sprintf("Provider: %s", name), + Passed: false, + Error: err.Error(), + } + } + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return CheckResult{ + Name: fmt.Sprintf("Provider: %s", name), + Passed: false, + Error: err.Error(), + } + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return CheckResult{ + Name: fmt.Sprintf("Provider: %s", name), + Passed: false, + Error: fmt.Sprintf("discovery endpoint returned status %d", resp.StatusCode), + } + } + + return CheckResult{ + Name: fmt.Sprintf("Provider: %s", name), + Passed: true, + Value: "OK", + } +} diff --git a/pkg/doctor/system.go b/pkg/doctor/system.go new file mode 100644 index 000000000..50f720ccf --- /dev/null +++ b/pkg/doctor/system.go @@ -0,0 +1,82 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package doctor + +import ( + "fmt" + "os" + "runtime" + + "code.vikunja.io/api/pkg/version" +) + +// CheckSystem returns system information checks. +func CheckSystem() CheckGroup { + results := []CheckResult{ + checkVersion(), + checkGoVersion(), + checkOS(), + checkUser(), + checkWorkingDirectory(), + } + + return CheckGroup{ + Name: "System", + Results: results, + } +} + +func checkVersion() CheckResult { + return CheckResult{ + Name: "Version", + Passed: true, + Value: version.Version, + } +} + +func checkGoVersion() CheckResult { + return CheckResult{ + Name: "Go", + Passed: true, + Value: runtime.Version(), + } +} + +func checkOS() CheckResult { + return CheckResult{ + Name: "OS", + Passed: true, + Value: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), + } +} + +func checkWorkingDirectory() CheckResult { + wd, err := os.Getwd() + if err != nil { + return CheckResult{ + Name: "Working directory", + Passed: false, + Error: err.Error(), + } + } + + return CheckResult{ + Name: "Working directory", + Passed: true, + Value: wd, + } +} diff --git a/pkg/doctor/system_unix.go b/pkg/doctor/system_unix.go new file mode 100644 index 000000000..ed2e1066c --- /dev/null +++ b/pkg/doctor/system_unix.go @@ -0,0 +1,40 @@ +//go:build !windows + +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package doctor + +import ( + "fmt" + "os" + osuser "os/user" +) + +func checkUser() CheckResult { + uid := os.Getuid() + username := "unknown" + + if u, err := osuser.Current(); err == nil { + username = u.Username + } + + return CheckResult{ + Name: "User", + Passed: true, + Value: fmt.Sprintf("%s (uid=%d)", username, uid), + } +} diff --git a/pkg/doctor/system_windows.go b/pkg/doctor/system_windows.go new file mode 100644 index 000000000..caa52bc30 --- /dev/null +++ b/pkg/doctor/system_windows.go @@ -0,0 +1,37 @@ +//go:build windows + +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package doctor + +import ( + osuser "os/user" +) + +func checkUser() CheckResult { + username := "unknown" + + if u, err := osuser.Current(); err == nil { + username = u.Username + } + + return CheckResult{ + Name: "User", + Passed: true, + Value: username, + } +} diff --git a/pkg/doctor/types.go b/pkg/doctor/types.go new file mode 100644 index 000000000..117f1377a --- /dev/null +++ b/pkg/doctor/types.go @@ -0,0 +1,32 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package doctor + +// CheckResult represents the result of a single diagnostic check. +type CheckResult struct { + Name string + Passed bool + Value string // e.g., "vikunja (uid=1000)" or "OK" + Error string // only populated if Passed is false + Lines []string // additional lines to display (e.g., list of CORS origins) +} + +// CheckGroup represents a category of checks with a header. +type CheckGroup struct { + Name string // e.g., "Database (sqlite)" + Results []CheckResult +}