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)
This commit is contained in:
Quentin McGaw
2026-04-27 02:47:30 +00:00
parent 13503b0ae0
commit d9cc7dcffb
303 changed files with 304957 additions and 304344 deletions
+95
View File
@@ -0,0 +1,95 @@
package models
import (
"errors"
"fmt"
"net/netip"
"reflect"
"strings"
"github.com/qdm12/gluetun-servers/pkg/constants"
)
// Server is a mirror of the Server struct in gluetun's internal/models package.
// A unit test in gluetun ensures this is maintained.
type Server struct {
VPN string `json:"vpn,omitempty"`
// Surfshark: country is also used for multi-hop
Country string `json:"country,omitempty"`
Region string `json:"region,omitempty"`
City string `json:"city,omitempty"`
ISP string `json:"isp,omitempty"`
Categories []string `json:"categories,omitempty"`
Owned bool `json:"owned,omitempty"`
Number uint16 `json:"number,omitempty"`
ServerName string `json:"server_name,omitempty"`
Hostname string `json:"hostname,omitempty"`
TCP bool `json:"tcp,omitempty"`
UDP bool `json:"udp,omitempty"`
OvpnX509 string `json:"x509,omitempty"`
RetroLoc string `json:"retroloc,omitempty"` // TODO remove in v4
MultiHop bool `json:"multihop,omitempty"`
WgPubKey string `json:"wgpubkey,omitempty"`
Free bool `json:"free,omitempty"` // TODO v4 create a SubscriptionTier struct
Premium bool `json:"premium,omitempty"`
Stream bool `json:"stream,omitempty"` // TODO v4 create a Features struct
SecureCore bool `json:"secure_core,omitempty"`
Tor bool `json:"tor,omitempty"`
PortForward bool `json:"port_forward,omitempty"`
Keep bool `json:"keep,omitempty"`
IPs []netip.Addr `json:"ips,omitempty"`
}
func (s *Server) HasMinimumInformation() (err error) {
switch {
case s.VPN == "":
return errors.New("vpn field is empty")
case len(s.IPs) == 0:
return errors.New("ips field is empty")
case s.VPN == constants.Wireguard && (s.TCP || s.UDP):
return errors.New("no network protocol should be set")
case s.VPN == constants.OpenVPN && !s.TCP && !s.UDP:
return errors.New("both TCP and UDP fields are false for OpenVPN")
case s.VPN == constants.Wireguard && s.WgPubKey == "":
return errors.New("wireguard public key field is empty")
default:
return nil
}
}
func (s *Server) Equal(other Server) (equal bool) {
if !ipsAreEqual(s.IPs, other.IPs) {
return false
}
serverCopy := *s
serverCopy.IPs = nil
other.IPs = nil
return reflect.DeepEqual(serverCopy, other)
}
func ipsAreEqual(a, b []netip.Addr) (equal bool) {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].Compare(b[i]) != 0 {
return false
}
}
return true
}
func (s *Server) Key() (key string) {
var protocols []string
if s.TCP {
protocols = append(protocols, "tcp")
}
if s.UDP {
protocols = append(protocols, "udp")
}
return fmt.Sprintf("%s-%s-%s", s.VPN, strings.Join(protocols, "-"), s.Hostname)
}
+125
View File
@@ -0,0 +1,125 @@
package models
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_Server_Equal(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
a *Server
b Server
equal bool
}{
"same IPs": {
a: &Server{
IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
},
b: Server{
IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
},
equal: true,
},
"same IP strings": {
a: &Server{
IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
},
b: Server{
IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
},
equal: true,
},
"different IPs": {
a: &Server{
IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 2, 3, 4}), netip.AddrFrom4([4]byte{2, 3, 4, 5})},
},
b: Server{
IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 2, 3, 4}), netip.AddrFrom4([4]byte{1, 2, 3, 4})},
},
},
"all fields equal": {
a: &Server{
VPN: "vpn",
Country: "country",
Region: "region",
City: "city",
ISP: "isp",
Owned: true,
Number: 1,
ServerName: "server_name",
Hostname: "hostname",
TCP: true,
UDP: true,
OvpnX509: "x509",
RetroLoc: "retroloc",
MultiHop: true,
WgPubKey: "wgpubkey",
Free: true,
Stream: true,
SecureCore: true,
Tor: true,
PortForward: true,
IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
Keep: true,
},
b: Server{
VPN: "vpn",
Country: "country",
Region: "region",
City: "city",
ISP: "isp",
Owned: true,
Number: 1,
ServerName: "server_name",
Hostname: "hostname",
TCP: true,
UDP: true,
OvpnX509: "x509",
RetroLoc: "retroloc",
MultiHop: true,
WgPubKey: "wgpubkey",
Free: true,
Stream: true,
SecureCore: true,
Tor: true,
PortForward: true,
IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
Keep: true,
},
equal: true,
},
"different field": {
a: &Server{
VPN: "vpn",
},
b: Server{
VPN: "other vpn",
},
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
ipsOfANotNil := testCase.a.IPs != nil
ipsOfBNotNil := testCase.b.IPs != nil
equal := testCase.a.Equal(testCase.b)
assert.Equal(t, testCase.equal, equal)
// Ensure IPs field is not modified
if ipsOfANotNil {
assert.NotNil(t, testCase.a)
}
if ipsOfBNotNil {
assert.NotNil(t, testCase.b)
}
})
}
}
+9
View File
@@ -0,0 +1,9 @@
package models
type Servers struct {
Version uint16 `json:"version"`
Timestamp int64 `json:"timestamp"`
Preferred bool `json:"preferred,omitempty"`
Filepath string `json:"filepath,omitempty"`
Servers []Server `json:"servers,omitempty"`
}
+38
View File
@@ -0,0 +1,38 @@
package models
type SortableServers []Server
func (s SortableServers) Len() int {
return len(s)
}
func (s SortableServers) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
func (s SortableServers) Less(i, j int) bool {
a, b := s[i], s[j]
if a.Country == b.Country { //nolint:nestif
if a.Region == b.Region {
if a.City == b.City {
if a.ServerName == b.ServerName {
if a.Number == b.Number {
if a.Hostname == b.Hostname {
if a.ISP == b.ISP {
return a.VPN < b.VPN
}
return a.ISP < b.ISP
}
return a.Hostname < b.Hostname
}
return a.Number < b.Number
}
return a.ServerName < b.ServerName
}
return a.City < b.City
}
return a.Region < b.Region
}
return a.Country < b.Country
}