Files
gluetun/internal/storage/read.go
T
Quentin McGaw 25f67cd170 refactor(storage): new storage file structure
- new directory structure containing manifest.json and one json file per provider, by default.
- the manifest.json file can specify a filepath for each vpn provider
- each vpn provider json data file can contain the `"preferred": true` field to enforce it is used even if outdated, unless there is a version mismatch
- `STORAGE_SERVERS_DIRECTORY_PATH` replaces `STORAGE_FILEPATH` (which is now a migration source only). It sets the directory where server manifest and per-provider JSON files are stored (default: `/gluetun/servers/`).
- First-run migration: On startup, gluetun checks for the old /gluetun/servers.json file; if found and no new manifest exists, it automatically migrates all data to /gluetun/servers/ directory structure
- Silent fallback: If legacy file isn't found, uses the new directory path normally
- Legacy cleanup: After successful migration, attempts to remove the old fat JSON file (logs warning only if removal fails, e.g., read-only bind mounts)

Co-authored-by: Copilot <copilot@github.com>
2026-04-27 02:47:30 +00:00

165 lines
5.0 KiB
Go

package storage
import (
"encoding/json"
"fmt"
"io"
"os"
"github.com/qdm12/gluetun/internal/constants/providers"
"github.com/qdm12/gluetun/internal/models"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// readFromFile reads the servers data starting from the given manifest file path.
// It only reads servers that have the same version as the hardcoded servers version
// to avoid JSON decoding errors.
func (s *Storage) readFromFile(manifestPath string, hardcodedVersions map[string]uint16) (
servers models.AllServers, found bool, err error,
) {
file, err := os.Open(manifestPath)
if os.IsNotExist(err) {
return servers, false, nil
} else if err != nil {
return servers, false, err
}
b, err := io.ReadAll(file)
if err != nil {
return servers, true, err
}
if err := file.Close(); err != nil {
return servers, true, err
}
servers, err = s.extractServersFromBytes(b, hardcodedVersions)
return servers, true, err
}
func (s *Storage) extractServersFromBytes(b []byte, hardcodedVersions map[string]uint16) (
servers models.AllServers, err error,
) {
rawMessages := make(map[string]json.RawMessage)
if err := json.Unmarshal(b, &rawMessages); err != nil {
return servers, fmt.Errorf("decoding servers: %w", err)
}
// Note schema version is at map key "version" as number
if rawVersion, ok := rawMessages["version"]; ok {
err := json.Unmarshal(rawVersion, &servers.Version)
if err != nil {
return servers, fmt.Errorf("decoding servers schema version: %w", err)
}
}
allProviders := providers.All()
servers.ProviderToServers = make(map[string]models.Servers, len(allProviders))
titleCaser := cases.Title(language.English)
for _, provider := range allProviders {
hardcodedVersion, ok := hardcodedVersions[provider]
if !ok {
panicOnProviderMissingHardcoded(provider)
}
rawMessage, ok := rawMessages[provider]
if !ok {
// If the provider is not found in the data bytes, just don't set it in
// the providers map. That way the hardcoded servers will override them.
// This is user provided and could come from different sources in the
// future (e.g. a file or API request).
continue
}
mergedServers, versionsMatch, err := s.readServers(provider,
hardcodedVersion, rawMessage, titleCaser)
if err != nil {
return models.AllServers{}, err
} else if !versionsMatch {
// mergedServers is the empty struct in this case, so don't set the key
// in the providerToServers map.
continue
}
servers.ProviderToServers[provider] = mergedServers
}
return servers, nil
}
func (s *Storage) readServers(provider string, hardcodedVersion uint16,
rawMessage json.RawMessage, titleCaser cases.Caser) (servers models.Servers,
versionsMatch bool, err error,
) {
provider = titleCaser.String(provider)
var metadata struct {
Version uint16 `json:"version"`
Timestamp int64 `json:"timestamp"`
Filepath string `json:"filepath"`
}
err = json.Unmarshal(rawMessage, &metadata)
if err != nil {
return servers, false, fmt.Errorf("decoding servers version for provider %s: %w",
provider, err)
}
if metadata.Filepath != "" {
providerFile, err := os.Open(metadata.Filepath)
if os.IsNotExist(err) {
return models.Servers{}, false, nil
} else if err != nil {
return models.Servers{}, false, fmt.Errorf("opening servers file %s for provider %s: %w",
metadata.Filepath, provider, err)
}
defer providerFile.Close()
var referencedServers models.Servers
err = json.NewDecoder(providerFile).Decode(&referencedServers)
if err != nil {
return models.Servers{}, false, fmt.Errorf("decoding servers file %s for provider %s: %w",
metadata.Filepath, provider, err)
}
versionsMatch = referencedServers.Version == hardcodedVersion
if !versionsMatch {
if referencedServers.Preferred {
s.logger.Warn(fmt.Sprintf(
"%s preferred servers from file %s discarded because they have version %d and hardcoded servers have version %d",
provider, metadata.Filepath, referencedServers.Version, hardcodedVersion))
} else {
s.logger.Info(fmt.Sprintf(
"%s servers from file %s discarded because they have version %d and hardcoded servers have version %d",
provider, metadata.Filepath, referencedServers.Version, hardcodedVersion))
}
return models.Servers{}, false, nil
}
referencedServers.Filepath = metadata.Filepath
return referencedServers, true, nil
}
err = json.Unmarshal(rawMessage, &servers)
if err != nil {
return servers, false, fmt.Errorf("decoding servers for provider %s: %w",
provider, err)
}
versionsMatch = servers.Version == hardcodedVersion
if !versionsMatch {
if servers.Preferred {
s.logger.Warn(fmt.Sprintf(
"%s preferred servers from file discarded because they have version %d and hardcoded servers have version %d",
provider, servers.Version, hardcodedVersion))
} else {
s.logger.Info(fmt.Sprintf(
"%s servers from file discarded because they have version %d and hardcoded servers have version %d",
provider, servers.Version, hardcodedVersion))
}
return servers, false, nil
}
return servers, true, nil
}