mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-02-01 14:44:05 +00:00
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:
2
go.mod
2
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
68
pkg/cmd/doctor.go
Normal file
68
pkg/cmd/doctor.go
Normal 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
120
pkg/doctor/config.go
Normal 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
124
pkg/doctor/database.go
Normal 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
33
pkg/doctor/doctor.go
Normal 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
132
pkg/doctor/files.go
Normal 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
46
pkg/doctor/files_unix.go
Normal 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),
|
||||
}
|
||||
}
|
||||
27
pkg/doctor/files_windows.go
Normal file
27
pkg/doctor/files_windows.go
Normal 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
75
pkg/doctor/output.go
Normal 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
406
pkg/doctor/services.go
Normal 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
82
pkg/doctor/system.go
Normal 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
40
pkg/doctor/system_unix.go
Normal 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),
|
||||
}
|
||||
}
|
||||
37
pkg/doctor/system_windows.go
Normal file
37
pkg/doctor/system_windows.go
Normal 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
32
pkg/doctor/types.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user