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
@@ -0,0 +1,12 @@
package html
import "golang.org/x/net/html"
func Attribute(node *html.Node, key string) (value string) {
for _, attribute := range node.Attr {
if attribute.Key == key {
return attribute.Val
}
}
return ""
}
+43
View File
@@ -0,0 +1,43 @@
package html
import (
"container/list"
"fmt"
"golang.org/x/net/html"
)
// BFS returns the node matching the match function and nil
// if no node is found.
func BFS(rootNode *html.Node, match MatchFunc) (node *html.Node) {
visited := make(map[*html.Node]struct{})
queue := list.New()
_ = queue.PushBack(rootNode)
for queue.Len() > 0 {
listElement := queue.Front()
node, ok := queue.Remove(listElement).(*html.Node)
if !ok {
panic(fmt.Sprintf("linked list has bad type %T", listElement.Value))
}
if node == nil {
continue
}
if _, ok := visited[node]; ok {
continue
}
visited[node] = struct{}{}
if match(node) {
return node
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
_ = queue.PushBack(child)
}
}
return nil
}
+22
View File
@@ -0,0 +1,22 @@
package html
import (
"strings"
"golang.org/x/net/html"
)
func HasClassStrings(node *html.Node, classStrings ...string) (match bool) {
targetClasses := make(map[string]struct{}, len(classStrings))
for _, classString := range classStrings {
targetClasses[classString] = struct{}{}
}
classAttribute := Attribute(node, "class")
classes := strings.Fields(classAttribute)
for _, class := range classes {
delete(targetClasses, class)
}
return len(targetClasses) == 0
}
@@ -0,0 +1,27 @@
package html
import (
"bytes"
"fmt"
"golang.org/x/net/html"
)
func WrapError(sentinelError error, node *html.Node) error {
return fmt.Errorf("%w: in HTML code: %s",
sentinelError, mustRenderHTML(node))
}
func WrapWarning(warning string, node *html.Node) string {
return fmt.Sprintf("%s: in HTML code: %s",
warning, mustRenderHTML(node))
}
func mustRenderHTML(node *html.Node) (rendered string) {
stringBuffer := bytes.NewBufferString("")
err := html.Render(stringBuffer, node)
if err != nil {
panic(err)
}
return stringBuffer.String()
}
@@ -0,0 +1,41 @@
package html
import (
"context"
"fmt"
"net/http"
"golang.org/x/net/html"
)
func Fetch(ctx context.Context, client *http.Client, url string) (
rootNode *html.Node, err error,
) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating HTTP request: %w", err)
}
response, err := client.Do(request)
if err != nil {
return nil, err
}
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP status code not OK: %d %s",
response.StatusCode, response.Status)
}
rootNode, err = html.Parse(response.Body)
if err != nil {
_ = response.Body.Close()
return nil, fmt.Errorf("parsing HTML code: %w", err)
}
err = response.Body.Close()
if err != nil {
return nil, fmt.Errorf("closing response body: %w", err)
}
return rootNode, nil
}
@@ -0,0 +1,94 @@
package html
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/html"
)
func parseTestHTML(t *testing.T, htmlString string) *html.Node {
t.Helper()
rootNode, err := html.Parse(strings.NewReader(htmlString))
require.NoError(t, err)
return rootNode
}
type roundTripFunc func(r *http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
func Test_Fetch(t *testing.T) {
t.Parallel()
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
testCases := map[string]struct {
ctx context.Context
url string
responseStatus int
responseBody io.ReadCloser
rootNode *html.Node
errMessage string
}{
"context canceled": {
ctx: canceledCtx,
url: "https://example.com/path",
errMessage: `Get "https://example.com/path": context canceled`,
},
"response status not ok": {
ctx: context.Background(),
url: "https://example.com/path",
responseStatus: http.StatusNotFound,
errMessage: `HTTP status code not OK: 404 Not Found`,
},
"success": {
ctx: context.Background(),
url: "https://example.com/path",
responseStatus: http.StatusOK,
rootNode: parseTestHTML(t, "some body"),
responseBody: io.NopCloser(strings.NewReader("some body")),
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
client := &http.Client{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, r.URL.String(), testCase.url)
ctxErr := r.Context().Err()
if ctxErr != nil {
return nil, ctxErr
}
return &http.Response{
StatusCode: testCase.responseStatus,
Status: http.StatusText(testCase.responseStatus),
Body: testCase.responseBody,
}, nil
}),
}
rootNode, err := Fetch(testCase.ctx, client, testCase.url)
if testCase.errMessage != "" {
assert.EqualError(t, err, testCase.errMessage)
} else {
assert.NoError(t, err)
}
assert.Equal(t, testCase.rootNode, rootNode)
})
}
}
@@ -0,0 +1,45 @@
package html
import (
"golang.org/x/net/html"
)
type MatchFunc func(node *html.Node) (match bool)
func MatchID(id string) MatchFunc {
return func(node *html.Node) (match bool) {
if node == nil {
return false
}
return Attribute(node, "id") == id
}
}
func MatchData(data string) MatchFunc {
return func(node *html.Node) (match bool) {
return node != nil && node.Type == html.ElementNode && node.Data == data
}
}
func DirectChild(parent *html.Node,
matchFunc MatchFunc,
) (child *html.Node) {
for child := parent.FirstChild; child != nil; child = child.NextSibling {
if matchFunc(child) {
return child
}
}
return nil
}
func DirectChildren(parent *html.Node,
matchFunc MatchFunc,
) (children []*html.Node) {
for child := parent.FirstChild; child != nil; child = child.NextSibling {
if matchFunc(child) {
children = append(children, child)
}
}
return children
}