feat(doctor): add user namespace detection and improved storage diagnostics (#2180)

This PR adds support for detecting and handling Linux user namespaces (commonly used in rootless Docker containers) and improves error diagnostics when file storage validation fails.

Docs PR: https://github.com/go-vikunja/website/pull/289

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
kolaente
2026-02-01 11:57:35 +01:00
committed by GitHub
parent 2becfcc597
commit acbf751ba0
10 changed files with 506 additions and 9 deletions

View File

@@ -25,6 +25,7 @@ import (
"strconv" "strconv"
"syscall" "syscall"
"code.vikunja.io/api/pkg/utils"
"golang.org/x/sys/unix" "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{ results = append(results, CheckResult{
Name: "Ownership match", Name: "Ownership match",
Passed: false, Passed: false,
Error: fmt.Sprintf( Error: errMsg,
"directory owned by uid %d but Vikunja runs as uid %d",
uid, currentUID,
),
}) })
} else if currentUID != 0 && !isGroupMember(int(gid)) { case currentUID != 0 && !isGroupMember(int(gid)):
results = append(results, CheckResult{ results = append(results, CheckResult{
Name: "Ownership match", Name: "Ownership match",
Passed: false, Passed: false,
@@ -121,6 +131,21 @@ func checkDirectoryOwnership(info os.FileInfo) []CheckResult {
gid, 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 return results

View File

@@ -32,6 +32,7 @@ func CheckSystem() CheckGroup {
checkOS(), checkOS(),
checkUser(), checkUser(),
checkWorkingDirectory(), checkWorkingDirectory(),
checkUserNamespace(),
} }
return CheckGroup{ return CheckGroup{

View File

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

View File

@@ -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 <https://www.gnu.org/licenses/>.
//go:build !linux
package doctor
func checkUserNamespace() CheckResult {
return CheckResult{
Name: "User namespace",
Passed: true,
Value: "not applicable (Linux only)",
}
}

View File

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

View File

@@ -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 <https://www.gnu.org/licenses/>.
//go:build windows
package files
func storageDiagnosticInfo(_ string) string {
return ""
}

View File

@@ -196,6 +196,11 @@ func FileStat(file *File) (os.FileInfo, error) {
func ValidateFileStorage() error { func ValidateFileStorage() error {
basePath := config.FilesBasePath.GetString() basePath := config.FilesBasePath.GetString()
diag := storageDiagnosticInfo(basePath)
if diag != "" {
diag = "\n" + diag
}
// For local filesystem, ensure the base directory exists // For local filesystem, ensure the base directory exists
if config.FilesType.GetString() == "local" { if config.FilesType.GetString() == "local" {
// Check if directory exists // Check if directory exists
@@ -203,13 +208,13 @@ func ValidateFileStorage() error {
if err != nil { if err != nil {
if !errors.Is(err, os.ErrNotExist) { if !errors.Is(err, os.ErrNotExist) {
// Error other than "file doesn't exist" // 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 // Directory doesn't exist, try to create it
err = afs.MkdirAll(basePath, 0755) err = afs.MkdirAll(basePath, 0755)
if err != nil { 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() { } else if !info.IsDir() {
// Path exists but is not a directory // Path exists but is not a directory
@@ -222,7 +227,7 @@ func ValidateFileStorage() error {
err := writeToStorage(path, bytes.NewReader([]byte{}), 0) err := writeToStorage(path, bytes.NewReader([]byte{}), 0)
if err != nil { 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) err = afs.Remove(path)

156
pkg/utils/userns_linux.go Normal file
View File

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

View File

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

40
pkg/utils/userns_other.go Normal file
View File

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