diff --git a/pkg/doctor/files_unix.go b/pkg/doctor/files_unix.go index 3f0e9a785..c21a3a025 100644 --- a/pkg/doctor/files_unix.go +++ b/pkg/doctor/files_unix.go @@ -25,6 +25,7 @@ import ( "strconv" "syscall" + "code.vikunja.io/api/pkg/utils" "golang.org/x/sys/unix" ) @@ -103,16 +104,25 @@ func checkDirectoryOwnership(info os.FileInfo) []CheckResult { }, } - if currentUID != 0 && currentUID != int(uid) { + nsActive := utils.IsUserNamespaceActive() + + switch { + case currentUID != 0 && currentUID != int(uid): + errMsg := fmt.Sprintf( + "directory owned by uid %d but Vikunja runs as uid %d", + uid, currentUID, + ) + if nsActive { + if hostUID, ok := utils.MapToHostUID(int64(currentUID)); ok { + errMsg += fmt.Sprintf(" (user namespace active, host uid=%d)", hostUID) + } + } results = append(results, CheckResult{ Name: "Ownership match", Passed: false, - Error: fmt.Sprintf( - "directory owned by uid %d but Vikunja runs as uid %d", - uid, currentUID, - ), + Error: errMsg, }) - } else if currentUID != 0 && !isGroupMember(int(gid)) { + case currentUID != 0 && !isGroupMember(int(gid)): results = append(results, CheckResult{ Name: "Ownership match", Passed: false, @@ -121,6 +131,21 @@ func checkDirectoryOwnership(info os.FileInfo) []CheckResult { gid, ), }) + case currentUID != 0 && nsActive: + matchResult := CheckResult{ + Name: "Ownership match", + Passed: true, + Value: fmt.Sprintf("uid %d matches", currentUID), + } + if hostUID, ok := utils.MapToHostUID(int64(currentUID)); ok { + matchResult.Lines = []string{ + fmt.Sprintf("WARNING: user namespace active — uid %d maps to host uid %d,", currentUID, hostUID), + "which may differ from the actual host directory owner.", + "If writes fail, ensure the host directory is owned by the mapped host uid,", + "or run the container with --user 0:0.", + } + } + results = append(results, matchResult) } return results diff --git a/pkg/doctor/system.go b/pkg/doctor/system.go index 50f720ccf..b7d487e95 100644 --- a/pkg/doctor/system.go +++ b/pkg/doctor/system.go @@ -32,6 +32,7 @@ func CheckSystem() CheckGroup { checkOS(), checkUser(), checkWorkingDirectory(), + checkUserNamespace(), } return CheckGroup{ diff --git a/pkg/doctor/userns_linux.go b/pkg/doctor/userns_linux.go new file mode 100644 index 000000000..dbcecfa87 --- /dev/null +++ b/pkg/doctor/userns_linux.go @@ -0,0 +1,40 @@ +// 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 . + +//go:build linux + +package doctor + +import ( + "code.vikunja.io/api/pkg/utils" +) + +func checkUserNamespace() CheckResult { + if !utils.IsUserNamespaceActive() { + return CheckResult{ + Name: "User namespace", + Passed: true, + Value: "not active", + } + } + + return CheckResult{ + Name: "User namespace", + Passed: true, + Value: "active (" + utils.UIDMappingSummary() + ")", + Lines: []string{"UIDs inside this container are remapped. See directory ownership check for details."}, + } +} diff --git a/pkg/doctor/userns_other.go b/pkg/doctor/userns_other.go new file mode 100644 index 000000000..28edf32a3 --- /dev/null +++ b/pkg/doctor/userns_other.go @@ -0,0 +1,27 @@ +// 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 . + +//go:build !linux + +package doctor + +func checkUserNamespace() CheckResult { + return CheckResult{ + Name: "User namespace", + Passed: true, + Value: "not applicable (Linux only)", + } +} diff --git a/pkg/files/diagnostics_unix.go b/pkg/files/diagnostics_unix.go new file mode 100644 index 000000000..b7ae353fa --- /dev/null +++ b/pkg/files/diagnostics_unix.go @@ -0,0 +1,69 @@ +// 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 . + +//go:build !windows + +package files + +import ( + "fmt" + "os" + "strings" + "syscall" + + "code.vikunja.io/api/pkg/utils" +) + +// storageDiagnosticInfo gathers process/directory identity and user namespace +// status. It is best-effort: any failure is silently omitted. +func storageDiagnosticInfo(basePath string) string { + var parts []string + + uid := os.Getuid() + gid := os.Getgid() + parts = append(parts, fmt.Sprintf("process uid=%d gid=%d", uid, gid)) + + info, err := os.Stat(basePath) + if err == nil { + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + parts = append(parts, fmt.Sprintf("dir owner uid=%d gid=%d", stat.Uid, stat.Gid)) + } + } + + if utils.IsUserNamespaceActive() { + summary := utils.UIDMappingSummary() + parts = append(parts, fmt.Sprintf("user namespace ACTIVE (%s)", summary)) + + if hostUID, ok := utils.MapToHostUID(int64(uid)); ok { + parts = append(parts, fmt.Sprintf("process host uid=%d", hostUID)) + } + } + + result := "[" + strings.Join(parts, ", ") + "]" + + if utils.IsUserNamespaceActive() { + hostUID, ok := utils.MapToHostUID(int64(uid)) + if ok { + result += fmt.Sprintf( + "\n Hint: A user namespace is active (common in rootless Docker). "+ + "The process appears as uid %d inside the container but maps to uid %d on the host. "+ + "Ensure the host directory is owned by uid %d, or run the container with --user 0:0.", + uid, hostUID, hostUID) + } + } + + return result +} diff --git a/pkg/files/diagnostics_windows.go b/pkg/files/diagnostics_windows.go new file mode 100644 index 000000000..28def15f0 --- /dev/null +++ b/pkg/files/diagnostics_windows.go @@ -0,0 +1,23 @@ +// 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 . + +//go:build windows + +package files + +func storageDiagnosticInfo(_ string) string { + return "" +} diff --git a/pkg/files/filehandling.go b/pkg/files/filehandling.go index fa5f9b9ab..e47858f6d 100644 --- a/pkg/files/filehandling.go +++ b/pkg/files/filehandling.go @@ -196,6 +196,11 @@ func FileStat(file *File) (os.FileInfo, error) { func ValidateFileStorage() error { basePath := config.FilesBasePath.GetString() + diag := storageDiagnosticInfo(basePath) + if diag != "" { + diag = "\n" + diag + } + // For local filesystem, ensure the base directory exists if config.FilesType.GetString() == "local" { // Check if directory exists @@ -203,13 +208,13 @@ func ValidateFileStorage() error { if err != nil { if !errors.Is(err, os.ErrNotExist) { // Error other than "file doesn't exist" - return fmt.Errorf("failed to access file storage directory at %s: %w", basePath, err) + return fmt.Errorf("failed to access file storage directory at %s: %w%s", basePath, err, diag) } // Directory doesn't exist, try to create it err = afs.MkdirAll(basePath, 0755) if err != nil { - return fmt.Errorf("failed to create file storage directory at %s: %w", basePath, err) + return fmt.Errorf("failed to create file storage directory at %s: %w%s", basePath, err, diag) } } else if !info.IsDir() { // Path exists but is not a directory @@ -222,7 +227,7 @@ func ValidateFileStorage() error { err := writeToStorage(path, bytes.NewReader([]byte{}), 0) if err != nil { - return fmt.Errorf("failed to create test file at %s: %w", path, err) + return fmt.Errorf("failed to create test file at %s: %w%s", path, err, diag) } err = afs.Remove(path) diff --git a/pkg/utils/userns_linux.go b/pkg/utils/userns_linux.go new file mode 100644 index 000000000..b4cf69c91 --- /dev/null +++ b/pkg/utils/userns_linux.go @@ -0,0 +1,156 @@ +// 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 . + +//go:build linux + +package utils + +import ( + "fmt" + "os" + "strconv" + "strings" + "sync" +) + +// UIDMapEntry represents a single line from /proc/self/uid_map. +// Fields use int64 to avoid overflow on 32-bit architectures where the +// trivial mapping count (4294967295) exceeds math.MaxInt32. +type UIDMapEntry struct { + InsideUID int64 + OutsideUID int64 + Count int64 +} + +var ( + uidMapOnce sync.Once + uidMapEntries []UIDMapEntry + uidMapErr error +) + +func loadUIDMap() { + data, err := os.ReadFile("/proc/self/uid_map") + if err != nil { + uidMapErr = err + return + } + uidMapEntries, uidMapErr = parseUIDMap(string(data)) +} + +func parseUIDMap(content string) ([]UIDMapEntry, error) { + var entries []UIDMapEntry + for _, line := range strings.Split(strings.TrimSpace(content), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) != 3 { + return nil, fmt.Errorf("unexpected uid_map line: %q", line) + } + inside, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + return nil, fmt.Errorf("parsing inside uid %q: %w", fields[0], err) + } + outside, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + return nil, fmt.Errorf("parsing outside uid %q: %w", fields[1], err) + } + count, err := strconv.ParseInt(fields[2], 10, 64) + if err != nil { + return nil, fmt.Errorf("parsing count %q: %w", fields[2], err) + } + entries = append(entries, UIDMapEntry{ + InsideUID: inside, + OutsideUID: outside, + Count: count, + }) + } + return entries, nil +} + +func isTrivialMapping(entries []UIDMapEntry) bool { + if len(entries) != 1 { + return len(entries) == 0 + } + e := entries[0] + return e.InsideUID == 0 && e.OutsideUID == 0 && e.Count == 4294967295 +} + +func mapContainerUID(entries []UIDMapEntry, containerUID int64) (hostUID int64, ok bool) { + for _, e := range entries { + if containerUID >= e.InsideUID && containerUID < e.InsideUID+e.Count { + return e.OutsideUID + (containerUID - e.InsideUID), true + } + } + return 0, false +} + +func uidMappingSummaryFromEntries(entries []UIDMapEntry) string { + var parts []string + for _, e := range entries { + if e.Count == 1 { + parts = append(parts, fmt.Sprintf("%d→%d", e.InsideUID, e.OutsideUID)) + } else { + parts = append(parts, fmt.Sprintf("%d-%d→%d-%d", + e.InsideUID, e.InsideUID+e.Count-1, + e.OutsideUID, e.OutsideUID+e.Count-1)) + } + } + return strings.Join(parts, ", ") +} + +// IsUserNamespaceActive returns true if the process is running inside a +// Linux user namespace with non-trivial UID remapping (e.g., rootless Docker). +func IsUserNamespaceActive() bool { + uidMapOnce.Do(loadUIDMap) + if uidMapErr != nil { + return false + } + return !isTrivialMapping(uidMapEntries) +} + +// GetUIDMapping returns a copy of the parsed uid_map entries. +// The returned slice is safe to modify without affecting cached state. +func GetUIDMapping() ([]UIDMapEntry, error) { + uidMapOnce.Do(loadUIDMap) + if uidMapEntries == nil { + return nil, uidMapErr + } + out := make([]UIDMapEntry, len(uidMapEntries)) + copy(out, uidMapEntries) + return out, uidMapErr +} + +// MapToHostUID maps a container UID to the corresponding host UID. +// Returns mapped=false if no mapping covers that UID. +func MapToHostUID(containerUID int64) (hostUID int64, mapped bool) { + uidMapOnce.Do(loadUIDMap) + if uidMapErr != nil { + return 0, false + } + return mapContainerUID(uidMapEntries, containerUID) +} + +// UIDMappingSummary returns a human-readable summary of the UID mapping, +// e.g., "0→1001, 1-65536→101001-166536". +func UIDMappingSummary() string { + uidMapOnce.Do(loadUIDMap) + if uidMapErr != nil { + return "" + } + return uidMappingSummaryFromEntries(uidMapEntries) +} diff --git a/pkg/utils/userns_linux_test.go b/pkg/utils/userns_linux_test.go new file mode 100644 index 000000000..8a8c4836a --- /dev/null +++ b/pkg/utils/userns_linux_test.go @@ -0,0 +1,111 @@ +// 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 . + +//go:build linux + +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseUIDMap_Trivial(t *testing.T) { + input := " 0 0 4294967295\n" + entries, err := parseUIDMap(input) + require.NoError(t, err) + assert.Len(t, entries, 1) + assert.Equal(t, UIDMapEntry{InsideUID: 0, OutsideUID: 0, Count: 4294967295}, entries[0]) +} + +func TestParseUIDMap_RootlessDocker(t *testing.T) { + input := " 0 1001 1\n 1 101001 65536\n" + entries, err := parseUIDMap(input) + require.NoError(t, err) + assert.Len(t, entries, 2) + assert.Equal(t, UIDMapEntry{InsideUID: 0, OutsideUID: 1001, Count: 1}, entries[0]) + assert.Equal(t, UIDMapEntry{InsideUID: 1, OutsideUID: 101001, Count: 65536}, entries[1]) +} + +func TestParseUIDMap_LargeCount(t *testing.T) { + // Verify that the full 32-bit range parses correctly (would overflow int on 32-bit arch). + input := " 0 0 4294967295\n" + entries, err := parseUIDMap(input) + require.NoError(t, err) + assert.Equal(t, int64(4294967295), entries[0].Count) +} + +func TestParseUIDMap_Empty(t *testing.T) { + entries, err := parseUIDMap("") + require.NoError(t, err) + assert.Empty(t, entries) +} + +func TestIsTrivialMapping(t *testing.T) { + trivial := []UIDMapEntry{{InsideUID: 0, OutsideUID: 0, Count: 4294967295}} + assert.True(t, isTrivialMapping(trivial)) + + rootless := []UIDMapEntry{ + {InsideUID: 0, OutsideUID: 1001, Count: 1}, + {InsideUID: 1, OutsideUID: 101001, Count: 65536}, + } + assert.False(t, isTrivialMapping(rootless)) + + assert.True(t, isTrivialMapping(nil)) +} + +func TestMapToHostUID(t *testing.T) { + entries := []UIDMapEntry{ + {InsideUID: 0, OutsideUID: 1001, Count: 1}, + {InsideUID: 1, OutsideUID: 101001, Count: 65536}, + } + + hostUID, ok := mapContainerUID(entries, 0) + assert.True(t, ok) + assert.Equal(t, int64(1001), hostUID) + + hostUID, ok = mapContainerUID(entries, 1) + assert.True(t, ok) + assert.Equal(t, int64(101001), hostUID) + + hostUID, ok = mapContainerUID(entries, 1000) + assert.True(t, ok) + assert.Equal(t, int64(102000), hostUID) + + _, ok = mapContainerUID(entries, 70000) + assert.False(t, ok) +} + +func TestUIDMappingSummaryString(t *testing.T) { + entries := []UIDMapEntry{ + {InsideUID: 0, OutsideUID: 1001, Count: 1}, + {InsideUID: 1, OutsideUID: 101001, Count: 65536}, + } + + summary := uidMappingSummaryFromEntries(entries) + assert.Equal(t, "0→1001, 1-65536→101001-166536", summary) +} + +func TestUIDMappingSummaryString_Single(t *testing.T) { + entries := []UIDMapEntry{ + {InsideUID: 0, OutsideUID: 1001, Count: 1}, + } + + summary := uidMappingSummaryFromEntries(entries) + assert.Equal(t, "0→1001", summary) +} diff --git a/pkg/utils/userns_other.go b/pkg/utils/userns_other.go new file mode 100644 index 000000000..ce868dbf2 --- /dev/null +++ b/pkg/utils/userns_other.go @@ -0,0 +1,40 @@ +// 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 . + +//go:build !linux + +package utils + +// UIDMapEntry represents a single line from /proc/self/uid_map. +// On non-Linux platforms this type exists for API compatibility but is never populated. +// Fields use int64 to avoid overflow on 32-bit architectures. +type UIDMapEntry struct { + InsideUID int64 + OutsideUID int64 + Count int64 +} + +// IsUserNamespaceActive always returns false on non-Linux platforms. +func IsUserNamespaceActive() bool { return false } + +// GetUIDMapping returns nil on non-Linux platforms. +func GetUIDMapping() ([]UIDMapEntry, error) { return nil, nil } + +// MapToHostUID always returns mapped=false on non-Linux platforms. +func MapToHostUID(_ int64) (int64, bool) { return 0, false } + +// UIDMappingSummary returns empty string on non-Linux platforms. +func UIDMappingSummary() string { return "" }