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
This commit is contained in:
kolaente
2026-01-27 10:12:31 +01:00
committed by GitHub
parent d61caab168
commit 3aa1e90d7f
15 changed files with 1223 additions and 3 deletions

2
go.mod
View File

@@ -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

2
go.sum
View File

@@ -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=

68
pkg/cmd/doctor.go Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
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")
},
}

120
pkg/doctor/config.go Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
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
}

124
pkg/doctor/database.go Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
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,
}
}

33
pkg/doctor/doctor.go Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
// 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
}

132
pkg/doctor/files.go Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
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
}

46
pkg/doctor/files_unix.go Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
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),
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
package doctor
func checkDiskSpace(_ string) CheckResult {
return CheckResult{
Name: "Disk space",
Passed: true,
Value: "check not available on Windows",
}
}

75
pkg/doctor/output.go Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
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
}

406
pkg/doctor/services.go Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
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",
}
}

82
pkg/doctor/system.go Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
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,
}
}

40
pkg/doctor/system_unix.go Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
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),
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
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,
}
}

32
pkg/doctor/types.go Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
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
}