Rewrite of the entrypoint in Golang (#71)

- General improvements
    - Parallel download of only needed files at start
    - Prettier console output with all streams merged (openvpn, unbound, shadowsocks etc.)
    - Simplified Docker final image
    - Faster bootup
- DNS over TLS
    - Finer grain blocking at DNS level: malicious, ads and surveillance
    - Choose your DNS over TLS providers
    - Ability to use multiple DNS over TLS providers for DNS split horizon
    - Environment variables for DNS logging
    - DNS block lists needed are downloaded and built automatically at start, in parallel
- PIA
    - A random region is selected if the REGION parameter is left empty (thanks @rorph for your PR)
    - Routing and iptables adjusted so it can work as a Kubernetes pod sidecar (thanks @rorph for your PR)
This commit is contained in:
Quentin McGaw
2020-02-06 20:42:46 -05:00
committed by GitHub
parent 3de4ffcf66
commit 64649039d9
74 changed files with 4598 additions and 1019 deletions

40
internal/dns/command.go Normal file
View File

@@ -0,0 +1,40 @@
package dns
import (
"fmt"
"io"
"strings"
"github.com/qdm12/private-internet-access-docker/internal/constants"
)
func (c *configurator) Start(verbosityDetailsLevel uint8) (stdout io.ReadCloser, err error) {
c.logger.Info("%s: starting unbound", logPrefix)
args := []string{"-d", "-c", string(constants.UnboundConf)}
if verbosityDetailsLevel > 0 {
args = append(args, "-"+strings.Repeat("v", int(verbosityDetailsLevel)))
}
// Only logs to stderr
_, stdout, _, err = c.commander.Start("unbound", args...)
return stdout, err
}
func (c *configurator) Version() (version string, err error) {
output, err := c.commander.Run("unbound", "-V")
if err != nil {
return "", fmt.Errorf("unbound version: %w", err)
}
for _, line := range strings.Split(output, "\n") {
if strings.Contains(line, "Version ") {
words := strings.Fields(line)
if len(words) < 2 {
continue
}
version = words[1]
}
}
if version == "" {
return "", fmt.Errorf("unbound version was not found in %q", output)
}
return version, nil
}

View File

@@ -0,0 +1,69 @@
package dns
import (
"fmt"
"testing"
commandMocks "github.com/qdm12/golibs/command/mocks"
loggingMocks "github.com/qdm12/golibs/logging/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/qdm12/private-internet-access-docker/internal/constants"
)
func Test_Start(t *testing.T) {
t.Parallel()
logger := &loggingMocks.Logger{}
logger.On("Info", "%s: starting unbound", logPrefix).Once()
commander := &commandMocks.Commander{}
commander.On("Start", "unbound", "-d", "-c", string(constants.UnboundConf), "-vv").
Return(nil, nil, nil, nil).Once()
c := &configurator{commander: commander, logger: logger}
stdout, err := c.Start(2)
assert.Nil(t, stdout)
assert.NoError(t, err)
logger.AssertExpectations(t)
commander.AssertExpectations(t)
}
func Test_Version(t *testing.T) {
t.Parallel()
tests := map[string]struct {
runOutput string
runErr error
version string
err error
}{
"no data": {
err: fmt.Errorf(`unbound version was not found in ""`),
},
"2 lines with version": {
runOutput: "Version \nVersion 1.0-a hello\n",
version: "1.0-a",
},
"run error": {
runErr: fmt.Errorf("error"),
err: fmt.Errorf("unbound version: error"),
},
}
for name, tc := range tests {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
commander := &commandMocks.Commander{}
commander.On("Run", "unbound", "-V").
Return(tc.runOutput, tc.runErr).Once()
c := &configurator{commander: commander}
version, err := c.Version()
if tc.err != nil {
require.Error(t, err)
assert.Equal(t, tc.err.Error(), err.Error())
} else {
assert.NoError(t, err)
}
assert.Equal(t, tc.version, version)
commander.AssertExpectations(t)
})
}
}

280
internal/dns/conf.go Normal file
View File

@@ -0,0 +1,280 @@
package dns
import (
"fmt"
"sort"
"strings"
"github.com/qdm12/golibs/files"
"github.com/qdm12/golibs/logging"
"github.com/qdm12/golibs/network"
"github.com/qdm12/private-internet-access-docker/internal/constants"
"github.com/qdm12/private-internet-access-docker/internal/settings"
)
func (c *configurator) MakeUnboundConf(settings settings.DNS, uid, gid int) (err error) {
c.logger.Info("%s: generating Unbound configuration", logPrefix)
lines, warnings, err := generateUnboundConf(settings, c.client, c.logger)
for _, warning := range warnings {
c.logger.Warn(warning)
}
if err != nil {
return err
}
return c.fileManager.WriteLinesToFile(
string(constants.UnboundConf),
lines,
files.FileOwnership(uid, gid),
files.FilePermissions(0400))
}
// MakeUnboundConf generates an Unbound configuration from the user provided settings
func generateUnboundConf(settings settings.DNS, client network.Client, logger logging.Logger) (lines []string, warnings []error, err error) {
serverSection := map[string]string{
// Logging
"verbosity": fmt.Sprintf("%d", settings.VerbosityLevel),
"val-log-level": fmt.Sprintf("%d", settings.ValidationLogLevel),
"use-syslog": "no",
// Performance
"num-threads": "1",
"prefetch": "yes",
"prefetch-key": "yes",
"key-cache-size": "16m",
"key-cache-slabs": "4",
"msg-cache-size": "4m",
"msg-cache-slabs": "4",
"rrset-cache-size": "4m",
"rrset-cache-slabs": "4",
"cache-min-ttl": "3600",
"cache-max-ttl": "9000",
// Privacy
"rrset-roundrobin": "yes",
"hide-identity": "yes",
"hide-version": "yes",
// Security
"tls-cert-bundle": "\"/etc/ssl/certs/ca-certificates.crt\"",
"root-hints": fmt.Sprintf("%q", constants.RootHints),
"trust-anchor-file": fmt.Sprintf("%q", constants.RootKey),
"harden-below-nxdomain": "yes",
"harden-referral-path": "yes",
"harden-algo-downgrade": "yes",
// Network
"do-ip4": "yes",
"do-ip6": "no",
"interface": "127.0.0.1",
"port": "53",
// Other
"username": "\"nonrootuser\"",
}
// Block lists
hostnamesLines, ipsLines, warnings := buildBlocked(client,
settings.BlockMalicious, settings.BlockAds, settings.BlockSurveillance,
settings.AllowedHostnames, settings.PrivateAddresses,
)
logger.Info("%s: %d hostnames blocked overall", logPrefix, len(hostnamesLines))
logger.Info("%s: %d IP addresses blocked overall", logPrefix, len(ipsLines))
sort.Slice(hostnamesLines, func(i, j int) bool { // for unit tests really
return hostnamesLines[i] < hostnamesLines[j]
})
sort.Slice(ipsLines, func(i, j int) bool { // for unit tests really
return ipsLines[i] < ipsLines[j]
})
// Server
lines = append(lines, "server:")
var serverLines []string
for k, v := range serverSection {
serverLines = append(serverLines, " "+k+": "+v)
}
sort.Slice(serverLines, func(i, j int) bool {
return serverLines[i] < serverLines[j]
})
lines = append(lines, serverLines...)
lines = append(lines, hostnamesLines...)
lines = append(lines, ipsLines...)
// Forward zone
lines = append(lines, "forward-zone:")
forwardZoneSection := map[string]string{
"name": "\".\"",
"forward-tls-upstream": "yes",
}
var forwardZoneLines []string
for k, v := range forwardZoneSection {
forwardZoneLines = append(forwardZoneLines, " "+k+": "+v)
}
sort.Slice(forwardZoneLines, func(i, j int) bool {
return forwardZoneLines[i] < forwardZoneLines[j]
})
for _, provider := range settings.Providers {
forwardAddresses, ok := constants.DNSAddressesMapping[provider]
if !ok || len(forwardAddresses) == 0 {
return nil, warnings, fmt.Errorf("DNS provider %q does not have any matching forward addresses", provider)
}
for _, forwardAddress := range forwardAddresses {
forwardZoneLines = append(forwardZoneLines, fmt.Sprintf(" forward-addr: %s", forwardAddress))
}
}
lines = append(lines, forwardZoneLines...)
return lines, warnings, nil
}
func buildBlocked(client network.Client, blockMalicious, blockAds, blockSurveillance bool,
allowedHostnames, privateAddresses []string) (hostnamesLines, ipsLines []string, errs []error) {
chHostnames := make(chan []string)
chIPs := make(chan []string)
chErrors := make(chan []error)
go func() {
lines, errs := buildBlockedHostnames(client, blockMalicious, blockAds, blockSurveillance, allowedHostnames)
chHostnames <- lines
chErrors <- errs
}()
go func() {
lines, errs := buildBlockedIPs(client, blockMalicious, blockAds, blockSurveillance, privateAddresses)
chIPs <- lines
chErrors <- errs
}()
n := 2
for n > 0 {
select {
case lines := <-chHostnames:
hostnamesLines = append(hostnamesLines, lines...)
case lines := <-chIPs:
ipsLines = append(ipsLines, lines...)
case routineErrs := <-chErrors:
errs = append(errs, routineErrs...)
n--
}
}
return hostnamesLines, ipsLines, errs
}
func getList(client network.Client, URL string) (results []string, err error) {
content, status, err := client.GetContent(URL)
if err != nil {
return nil, err
} else if status != 200 {
return nil, fmt.Errorf("HTTP status code is %d and not 200", status)
}
results = strings.Split(string(content), "\n")
// remove empty lines
last := len(results) - 1
for i := range results {
if len(results[i]) == 0 {
results[i] = results[last]
last--
}
}
results = results[:last+1]
if len(results) == 0 {
return nil, nil
}
return results, nil
}
func buildBlockedHostnames(client network.Client, blockMalicious, blockAds, blockSurveillance bool,
allowedHostnames []string) (lines []string, errs []error) {
chResults := make(chan []string)
chError := make(chan error)
listsLeftToFetch := 0
if blockMalicious {
listsLeftToFetch++
go func() {
results, err := getList(client, string(constants.MaliciousBlockListHostnamesURL))
chResults <- results
chError <- err
}()
}
if blockAds {
listsLeftToFetch++
go func() {
results, err := getList(client, string(constants.AdsBlockListHostnamesURL))
chResults <- results
chError <- err
}()
}
if blockSurveillance {
listsLeftToFetch++
go func() {
results, err := getList(client, string(constants.SurveillanceBlockListHostnamesURL))
chResults <- results
chError <- err
}()
}
uniqueResults := make(map[string]struct{})
for listsLeftToFetch > 0 {
select {
case results := <-chResults:
for _, result := range results {
uniqueResults[result] = struct{}{}
}
case err := <-chError:
listsLeftToFetch--
if err != nil {
errs = append(errs, err)
}
}
}
for _, allowedHostname := range allowedHostnames {
delete(uniqueResults, allowedHostname)
}
for result := range uniqueResults {
lines = append(lines, " local-zone: \""+result+"\" static")
}
return lines, errs
}
func buildBlockedIPs(client network.Client, blockMalicious, blockAds, blockSurveillance bool,
privateAddresses []string) (lines []string, errs []error) {
chResults := make(chan []string)
chError := make(chan error)
listsLeftToFetch := 0
if blockMalicious {
listsLeftToFetch++
go func() {
results, err := getList(client, string(constants.MaliciousBlockListIPsURL))
chResults <- results
chError <- err
}()
}
if blockAds {
listsLeftToFetch++
go func() {
results, err := getList(client, string(constants.AdsBlockListIPsURL))
chResults <- results
chError <- err
}()
}
if blockSurveillance {
listsLeftToFetch++
go func() {
results, err := getList(client, string(constants.SurveillanceBlockListIPsURL))
chResults <- results
chError <- err
}()
}
uniqueResults := make(map[string]struct{})
for listsLeftToFetch > 0 {
select {
case results := <-chResults:
for _, result := range results {
uniqueResults[result] = struct{}{}
}
case err := <-chError:
listsLeftToFetch--
if err != nil {
errs = append(errs, err)
}
}
}
for _, privateAddress := range privateAddresses {
uniqueResults[privateAddress] = struct{}{}
}
for result := range uniqueResults {
lines = append(lines, " private-address: "+result)
}
return lines, errs
}

518
internal/dns/conf_test.go Normal file
View File

@@ -0,0 +1,518 @@
package dns
import (
"fmt"
"strings"
"testing"
"github.com/qdm12/golibs/logging"
"github.com/qdm12/golibs/network/mocks"
"github.com/qdm12/private-internet-access-docker/internal/constants"
"github.com/qdm12/private-internet-access-docker/internal/models"
"github.com/qdm12/private-internet-access-docker/internal/settings"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_generateUnboundConf(t *testing.T) {
t.Parallel()
settings := settings.DNS{
Providers: []models.DNSProvider{constants.Cloudflare, constants.Quad9},
AllowedHostnames: []string{"a"},
PrivateAddresses: []string{"9.9.9.9"},
BlockMalicious: true,
BlockSurveillance: false,
BlockAds: false,
VerbosityLevel: 2,
ValidationLogLevel: 3,
}
client := &mocks.Client{}
client.On("GetContent", string(constants.MaliciousBlockListHostnamesURL)).
Return([]byte("b\na\nc"), 200, nil).Once()
client.On("GetContent", string(constants.MaliciousBlockListIPsURL)).
Return([]byte("c\nd\n"), 200, nil).Once()
emptyLogger, err := logging.NewEmptyLogger()
require.NoError(t, err)
lines, warnings, err := generateUnboundConf(settings, client, emptyLogger)
require.Len(t, warnings, 0)
require.NoError(t, err)
client.AssertExpectations(t)
expected := `
server:
cache-max-ttl: 9000
cache-min-ttl: 3600
do-ip4: yes
do-ip6: no
harden-algo-downgrade: yes
harden-below-nxdomain: yes
harden-referral-path: yes
hide-identity: yes
hide-version: yes
interface: 127.0.0.1
key-cache-size: 16m
key-cache-slabs: 4
msg-cache-size: 4m
msg-cache-slabs: 4
num-threads: 1
port: 53
prefetch-key: yes
prefetch: yes
root-hints: "/etc/unbound/root.hints"
rrset-cache-size: 4m
rrset-cache-slabs: 4
rrset-roundrobin: yes
tls-cert-bundle: "/etc/ssl/certs/ca-certificates.crt"
trust-anchor-file: "/etc/unbound/root.key"
use-syslog: no
username: "nonrootuser"
val-log-level: 3
verbosity: 2
local-zone: "b" static
local-zone: "c" static
private-address: 9.9.9.9
private-address: c
private-address: d
forward-zone:
forward-tls-upstream: yes
name: "."
forward-addr: 1.1.1.1@853#cloudflare-dns.com
forward-addr: 1.0.0.1@853#cloudflare-dns.com
forward-addr: 9.9.9.9@853#dns.quad9.net
forward-addr: 149.112.112.112@853#dns.quad9.net`
assert.Equal(t, expected, "\n"+strings.Join(lines, "\n"))
}
func Test_buildBlocked(t *testing.T) {
t.Parallel()
type blockParams struct {
blocked bool
content []byte
clientErr error
}
tests := map[string]struct {
malicious blockParams
ads blockParams
surveillance blockParams
allowedHostnames []string
privateAddresses []string
hostnamesLines []string
ipsLines []string
errsString []string
}{
"none blocked": {},
"all blocked without lists": {
malicious: blockParams{
blocked: true,
},
ads: blockParams{
blocked: true,
},
surveillance: blockParams{
blocked: true,
},
},
"all blocked with lists": {
malicious: blockParams{
blocked: true,
content: []byte("malicious"),
},
ads: blockParams{
blocked: true,
content: []byte("ads"),
},
surveillance: blockParams{
blocked: true,
content: []byte("surveillance"),
},
hostnamesLines: []string{
" local-zone: \"ads\" static",
" local-zone: \"malicious\" static",
" local-zone: \"surveillance\" static"},
ipsLines: []string{
" private-address: ads",
" private-address: malicious",
" private-address: surveillance"},
},
"all blocked with allowed hostnames": {
malicious: blockParams{
blocked: true,
content: []byte("malicious"),
},
ads: blockParams{
blocked: true,
content: []byte("ads"),
},
surveillance: blockParams{
blocked: true,
content: []byte("surveillance"),
},
allowedHostnames: []string{"ads"},
hostnamesLines: []string{
" local-zone: \"malicious\" static",
" local-zone: \"surveillance\" static"},
ipsLines: []string{
" private-address: ads",
" private-address: malicious",
" private-address: surveillance"},
},
"all blocked with private addresses": {
malicious: blockParams{
blocked: true,
content: []byte("malicious"),
},
ads: blockParams{
blocked: true,
content: []byte("ads"),
},
surveillance: blockParams{
blocked: true,
content: []byte("surveillance"),
},
privateAddresses: []string{"ads", "192.100.1.5"},
hostnamesLines: []string{
" local-zone: \"ads\" static",
" local-zone: \"malicious\" static",
" local-zone: \"surveillance\" static"},
ipsLines: []string{
" private-address: 192.100.1.5",
" private-address: ads",
" private-address: malicious",
" private-address: surveillance"},
},
"all blocked with lists and one error": {
malicious: blockParams{
blocked: true,
content: []byte("malicious"),
},
ads: blockParams{
blocked: true,
content: []byte("ads"),
clientErr: fmt.Errorf("ads error"),
},
surveillance: blockParams{
blocked: true,
content: []byte("surveillance"),
},
hostnamesLines: []string{
" local-zone: \"malicious\" static",
" local-zone: \"surveillance\" static"},
ipsLines: []string{
" private-address: malicious",
" private-address: surveillance"},
errsString: []string{"ads error", "ads error"},
},
"all blocked with errors": {
malicious: blockParams{
blocked: true,
clientErr: fmt.Errorf("malicious"),
},
ads: blockParams{
blocked: true,
clientErr: fmt.Errorf("ads"),
},
surveillance: blockParams{
blocked: true,
clientErr: fmt.Errorf("surveillance"),
},
errsString: []string{"malicious", "malicious", "ads", "ads", "surveillance", "surveillance"},
},
}
for name, tc := range tests {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
client := &mocks.Client{}
if tc.malicious.blocked {
client.On("GetContent", string(constants.MaliciousBlockListHostnamesURL)).
Return(tc.malicious.content, 200, tc.malicious.clientErr).Once()
client.On("GetContent", string(constants.MaliciousBlockListIPsURL)).
Return(tc.malicious.content, 200, tc.malicious.clientErr).Once()
}
if tc.ads.blocked {
client.On("GetContent", string(constants.AdsBlockListHostnamesURL)).
Return(tc.ads.content, 200, tc.ads.clientErr).Once()
client.On("GetContent", string(constants.AdsBlockListIPsURL)).
Return(tc.ads.content, 200, tc.ads.clientErr).Once()
}
if tc.surveillance.blocked {
client.On("GetContent", string(constants.SurveillanceBlockListHostnamesURL)).
Return(tc.surveillance.content, 200, tc.surveillance.clientErr).Once()
client.On("GetContent", string(constants.SurveillanceBlockListIPsURL)).
Return(tc.surveillance.content, 200, tc.surveillance.clientErr).Once()
}
hostnamesLines, ipsLines, errs := buildBlocked(client, tc.malicious.blocked, tc.ads.blocked, tc.surveillance.blocked,
tc.allowedHostnames, tc.privateAddresses)
var errsString []string
for _, err := range errs {
errsString = append(errsString, err.Error())
}
assert.ElementsMatch(t, tc.errsString, errsString)
assert.ElementsMatch(t, tc.hostnamesLines, hostnamesLines)
assert.ElementsMatch(t, tc.ipsLines, ipsLines)
client.AssertExpectations(t)
})
}
}
func Test_getList(t *testing.T) {
t.Parallel()
tests := map[string]struct {
content []byte
status int
clientErr error
results []string
err error
}{
"no result": {nil, 200, nil, nil, nil},
"bad status": {nil, 500, nil, nil, fmt.Errorf("HTTP status code is 500 and not 200")},
"network error": {nil, 200, fmt.Errorf("error"), nil, fmt.Errorf("error")},
"results": {[]byte("a\nb\nc\n"), 200, nil, []string{"a", "b", "c"}, nil},
}
for name, tc := range tests {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
client := &mocks.Client{}
client.On("GetContent", "irrelevant_url").Return(
tc.content, tc.status, tc.clientErr,
).Once()
results, err := getList(client, "irrelevant_url")
if tc.err != nil {
require.Error(t, err)
assert.Equal(t, tc.err.Error(), err.Error())
} else {
assert.NoError(t, err)
}
assert.Equal(t, tc.results, results)
client.AssertExpectations(t)
})
}
}
func Test_buildBlockedHostnames(t *testing.T) {
t.Parallel()
type blockParams struct {
blocked bool
content []byte
clientErr error
}
tests := map[string]struct {
malicious blockParams
ads blockParams
surveillance blockParams
allowedHostnames []string
lines []string
errsString []string
}{
"nothing blocked": {
lines: nil,
errsString: nil,
},
"only malicious blocked": {
malicious: blockParams{
blocked: true,
content: []byte("site_a\nsite_b"),
clientErr: nil,
},
lines: []string{
" local-zone: \"site_a\" static",
" local-zone: \"site_b\" static"},
errsString: nil,
},
"all blocked with some duplicates": {
malicious: blockParams{
blocked: true,
content: []byte("site_a\nsite_b"),
},
ads: blockParams{
blocked: true,
content: []byte("site_a\nsite_c"),
},
surveillance: blockParams{
blocked: true,
content: []byte("site_c\nsite_a"),
},
lines: []string{
" local-zone: \"site_a\" static",
" local-zone: \"site_b\" static",
" local-zone: \"site_c\" static"},
errsString: nil,
},
"all blocked with one errored": {
malicious: blockParams{
blocked: true,
content: []byte("site_a\nsite_b"),
},
ads: blockParams{
blocked: true,
content: []byte("site_a\nsite_c"),
},
surveillance: blockParams{
blocked: true,
clientErr: fmt.Errorf("surveillance error"),
},
lines: []string{
" local-zone: \"site_a\" static",
" local-zone: \"site_b\" static",
" local-zone: \"site_c\" static"},
errsString: []string{"surveillance error"},
},
"blocked with allowed hostnames": {
malicious: blockParams{
blocked: true,
content: []byte("site_a\nsite_b"),
},
ads: blockParams{
blocked: true,
content: []byte("site_c\nsite_d"),
},
allowedHostnames: []string{"site_b", "site_c"},
lines: []string{
" local-zone: \"site_a\" static",
" local-zone: \"site_d\" static"},
},
}
for name, tc := range tests {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
client := &mocks.Client{}
if tc.malicious.blocked {
client.On("GetContent", string(constants.MaliciousBlockListHostnamesURL)).
Return(tc.malicious.content, 200, tc.malicious.clientErr).Once()
}
if tc.ads.blocked {
client.On("GetContent", string(constants.AdsBlockListHostnamesURL)).
Return(tc.ads.content, 200, tc.ads.clientErr).Once()
}
if tc.surveillance.blocked {
client.On("GetContent", string(constants.SurveillanceBlockListHostnamesURL)).
Return(tc.surveillance.content, 200, tc.surveillance.clientErr).Once()
}
lines, errs := buildBlockedHostnames(client,
tc.malicious.blocked, tc.ads.blocked, tc.surveillance.blocked, tc.allowedHostnames)
var errsString []string
for _, err := range errs {
errsString = append(errsString, err.Error())
}
assert.ElementsMatch(t, tc.errsString, errsString)
assert.ElementsMatch(t, tc.lines, lines)
client.AssertExpectations(t)
})
}
}
func Test_buildBlockedIPs(t *testing.T) {
t.Parallel()
type blockParams struct {
blocked bool
content []byte
clientErr error
}
tests := map[string]struct {
malicious blockParams
ads blockParams
surveillance blockParams
privateAddresses []string
lines []string
errsString []string
}{
"nothing blocked": {
lines: nil,
errsString: nil,
},
"only malicious blocked": {
malicious: blockParams{
blocked: true,
content: []byte("site_a\nsite_b"),
clientErr: nil,
},
lines: []string{
" private-address: site_a",
" private-address: site_b"},
errsString: nil,
},
"all blocked with some duplicates": {
malicious: blockParams{
blocked: true,
content: []byte("site_a\nsite_b"),
},
ads: blockParams{
blocked: true,
content: []byte("site_a\nsite_c"),
},
surveillance: blockParams{
blocked: true,
content: []byte("site_c\nsite_a"),
},
lines: []string{
" private-address: site_a",
" private-address: site_b",
" private-address: site_c"},
errsString: nil,
},
"all blocked with one errored": {
malicious: blockParams{
blocked: true,
content: []byte("site_a\nsite_b"),
},
ads: blockParams{
blocked: true,
content: []byte("site_a\nsite_c"),
},
surveillance: blockParams{
blocked: true,
clientErr: fmt.Errorf("surveillance error"),
},
lines: []string{
" private-address: site_a",
" private-address: site_b",
" private-address: site_c"},
errsString: []string{"surveillance error"},
},
"blocked with private addresses": {
malicious: blockParams{
blocked: true,
content: []byte("site_a\nsite_b"),
},
ads: blockParams{
blocked: true,
content: []byte("site_c"),
},
privateAddresses: []string{"site_c", "site_d"},
lines: []string{
" private-address: site_a",
" private-address: site_b",
" private-address: site_c",
" private-address: site_d"},
},
}
for name, tc := range tests {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
client := &mocks.Client{}
if tc.malicious.blocked {
client.On("GetContent", string(constants.MaliciousBlockListIPsURL)).
Return(tc.malicious.content, 200, tc.malicious.clientErr).Once()
}
if tc.ads.blocked {
client.On("GetContent", string(constants.AdsBlockListIPsURL)).
Return(tc.ads.content, 200, tc.ads.clientErr).Once()
}
if tc.surveillance.blocked {
client.On("GetContent", string(constants.SurveillanceBlockListIPsURL)).
Return(tc.surveillance.content, 200, tc.surveillance.clientErr).Once()
}
lines, errs := buildBlockedIPs(client,
tc.malicious.blocked, tc.ads.blocked, tc.surveillance.blocked, tc.privateAddresses)
var errsString []string
for _, err := range errs {
errsString = append(errsString, err.Error())
}
assert.ElementsMatch(t, tc.errsString, errsString)
assert.ElementsMatch(t, tc.lines, lines)
client.AssertExpectations(t)
})
}
}

38
internal/dns/dns.go Normal file
View File

@@ -0,0 +1,38 @@
package dns
import (
"io"
"github.com/qdm12/golibs/command"
"github.com/qdm12/golibs/files"
"github.com/qdm12/golibs/logging"
"github.com/qdm12/golibs/network"
"github.com/qdm12/private-internet-access-docker/internal/settings"
)
const logPrefix = "dns configurator"
type Configurator interface {
DownloadRootHints(uid, gid int) error
DownloadRootKey(uid, gid int) error
MakeUnboundConf(settings settings.DNS, uid, gid int) (err error)
SetLocalNameserver() error
Start(logLevel uint8) (stdout io.ReadCloser, err error)
Version() (version string, err error)
}
type configurator struct {
logger logging.Logger
client network.Client
fileManager files.FileManager
commander command.Commander
}
func NewConfigurator(logger logging.Logger, client network.Client, fileManager files.FileManager) Configurator {
return &configurator{
logger: logger,
client: client,
fileManager: fileManager,
commander: command.NewCommander(),
}
}

32
internal/dns/os.go Normal file
View File

@@ -0,0 +1,32 @@
package dns
import (
"strings"
"github.com/qdm12/private-internet-access-docker/internal/constants"
)
func (c *configurator) SetLocalNameserver() error {
c.logger.Info("%s: setting local nameserver to 127.0.0.1", logPrefix)
data, err := c.fileManager.ReadFile(string(constants.ResolvConf))
if err != nil {
return err
}
s := strings.TrimSuffix(string(data), "\n")
lines := strings.Split(s, "\n")
if len(lines) == 1 && lines[0] == "" {
lines = nil
}
found := false
for i := range lines {
if strings.HasPrefix(lines[i], "nameserver ") {
lines[i] = "nameserver 127.0.0.1"
found = true
}
}
if !found {
lines = append(lines, "nameserver 127.0.0.1")
}
data = []byte(strings.Join(lines, "\n"))
return c.fileManager.WriteToFile(string(constants.ResolvConf), data)
}

72
internal/dns/os_test.go Normal file
View File

@@ -0,0 +1,72 @@
package dns
import (
"fmt"
"testing"
filesmocks "github.com/qdm12/golibs/files/mocks"
loggingmocks "github.com/qdm12/golibs/logging/mocks"
"github.com/qdm12/private-internet-access-docker/internal/constants"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_SetLocalNameserver(t *testing.T) {
t.Parallel()
tests := map[string]struct {
data []byte
writtenData []byte
readErr error
writeErr error
err error
}{
"no data": {
writtenData: []byte("nameserver 127.0.0.1"),
},
"read error": {
readErr: fmt.Errorf("error"),
err: fmt.Errorf("error"),
},
"write error": {
writtenData: []byte("nameserver 127.0.0.1"),
writeErr: fmt.Errorf("error"),
err: fmt.Errorf("error"),
},
"lines without nameserver": {
data: []byte("abc\ndef\n"),
writtenData: []byte("abc\ndef\nnameserver 127.0.0.1"),
},
"lines with nameserver": {
data: []byte("abc\nnameserver abc def\ndef\n"),
writtenData: []byte("abc\nnameserver 127.0.0.1\ndef"),
},
}
for name, tc := range tests {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
fileManager := &filesmocks.FileManager{}
fileManager.On("ReadFile", string(constants.ResolvConf)).
Return(tc.data, tc.readErr).Once()
if tc.readErr == nil {
fileManager.On("WriteToFile", string(constants.ResolvConf), tc.writtenData).
Return(tc.writeErr).Once()
}
logger := &loggingmocks.Logger{}
logger.On("Info", "%s: setting local nameserver to 127.0.0.1", logPrefix).Once()
c := &configurator{
fileManager: fileManager,
logger: logger,
}
err := c.SetLocalNameserver()
if tc.err != nil {
require.Error(t, err)
assert.Equal(t, tc.err.Error(), err.Error())
} else {
assert.NoError(t, err)
}
fileManager.AssertExpectations(t)
logger.AssertExpectations(t)
})
}
}

38
internal/dns/roots.go Normal file
View File

@@ -0,0 +1,38 @@
package dns
import (
"fmt"
"github.com/qdm12/golibs/files"
"github.com/qdm12/private-internet-access-docker/internal/constants"
)
func (c *configurator) DownloadRootHints(uid, gid int) error {
c.logger.Info("%s: downloading root hints from %s", logPrefix, constants.NamedRootURL)
content, status, err := c.client.GetContent(string(constants.NamedRootURL))
if err != nil {
return err
} else if status != 200 {
return fmt.Errorf("HTTP status code is %d for %s", status, constants.NamedRootURL)
}
return c.fileManager.WriteToFile(
string(constants.RootHints),
content,
files.FileOwnership(uid, gid),
files.FilePermissions(0400))
}
func (c *configurator) DownloadRootKey(uid, gid int) error {
c.logger.Info("%s: downloading root key from %s", logPrefix, constants.RootKeyURL)
content, status, err := c.client.GetContent(string(constants.RootKeyURL))
if err != nil {
return err
} else if status != 200 {
return fmt.Errorf("HTTP status code is %d for %s", status, constants.RootKeyURL)
}
return c.fileManager.WriteToFile(
string(constants.RootKey),
content,
files.FileOwnership(uid, gid),
files.FilePermissions(0400))
}

144
internal/dns/roots_test.go Normal file
View File

@@ -0,0 +1,144 @@
package dns
import (
"fmt"
"net/http"
"testing"
filesMocks "github.com/qdm12/golibs/files/mocks"
loggingMocks "github.com/qdm12/golibs/logging/mocks"
networkMocks "github.com/qdm12/golibs/network/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/qdm12/private-internet-access-docker/internal/constants"
)
func Test_DownloadRootHints(t *testing.T) {
t.Parallel()
tests := map[string]struct {
content []byte
status int
clientErr error
writeErr error
err error
}{
"no data": {
status: http.StatusOK,
},
"bad status": {
status: http.StatusBadRequest,
err: fmt.Errorf("HTTP status code is 400 for https://raw.githubusercontent.com/qdm12/files/master/named.root.updated"),
},
"client error": {
clientErr: fmt.Errorf("error"),
err: fmt.Errorf("error"),
},
"write error": {
status: http.StatusOK,
writeErr: fmt.Errorf("error"),
err: fmt.Errorf("error"),
},
"data": {
content: []byte("content"),
status: http.StatusOK,
},
}
for name, tc := range tests {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
logger := &loggingMocks.Logger{}
logger.On("Info", "%s: downloading root hints from %s", logPrefix, constants.NamedRootURL).Once()
client := &networkMocks.Client{}
client.On("GetContent", string(constants.NamedRootURL)).
Return(tc.content, tc.status, tc.clientErr).Once()
fileManager := &filesMocks.FileManager{}
if tc.clientErr == nil && tc.status == http.StatusOK {
fileManager.On(
"WriteToFile",
string(constants.RootHints),
tc.content,
mock.AnythingOfType("files.WriteOptionSetter"),
mock.AnythingOfType("files.WriteOptionSetter")).
Return(tc.writeErr).Once()
}
c := &configurator{logger: logger, client: client, fileManager: fileManager}
err := c.DownloadRootHints(1000, 1000)
if tc.err != nil {
require.Error(t, err)
assert.Equal(t, tc.err.Error(), err.Error())
} else {
assert.NoError(t, err)
}
logger.AssertExpectations(t)
client.AssertExpectations(t)
fileManager.AssertExpectations(t)
})
}
}
func Test_DownloadRootKey(t *testing.T) {
t.Parallel()
tests := map[string]struct {
content []byte
status int
clientErr error
writeErr error
err error
}{
"no data": {
status: http.StatusOK,
},
"bad status": {
status: http.StatusBadRequest,
err: fmt.Errorf("HTTP status code is 400 for https://raw.githubusercontent.com/qdm12/files/master/root.key.updated"),
},
"client error": {
clientErr: fmt.Errorf("error"),
err: fmt.Errorf("error"),
},
"write error": {
status: http.StatusOK,
writeErr: fmt.Errorf("error"),
err: fmt.Errorf("error"),
},
"data": {
content: []byte("content"),
status: http.StatusOK,
},
}
for name, tc := range tests {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
logger := &loggingMocks.Logger{}
logger.On("Info", "%s: downloading root key from %s", logPrefix, constants.RootKeyURL).Once()
client := &networkMocks.Client{}
client.On("GetContent", string(constants.RootKeyURL)).
Return(tc.content, tc.status, tc.clientErr).Once()
fileManager := &filesMocks.FileManager{}
if tc.clientErr == nil && tc.status == http.StatusOK {
fileManager.On(
"WriteToFile",
string(constants.RootKey),
tc.content,
mock.AnythingOfType("files.WriteOptionSetter"),
mock.AnythingOfType("files.WriteOptionSetter"),
).Return(tc.writeErr).Once()
}
c := &configurator{logger: logger, client: client, fileManager: fileManager}
err := c.DownloadRootKey(1000, 1001)
if tc.err != nil {
require.Error(t, err)
assert.Equal(t, tc.err.Error(), err.Error())
} else {
assert.NoError(t, err)
}
logger.AssertExpectations(t)
client.AssertExpectations(t)
fileManager.AssertExpectations(t)
})
}
}