feat: environment configuration

Environment variables can now be used to configure any
config property including the feeds list.

This makes it easier to use in environments without access
to config files like GCR. Some may prefer not to have a separate
config file as well.

Also added build metadata to the docker image and binaries.
This commit is contained in:
Evan Buss
2024-08-11 18:47:46 +00:00
parent d8d35b6cef
commit 9da7ea1bbc
10 changed files with 239 additions and 39 deletions

View File

@@ -3,7 +3,7 @@ testdata_dir = "testdata"
tmp_dir = "tmp" tmp_dir = "tmp"
[build] [build]
args_bin = ["--dev"] args_bin = []
bin = "./tmp/main" bin = "./tmp/main"
cmd = "go build -o ./tmp/main ." cmd = "go build -o ./tmp/main ."
delay = 1000 delay = 1000

View File

@@ -13,4 +13,5 @@ cp kindlegen/kindlegen /usr/local/bin/kindlegen
chmod +x /usr/local/bin/kindlegen chmod +x /usr/local/bin/kindlegen
rm -rf kindlegen kindlegen_linux_2.6_i386_v2_9.tar.gz rm -rf kindlegen kindlegen_linux_2.6_i386_v2_9.tar.gz
go install github.com/air-verse/air@latest go install github.com/air-verse/air@latest
go install github.com/goreleaser/goreleaser/v2@latest

View File

@@ -71,6 +71,10 @@ jobs:
platforms: ${{ steps.set-platforms.outputs.DOCKER_PLATFORMS}} platforms: ${{ steps.set-platforms.outputs.DOCKER_PLATFORMS}}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
build-args:
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
BUILDTIME=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
- name: Docker Hub Description - name: Docker Hub Description
uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4.0.0 uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4.0.0

View File

@@ -22,6 +22,8 @@ By running your own OPDS Proxy you can allow eReaders to navigate and download b
You can run OPDS Proxy as a Docker container or as an executable. You can run OPDS Proxy as a Docker container or as an executable.
Configuration is done via YAML file (default `config.yml`), environment variables, and command flags in that order of precedence.
### Docker ### Docker
Docker images are published to [Docker Hub](https://hub.docker.com/r/evanbuss/opds-proxy) and the [GitHub Container Registry](https://github.com/evan-buss/opds-proxy/pkgs/container/opds-proxy). Docker images are published to [Docker Hub](https://hub.docker.com/r/evanbuss/opds-proxy) and the [GitHub Container Registry](https://github.com/evan-buss/opds-proxy/pkgs/container/opds-proxy).
@@ -32,6 +34,14 @@ services:
image: evanbuss/opds-proxy:latest image: evanbuss/opds-proxy:latest
#image: ghcr.io/evan-buss/opds-proxy:latest #image: ghcr.io/evan-buss/opds-proxy:latest
container_name: opds-proxy container_name: opds-proxy
# You can also use environment variables to configure the container
# environment:
# - OPDS__PORT=5228
# - OPDS__FEEDS__0__NAME=Some Feed
# - OPDS__FEEDS__0__URL=http://some-feed.com/opds
# - OPDS__FEEDS__0__AUTH__USERNAME=user
# - OPDS__FEEDS__0__AUTH__PASSWORD=password
# - OPDS__FEEDS__0__AUTH__LOCAL_ONLY=true
ports: ports:
- 8080:8080 - 8080:8080
volumes: volumes:

View File

@@ -23,7 +23,11 @@ RUN go mod verify
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o opds-proxy ARG VERSION=dev
ARG REVISION=unknown
ARG BUILDTIME=unknown
RUN CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${REVISION} -X main.date=${BUILDTIME}" -o opds-proxy
FROM gcr.io/distroless/static FROM gcr.io/distroless/static

15
go.mod
View File

@@ -8,7 +8,16 @@ require (
github.com/knadh/koanf/v2 v2.1.1 github.com/knadh/koanf/v2 v2.1.1
) )
require github.com/gorilla/securecookie v1.1.2 require (
github.com/gorilla/securecookie v1.1.2
github.com/spf13/pflag v1.0.5
)
require (
github.com/tidwall/gjson v1.14.2 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
)
require ( require (
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
@@ -16,9 +25,11 @@ require (
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/knadh/koanf/maps v0.1.1 // indirect github.com/knadh/koanf/maps v0.1.1 // indirect
github.com/knadh/koanf/providers/confmap v0.1.0 github.com/knadh/koanf/parsers/json v0.1.0
github.com/knadh/koanf/providers/posflag v0.1.0
github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/tidwall/sjson v1.2.5
golang.org/x/sys v0.22.0 // indirect golang.org/x/sys v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

16
go.sum
View File

@@ -14,12 +14,14 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs=
github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/json v0.1.0 h1:dzSZl5pf5bBcW0Acnu20Djleto19T0CfHcvZ14NJ6fU=
github.com/knadh/koanf/parsers/json v0.1.0/go.mod h1:ll2/MlXcZ2BfXD6YJcjVFzhG9P0TdJ207aIBKQhV2hY=
github.com/knadh/koanf/parsers/yaml v0.1.0 h1:ZZ8/iGfRLvKSaMEECEBPM1HQslrZADk8fP1XFUxVI5w= github.com/knadh/koanf/parsers/yaml v0.1.0 h1:ZZ8/iGfRLvKSaMEECEBPM1HQslrZADk8fP1XFUxVI5w=
github.com/knadh/koanf/parsers/yaml v0.1.0/go.mod h1:cvbUDC7AL23pImuQP0oRw/hPuccrNBS2bps8asS0CwY= github.com/knadh/koanf/parsers/yaml v0.1.0/go.mod h1:cvbUDC7AL23pImuQP0oRw/hPuccrNBS2bps8asS0CwY=
github.com/knadh/koanf/providers/confmap v0.1.0 h1:gOkxhHkemwG4LezxxN8DMOFopOPghxRVp7JbIvdvqzU=
github.com/knadh/koanf/providers/confmap v0.1.0/go.mod h1:2uLhxQzJnyHKfxG927awZC7+fyHFdQkd697K4MdLnIU=
github.com/knadh/koanf/providers/file v1.0.0 h1:DtPvSQBeF+N0QLPMz0yf2bx0nFSxUcncpqQvzCxfCyk= github.com/knadh/koanf/providers/file v1.0.0 h1:DtPvSQBeF+N0QLPMz0yf2bx0nFSxUcncpqQvzCxfCyk=
github.com/knadh/koanf/providers/file v1.0.0/go.mod h1:/faSBcv2mxPVjFrXck95qeoyoZ5myJ6uxN8OOVNJJCI= github.com/knadh/koanf/providers/file v1.0.0/go.mod h1:/faSBcv2mxPVjFrXck95qeoyoZ5myJ6uxN8OOVNJJCI=
github.com/knadh/koanf/providers/posflag v0.1.0 h1:mKJlLrKPcAP7Ootf4pBZWJ6J+4wHYujwipe7Ie3qW6U=
github.com/knadh/koanf/providers/posflag v0.1.0/go.mod h1:SYg03v/t8ISBNrMBRMlojH8OsKowbkXV7giIbBVgbz0=
github.com/knadh/koanf/v2 v2.1.1 h1:/R8eXqasSTsmDCsAyYj+81Wteg8AqrV9CP6gvsTsOmM= github.com/knadh/koanf/v2 v2.1.1 h1:/R8eXqasSTsmDCsAyYj+81Wteg8AqrV9CP6gvsTsOmM=
github.com/knadh/koanf/v2 v2.1.1/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es= github.com/knadh/koanf/v2 v2.1.1/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
@@ -28,8 +30,18 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tidwall/gjson v1.14.2 h1:6BBkirS0rAHjumnjHF6qgy5d2YAJ1TLIaFE2lzfOLqo=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View File

@@ -0,0 +1,105 @@
package envextended
import (
"errors"
"os"
"strings"
"github.com/tidwall/sjson"
)
type Env struct {
prefix string
delim string
cb func(key string, value string) (string, interface{})
out string
}
func Provider(prefix, delim string, cb func(s string) string) *Env {
e := &Env{
prefix: prefix,
delim: delim,
out: "{}",
}
if cb != nil {
e.cb = func(key string, value string) (string, interface{}) {
return cb(key), value
}
}
return e
}
// ProviderWithValue works exactly the same as Provider except the callback
// takes a (key, value) with the variable name and value and allows you
// to modify both. This is useful for cases where you may want to return
// other types like a string slice instead of just a string.
func ProviderWithValue(prefix, delim string, cb func(key string, value string) (string, interface{})) *Env {
return &Env{
prefix: prefix,
delim: delim,
cb: cb,
}
}
// ReadBytes reads the contents of a file on disk and returns the bytes.
func (e *Env) ReadBytes() ([]byte, error) {
// Collect the environment variable keys.
var keys []string
for _, k := range os.Environ() {
if e.prefix != "" {
if strings.HasPrefix(k, e.prefix) {
keys = append(keys, k)
}
} else {
keys = append(keys, k)
}
}
for _, k := range keys {
parts := strings.SplitN(k, "=", 2)
var (
key string
value interface{}
)
// If there's a transformation callback,
// run it through every key/value.
if e.cb != nil {
key, value = e.cb(parts[0], parts[1])
// If the callback blanked the key, it should be omitted
if key == "" {
continue
}
} else {
key = parts[0]
value = parts[1]
}
if err := e.set(key, value); err != nil {
return []byte{}, err
}
}
if e.out == "" {
return []byte("{}"), nil
}
return []byte(e.out), nil
}
func (e *Env) set(key string, value interface{}) error {
out, err := sjson.Set(e.out, strings.Replace(key, e.delim, ".", -1), value)
if err != nil {
return err
}
e.out = out
return nil
}
// Read is not supported by the file provider.
func (e *Env) Read() (map[string]interface{}, error) {
return nil, errors.New("envextended provider does not support this method")
}

115
main.go
View File

@@ -2,24 +2,33 @@ package main
import ( import (
"encoding/hex" "encoding/hex"
"flag" "errors"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
"strings"
"github.com/evan-buss/opds-proxy/internal/envextended"
"github.com/gorilla/securecookie" "github.com/gorilla/securecookie"
"github.com/knadh/koanf/parsers/json"
"github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag"
"github.com/knadh/koanf/v2" "github.com/knadh/koanf/v2"
flag "github.com/spf13/pflag"
) )
// Version information set at build time
var version = "dev"
var commit = "unknown"
var date = "unknown"
type ProxyConfig struct { type ProxyConfig struct {
Port string `koanf:"port"` Port string `koanf:"port"`
Auth AuthConfig `koanf:"auth"` Auth AuthConfig `koanf:"auth"`
Feeds []FeedConfig `koanf:"feeds" ` Feeds []FeedConfig `koanf:"feeds" `
isDevMode bool DebugMode bool `koanf:"debug"`
} }
type AuthConfig struct { type AuthConfig struct {
@@ -40,44 +49,54 @@ type FeedConfigAuth struct {
} }
func main() { func main() {
fs := flag.NewFlagSet("", flag.ContinueOnError) var k = koanf.New(".")
// These aren't mapped to the config file.
configPath := fs.String("config", "config.yml", "config file to load")
generateKeys := fs.Bool("generate-keys", false, "generate cookie signing keys and exit")
isDevMode := fs.Bool("dev", false, "enable development mode")
port := fs.String("port", "8080", "port to listen on") fs := flag.NewFlagSet("", flag.ContinueOnError)
fs.StringP("port", "p", "8080", "port to listen on")
fs.StringP("config", "c", "config.yml", "config file to load")
fs.Bool("generate-keys", false, "generate cookie signing keys and exit")
fs.BoolP("version", "v", false, "print version and exit")
fs.Usage = func() {
fmt.Println("Usage: opds-proxy [flags]")
fmt.Println(fs.FlagUsages())
os.Exit(0)
}
if err := fs.Parse(os.Args[1:]); err != nil { if err := fs.Parse(os.Args[1:]); err != nil {
log.Fatal(err) log.Fatalf("error parsing flags: %v", err)
} }
if *generateKeys { if showVersion, _ := fs.GetBool("version"); showVersion {
fmt.Println("opds-proxy")
fmt.Printf(" Version: %s\n", version)
fmt.Printf(" Commit: %s\n", commit)
fmt.Printf(" Build Date: %s\n", date)
os.Exit(0)
}
if generate, _ := fs.GetBool("generate-keys"); generate {
displayKeys() displayKeys()
os.Exit(0) os.Exit(0)
} }
var k = koanf.New(".") // YAML Config
configPath, _ := fs.GetString("config")
// Load config file from disk. if err := k.Load(file.Provider(configPath), yaml.Parser()); err != nil && !os.IsNotExist(err) {
// Feed options must be defined here. log.Fatalf("error loading config file: %v", err)
if err := k.Load(file.Provider(*configPath), yaml.Parser()); err != nil && !os.IsNotExist(err) {
log.Fatal(err)
} }
// Selectively add command line options to the config. Overriding the config file. // Environment Variables Config
if err := k.Load(confmap.Provider(map[string]interface{}{ if err := k.Load(envextended.ProviderWithValue("OPDS", ".", envCallback), json.Parser()); err != nil {
"port": *port, log.Fatalf("error loading environment variables: %v", err)
}, "."), nil); err != nil { }
log.Fatal(err)
// CLI Flags Config
if err := k.Load(posflag.Provider(fs, ".", k), nil); err != nil {
log.Fatalf("error loading CLI flags: %v", err)
} }
config := ProxyConfig{} config := ProxyConfig{}
k.Unmarshal("", &config) k.Unmarshal("", &config)
if len(config.Feeds) == 0 {
log.Fatal("No feeds defined in config")
}
if config.Auth.HashKey == "" || config.Auth.BlockKey == "" { if config.Auth.HashKey == "" || config.Auth.BlockKey == "" {
log.Println("Generating new cookie signing credentials") log.Println("Generating new cookie signing credentials")
hashKey, blockKey := displayKeys() hashKey, blockKey := displayKeys()
@@ -86,16 +105,17 @@ func main() {
config.Auth.BlockKey = blockKey config.Auth.BlockKey = blockKey
} }
// This should only be set by the command line flag, if err := config.Validate(); err != nil {
// so we don't use koanf to set this. log.Fatalf("invalid configuration: %v", err)
config.isDevMode = *isDevMode }
server, err := NewServer(&config) server, err := NewServer(&config)
if err != nil { if err != nil {
log.Fatal(err) log.Fatalf("error creating server: %v", err)
} }
if err = server.Serve(); err != nil && err != http.ErrServerClosed { if err = server.Serve(); err != nil && err != http.ErrServerClosed {
log.Fatal(err) log.Fatalf("error serving: %v", err)
} }
} }
@@ -110,3 +130,36 @@ func displayKeys() (string, string) {
return hashKey, blockKey return hashKey, blockKey
} }
func envCallback(key string, value string) (string, interface{}) {
key = strings.TrimPrefix(key, "OPDS__")
key = strings.ReplaceAll(key, "__", ".")
key = strings.ToLower(key)
return key, value
}
func (c *ProxyConfig) Validate() error {
if c.Port == "" {
return errors.New("port is required")
}
if c.Auth.HashKey == "" || c.Auth.BlockKey == "" {
return errors.New("auth.hash_key and auth.block_key are required")
}
if len(c.Feeds) == 0 {
return errors.New("at least one feed must be defined")
}
for _, feed := range c.Feeds {
if feed.Name == "" {
return errors.New("feed.name is required")
}
if feed.Url == "" {
return errors.New("feed.url is required")
}
}
return nil
}

View File

@@ -66,7 +66,7 @@ func NewServer(config *ProxyConfig) (*Server, error) {
return nil, err return nil, err
} }
if !config.isDevMode { if !config.DebugMode {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
} }