initial commit

This commit is contained in:
Dimitri Herzog 2020-01-12 18:23:35 +01:00
commit 01a8a402dc
43 changed files with 4999 additions and 0 deletions

4
.dockerignore Normal file
View File

@ -0,0 +1,4 @@
bin/
.idea
.github
testdata/

40
.github/workflows/ci-build.yml vendored Normal file
View File

@ -0,0 +1,40 @@
name: CI Build
on: [push]
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.13
uses: actions/setup-go@v1
with:
go-version: 1.13
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v1
- name: Get dependencies
run: |
go get -v -t -d ./...
if [ -f Gopkg.toml ]; then
curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh
dep ensure
fi
- name: Install golangci-lint
run: curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $(go env GOPATH)/bin v1.21.0
- name: Run golangci-lint
run: make lint
- name: Test
run: make test
- name: Build
run: make build
- name: Docker images
run: make docker-build

54
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,54 @@
name: Release
on:
push:
tags:
- v*
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.13
uses: actions/setup-go@v1
with:
go-version: 1.13
id: go
- uses: actions/checkout@v1
- name: Build
run: make build
- name: Test
run: make test
- name: Build multiarch binaries
run: make buildMultiArchRelease
- name: Upload amd64 binary to release
uses: svenstaro/upload-release-action@v1-release
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: bin/blocky_amd64
asset_name: blocky_amd64
tag: ${{ github.ref }}
overwrite: true
- name: Upload arm32v6 binary to release
uses: svenstaro/upload-release-action@v1-release
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: bin/blocky_arm32v6
asset_name: blocky_arm32v6
tag: ${{ github.ref }}
overwrite: true
- name: Build the Docker image and push
run: |
mkdir -p ~/.docker && echo "{\"experimental\": \"enabled\"}" > ~/.docker/config.json
echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
make docker-build
make dockerManifestAndPush

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.idea/
*.iml
bin/
config.yml
todo.txt

53
.golangci.yml Normal file
View File

@ -0,0 +1,53 @@
linters:
enable:
- govet
- errcheck
- staticcheck
- unused
- gosimple
- structcheck
- varcheck
- ineffassign
- deadcode
- typecheck
- bodyclose
- golint
- stylecheck
- gosec
- interfacer
- unconvert
- dupl
- goconst
- gocyclo
- gocognit
- gofmt
- goimports
- maligned
- depguard
- misspell
- lll
- unparam
- dogsled
- funlen
- gochecknoglobals
- gochecknoinits
- gocritic
- godox
- nakedret
- prealloc
- whitespace
- wsl
disable-all: false
presets:
- bugs
- unused
fast: false
issues:
exclude-rules:
# Exclude some linters from running on tests files.
- path: _test\.go
linters:
- gochecknoglobals
- dupl

38
Dockerfile Normal file
View File

@ -0,0 +1,38 @@
# build stage
FROM golang:alpine AS build-env
RUN apk add --no-cache \
git \
make \
gcc \
libc-dev \
tzdata \
zip \
ca-certificates
ENV GO111MODULE=on \
CGO_ENABLED=0
WORKDIR /src
COPY go.mod .
COPY go.sum .
RUN go mod download
# add source
ADD . .
ARG opts
RUN env ${opts} make build
# final stage
FROM scratch
COPY --from=build-env /src/bin/blocky /app/blocky
# the timezone data:
COPY --from=build-env /usr/share/zoneinfo /usr/share/zoneinfo
# the tls certificates:
COPY --from=build-env /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
WORKDIR /app
ENTRYPOINT ["/app/blocky"]

47
Makefile Normal file
View File

@ -0,0 +1,47 @@
.PHONY: all clean build test lint run buildMultiArchRelease docker-build dockerManifestAndPush help
.DEFAULT_GOAL := help
VERSION := $(shell git describe --always --tags)
BUILD_TIME=$(shell date '+%Y%m%d-%H%M%S')
DOCKER_IMAGE_NAME="spx01/blocky"
BINARY_NAME=blocky
BIN_OUT_DIR=bin
all: test lint build ## Build binary (with tests)
clean: ## cleans output directory
$(shell rm -rf $(BIN_OUT_DIR)/*)
build: ## Build binary
go build -v -ldflags="-w -s -X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}" -o $(BIN_OUT_DIR)/$(BINARY_NAME)$(BINARY_SUFFIX)
test: ## run tests
go test -v -cover ./...
lint: ## run golangcli-lint checks
$(shell go env GOPATH)/bin/golangci-lint run
run: build ## Build and run binary
./$(BIN_OUT_DIR)/$(BINARY_NAME)
buildMultiArchRelease: ## builds binary for multiple archs
$(MAKE) build GOARCH=arm GOARM=6 BINARY_SUFFIX=_arm32v6
$(MAKE) build GOARCH=amd64 BINARY_SUFFIX=_amd64
docker-build: ## Build multi arch docker images
docker build --build-arg opts="GOARCH=arm GOARM=6" --pull --tag ${DOCKER_IMAGE_NAME}:${VERSION}-arm32v6 .
docker build --build-arg opts="GOARCH=amd64" --pull --tag ${DOCKER_IMAGE_NAME}:${VERSION}-amd64 .
dockerManifestAndPush: ## create manifest for multi arch images and push to docker hub
docker push ${DOCKER_IMAGE_NAME}:${VERSION}-arm32v6
docker push ${DOCKER_IMAGE_NAME}:${VERSION}-amd64
docker manifest create ${DOCKER_IMAGE_NAME}:${VERSION} ${DOCKER_IMAGE_NAME}:${VERSION}-amd64 ${DOCKER_IMAGE_NAME}:${VERSION}-arm32v6
docker manifest annotate ${DOCKER_IMAGE_NAME}:${VERSION} ${DOCKER_IMAGE_NAME}:${VERSION}-arm32v6 --os linux --arch arm
docker manifest push ${DOCKER_IMAGE_NAME}:${VERSION} --purge
docker manifest create ${DOCKER_IMAGE_NAME}:latest ${DOCKER_IMAGE_NAME}:${VERSION}-amd64 ${DOCKER_IMAGE_NAME}:${VERSION}-arm32v6
docker manifest annotate ${DOCKER_IMAGE_NAME}:latest ${DOCKER_IMAGE_NAME}:${VERSION}-arm32v6 --os linux --arch arm
docker manifest push ${DOCKER_IMAGE_NAME}:latest --purge
help: ## Shows help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

47
config.yml Normal file
View File

@ -0,0 +1,47 @@
upstream:
externalResolvers:
# - udp:8.8.8.8
# - udp:8.8.4.4
- tcp-tls:1.1.1.1:853
- tcp-tls:1.0.0.1:853
customDNS:
mapping:
spx.duckdns.org: 192.168.178.3
conditional:
mapping:
fritz.box: udp:192.168.178.1
blocking:
blackLists:
ads:
- https://s3.amazonaws.com/lists.disconnect.me/simple_ad.txt
- https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
- https://mirror1.malwaredomains.com/files/justdomains
- http://sysctl.org/cameleon/hosts
- https://zeustracker.abuse.ch/blocklist.php?download=domainblocklist
- https://s3.amazonaws.com/lists.disconnect.me/simple_tracking.txt
special:
- https://hosts-file.net/ad_servers.txt
whiteLists:
ads:
- whitelist.txt
clientGroupsBlock:
default:
- ads
- special
Laptop-D.fritz.box:
- ads
blockType: zeroIp
clientLookup:
upstream: udp:192.168.178.1
singleNameOrder:
- 2
- 1
#queryLog:
# dir: /tmp
# perClient: true
# logRetentionDays: 7
port: 55555
logLevel: info

148
config/config.go Normal file
View File

@ -0,0 +1,148 @@
package config
import (
"fmt"
"io/ioutil"
"log"
"net"
"reflect"
"strconv"
"strings"
"gopkg.in/yaml.v2"
)
// nolint:gochecknoglobals
var netDefaultPort = map[string]uint16{
"udp": 53,
"tcp": 53,
"tcp-tls": 853,
}
// Upstream is the definition of external DNS server
type Upstream struct {
Net string
Host string
Port uint16
}
func (u *Upstream) UnmarshalYAML(unmarshal func(interface{}) error) error {
var s string
if err := unmarshal(&s); err != nil {
return err
}
upstream, err := parseUpstream(s)
if err != nil {
return err
}
*u = upstream
return nil
}
// parseUpstream creates new Upstream from passed string in format net:host:port
func parseUpstream(upstream string) (result Upstream, err error) {
if strings.Trim(upstream, " ") == "" {
return Upstream{}, nil
}
parts := strings.Split(upstream, ":")
if len(parts) < 2 || len(parts) > 3 {
err = fmt.Errorf("wrong configuration, couldn't parse input '%s', please enter net:host[:port]", upstream)
return
}
net := strings.TrimSpace(parts[0])
if _, ok := netDefaultPort[net]; !ok {
err = fmt.Errorf("wrong configuration, couldn't parse net '%s', please user one of %s",
net, reflect.ValueOf(netDefaultPort).MapKeys())
return
}
var port uint16
host := strings.TrimSpace(parts[1])
if len(parts) == 3 {
var p int
p, err = strconv.Atoi(strings.TrimSpace(parts[2]))
if err != nil {
err = fmt.Errorf("can't convert port to number %v", err)
return
}
if p < 1 || p > 65535 {
err = fmt.Errorf("invalid port %d", p)
return
}
port = uint16(p)
} else {
port = netDefaultPort[net]
}
return Upstream{Net: net, Host: host, Port: port}, nil
}
// main configuration
type Config struct {
Upstream UpstreamConfig `yaml:"upstream"`
CustomDNS CustomDNSConfig `yaml:"customDNS"`
Conditional ConditionalUpstreamConfig `yaml:"conditional"`
Blocking BlockingConfig `yaml:"blocking"`
ClientLookup ClientLookupConfig `yaml:"clientLookup"`
QueryLog QueryLogConfig `yaml:"queryLog"`
Port uint16
LogLevel string `yaml:"logLevel"`
}
type UpstreamConfig struct {
ExternalResolvers []Upstream `yaml:"externalResolvers"`
}
type CustomDNSConfig struct {
Mapping map[string]net.IP `yaml:"mapping"`
}
type ConditionalUpstreamConfig struct {
Mapping map[string]Upstream `yaml:"mapping"`
}
type BlockingConfig struct {
BlackLists map[string][]string `yaml:"blackLists"`
WhiteLists map[string][]string `yaml:"whiteLists"`
ClientGroupsBlock map[string][]string `yaml:"clientGroupsBlock"`
BlockType string `yaml:"blockType"`
}
type ClientLookupConfig struct {
Upstream Upstream `yaml:"upstream"`
SingleNameOrder []uint `yaml:"singleNameOrder"`
}
type QueryLogConfig struct {
Dir string `yaml:"dir"`
PerClient bool `yaml:"perClient"`
LogRetentionDays uint64 `yaml:"logRetentionDays"`
}
func NewConfig() Config {
cfg := Config{}
data, err := ioutil.ReadFile("config.yml")
if err != nil {
log.Fatal("Can't read config file: ", err)
}
err = yaml.UnmarshalStrict(data, &cfg)
if err != nil {
log.Fatal("wrong file structure: ", err)
}
return cfg
}

110
config/config_test.go Normal file
View File

@ -0,0 +1,110 @@
package config
import (
"net"
"os"
"reflect"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_NewConfig(t *testing.T) {
err := os.Chdir("../testdata")
assert.NoError(t, err)
cfg := NewConfig()
assert.Equal(t, uint16(55555), cfg.Port)
assert.Len(t, cfg.Upstream.ExternalResolvers, 3)
assert.Equal(t, "8.8.8.8", cfg.Upstream.ExternalResolvers[0].Host)
assert.Equal(t, "8.8.4.4", cfg.Upstream.ExternalResolvers[1].Host)
assert.Equal(t, "1.1.1.1", cfg.Upstream.ExternalResolvers[2].Host)
assert.Len(t, cfg.CustomDNS.Mapping, 1)
assert.Equal(t, net.ParseIP("192.168.178.3"), cfg.CustomDNS.Mapping["my.duckdns.org"])
assert.Len(t, cfg.Conditional.Mapping, 1)
assert.Equal(t, "192.168.178.1", cfg.ClientLookup.Upstream.Host)
assert.Equal(t, []uint{2, 1}, cfg.ClientLookup.SingleNameOrder)
assert.Len(t, cfg.Blocking.BlackLists, 2)
assert.Len(t, cfg.Blocking.WhiteLists, 1)
assert.Len(t, cfg.Blocking.ClientGroupsBlock, 2)
}
var tests = []struct {
name string
args string
wantResult Upstream
wantErr bool
}{
{
name: "udpWithPort",
args: "udp:4.4.4.4:531",
wantResult: Upstream{Net: "udp", Host: "4.4.4.4", Port: 531},
},
{
name: "udpDefault",
args: "udp:4.4.4.4",
wantResult: Upstream{Net: "udp", Host: "4.4.4.4", Port: 53},
},
{
name: "tcpWithPort",
args: "tcp:4.4.4.4:4711",
wantResult: Upstream{Net: "tcp", Host: "4.4.4.4", Port: 4711},
},
{
name: "tcpDefault",
args: "tcp:4.4.4.4",
wantResult: Upstream{Net: "tcp", Host: "4.4.4.4", Port: 53},
},
{
name: "tcpTlsDefault",
args: "tcp-tls:4.4.4.4",
wantResult: Upstream{Net: "tcp-tls", Host: "4.4.4.4", Port: 853},
},
{
name: "empty",
args: "",
wantResult: Upstream{},
},
{
name: "negativePort",
args: "tcp:4.4.4.4:-1",
wantErr: true,
},
{
name: "invalidPort",
args: "tcp:4.4.4.4:65536",
wantErr: true,
},
{
name: "notNumericPort",
args: "tcp:4.4.4.4:A53",
wantErr: true,
},
{
name: "wrongProtocol",
args: "bla:4.4.4.4:53",
wantErr: true,
},
{
name: "wrongFormat",
args: "tcp-4.4.4.4",
wantErr: true,
},
}
func Test_parseUpstream(t *testing.T) {
for _, tt := range tests {
rr := tt
t.Run(tt.name, func(t *testing.T) {
gotResult, err := parseUpstream(rr.args)
if (err != nil) != rr.wantErr {
t.Errorf("parseUpstream() error = %v, wantErr %v", err, rr.wantErr)
return
}
if !reflect.DeepEqual(gotResult, rr.wantResult) {
t.Errorf("parseUpstream() = %v, want %v", gotResult, rr.wantResult)
}
})
}
}

127
docs/README.md Normal file
View File

@ -0,0 +1,127 @@
![](https://github.com/0xERR0R/blocky/workflows/CI%20Build/badge.svg) ![](https://github.com/0xERR0R/blocky/workflows/Docker%20Image%20Release/badge.svg)
<p align="center">
<img height="200" src="blocky.svg">
</p>
# Blocky
Blocky is a DNS proxy for local network written in Go with following features:
- Blocking of DNS queries with external lists (Ad-block) with whitelisting
- Definition of black and white lists per client group (Kids, Smart home devices etc) -> for example: you can block some domains for you Kids and allow your network camera only domains from a whitelist
- periodical reload of external black and white lists
- Caching of DNS answers for queries -> improves DNS resolution speed and reduces amount of external DNS queries
- Custom DNS resolution for certain domain names
- Supports UDP, TCP and TCP over TLS DNS resolvers
- Delegates DNS query to 2 external resolver from a list of configured resolvers, uses the answer from the fastest one -> improves you privacy and resolution time
- Logging of all DNS queries per day / per client in a text file
- Simple configuration in a single file
- Only one binary in docker container, low memory footprint
- Runs fine on raspbery pi
## Installation and configuration
Create `config.yml` file with your configuration:
```yml
upstream:
# these external DNS resolvers will be used. Blocky picks 2 random resolvers from the list for each query
# format for resolver: net:host:port. net could be tcp, udp or tcp-tls. If port is empty, default port will be used (53 for udp and tcp, 853 for tcp-tls)
externalResolvers:
- udp:8.8.8.8
- udp:8.8.4.4
- udp:1.1.1.1
- tcp-tls:1.0.0.1:853
# optional: custom IP address for domain name (with all sub-domains)
# example: query "printer.lan" or "my.printer.lan" will return 192.168.178.3
customDNS:
mapping:
printer.lan: 192.168.178.3
# optional: definition, which DNS resolver should be used for queries to the domain (with all sub-domains).
# Example: Query client.fritz.box will ask DNS server 192.168.178.1. This is necessary for local network, to resolve clients by host name
conditional:
mapping:
fritz.box: udp:192.168.178.1
# optional: use black and white lists to block queries (for example ads, trackers, adult pages etc.)
blocking:
# definition of blacklist groups. Can be external link (http/https) or local file
blackLists:
ads:
- https://s3.amazonaws.com/lists.disconnect.me/simple_ad.txt
- https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
- https://mirror1.malwaredomains.com/files/justdomains
- http://sysctl.org/cameleon/hosts
- https://zeustracker.abuse.ch/blocklist.php?download=domainblocklist
- https://s3.amazonaws.com/lists.disconnect.me/simple_tracking.txt
special:
- https://hosts-file.net/ad_servers.txt
# definition of whitelist groups. Attention: if the same group has black and whitelists, whitelists will be used to disable particular blacklist entries. If a group has only whitelist entries -> this means only domains from this list are allowed, all other domains will be blocked
whiteLists:
ads:
- whitelist.txt
# definition: which groups should be appied for which client
clientGroupsBlock:
# default will be used, if no special definition for a client name exists
default:
- ads
- special
# use client name or ip address
laptop.fritz.box:
- ads
# which response will be sent, if query is blocked:
# zeroIp: 0.0.0.0 will be returned (default)
# nxDomain: return NXDOMAIN as return code
blockType: zeroIp
#optional: configuration of client name resolution
clientLookup:
# this DNS resolver will be used to perform reverse DNS lookup (typically local router)
upstream: udp:192.168.178.1
# optional: some routers return multiple names for client (host name and user defined name). Define which single name should be used.
# Example: take second name if present, if not take first name
singleNameOrder:
- 2
- 1
# optional: write query information (question, answer, client, duration etc) to daily csv file
queryLog:
# directory (should be mounted as volume in docker)
dir: /logs
# if true, write one file per client. Writes all queries to single file otherwise
perClient: true
# if > 0, deletes log files which are older than ... days
logRetentionDays: 7
# Port, should be 53 (UDP and TCP)
port: 53
# Log level (one from debug, info, warn, error)
logLevel: info
```
### Run with docker
Start docker container with following `docker-compose.yml` file:
```yml
version: "2.1"
services:
blocky:
image: spx01/blocky
container_name: blocky
restart: unless-stopped
ports:
- "53:53/tcp"
- "53:53/udp"
environment:
- TZ=Europe/Berlin
volumes:
# config file
- ./config.yml:/app/config.yml
# write query logs in this directory. You can also use a volume
- ./logs:/logs
```
### Run standalone
Download binary file for your architecture, put it in one directory with config file. Please be aware, you must run the binary with root privileges if you want to use port 53 or 953.
### Additional information
To print runtime configuration and statistics, you can send SIGUSR1 signal to running process:
`kill -s USR1 <PID>` or `docker kill -s SIGUSR1 blocky` for docker setup

768
docs/blocky.svg Normal file
View File

@ -0,0 +1,768 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!--?xml version="1.0" encoding="UTF-8" standalone="no"?-->
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="127.60016mm"
height="140.63579mm"
viewBox="0 0 452.12655 498.31578"
id="svg2"
version="1.1"
inkscape:version="0.92.4 5da689c313, 2019-01-14"
sodipodi:docname="knight.svg"
style="enable-background:new"
inkscape:export-filename="f:\z-knight.png"
inkscape:export-xdpi="221.17"
inkscape:export-ydpi="221.17">
<defs
id="defs4">
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath3542">
<rect
style="fill:#0000ff;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="rect3544"
width="1093.424"
height="1518.2074"
x="-1174.7097"
y="-295.40738" />
</clipPath>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="271.22743"
inkscape:cy="312.25755"
inkscape:document-units="px"
inkscape:current-layer="layer12"
showgrid="false"
inkscape:window-width="1920"
inkscape:window-height="1028"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:snap-bbox="true"
inkscape:bbox-nodes="true"
inkscape:snap-global="false"
showguides="false"
fit-margin-top="5"
fit-margin-right="5"
fit-margin-bottom="5"
fit-margin-left="5">
<inkscape:grid
type="xygrid"
id="grid4305"
originx="-187.25692"
originy="-329.63927" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:rdf>
<cc:work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:work>
</rdf:rdf>
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:groupmode="layer"
id="layer11"
inkscape:label="background"
style="display:inline"
transform="translate(-187.25691,-224.40713)" />
<g
inkscape:groupmode="layer"
id="layer5"
inkscape:label="gopher-body"
style="display:inline;opacity:1"
transform="translate(-187.25691,-224.40713)">
<g
transform="matrix(-0.20408679,0.36109427,0.8060854,0.48598006,306.56137,186.46802)"
id="g4640"
style="display:inline;opacity:1">
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 767.29926,387.32674 c 11.1235,7.96555 31.77795,11.29978 44.73159,15.54502 12.95363,4.24526 18.14889,9.35948 22.12936,13.37285 3.98046,4.01338 5.94428,7.14463 4.71807,9.52723 -1.2262,2.38259 -5.54351,3.99405 -14.00119,4.81166 -8.45765,0.81761 -15.90978,0.12055 -23.02358,-1.72572 -7.11381,-1.84628 -13.80694,-4.86649 -21.70559,-8.603 -7.89866,-3.73649 -17.3272,-8.0507 -25.81115,-14.18439 -8.48395,-6.13369 -17.62324,-13.90003 -23.14238,-24.13356 -5.51915,-10.23352 -5.78201,-21.34406 -5.37146,-30.88264 0.41055,-9.53859 1.51092,-17.55377 2.71572,-23.74931 1.20482,-6.19553 2.71509,-10.67437 4.77102,-13.66952 2.05591,-2.99513 4.65165,-4.52673 7.71923,-4.52673 3.06759,0 5.70357,1.83092 7.62535,5.49926 1.9218,3.66832 3.04778,9.24444 3.28639,16.76004 0.23861,7.51561 -0.67126,17.08072 0.34029,27.19831 1.01155,10.1176 3.89485,20.79494 15.01833,28.7605 z"
id="path4642"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" />
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#e1d6b9;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 760.81735,387.61463 c 8.35351,7.22933 23.40419,11.34465 36.92829,14.85447 13.52408,3.50986 21.76315,7.50998 26.41399,11.29491 4.65086,3.78492 7.04347,6.96136 6.89289,9.28045 -0.15059,2.31908 -3.07202,3.85186 -9.99413,4.53735 -6.92209,0.68549 -13.12478,-0.17957 -19.18856,-2.15841 -6.06375,-1.97886 -12.01277,-5.06603 -19.62326,-8.64782 -7.61047,-3.5818 -16.94465,-7.61787 -24.98938,-13.21535 -8.04472,-5.59749 -15.82286,-12.65396 -20.9022,-21.24583 -5.07935,-8.59186 -6.01346,-17.801 -5.99188,-25.91871 0.0216,-8.1177 0.93462,-15.14861 1.86635,-20.66954 0.93173,-5.52092 2.01706,-9.59713 3.38259,-12.30465 1.36554,-2.70753 3.03466,-4.06947 5.01979,-4.01398 1.98511,0.0555 3.57672,1.84704 4.61437,5.2751 1.03765,3.42807 1.44745,8.54444 1.4737,15.15288 0.0262,6.60845 -0.43638,14.76057 0.91317,23.27473 1.34954,8.51418 4.83074,17.27506 13.18427,24.5044 z"
id="path4644"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" />
</g>
<g
style="display:inline"
id="g4533-2"
transform="matrix(-0.60102903,0.32221978,0.53870829,0.77401445,526.12645,47.501077)" />
<g
style="opacity:1"
transform="matrix(-0.32879267,0.17361606,0.20143296,0.28338802,180.57308,285.47661)"
id="g4404">
<path
sodipodi:nodetypes="sssssssssssssssss"
inkscape:connector-curvature="0"
id="path4406"
d="m -626.54672,402.3529 c 2.22767,10.86299 0.34493,21.82632 -3.86747,31.42527 -4.21252,9.59894 -10.55173,17.86115 -17.72096,24.29983 -7.1694,6.43883 -15.25476,11.10591 -24.5716,13.61353 -9.31698,2.50761 -20.94966,4.46936 -31.63903,1.98398 -10.68939,-2.48537 -18.0688,-9.22838 -24.09401,-15.89285 -6.02508,-6.66442 -12.35923,-14.47524 -22.96531,-22.06805 -10.60584,-7.59266 -20.8648,-15.59839 -25.16123,-23.3775 -4.29632,-7.77931 -7.008,-15.66934 -7.81517,-23.39095 -0.80717,-7.7215 0.35908,-14.55922 3.12288,-20.54462 2.76393,-5.98548 7.12557,-11.1208 12.7854,-15.40902 5.65998,-4.28811 12.61751,-7.73606 20.64204,-10.24271 8.02465,-2.50651 17.11262,-4.07552 27.13941,-4.41504 10.0268,-0.3395 20.06604,0.59388 29.76158,2.87504 9.69543,2.2813 19.05511,5.92037 27.47739,11.02309 8.42215,5.10286 15.89307,11.69212 21.60465,19.6287 5.71147,7.93674 13.0738,19.62846 15.30143,30.4913 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#96d6ff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
<path
sodipodi:nodetypes="csssccscsccscscccsccscsssscscscc"
inkscape:connector-curvature="0"
id="path4408"
d="m -784.21409,457.33922 c -0.56136,0.0656 -1.08141,0.1809 -1.55606,0.33615 -0.63289,0.20699 -1.18396,0.48516 -1.6349,0.82686 -0.45093,0.3417 -0.80184,0.74659 -1.02778,1.21891 -0.22595,0.47234 -0.32669,1.01119 -0.27449,1.62035 0.0522,0.60917 0.25282,1.23371 0.57968,1.84938 0.32687,0.61567 0.98957,1.25218 1.83531,1.84156 0.84574,0.58937 1.35671,1.20529 1.82543,1.72857 0.46713,0.52147 1.13451,0.85371 2.02424,0.92674 0.10253,0.008 0.12328,-0.30471 0.0344,-0.32876 -0.78083,-0.20262 -1.25826,-0.72023 -1.71877,-1.11076 -0.4254,-0.46645 -0.87231,-1.01406 -1.62104,-1.54604 -0.74871,-0.53197 -1.47289,-1.09304 -1.77689,-1.63886 -0.30398,-0.54584 -0.49685,-1.10009 -0.55469,-1.64239 -0.0579,-0.54231 0.0245,-1.0222 0.21918,-1.44322 0.19469,-0.42103 0.50198,-0.78371 0.90168,-1.08623 0.39973,-0.30252 0.89062,-0.54587 1.4577,-0.7237 0.28355,-0.0889 0.5872,-0.16119 0.90722,-0.21465 0.32002,-0.0535 0.6576,-0.0885 1.01178,-0.10163 0.70839,-0.0255 1.4163,0.0392 2.10043,0.1987 0.68412,0.15947 1.34499,0.41522 1.93838,0.77329 0.59338,0.35806 1.11885,0.81986 1.52108,1.37653 0.40222,0.55667 0.92117,1.37523 1.07925,2.13677 0.12981,0.62539 0.0734,1.25844 -0.13288,1.83379 -0.0385,0.10712 0.4977,0.29416 0.62787,-0.0111 0.24265,-0.5698 0.23445,-1.24057 0.1026,-1.8741 -0.17834,-0.85666 -0.69031,-1.76937 -1.13671,-2.40019 -0.4464,-0.6308 -1.03123,-1.15292 -1.68895,-1.55276 -0.65772,-0.39984 -1.38674,-0.68003 -2.14271,-0.85021 -0.75599,-0.17016 -1.54036,-0.23166 -2.32498,-0.19142 -0.19617,0.0101 -0.38815,0.0268 -0.57528,0.0484 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
transform="matrix(13.851095,0,0,13.851095,10133.213,-6001.611)" />
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m -753.77185,413.0219 c -0.13663,-2.61847 2.18018,-4.94804 7.2193,-6.20054 7.65443,-1.90257 20.03831,1.84566 27.93811,5.67152 4.33357,2.09883 8.88981,3.89076 12.66635,7.19411 1.28185,1.12133 2.51799,2.28349 3.36855,4.40869 -1.65849,0.577 -4.10492,-0.92134 -5.87278,-2.13046 -6.96771,-4.76531 -14.69502,-8.08983 -22.67695,-9.12646 -6.71591,-0.87187 -8.86923,-3.11022 -14.75541,-2.56175 -3.72583,0.34716 -4.90626,2.13878 -7.88716,2.74489 z"
id="path4365-1-2"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cssscsssc" />
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m -720.16989,411.68353 c 0.28532,-2.32502 0.86962,3.90377 -0.31886,5.45995 -4.46007,5.84 -8.20289,12.32072 -12.42083,18.36519 -1.37385,1.96787 -3.29463,0.0414 -2.42738,-2.09874 0.88118,-2.1739 2.06053,-3.99898 3.34915,-5.8153 1.20809,-1.70147 2.81353,-3.0576 3.88834,-4.85958 2.06619,-3.46267 2.39577,-6.62873 4.25443,-10.2393 0.63712,-1.23818 3.5225,0.42546 3.67386,-0.80905 z"
id="path4367-9-2"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssss" />
</g>
<g
style="display:inline;opacity:1"
id="g4198"
transform="matrix(0.69027452,0,0,0.73815345,662.65805,219.87628)">
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m -140.71724,398.66408 c -9.31409,71.69689 -25.7611,141.32 -83.87724,188.8641 -73.31672,59.97949 -186.48319,61.17047 -281.81894,4.26066 -27.57065,-16.45805 -49.52457,-62.17665 -53.04177,-91.74122 -7.35191,-61.79791 -1.78113,-96.91393 -8.12884,-153.94253 -5.05249,-45.39216 -29.63784,-82.95495 -27.30836,-137.00138 1.56315,-36.26681 11.06536,-78.46439 40.50727,-100.88356 38.57103,-29.370718 83.60539,-46.188952 134.68095,-45.031125 72.73731,1.648875 151.17838,6.326503 212.18714,49.939365 43.544,31.12796 68.50323,82.53699 72.90385,135.3004 4.52019,54.19698 -0.16075,104.48555 -6.10406,150.23529 z"
id="path4188"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssss" />
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#8dc9f0;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m -158.93683,464.92976 c -15.56115,65.9367 -58.42288,127.39267 -134.42207,151.72082 -70.61462,22.6045 -141.88424,10.56397 -210.57664,-32.28314 -26.14623,-16.30879 -46.09162,-61.46233 -48.95901,-89.47579 -6.03547,-58.9646 -2.56071,-95.43877 -8.30519,-149.8595 -4.7951,-45.42661 -28.02123,-78.34585 -27.29597,-132.22289 0.47399,-35.21112 8.99044,-76.95773 37.82112,-98.79995 36.52466,-27.671205 78.3526,-45.238515 126.45621,-45.012482 76.22124,0.358155 162.16208,5.533182 222.84373,56.658952 55.47879,46.74224 63.38318,129.04796 60.81019,193.3049 -2.12217,52.99813 -7.67242,100.63054 -18.37237,145.96908 z"
id="ellipse4190"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssss" />
</g>
<g
id="g4376"
transform="matrix(0.40138799,-0.13710458,0.13710458,0.40138799,491.2872,42.949045)"
style="opacity:1">
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#96d6ff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m -626.57295,401.69566 c 2.24713,11.35067 0.36741,22.38948 -3.843,32.03835 -4.21053,9.64886 -10.54997,17.90531 -17.7192,24.34399 -7.1694,6.43883 -15.25457,11.1106 -24.57171,13.61082 -9.31727,2.5002 -20.94956,4.47176 -31.64526,1.82793 -10.69571,-2.64383 -18.09209,-9.81214 -24.14818,-17.25062 -6.05597,-7.43843 -12.44269,-16.56671 -23.09665,-25.35944 -10.65372,-8.79255 -20.95218,-17.78817 -25.30072,-26.87318 -4.34843,-9.08528 -7.1154,-18.36084 -7.98,-27.52156 -0.86459,-9.1606 0.24716,-17.36404 2.9617,-24.58398 2.71467,-7.22004 7.03243,-13.45488 12.66059,-18.5369 5.6283,-5.08191 12.56665,-9.01064 20.59229,-11.48936 8.02576,-2.47858 17.13537,-3.50537 27.20916,-2.66707 10.0738,0.83832 20.1809,3.47234 29.95223,7.6529 9.77122,4.18068 19.21426,9.9086 27.71179,16.89733 8.49741,6.98886 16.03465,15.24007 21.79567,24.41557 5.7609,9.17565 13.1742,22.14471 15.42129,33.49522 z"
id="path4398"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" />
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m -784.27135,455.90422 c -0.56339,0.0147 -1.08437,0.10666 -1.55902,0.26191 -0.63289,0.20699 -1.18231,0.52669 -1.63059,0.93484 -0.44828,0.40815 -0.79558,0.90361 -1.01756,1.4752 -0.22199,0.5716 -0.31844,1.21792 -0.26185,1.93717 0.0566,0.71926 0.26134,1.4471 0.59196,2.157 0.33063,0.7099 0.99621,1.41858 1.84494,2.08284 0.84872,0.66425 1.36325,1.36931 1.83382,1.93901 0.46898,0.56774 1.13678,0.9105 2.02675,0.98962 0.10256,0.009 0.12294,-0.31321 0.034,-0.33899 -0.78143,-0.21746 -1.26048,-0.77583 -1.72293,-1.21489 -0.42768,-0.5236 -0.87838,-1.16625 -1.63058,-1.78505 -0.75217,-0.61879 -1.47924,-1.25213 -1.78697,-1.89162 -0.30772,-0.63951 -0.50455,-1.29287 -0.56648,-1.9378 -0.062,-0.64492 0.0165,-1.22191 0.20772,-1.73042 0.1912,-0.50852 0.49539,-0.94884 0.89287,-1.30706 0.3975,-0.35822 0.88707,-0.63484 1.45426,-0.80994 0.2836,-0.0875 0.58767,-0.1494 0.90851,-0.1822 0.32084,-0.0328 0.65966,-0.0369 1.01552,-0.008 0.71174,0.0585 1.42446,0.24383 2.11396,0.53794 0.6895,0.29412 1.35628,0.69807 1.95502,1.19025 0.59873,0.49218 1.12894,1.07271 1.53474,1.71893 0.4058,0.64623 0.9285,1.5589 1.08808,2.35795 0.13104,0.65619 0.075,1.29927 -0.13103,1.88026 -0.0384,0.10817 0.49808,0.30362 0.62824,-0.002 0.24262,-0.57052 0.23429,-1.24452 0.10166,-1.89748 -0.17938,-0.88293 -0.69436,-1.871 -1.14416,-2.58711 -0.44981,-0.71609 -1.03943,-1.35821 -1.70275,-1.89855 -0.66333,-0.54034 -1.3987,-0.97968 -2.16052,-1.29649 -0.76184,-0.31679 -1.55154,-0.51173 -2.33984,-0.56369 -0.19709,-0.013 -0.38986,-0.0163 -0.57767,-0.0116 z"
id="path4369"
inkscape:connector-curvature="0"
sodipodi:nodetypes="csssccscsccscscccsccscsssscscscc"
transform="matrix(13.851095,0,0,13.851095,10133.213,-6001.611)" />
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m -730.27274,382.91266 c 1.8068,-2.76405 6.31309,-3.63001 13.24575,-1.6171 10.53068,3.05761 22.43414,14.97755 28.94834,24.04709 3.57338,4.97534 7.6424,9.78266 9.64772,15.62449 0.68055,1.98294 1.27611,3.97774 0.68898,6.70435 -2.4056,-0.49416 -4.1871,-3.62313 -5.37952,-6.01329 -4.69962,-9.4202 -11.38574,-17.86492 -20.09536,-24.13889 -7.3284,-5.27852 -8.20487,-8.9719 -15.61502,-12.25742 -4.69053,-2.07967 -7.44128,-1.02076 -11.44089,-2.34923 z"
id="path4365-1"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cssscsssc" />
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m -689.31909,403.49962 c 2.08771,-2.1886 -1.9021,4.5559 -4.48533,5.36905 -9.69439,3.05157 -19.01784,7.22624 -28.57811,10.64488 -3.11327,1.11257 -3.94795,-2.11026 -1.30738,-3.72982 2.68251,-1.64492 5.45711,-2.73872 8.35507,-3.75217 2.71578,-0.94874 5.64428,-1.2851 8.27731,-2.4236 5.06052,-2.18718 7.83343,-5.20599 12.75841,-7.67984 1.68866,-0.84854 3.86766,2.73608 4.97603,1.5739 z"
id="path4367-9"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssss" />
</g>
<g
id="g4634"
transform="matrix(0.13058783,-0.42795023,-0.60869797,-0.11092817,657.59614,910.80957)"
style="display:inline;opacity:1">
<path
sodipodi:nodetypes="sssssssssssssssss"
inkscape:connector-curvature="0"
id="path4636"
d="m 423.50332,581.83521 c -0.004,4.40048 -1.19837,7.58856 -3.37524,9.82844 -2.17687,2.23987 -5.33154,3.55156 -9.14619,4.44292 -3.81465,0.89135 -8.28246,1.39523 -13.05675,1.83828 -4.77428,0.44304 -9.85163,0.79076 -14.95001,1.09928 -5.09838,0.30851 -9.94541,0.34741 -14.40217,0.0862 -4.45676,-0.26122 -8.52354,-0.79908 -11.99271,-1.71189 -3.46915,-0.91282 -6.33736,-2.21356 -8.3562,-4.09288 -2.01885,-1.87935 -3.18709,-4.34475 -3.25466,-7.51083 -0.0676,-3.16607 0.9983,-5.4859 2.92534,-7.0838 1.92703,-1.5979 4.71248,-2.46394 8.09977,-2.84688 3.38729,-0.38293 7.37282,-0.28336 11.77044,-0.16051 4.39762,0.12284 9.21051,0.23456 14.33166,-0.12202 5.12115,-0.35659 10.27171,-1.47349 15.16022,-2.54099 4.88852,-1.06749 9.50395,-2.05149 13.43823,-2.27114 3.9343,-0.21967 7.17754,0.32322 9.39823,2.04598 2.22069,1.72276 3.41425,4.59936 3.41004,8.99986 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#e1d6b9;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
<path
sodipodi:nodetypes="csscsscssssssssssssssssssssccsssc"
inkscape:connector-curvature="0"
id="path4638"
d="m 411.91406,568.54883 c -3.75011,-0.0271 -8.08701,0.53975 -12.76172,1.28711 -5.34251,0.85413 -11.10706,1.92059 -17.00976,2.32617 -5.9027,0.40562 -11.41103,0.38326 -16.44727,0.41406 -5.03624,0.0309 -9.6045,0.1607 -13.50781,0.85938 -3.9033,0.69867 -7.13503,1.96743 -9.4082,3.96875 -2.27316,2.00131 -3.58535,4.71676 -3.65235,8.17578 -0.067,3.45901 1.21821,6.3073 3.54297,8.58008 2.32476,2.27278 5.68789,3.9795 9.76172,5.25 4.07385,1.27051 8.85237,2.11894 14.05664,2.59765 5.20427,0.47871 10.83381,0.56134 16.70313,0.22266 5.86931,-0.33868 11.47146,-0.78653 16.60547,-1.34961 5.13399,-0.56309 9.79334,-1.22365 13.70703,-2.34375 1.48913,-0.4262 2.86677,-0.9287 4.12695,-1.51953 2.54507,-1.19325 2.05015,-6.17249 -0.0996,-4.54102 -1.99172,1.51153 -4.14364,1.68162 -7.15735,2.35061 -3.67269,0.81527 -8.18136,0.99111 -12.55008,1.3428 -4.3687,0.35167 -8.7789,1.78431 -13.31332,2.07736 -4.53444,0.29304 -8.86787,0.32801 -12.93181,0.0702 -4.06396,-0.25785 -7.85651,-0.78075 -11.12475,-1.64296 -3.26823,-0.86221 -5.99695,-2.08037 -7.8846,-3.81399 -1.88765,-1.73365 -2.92537,-3.9871 -2.97865,-6.80086 -0.0533,-2.81374 0.90176,-4.8192 2.66881,-6.10562 1.76704,-1.28641 5.61732,-0.58475 8.69196,-0.71399 3.07463,-0.12925 6.90624,-0.54484 10.78772,-0.41733 3.88147,0.12754 6.54592,-0.48119 11.04844,-1.2139 4.50252,-0.73264 9.15212,-2.3434 13.88736,-3.72101 4.73523,-1.37761 9.22461,-2.34259 13.00861,-2.55385 0.473,-0.0264 0.93707,-0.0422 1.38868,-0.0449 1.16046,-0.007 2.25007,0.0442 3.25,0.23633 1.15313,0.22156 2.31543,-2.86146 -0.83789,-2.92773 -0.51177,-0.0108 -1.03459,-0.045 -1.57032,-0.0488 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
</g>
</g>
<g
inkscape:groupmode="layer"
id="layer9"
inkscape:label="helmet-inside"
transform="translate(-50.232073,-102.51234)"
style="display:none">
<path
style="opacity:1;fill:#616161;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 424.59552,206.08005 52.3701,17.23572 -0.33146,47.39826 -28.8367,11.93242 z"
id="path4783"
inkscape:connector-curvature="0" />
</g>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="gopher-face"
style="display:inline"
transform="translate(-187.25691,-224.40713)">
<g
id="g4818"
transform="matrix(-0.65610141,0,0,0.65610141,666.99278,177.20199)">
<path
sodipodi:nodetypes="sssssssssssssssss"
inkscape:connector-curvature="0"
id="path4812"
d="m 547.42756,318.16456 c -0.44046,14.77191 -4.12869,29.02667 -10.38967,42.25266 -6.26099,13.22599 -15.09198,25.42687 -25.80466,35.99686 -10.71268,10.57 -23.30432,19.50822 -37.11826,26.08983 -13.81394,6.58161 -28.85103,10.80263 -44.50193,11.8618 -15.65091,1.05917 -30.4406,-1.15844 -43.81781,-6.16756 -13.37721,-5.00911 -25.3405,-12.8075 -35.30087,-22.80416 -9.96037,-9.99666 -17.91599,-22.19037 -23.26581,-35.90798 -5.34983,-13.71761 -8.0915,-28.95913 -7.64195,-44.98105 0.44955,-16.02192 4.04447,-31.2937 10.1422,-45.07896 6.09773,-13.78526 14.69591,-26.08175 25.16951,-36.25747 10.4736,-10.17571 22.82245,-18.23043 36.46168,-23.66123 13.63924,-5.4308 28.57214,-8.24285 44.22923,-8.02541 15.6571,0.21745 30.56095,3.42714 44.11009,8.94154 13.54914,5.5144 25.7404,13.33722 35.92568,22.91495 10.18529,9.57774 18.36233,20.91345 23.87736,33.53282 5.51504,12.61936 8.36566,26.52144 7.92521,41.29336 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
<path
sodipodi:nodetypes="sssssssssssssssss"
inkscape:connector-curvature="0"
id="ellipse4814"
d="m 539.72249,314.79002 c 10e-4,13.89984 -3.01572,27.53808 -8.51346,40.35257 -5.49774,12.81449 -13.48047,24.80543 -23.37659,35.2527 -9.89612,10.44726 -21.70519,19.34133 -34.78531,25.87862 -13.08011,6.53727 -27.4256,10.71236 -42.3773,11.7667 -14.9517,1.05435 -29.09103,-1.11258 -41.85904,-5.93108 -12.76803,-4.81852 -24.16883,-12.28715 -33.66552,-21.79076 -9.49671,-9.50362 -17.08979,-21.04298 -22.23241,-33.95465 -5.14261,-12.91166 -7.83328,-27.19561 -7.52333,-42.13595 0.30995,-14.94034 3.58995,-29.10832 9.22975,-41.85842 5.63981,-12.7501 13.63743,-24.08168 23.39638,-33.47108 9.75897,-9.38941 21.27795,-16.83842 34.00359,-21.94183 12.72563,-5.10342 26.66067,-7.86812 41.28534,-7.94317 14.62467,-0.0751 28.55938,2.53224 41.26083,7.24431 12.70145,4.71207 24.16709,11.5339 33.81555,20.03646 9.64847,8.50257 17.47884,18.68937 22.90117,30.21241 5.42232,11.52304 8.43889,24.38332 8.44035,38.28317 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
<circle
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394455;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="path4828"
cx="383.30621"
cy="309.06165"
r="30.809652" />
<circle
r="15.152287"
cy="294.91949"
cx="369.66916"
id="circle4830"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
</g>
<g
transform="matrix(-0.49821858,-0.255998,-0.255998,0.49821858,861.52844,319.81615)"
id="g4822">
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 544.2609,323.96628 c -5.95391,12.33766 -15.20034,24.2228 -25.89846,35.91934 -10.69814,11.69654 -22.74349,23.28172 -34.52447,34.21851 -11.78099,10.93679 -23.27607,21.15489 -34.23709,29.30247 -10.96102,8.14759 -21.47285,14.18083 -32.04267,16.95199 -10.56982,2.77117 -20.29711,2.02561 -29.30402,-1.67713 -9.00692,-3.70274 -20.58076,-7.76561 -27.66538,-16.71749 -7.08461,-8.95188 -12.84054,-20.18257 -16.5035,-33.03389 -3.66297,-12.85133 -5.229,-27.32914 -3.92417,-42.72858 1.30484,-15.39944 5.36688,-30.24976 11.81788,-43.75488 6.45101,-13.5051 15.29008,-25.65823 26.00811,-35.78271 10.71803,-10.12447 28.44246,-20.29305 42.24879,-25.86698 13.80633,-5.57394 28.83304,-8.62768 44.20973,-8.80364 15.3767,-0.17594 29.62737,2.52591 41.94358,7.37479 12.31622,4.84887 22.69735,11.85058 30.35956,20.34718 7.66222,8.49661 12.60139,18.48263 14.06496,29.34879 1.4636,10.86615 -0.59894,22.56457 -6.55285,34.90223 z"
id="path4824"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" />
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 538.18032,322.65868 c -5.17728,11.63182 -13.27733,23.10077 -22.96883,34.40428 -9.69151,11.30351 -20.93897,22.46482 -32.34413,32.7753 -11.40514,10.31051 -22.90789,19.71873 -33.85893,27.13351 -10.95103,7.41476 -21.39599,12.82014 -31.59528,15.28718 -10.19931,2.46703 -19.30202,1.76338 -27.56839,-1.62958 -8.26637,-3.39295 -19.13397,-6.9512 -25.3913,-15.16185 -6.25732,-8.21068 -11.24381,-18.53447 -14.30417,-30.37519 -3.06035,-11.84072 -4.18965,-25.20221 -2.68634,-39.42576 1.5033,-14.22354 5.50837,-27.94818 11.67956,-40.43838 6.17119,-12.4902 14.50792,-23.74111 24.54768,-33.13895 10.03978,-9.39782 26.99021,-19.0621 39.83566,-24.2929 12.84546,-5.2308 26.78412,-8.15811 41.0009,-8.45853 14.21678,-0.30038 27.34319,2.03758 38.64284,6.33106 11.29965,4.29349 20.7704,10.54463 27.74089,18.16875 6.97048,7.62413 11.43794,16.6127 12.81335,26.51165 1.37541,9.89894 -0.36624,20.67759 -5.54351,32.30941 z"
id="path4826"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" />
<circle
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394455;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="path4828-0"
cx="441.87363"
cy="238.13869"
r="27.721321"
transform="rotate(9.4590451)" />
<circle
r="13.633434"
cy="224.78665"
cx="434.41431"
id="circle4830-3"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
transform="rotate(9.4590451)" />
</g>
</g>
<g
inkscape:groupmode="layer"
id="layer14"
inkscape:label="arm"
transform="translate(-50.232073,-102.51234)"
style="display:inline">
<rect
style="opacity:1;fill:#b3b3b3;fill-opacity:1;stroke:none;stroke-width:5.73911715;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect4964"
width="54.988182"
height="16.638491"
x="-98.824585"
y="482.47922"
transform="rotate(-29.393453)" />
<path
style="opacity:1;fill:#aeece6;fill-opacity:1;stroke:none;stroke-width:4.50708008;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 200.04708,436.85004 12.61982,22.5354 151.43789,-73.91611 30.64815,-29.29602 -44.07934,10.20407 z"
id="path4949"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccc" />
<path
style="opacity:1;fill:#d3f5f1;fill-opacity:1;stroke:none;stroke-width:5;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 421.07031,379.91797 -47.01758,10.88476 -160.66992,75.16993 1.25391,2.24023 159.59961,-75.68359 41.54297,-9.54492 -28.10742,26.86914 -161.98829,78.08593 0.94532,1.68946 0.32031,0.33007 161.42969,-78.79101 z"
transform="scale(0.93749999)"
id="path4974"
inkscape:connector-curvature="0" />
<path
style="opacity:1;fill:#d3f5f1;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 204.60937,441.01706 148.35938,-69.375 27.42187,-7.96875 -21.79687,15.70312 -149.29688,71.25 z"
id="path4972"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccc" />
<path
style="opacity:1;fill:#616161;fill-opacity:1;stroke:none;stroke-width:4.50708008;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 187.24472,433.75337 1.2748,-7.64876 8.92357,1.59349 22.3089,38.24384 -2.86829,7.33007 -8.60486,-4.46178 z"
id="path4953"
inkscape:connector-curvature="0" />
<path
style="display:inline;opacity:1;fill:#7d7d7d;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;enable-background:new"
d="m 196.22213,428.9844 -6.29767,-0.82864 22.70476,38.61467 6.64089,-0.66292 -4.60016,-2.28343 -20.57873,-33.47364 z"
id="path4955"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccc" />
<path
style="opacity:1;fill:#b3b3b3;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 148.82388,459.97557 -2.15446,11.60097 9.28077,14.91554 10.93806,0.66291 -0.82864,-5.96622 -10.60661,-18.56155 z"
id="path4966"
inkscape:connector-curvature="0" />
<path
style="opacity:1;fill:#616161;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 149.65252,462.4615 c 0.16573,1.49155 9.11504,15.9099 9.11504,15.9099 l -1.823,5.80048 -8.78359,-13.42398 z"
id="path4968"
inkscape:connector-curvature="0" />
<path
style="opacity:1;fill:#b3b3b3;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 193.59375,430.23581 20.50781,33.98437 1.17188,0.70313 0.35156,-1.05469 -20.15625,-33.28125 z"
id="path4970"
inkscape:connector-curvature="0" />
</g>
<g
inkscape:groupmode="layer"
id="layer7"
inkscape:label="gopher-mouth"
style="display:inline"
transform="translate(-187.25691,-224.40713)">
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#2e3436;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 498.0625,437.94867 -6.36763,0.0828 -3.71113,-0.0821 c -1.18372,-0.0262 -2.23819,0.53559 -3.00662,1.36379 -0.76845,0.82822 -1.14658,1.97521 -1.32551,3.22687 l -1.01303,7.08562 -1.40711,7.111 c -0.25342,1.28069 0.0841,2.40965 0.70518,3.23132 0.6211,0.82165 1.57363,1.28978 2.69674,1.31649 l 3.7446,0.0891 7.40657,-0.17258 c 1.42055,-0.0331 2.74014,-0.58514 3.70785,-1.43299 0.96771,-0.84787 1.54004,-2.00084 1.65553,-3.2592 l 0.6476,-7.05621 0.52522,-7.04505 c 0.0935,-1.25398 -0.46676,-2.37726 -1.25366,-3.18163 -0.78689,-0.80437 -1.85738,-1.2842 -3.00457,-1.27716 z"
id="rect4659"
inkscape:connector-curvature="0"
sodipodi:nodetypes="scssscssscssscsss" />
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 496.89993,440.09359 -5.19684,0.0698 -2.47497,-0.10149 c -0.94018,-0.0386 -1.80825,0.43586 -2.46124,1.11384 -0.65298,0.67797 -1.03424,1.61771 -1.21175,2.64338 l -1.0026,5.79325 -1.25494,5.80832 c -0.22406,1.03701 0.002,1.97056 0.48938,2.64162 0.48783,0.67105 1.26653,1.03411 2.19892,1.07115 l 2.54193,0.101 5.88547,-0.12754 c 1.11447,-0.0242 2.17518,-0.47212 2.97321,-1.1643 0.79803,-0.69218 1.30904,-1.6349 1.43939,-2.66511 l 0.73009,-5.77006 0.63032,-5.76301 c 0.11259,-1.02637 -0.28558,-1.94744 -0.89178,-2.6062 -0.60618,-0.65877 -1.45658,-1.05733 -2.39458,-1.04471 z"
id="rect4661"
inkscape:connector-curvature="0"
sodipodi:nodetypes="scssscssscssscsss" />
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 467.92106,431.94061 c 0.17729,2.27145 1.57656,4.32647 3.56538,6.17684 1.98881,1.85037 4.73553,3.49055 7.9169,4.83408 3.18137,1.34353 6.76993,2.37673 10.40491,2.92876 3.63499,0.55204 7.31771,0.61337 10.93742,0.17695 3.61969,-0.43645 6.8614,-1.30517 9.67542,-2.37849 2.81402,-1.07332 5.17844,-2.3467 7.04073,-3.75925 1.86231,-1.41254 3.23922,-2.97722 4.10853,-4.72358 0.86932,-1.74636 1.22997,-3.67959 0.91461,-5.76285 -0.31535,-2.08326 -1.29186,-4.11481 -2.79935,-5.98131 -1.5075,-1.86649 -3.53491,-3.56576 -5.91642,-4.97983 -2.3815,-1.41407 -5.11304,-2.54212 -8.12844,-3.28158 -3.0154,-0.73946 -6.31783,-1.09096 -9.93094,-0.97174 -3.6131,0.11924 -7.2186,0.69446 -10.6419,1.64517 -3.4233,0.95069 -6.6496,2.2832 -9.33875,3.91065 -2.68913,1.62746 -4.89892,3.50256 -6.18894,5.61926 -1.32139,2.16817 -1.77021,4.61153 -1.61916,6.54692 z"
id="ellipse4650"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" />
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#e1d0cb;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 475.57039,431.43056 c 0.31019,1.80429 1.36577,3.48937 2.98663,4.99917 1.62086,1.5098 3.80505,2.84719 6.28703,3.91437 2.48197,1.06719 5.24944,1.8562 8.07117,2.27071 2.82174,0.4145 5.70079,0.45265 8.53169,0.10713 2.83089,-0.34553 5.35911,-1.02976 7.553,-1.90451 2.19389,-0.87475 4.04484,-1.93848 5.497,-3.12538 1.45217,-1.1869 2.50911,-2.50179 3.13219,-3.93394 0.62308,-1.43214 0.81446,-2.98543 0.48985,-4.63056 -0.32461,-1.64514 -1.13916,-3.22548 -2.3414,-4.6674 -1.20224,-1.44192 -2.78948,-2.74346 -4.65903,-3.82078 -1.86955,-1.07733 -4.01937,-1.92982 -6.38974,-2.4811 -2.37037,-0.55129 -4.96168,-0.80162 -7.76722,-0.68542 -2.80553,0.11621 -5.57317,0.58631 -8.1874,1.34158 -2.61424,0.75528 -5.07126,1.79757 -7.14628,3.06167 -2.07504,1.26412 -3.75959,2.75051 -4.8326,4.37276 -1.07302,1.62225 -1.53509,3.37741 -1.22489,5.1817 z"
id="ellipse4652"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" />
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 485.60866,420.41917 c 0.45232,1.29294 1.43586,2.44115 2.79664,3.4102 1.36078,0.96906 3.0934,1.76079 4.97332,2.36791 1.87992,0.60712 3.89927,1.0315 5.87533,1.25741 1.97606,0.2259 3.90879,0.25223 5.71982,0.052 1.81102,-0.20028 3.33955,-0.60742 4.63321,-1.17435 1.29367,-0.56695 2.35232,-1.29343 3.18646,-2.14861 0.83413,-0.85519 1.44471,-1.8405 1.79916,-2.93195 0.35445,-1.09146 0.45213,-2.29028 0.21175,-3.55738 -0.24038,-1.2671 -0.80099,-2.48156 -1.64917,-3.57911 -0.84818,-1.09755 -1.9831,-2.07741 -3.35494,-2.8723 -1.37184,-0.7949 -2.98056,-1.40441 -4.76729,-1.7664 -1.78672,-0.36199 -3.75169,-0.47615 -5.82322,-0.29097 -2.07153,0.18518 -4.05358,0.65136 -5.84566,1.3298 -1.79207,0.67844 -3.39432,1.56902 -4.69144,2.60198 -1.29713,1.03296 -2.28898,2.20893 -2.84443,3.45293 -0.55546,1.24399 -0.67186,2.55593 -0.21954,3.84888 z"
id="path4648"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" />
</g>
<g
inkscape:groupmode="layer"
id="layer12"
inkscape:label="gopher-hands"
style="display:inline"
transform="translate(-187.25691,-224.40713)">
<g
id="g4533"
transform="matrix(-0.18127985,-0.40906258,-0.53287823,0.31441862,696.3032,549.41392)">
<path
sodipodi:nodetypes="sssssssssssssssss"
inkscape:connector-curvature="0"
id="ellipse4523"
d="m 423.50332,581.83521 c -0.004,4.40048 -1.19837,7.58856 -3.37524,9.82844 -2.17687,2.23987 -5.33154,3.55156 -9.14619,4.44292 -3.81465,0.89135 -8.28246,1.39523 -13.05675,1.83828 -4.77428,0.44304 -9.85163,0.79076 -14.95001,1.09928 -5.09838,0.30851 -9.94541,0.34741 -14.40217,0.0862 -4.45676,-0.26122 -8.52354,-0.79908 -11.99271,-1.71189 -3.46915,-0.91282 -6.33736,-2.21356 -8.3562,-4.09288 -2.01885,-1.87935 -3.18709,-4.34475 -3.25466,-7.51083 -0.0676,-3.16607 0.9983,-5.4859 2.92534,-7.0838 1.92703,-1.5979 4.71248,-2.46394 8.09977,-2.84688 3.38729,-0.38293 7.37282,-0.28336 11.77044,-0.16051 4.39762,0.12284 9.21051,0.23456 14.33166,-0.12202 5.12115,-0.35659 10.27171,-1.47349 15.16022,-2.54099 4.88852,-1.06749 9.50395,-2.05149 13.43823,-2.27114 3.9343,-0.21967 7.17754,0.32322 9.39823,2.04598 2.22069,1.72276 3.41425,4.59936 3.41004,8.99986 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#e1d6b9;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
<path
sodipodi:nodetypes="ssscsscssssssssssssssssssssccsss"
inkscape:connector-curvature="0"
id="path4521"
d="m 411.91406,568.54883 c -3.75011,-0.0271 -8.08701,0.53975 -12.76172,1.28711 -5.34251,0.85413 -11.10706,1.92059 -17.00976,2.32617 -5.9027,0.40562 -11.41103,0.38326 -16.44727,0.41406 -5.03624,0.0309 -9.6045,0.1607 -13.50781,0.85938 -3.9033,0.69867 -7.13503,1.96743 -9.4082,3.96875 -2.27316,2.00131 -3.58535,4.71676 -3.65235,8.17578 -0.067,3.45901 1.21821,6.3073 3.54297,8.58008 2.32476,2.27278 5.68789,3.9795 9.76172,5.25 4.07385,1.27051 8.85237,2.11894 14.05664,2.59765 5.20427,0.47871 10.83381,0.56134 16.70313,0.22266 5.86931,-0.33868 11.47146,-0.78653 16.60547,-1.34961 5.13399,-0.56309 9.79334,-1.22365 13.70703,-2.34375 1.48913,-0.4262 2.86677,-0.9287 4.12695,-1.51953 2.54507,-1.19325 2.05015,-6.17249 -0.0996,-4.54102 -1.99172,1.51153 -4.55969,2.50355 -7.57031,3.20703 -3.66893,0.85731 -7.96668,1.34146 -12.5586,1.76758 -4.59191,0.42612 -9.47527,0.75991 -14.3789,1.05664 -4.90363,0.29673 -9.56506,0.33523 -13.85156,0.084 -4.28652,-0.25124 -8.19851,-0.76855 -11.53516,-1.64649 -3.33664,-0.87795 -6.09539,-2.12996 -8.03711,-3.9375 -1.94173,-1.80756 -3.06587,-4.17751 -3.13086,-7.22265 -0.065,-3.04513 0.96102,-5.2776 2.81445,-6.81446 1.85342,-1.53686 4.53117,-2.36997 7.78907,-2.73828 3.2579,-0.36831 7.09262,-0.27244 11.32226,-0.1543 4.22963,0.11816 8.85767,0.22578 13.7832,-0.11718 4.92553,-0.34297 9.88026,-1.41664 14.58204,-2.44336 4.70178,-1.02671 9.13982,-1.97234 12.92382,-2.1836 0.473,-0.0264 0.93707,-0.0422 1.38868,-0.0449 1.16046,-0.007 2.25007,0.0442 3.25,0.23633 1.15313,0.22156 2.31543,-2.86146 -0.83789,-2.92773 -0.51177,-0.0108 -1.03459,-0.045 -1.57032,-0.0488 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
</g>
</g>
<g
inkscape:groupmode="layer"
id="layer8"
inkscape:label="suite"
style="display:none"
transform="translate(-50.232073,-102.51234)">
<path
sodipodi:nodetypes="cccccccc"
inkscape:connector-curvature="0"
id="path4895"
d="m 324.09413,544.86506 4.14425,29.21457 7.57767,4.77275 12.8596,1.47382 14.30183,-6.0413 -2.4518,-23.40769 -16.10633,-10.21508 z"
style="opacity:1;fill:#b3b3b3;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
id="path4897"
d="m 344.42024,540.66221 -20.32581,4.20197 4.14421,29.21509 7.57708,4.77407 12.85439,1.47227 -3.89893,-39.44148 z"
style="opacity:1;fill:#7d7d7d;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
inkscape:connector-curvature="0" />
<path
id="path4899"
d="m 332.09763,543.20922 -8.0032,1.65496 4.14421,29.21509 7.53319,4.74609 z"
style="opacity:1;fill:#616161;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
inkscape:connector-curvature="0" />
<path
style="opacity:1;fill:#7d7d7d;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 133.24543,359.71004 -10.949,61.23368 16.91521,74.00049 17.65613,48.74633 67.19668,26.82571 118.43572,-0.56394 55.24739,-58.43528 29.16816,-70.93165 8.61786,-100.0998 c 0,0 -72.92039,21.21319 -76.23495,21.21319 -3.31457,0 -140.53747,-21.21319 -140.53747,-21.21319 l -65.08989,-2.0062 z"
id="path4859"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccsccc" />
<path
style="opacity:1;fill:#616161;fill-opacity:1;stroke:none;stroke-width:5;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 170.86719,361.25977 -19.25586,12.57617 -9.48242,9.85547 -11.67969,65.31445 18.04297,78.93555 18.83398,51.99609 71.67578,28.61328 125.48438,-0.59766 L 262.5,584.41797 l -41,-27.5 -36.5,-114.5 3.4668,-80.61524 z"
transform="scale(0.93749999)"
id="path4861"
inkscape:connector-curvature="0" />
<path
transform="translate(-137.02484,-121.89479)"
style="display:inline;opacity:1;fill:#b3b3b3;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;enable-background:new"
d="m 393.43109,506.1931 101.71875,7.96875 67.03125,-21.5625 6.25233,1.41475 -7.65858,24.83525 -63.75,47.34375 -101.71875,-30.46875 -63.97243,-44.63617 z"
id="path4866"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccc" />
<path
style="opacity:1;fill:#b3b3b3;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 189.26154,547.3143 0.66292,29.49961 6.96058,5.63476 12.59534,2.98311 14.91553,-4.30893 0.33146,-23.53341 -14.78637,-12.04679 z"
id="path4883"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccc" />
<path
style="opacity:1;fill:#7d7d7d;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 209.94187,545.54284 -20.67994,1.77063 0.66284,29.50013 6.95984,5.63598 12.59034,2.98096 0.78918,-39.62585 z"
id="path4890"
inkscape:connector-curvature="0" />
<path
style="opacity:1;fill:#616161;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 197.40463,546.61584 -8.1427,0.69763 0.66284,29.50013 6.91956,5.60302 z"
id="path4885"
inkscape:connector-curvature="0" />
<path
style="display:inline;opacity:1;fill:#b3b3b3;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;enable-background:new"
d="m 357.1875,433.51706 -4.6875,70.3125 -12.65625,59.53125 22.5,-60.9375 -0.46875,-71.25 z"
id="path4873"
inkscape:connector-curvature="0" />
<path
style="display:inline;opacity:1;fill:#b3b3b3;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;enable-background:new"
d="m 421.40625,440.07956 -49.6875,3.28125 31.875,0.9375 z"
id="path4875"
inkscape:connector-curvature="0" />
<path
style="display:inline;opacity:1;fill:#b3b3b3;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;enable-background:new"
d="m 246.5625,431.17331 71.25,8.4375 -42.65625,0.46875 z"
id="path4877"
inkscape:connector-curvature="0" />
<path
style="display:inline;opacity:1;fill:#b3b3b3;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;enable-background:new"
d="m 237.1875,483.67331 110.15625,17.8125 -67.03125,-4.21875 z"
id="path4879"
inkscape:connector-curvature="0" />
<path
style="display:inline;opacity:1;fill:#b3b3b3;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;enable-background:new"
d="m 372.1875,499.14206 29.0625,-3.75 -14.0625,5.15625 z"
id="path4881"
inkscape:connector-curvature="0" />
<path
style="opacity:1;fill:#b3b3b3;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 275.23207,493.95315 9.375,52.03125 -2.8125,-52.5 z"
id="path4979"
inkscape:connector-curvature="0" />
<path
style="opacity:1;fill:#b3b3b3;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 271.71644,437.23439 7.03125,41.25001 -2.10937,-41.71876 z"
id="path4981"
inkscape:connector-curvature="0" />
</g>
<g
inkscape:groupmode="layer"
id="layer13"
inkscape:label="shield"
style="display:inline"
transform="translate(-50.232073,-102.51234)">
<path
sodipodi:nodetypes="ccccc"
inkscape:connector-curvature="0"
id="path4926"
d="m 368.8138,406.45401 c 26.75894,-10.96313 50.24152,-24.28175 65.61035,-43.43363 18.1912,19.63416 31.82885,23.22438 49.75819,26.19107 2.75091,30.51252 -5.39746,121.91404 -64.45432,149.96295 -64.70891,-40.98189 -47.40645,-88.18419 -50.91422,-132.72039 z"
style="display:inline;opacity:1;fill:#1f8179;fill-opacity:1;stroke:none;stroke-width:4.70907879;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;enable-background:new" />
<path
style="display:inline;opacity:1;fill:#30cabd;fill-opacity:1;stroke:none;stroke-width:4.36651993;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;enable-background:new"
d="m 374.9586,407.67174 c 24.81238,-10.16563 46.58673,-22.51541 60.83756,-40.27408 16.86789,18.20587 29.51348,21.53492 46.13856,24.2858 2.5508,28.2929 -5.00482,113.04548 -59.76562,139.05397 -60.0017,-38.00067 -43.9579,-81.76926 -47.2105,-123.06569 z"
id="path4912"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#d3f5f1;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:18.2656765;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 401.41757,414.65658 -5.64924,4.23257 -3.34776,6.33337 3.90311,8.75369 -2.36694,20.23813 38.52429,28.08241 38.52542,-43.69159 -2.36692,-19.27912 3.90309,-10.33512 -3.34776,-4.97695 -5.64924,-1.94365 -9.19454,9.07274 -21.87005,0.0704 -21.86889,8.79047 z"
id="path4378"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccccccccc" />
<path
sodipodi:nodetypes="ccccccccccccccc"
inkscape:connector-curvature="0"
id="path4938"
d="m 403.12876,417.42326 -5.21749,3.90909 -3.09191,5.84934 3.60481,8.08468 -2.18604,18.69142 35.58003,25.93618 35.58108,-40.35242 -2.18602,-17.80569 3.60479,-9.54526 -3.09191,-4.59658 -5.21749,-1.7951 -8.49184,8.37935 -20.19861,0.065 -20.19753,8.11865 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#aeece6;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:16.8697052;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
<path
style="opacity:1;fill:#d3f5f1;fill-opacity:1;stroke:none;stroke-width:5;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 464.84961,391.89062 c -0.60619,0.75541 -1.22992,1.49906 -1.86133,2.23633 -0.11899,0.1386 -0.2395,0.2761 -0.35937,0.41407 -0.61784,0.71283 -1.24577,1.41706 -1.88672,2.11328 -0.002,0.002 -0.002,0.004 -0.004,0.006 l 3.38672,7.25781 c 15.09239,6.97647 29.7818,15.46409 46.125,17.75 2.81589,4.8446 3.0277,16.3255 2.36133,35.51562 2.05837,-15.93817 2.24593,-29.86353 1.49609,-38.81054 -0.0713,-0.20193 -0.13752,-0.40999 -0.21289,-0.60743 -1.10276,-0.18301 -2.18955,-0.36868 -3.26172,-0.56054 -1.06774,-0.19107 -2.12248,-0.38856 -3.16406,-0.5957 -0.071,-0.0141 -0.14001,-0.0307 -0.21094,-0.0449 -0.97781,-0.19605 -1.94773,-0.39829 -2.90625,-0.61523 -1.01852,-0.23052 -2.02852,-0.47517 -3.02929,-0.73633 -0.12001,-0.0313 -0.23765,-0.0697 -0.35743,-0.10156 -0.88127,-0.23408 -1.758,-0.47437 -2.6289,-0.73828 -0.20951,-0.0635 -0.41599,-0.1397 -0.625,-0.20508 -0.78661,-0.24611 -1.57262,-0.49604 -2.35352,-0.77149 -0.0972,-0.0343 -0.19387,-0.0746 -0.29101,-0.10937 -0.87846,-0.31448 -1.75449,-0.64278 -2.62891,-1 -0.25919,-0.10587 -0.51834,-0.22992 -0.77734,-0.33984 -0.72558,-0.30799 -1.45242,-0.61517 -2.17774,-0.95704 -0.0626,-0.0295 -0.12489,-0.0659 -0.1875,-0.0957 -0.91114,-0.43326 -1.82227,-0.8864 -2.73633,-1.37891 -0.26804,-0.1444 -0.53815,-0.31305 -0.80664,-0.46289 -0.71043,-0.39651 -1.42013,-0.79206 -2.13476,-1.22851 -0.24026,-0.14672 -0.48376,-0.31731 -0.72461,-0.46875 -0.75591,-0.47533 -1.51238,-0.95253 -2.27539,-1.47656 -0.99849,-0.68575 -2.00319,-1.41381 -3.01758,-2.18946 -0.0311,-0.0238 -0.0626,-0.0523 -0.0937,-0.0762 -0.98515,-0.75566 -1.97825,-1.55104 -2.98242,-2.39844 -1.03491,-0.87333 -2.08135,-1.79735 -3.14063,-2.77539 -1.05619,-0.97516 -2.12633,-2.00327 -3.21093,-3.08984 -10e-4,-10e-4 -0.003,-0.003 -0.004,-0.004 -1.09002,-1.0921 -2.1954,-2.24304 -3.31836,-3.45508 z"
transform="scale(0.93749999)"
id="path4940"
inkscape:connector-curvature="0" />
<path
style="opacity:1;fill:#27a49a;fill-opacity:1;stroke:none;stroke-width:5;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 464.84961,391.89062 c -15.20089,18.94259 -38.42799,32.11565 -64.89453,42.95899 3.46944,44.04953 -13.64244,90.73548 50.35937,131.26953 0.008,-0.004 0.0155,-0.008 0.0234,-0.0117 -0.1884,-0.20405 -0.37246,-0.40845 -0.57031,-0.61133 -43.17831,-31.88529 -48.49457,-87.76825 -44.0293,-127.30859 13.29063,-4.62307 59.10077,-33.73629 59.10077,-33.73629 0,0 0.42898,-6.93602 0.14533,-10.36918 -0.46271,-0.48457 0.33389,-1.68557 -0.13477,-2.19141 z"
transform="scale(0.93749999)"
id="path4928"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccc" />
</g>
<g
inkscape:groupmode="layer"
id="layer6"
inkscape:label="helmet"
style="display:none;opacity:1"
transform="translate(-50.232073,-102.51234)">
<path
style="display:inline;opacity:1;fill:#7d7d7d;fill-opacity:1;stroke:none;stroke-width:5;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;enable-background:new"
d="m 379.24023,128.24414 -12.60546,3.7168 -22.02539,30.16015 -125.19532,-10.54687 -44.22265,16.23437 -32.35547,-33.37304 -39.95703,11.15429 -30.400394,54.25391 50.697264,26.69727 -15.76172,42.0332 16,137 h 18.5625 l 25.4375,-32 105,21 110,8 64,-23 20.1211,-0.16797 4.60937,-4.95703 c 0,0 5.89679,-12.21131 8.66016,-30.80078 l 33.55469,-58.2461 v -48.08398 l -79.92383,-53.45117 -25.32813,-4.95703 9.08594,-40.23243 z m 17.49415,24.18945 a 7.864841,11.931376 33.165464 0 1 5.23828,10.875 7.864841,11.931376 33.165464 0 1 -12.01954,10.875 7.864841,11.931376 33.165464 0 1 -5.23828,-10.875 7.864841,11.931376 33.165464 0 1 12.01954,-10.875 z M 126.5625,180.73438 a 19.24034,14.622658 30.833296 0 1 11.89258,3.41015 19.24034,14.622658 30.833296 0 1 9.02734,22.41797 19.24034,14.622658 30.833296 0 1 -24.01562,2.69336 19.24034,14.622658 30.833296 0 1 -9.02735,-22.41797 19.24034,14.622658 30.833296 0 1 12.12305,-6.10351 z m 378.3125,58 1.41406,48.78906 -284.25781,26.16406 -7.07031,-65.76172 z m -100.00781,70.27148 9.35742,0.22461 -9.04883,64.9668 -7.33008,-1.23047 z m -17.9375,0.58008 -1.85352,65.56836 -7.41992,-0.41602 -0.0527,-64.3457 z m -27.36524,0.91992 4.94922,65.40625 -7.42383,0.35351 -6.71875,-63.99218 z"
id="path4701"
transform="scale(0.93749999)"
inkscape:connector-curvature="0" />
<path
style="opacity:1;fill:#9b9b9b;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 206.49727,144.26345 -21.54466,24.52776 218.33607,5.38027 -80.17565,-21.81579 z"
id="path4753"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
style="opacity:1;fill:#b3b3b3;fill-opacity:1;stroke:none;stroke-width:5;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 197.5,179.41797 -7.09305,53.26007 23.42689,11.34889 266.07031,-8.43555 26.10682,-1.287 -75.7574,-49.02298 -0.83203,-0.16211 -78.41609,-9.93414 -61.98554,-1.53581 z"
transform="scale(0.93749999)"
id="path4748"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccc" />
<path
style="opacity:1;fill:#9b9b9b;fill-opacity:1;stroke:none;stroke-width:5;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 379.24023,128.24414 -9.85937,2.90625 c 4.05918,3.86793 8.60142,8.14649 9.09766,8.14649 0.60056,0 21.82631,0.64358 38.3164,1.14062 l 0.39844,-1.75977 z"
transform="scale(0.93749999)"
id="path4755"
inkscape:connector-curvature="0" />
<path
style="opacity:1;fill:#616161;fill-opacity:1;stroke:none;stroke-width:5;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 171.82603,216.81351 c 0,0 -37.05247,9.00497 -49.83384,9.10446 l 1.18359,0.62305 -15.76172,42.0332 16,137 h 18.5625 l 25.4375,-32 0.94532,0.18945 -14.09384,-49.77443 21.09774,-75.00291 z"
id="path4760"
transform="scale(0.93749999)"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccccc" />
<path
style="opacity:1;fill:#616161;fill-opacity:1;stroke:none;stroke-width:5;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 135.05664,315.10156 v 90.47266 h 6.91992 l 25.4375,-32 105,21 110,8 64,-23 20.1211,-0.16797 4.60937,-4.95703 c 0,0 1.87983,-3.9794 4.00781,-10.68555 l -25.43329,-0.22503 -63.992,24.39495 -113.49072,-14.84961 z"
transform="scale(0.93749999)"
id="path4765"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccccccc" />
<path
style="opacity:1;fill:#616161;fill-opacity:1;stroke:none;stroke-width:5;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 134.79883,173.33594 -61.158205,24.43554 -1.162109,2.07227 50.697264,26.69727 -4.74219,12.64453 54.71935,-22.20174 z m -8.23633,7.39844 c 3.9725,-0.0177 8.16388,1.1842 11.89258,3.41015 9.12492,5.44612 13.16668,15.4832 9.02734,22.41797 -4.13971,6.93389 -14.89171,8.13974 -24.01562,2.69336 -9.12492,-5.44612 -13.16669,-15.4832 -9.02735,-22.41797 2.28739,-3.8317 6.7518,-6.07936 12.12305,-6.10351 z"
transform="scale(0.93749999)"
id="path4770"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccccccc" />
<path
style="opacity:1;fill:#9b9b9b;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 130.59378,129.01646 -6.79485,30.16252 6.79485,1.49155 4.80612,-30.99116 z"
id="path4776"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
style="opacity:1;fill:#616161;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 337.03125,291.01706 1.28906,1.17187 4.33594,60.11719 -1.05469,0.11719 z"
id="path4839"
inkscape:connector-curvature="0" />
<path
style="opacity:1;fill:#616161;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 362.57812,290.07956 1.75781,1.28906 -2.10937,59.88281 -1.28906,0.35157 z"
id="path4841"
inkscape:connector-curvature="0" />
<path
style="opacity:1;fill:#616161;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 388.47656,289.96237 1.05469,1.64063 -8.55469,58.94531 -1.28906,0.35156 z"
id="path4843"
inkscape:connector-curvature="0" />
<path
transform="translate(-137.02484,-121.89479)"
style="display:inline;opacity:1;fill:#7d7d7d;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;enable-background:new"
d="m 256.01765,494.86308 24.85922,-35.46582 112.69514,25.19067 102.08855,8.61787 -105.40311,-7.6235 -107.72329,-22.53903 z"
id="path4845"
inkscape:connector-curvature="0" />
<path
style="opacity:1;fill:#7d7d7d;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 102.91718,251.32383 36.95738,-17.89864 22.20757,-0.66291 -23.53339,5.80048 z"
id="path4853"
inkscape:connector-curvature="0" />
<path
style="opacity:1;fill:#7d7d7d;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 112.26562,312.34518 14.53125,-7.26562 15.70313,-1.17188 -19.6875,5.39063 z"
id="path4855"
inkscape:connector-curvature="0" />
<path
style="opacity:1;fill:#7d7d7d;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 360.62446,371.14529 59.4964,-21.87612 21.37894,0.49718 -22.04185,1.82301 z"
id="path4857"
inkscape:connector-curvature="0" />
</g>
<g
inkscape:groupmode="layer"
id="layer10"
inkscape:label="stud"
style="display:none"
transform="translate(-50.232073,-102.51234)" />
<g
inkscape:groupmode="layer"
id="layer4"
inkscape:label="palette"
style="display:none"
transform="translate(-187.25691,-224.40713)">
<rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21052647;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4162"
width="40.789474"
height="40.789474"
x="779.60529"
y="21.967466" />
<rect
y="21.967466"
x="824.60529"
height="40.789474"
width="40.789474"
id="rect4170"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21052742;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
<rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#bce8ff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21052647;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4208"
width="40.789474"
height="40.789474"
x="779.60529"
y="86.967468" />
<rect
y="-127.75694"
x="824.60529"
height="40.789474"
width="40.789474"
id="rect4223"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#abccd9;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21052647;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
transform="scale(1,-1)" />
<rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#c3b0cb;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21052647;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4227"
width="40.789474"
height="40.789474"
x="779.60529"
y="131.96747" />
<rect
y="131.96747"
x="824.60529"
height="40.789474"
width="40.789474"
id="rect4231"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#e1d0cb;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21052647;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
<rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#f5c3d2;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21052647;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4233"
width="40.789474"
height="40.789474"
x="869.60529"
y="131.96747" />
<rect
y="176.96747"
x="779.60529"
height="40.789474"
width="40.789474"
id="rect4248"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#cec4ad;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21052647;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
<rect
transform="scale(1,-1)"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#96d6ff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21052647;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4263"
width="40.789474"
height="40.789474"
x="869.60529"
y="-127.75694" />
<rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#f2f2ce;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21052647;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4267"
width="40.789474"
height="40.789474"
x="824.60529"
y="176.96747" />
<rect
y="-327.75693"
x="779.60529"
height="40.789474"
width="40.789474"
id="rect4280"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#24b8eb;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21052647;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
transform="scale(1,-1)" />
<rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#8aa9ff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21052647;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4284"
width="40.789474"
height="40.789474"
x="824.60529"
y="286.96747" />
<rect
y="331.96747"
x="779.60529"
height="40.789474"
width="40.789474"
id="rect4297"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#d4edf1;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21052647;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
<rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394d54;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21052647;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4301"
width="40.789474"
height="40.789474"
x="779.60529"
y="241.96747" />
<rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#d6e2ff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21052647;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4303"
width="40.789474"
height="40.789474"
x="824.60529"
y="331.96747" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 78 KiB

20
go.mod Normal file
View File

@ -0,0 +1,20 @@
module blocky
go 1.13
require (
github.com/golang/protobuf v1.3.2 // indirect
github.com/kr/pretty v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.4 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/miekg/dns v1.1.22
github.com/onsi/ginkgo v1.11.0 // indirect
github.com/onsi/gomega v1.8.1 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/sirupsen/logrus v1.4.2
github.com/stretchr/testify v1.4.0
github.com/x-cray/logrus-prefixed-formatter v0.5.2
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/yaml.v2 v2.2.4
)

82
go.sum Normal file
View File

@ -0,0 +1,82 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/miekg/dns v1.1.22 h1:Jm64b3bO9kP43ddLjL2EY3Io6bmy1qGb9Xxz6TqS6rc=
github.com/miekg/dns v1.1.22/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.11.0 h1:JAKSXpt1YjtLA7YpPiqO9ss6sNXEsPfSGdwN0UHqzrw=
github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.8.1 h1:C5Dqfs/LeauYDX0jJXIe2SWmwCbGzx9yF8C8xy3Lh34=
github.com/onsi/gomega v1.8.1/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
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/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392 h1:ACG4HJsFiNMf47Y4PeRoebLNy/2lXT9EtprMuTFWt1M=
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478 h1:l5EDrHhldLYb3ZRHDUhXF7Om7MvYXnkV9/iQNo1lX6g=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe h1:6fAMxZRR6sl1Uq8U61gxU+kPTs2tR8uOySCbBP7BN/M=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190907020128-2ca718005c18 h1:xFbv3LvlvQAmbNJFCBKRv1Ccvnh9FVsW0FX2kTWWowE=
golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

253
lists/list_cache.go Normal file
View File

@ -0,0 +1,253 @@
package lists
import (
"bufio"
"fmt"
"io"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/sirupsen/logrus"
)
const (
timeout = 30 * time.Second
listUpdatePeriod = 4 * time.Hour
)
type Matcher interface {
// matches passed domain name against cached list entries
Match(domain string, groupsToCheck []string) (found bool, group string)
// returns current configuration and stats
Configuration() []string
}
type ListCache struct {
groupCaches map[string][]string
lock sync.RWMutex
groupToLinks map[string][]string
}
func (b *ListCache) Configuration() (result []string) {
result = append(result, "group links:")
for group, links := range b.groupToLinks {
result = append(result, fmt.Sprintf(" %s:", group))
for _, link := range links {
result = append(result, fmt.Sprintf(" - %s", link))
}
}
result = append(result, "group caches:")
var total int
for group, cache := range b.groupCaches {
result = append(result, fmt.Sprintf(" %s: %d entries", group, len(cache)))
total += len(cache)
}
result = append(result, fmt.Sprintf(" TOTAL: %d entries", total))
return
}
// removes duplicates
func unique(in []string) []string {
keys := make(map[string]bool)
var list []string
for _, entry := range in {
if _, value := keys[entry]; !value {
keys[entry] = true
list = append(list, entry)
}
}
return list
}
func NewListCache(groupToLinks map[string][]string) *ListCache {
groupCaches := make(map[string][]string)
b := &ListCache{
groupToLinks: groupToLinks,
groupCaches: groupCaches,
}
b.refresh()
go periodicUpdate(b)
return b
}
// triggers periodical refresh (and download) of list entries
func periodicUpdate(cache *ListCache) {
ticker := time.NewTicker(listUpdatePeriod)
defer ticker.Stop()
for {
<-ticker.C
cache.refresh()
}
}
func logger() *logrus.Entry {
return logrus.WithField("prefix", "list_cache")
}
// downloads and reads files with domain names and creates cache for them
func createCacheForGroup(links []string) []string {
cache := make([]string, 0)
var wg sync.WaitGroup
c := make(chan []string, len(links))
for _, link := range links {
wg.Add(1)
go processFile(link, c, &wg)
}
wg.Wait()
Loop:
for {
select {
case res := <-c:
cache = append(cache, res...)
default:
close(c)
break Loop
}
}
cache = unique(cache)
sort.Strings(cache)
return cache
}
func (b *ListCache) Match(domain string, groupsToCheck []string) (found bool, group string) {
b.lock.RLock()
defer b.lock.RUnlock()
for _, g := range groupsToCheck {
if contains(domain, b.groupCaches[g]) {
return true, g
}
}
return false, ""
}
func contains(domain string, cache []string) bool {
idx := sort.SearchStrings(cache, domain)
if idx < len(cache) {
return cache[idx] == strings.ToLower(domain)
}
return false
}
func (b *ListCache) refresh() {
b.lock.Lock()
defer b.lock.Unlock()
for group, links := range b.groupToLinks {
b.groupCaches[group] = createCacheForGroup(links)
logger().WithFields(logrus.Fields{
"group": group,
"total_count": len(b.groupCaches[group]),
}).Info("group import finished")
}
}
func downloadFile(link string) (io.ReadCloser, error) {
client := http.Client{
Timeout: timeout,
}
logger().WithField("link", link).Info("starting download")
//nolint:bodyclose
resp, err := client.Get(link)
if err != nil {
return nil, err
}
return resp.Body, nil
}
func readFile(file string) (io.ReadCloser, error) {
logger().WithField("file", file).Info("starting processing of file")
file = strings.TrimPrefix(file, "file://")
return os.Open(file)
}
// downloads file (or reads local file) and writes file content as string array in the channel
func processFile(link string, ch chan<- []string, wg *sync.WaitGroup) {
defer wg.Done()
result := make([]string, 0)
var r io.ReadCloser
var err error
if strings.HasPrefix(link, "http") {
r, err = downloadFile(link)
} else {
r, err = readFile(link)
}
if err != nil {
logger().Warn("error during file processing: ", err)
return
}
defer r.Close()
var count int
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
// skip comments
if !strings.HasPrefix(line, "#") {
result = append(result, processLine(line))
count++
}
}
if err := scanner.Err(); err != nil {
logger().Warn("can't parse file: ", err)
} else {
logger().WithFields(logrus.Fields{
"source": link,
"count": count,
}).Info("file imported")
}
ch <- result
}
// return only first column (see hosts format)
func processLine(line string) string {
parts := strings.Fields(line)
if len(parts) > 0 {
return parts[len(parts)-1]
}
return ""
}

132
lists/list_cache_test.go Normal file
View File

@ -0,0 +1,132 @@
package lists
import (
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"testing"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
func Test_NoMatch_With_Empty_List(t *testing.T) {
file1 := tempFile("#empty file")
defer os.Remove(file1.Name())
lists := map[string][]string{
"gr1": {file1.Name()},
}
sut := NewListCache(lists)
found, group := sut.Match("google.com", []string{"gr1"})
assert.Equal(t, false, found)
assert.Equal(t, "", group)
}
func Test_Match_Download_Multiple_Groups(t *testing.T) {
server1 := testServer("blocked1.com\nblocked1a.com")
defer server1.Close()
server2 := testServer("blocked2.com")
defer server2.Close()
server3 := testServer("blocked3.com\nblocked1a.com")
defer server3.Close()
lists := map[string][]string{
"gr1": {server1.URL, server2.URL},
"gr2": {server3.URL},
}
sut := NewListCache(lists)
found, group := sut.Match("blocked1.com", []string{"gr1", "gr2"})
assert.Equal(t, true, found)
assert.Equal(t, "gr1", group)
found, group = sut.Match("blocked1a.com", []string{"gr1", "gr2"})
assert.Equal(t, true, found)
assert.Equal(t, "gr1", group)
found, group = sut.Match("blocked1a.com", []string{"gr2"})
assert.Equal(t, true, found)
assert.Equal(t, "gr2", group)
}
func Test_Match_Download_No_Group(t *testing.T) {
server1 := testServer("blocked1.com\nblocked1a.com")
defer server1.Close()
server2 := testServer("blocked2.com")
defer server2.Close()
server3 := testServer("blocked3.com\nblocked1a.com")
defer server3.Close()
lists := map[string][]string{
"gr1": {server1.URL, server2.URL},
"gr2": {server3.URL},
}
sut := NewListCache(lists)
found, group := sut.Match("blocked1.com", []string{})
assert.Equal(t, false, found)
assert.Equal(t, "", group)
}
func Test_Match_Files_Multiple_Groups(t *testing.T) {
file1 := tempFile("blocked1.com\nblocked1a.com")
defer os.Remove(file1.Name())
file2 := tempFile("blocked2.com")
defer os.Remove(file2.Name())
file3 := tempFile("blocked3.com\nblocked1a.com")
defer os.Remove(file3.Name())
lists := map[string][]string{
"gr1": {file1.Name(), file2.Name()},
"gr2": {"file://" + file3.Name()},
}
sut := NewListCache(lists)
found, group := sut.Match("blocked1.com", []string{"gr1", "gr2"})
assert.Equal(t, true, found)
assert.Equal(t, "gr1", group)
found, group = sut.Match("blocked1a.com", []string{"gr1", "gr2"})
assert.Equal(t, true, found)
assert.Equal(t, "gr1", group)
found, group = sut.Match("blocked1a.com", []string{"gr2"})
assert.Equal(t, true, found)
assert.Equal(t, "gr2", group)
}
func tempFile(data string) *os.File {
f, err := ioutil.TempFile("", "prefix")
if err != nil {
log.Fatal(err)
}
_, err = f.WriteString(data)
if err != nil {
log.Fatal(err)
}
return f
}
func testServer(data string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
_, err := rw.Write([]byte(data))
if err != nil {
log.Fatal("can't write to buffer:", err)
}
}))
}

88
main.go Normal file
View File

@ -0,0 +1,88 @@
package main
import (
"blocky/config"
"blocky/server"
"os"
"os/signal"
"syscall"
prefixed "github.com/x-cray/logrus-prefixed-formatter"
"github.com/sirupsen/logrus"
log "github.com/sirupsen/logrus"
)
//nolint:gochecknoglobals
var version = "undefined"
//nolint:gochecknoglobals
var buildTime = "undefined"
func main() {
cfg := config.NewConfig()
configureLog(&cfg)
printBanner()
signals := make(chan os.Signal)
done := make(chan bool)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
server, err := server.NewServer(&cfg)
if err != nil {
log.Fatal("cant start server ", err)
}
server.Start()
go func() {
<-signals
log.Infof("Terminating...")
server.Stop()
done <- true
}()
<-done
}
func configureLog(cfg *config.Config) {
if level, err := log.ParseLevel(cfg.LogLevel); err != nil {
log.Fatalf("invalid log level %s %v", cfg.LogLevel, err)
} else {
log.SetLevel(level)
}
logFormatter := &prefixed.TextFormatter{
TimestampFormat: "2006-01-02 15:04:05",
FullTimestamp: true,
ForceFormatting: true,
ForceColors: true,
QuoteEmptyFields: true}
logFormatter.SetColorScheme(&prefixed.ColorScheme{
PrefixStyle: "blue+b",
TimestampStyle: "white+h",
})
logrus.SetFormatter(logFormatter)
}
func printBanner() {
log.Info("_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/")
log.Info("_/ _/")
log.Info("_/ _/")
log.Info("_/ _/ _/ _/ _/")
log.Info("_/ _/_/_/ _/ _/_/ _/_/_/ _/ _/ _/ _/ _/")
log.Info("_/ _/ _/ _/ _/ _/ _/ _/_/ _/ _/ _/")
log.Info("_/ _/ _/ _/ _/ _/ _/ _/ _/ _/ _/ _/")
log.Info("_/ _/_/_/ _/ _/_/ _/_/_/ _/ _/ _/_/_/ _/")
log.Info("_/ _/ _/")
log.Info("_/ _/_/ _/")
log.Info("_/ _/")
log.Info("_/ _/")
log.Infof("_/ Version: %-18s Build time: %-18s _/", version, buildTime)
log.Info("_/ _/")
log.Info("_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/")
}

View File

@ -0,0 +1,220 @@
package resolver
import (
"blocky/config"
"blocky/lists"
"blocky/util"
"fmt"
"net"
"reflect"
"sort"
"strings"
"github.com/miekg/dns"
"github.com/sirupsen/logrus"
)
const (
BlockTTL = 6 * 60 * 60
)
type BlockType uint8
const (
ZeroIP BlockType = iota
NxDomain
)
func (b BlockType) String() string {
return [...]string{"ZeroIP", "NxDomain"}[b]
}
// nolint:gochecknoglobals
var typeToZeroIP = map[uint16]net.IP{
dns.TypeA: net.IPv4zero,
dns.TypeAAAA: net.IPv6zero,
}
func resolveBlockType(cfg config.BlockingConfig) BlockType {
cfgBlockType := strings.TrimSpace(strings.ToUpper(cfg.BlockType))
if cfgBlockType == "" || cfgBlockType == "ZEROIP" {
return ZeroIP
}
if cfgBlockType == "NXDOMAIN" {
return NxDomain
}
logrus.Fatalf("unknown blockType, please use one of: ZeroIP, NxDomain")
return ZeroIP
}
// checks request's question (domain name) against black and white lists
type BlockingResolver struct {
NextResolver
blacklistMatcher lists.Matcher
whitelistMatcher lists.Matcher
clientGroupsBlock map[string][]string
blockType BlockType
whitelistOnlyGroups []string
}
func NewBlockingResolver(cfg config.BlockingConfig) ChainedResolver {
bt := resolveBlockType(cfg)
blacklistMatcher := lists.NewListCache(cfg.BlackLists)
whitelistMatcher := lists.NewListCache(cfg.WhiteLists)
whitelistOnlyGroups := determineWhitelistOnlyGroups(&cfg)
return &BlockingResolver{
blockType: bt,
clientGroupsBlock: cfg.ClientGroupsBlock,
blacklistMatcher: blacklistMatcher,
whitelistMatcher: whitelistMatcher,
whitelistOnlyGroups: whitelistOnlyGroups,
}
}
// returns groups, which have only whitelist entries
func determineWhitelistOnlyGroups(cfg *config.BlockingConfig) (result []string) {
for g, links := range cfg.WhiteLists {
if len(links) > 0 {
if _, found := cfg.BlackLists[g]; !found {
result = append(result, g)
}
}
}
sort.Strings(result)
return
}
// sets answer and/or return code for DNS response, if request should be blocked
func (r *BlockingResolver) handleBlocked(question dns.Question, response *dns.Msg) (*dns.Msg, error) {
switch r.blockType {
case ZeroIP:
rr, err := util.CreateAnswerFromQuestion(question, typeToZeroIP[question.Qtype], BlockTTL)
if err != nil {
return nil, err
}
response.Answer = append(response.Answer, rr)
case NxDomain:
response.Rcode = dns.RcodeNameError
}
return response, nil
}
func (r *BlockingResolver) Configuration() (result []string) {
if len(r.clientGroupsBlock) > 0 {
result = append(result, "clientGroupsBlock")
for key, val := range r.clientGroupsBlock {
result = append(result, fmt.Sprintf(" %s = \"%s\"", key, strings.Join(val, ";")))
}
result = append(result, fmt.Sprintf("blockType = \"%s\"", r.blockType))
result = append(result, "blacklist:")
for _, c := range r.blacklistMatcher.Configuration() {
result = append(result, fmt.Sprintf(" %s", c))
}
result = append(result, "whitelist:")
for _, c := range r.whitelistMatcher.Configuration() {
result = append(result, fmt.Sprintf(" %s", c))
}
} else {
result = []string{"deactivated"}
}
return
}
func (r *BlockingResolver) Resolve(request *Request) (*Response, error) {
logger := withPrefix(request.Log, "blacklist_resolver")
groupsToCheck := r.groupsToCheckForClient(request)
if len(groupsToCheck) > 0 {
logger.WithField("groupsToCheck", strings.Join(groupsToCheck, "; ")).Debug("checking groups for request")
for _, question := range request.Req.Question {
domain := util.ExtractDomain(question)
logger := logger.WithField("domain", domain)
whitelistOnlyAlowed := reflect.DeepEqual(groupsToCheck, r.whitelistOnlyGroups)
if whitelisted, group := r.matches(groupsToCheck, r.whitelistMatcher, domain); whitelisted {
logger.WithField("group", group).Debugf("domain is whitelisted")
} else {
if whitelistOnlyAlowed {
logger.WithField("client_groups", groupsToCheck).Debug("white list only for client group(s), blocking...")
response := new(dns.Msg)
response.SetReply(request.Req)
resp, err := r.handleBlocked(question, response)
return &Response{Res: resp, Reason: fmt.Sprintf("BLOCKED (WHITELIST ONLY)")}, err
}
if blocked, group := r.matches(groupsToCheck, r.blacklistMatcher, domain); blocked {
logger.WithField("group", group).Debug("domain is blocked")
response := new(dns.Msg)
response.SetReply(request.Req)
resp, err := r.handleBlocked(question, response)
return &Response{Res: resp, Reason: fmt.Sprintf("BLOCKED (%s)", group)}, err
}
}
}
}
logger.WithField("next_resolver", r.next).Trace("go to next resolver")
return r.next.Resolve(request)
}
// returns groups which should be checked for client's request
func (r *BlockingResolver) groupsToCheckForClient(request *Request) (groups []string) {
// try client names
for _, cName := range request.ClientNames {
groupsByName, found := r.clientGroupsBlock[cName]
if found {
groups = append(groups, groupsByName...)
}
}
// try IP
groupsByIP, found := r.clientGroupsBlock[request.ClientIP.String()]
if found {
groups = append(groups, groupsByIP...)
}
if len(groups) == 0 {
if !found {
// return default
groups = r.clientGroupsBlock["default"]
}
}
sort.Strings(groups)
return
}
func (r *BlockingResolver) matches(groupsToCheck []string, m lists.Matcher,
domain string) (blocked bool, group string) {
if len(groupsToCheck) > 0 {
found, group := m.Match(domain, groupsToCheck)
if found {
return true, group
}
}
return false, ""
}
func (r BlockingResolver) String() string {
return fmt.Sprintf("blacklist resolver")
}

View File

@ -0,0 +1,300 @@
package resolver
import (
"blocky/config"
"blocky/util"
"net"
"testing"
"github.com/miekg/dns"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
var clientBlock = map[string][]string{
"default": {"gr0"},
"client1": {"gr1", "gr2"},
"altName": {"gr4"},
"192.168.178.55": {"gr3"},
}
type MatcherMock struct {
mock.Mock
}
func (b *MatcherMock) Configuration() (result []string) {
return
}
func (b *MatcherMock) Match(domain string, groupsToCheck []string) (found bool, group string) {
args := b.Called(domain, groupsToCheck)
return args.Bool(0), args.String(1)
}
func Test_Resolve_ClientName_IpZero(t *testing.T) {
b := &MatcherMock{}
b.On("Match", "blocked1.com", []string{"gr1", "gr2", "gr3"}).Return(true, "gr1")
w := &MatcherMock{}
w.On("Match", mock.Anything, mock.Anything).Return(false, "gr1")
sut := BlockingResolver{
clientGroupsBlock: clientBlock,
blacklistMatcher: b,
whitelistMatcher: w,
}
req := util.NewMsgWithQuestion("blocked1.com.", dns.TypeA)
// A
resp, err := sut.Resolve(&Request{
Req: req,
ClientNames: []string{"client1"},
ClientIP: net.ParseIP("192.168.178.55"),
Log: logrus.NewEntry(logrus.New()),
})
assert.NoError(t, err)
assert.Equal(t, dns.RcodeSuccess, resp.Res.Rcode)
assert.Equal(t, "blocked1.com. 21600 IN A 0.0.0.0", resp.Res.Answer[0].String())
b.AssertExpectations(t)
// AAAA
req = util.NewMsgWithQuestion("blocked1.com.", dns.TypeAAAA)
resp, err = sut.Resolve(&Request{
Req: req,
ClientNames: []string{"client1"},
ClientIP: net.ParseIP("192.168.178.55"),
Log: logrus.NewEntry(logrus.New()),
})
assert.NoError(t, err)
assert.Equal(t, dns.RcodeSuccess, resp.Res.Rcode)
assert.Equal(t, "blocked1.com. 21600 IN AAAA ::", resp.Res.Answer[0].String())
b.AssertExpectations(t)
}
func Test_Resolve_ClientIp_A_IpZero(t *testing.T) {
b := &MatcherMock{}
b.On("Match", "blocked1.com", []string{"gr3"}).Return(true, "gr1")
w := &MatcherMock{}
w.On("Match", mock.Anything, mock.Anything).Return(false, "gr1")
sut := BlockingResolver{
clientGroupsBlock: clientBlock,
blacklistMatcher: b,
whitelistMatcher: w,
}
req := util.NewMsgWithQuestion("blocked1.com.", dns.TypeA)
resp, err := sut.Resolve(&Request{
Req: req,
ClientNames: []string{"unknown"},
ClientIP: net.ParseIP("192.168.178.55"),
Log: logrus.NewEntry(logrus.New()),
})
assert.NoError(t, err)
assert.Equal(t, dns.RcodeSuccess, resp.Res.Rcode)
assert.Equal(t, "blocked1.com. 21600 IN A 0.0.0.0", resp.Res.Answer[0].String())
b.AssertExpectations(t)
}
func Test_Resolve_ClientWith2Names_A_IpZero(t *testing.T) {
b := &MatcherMock{}
b.On("Match", "blocked1.com", []string{"gr1", "gr2", "gr3", "gr4"}).Return(true, "gr1")
w := &MatcherMock{}
w.On("Match", mock.Anything, mock.Anything).Return(false, "gr1")
sut := BlockingResolver{
clientGroupsBlock: clientBlock,
blacklistMatcher: b,
whitelistMatcher: w,
}
req := util.NewMsgWithQuestion("blocked1.com.", dns.TypeA)
resp, err := sut.Resolve(&Request{
Req: req,
ClientNames: []string{"client1", "altName"},
ClientIP: net.ParseIP("192.168.178.55"),
Log: logrus.NewEntry(logrus.New()),
})
assert.NoError(t, err)
assert.Equal(t, dns.RcodeSuccess, resp.Res.Rcode)
assert.Equal(t, "blocked1.com. 21600 IN A 0.0.0.0", resp.Res.Answer[0].String())
b.AssertExpectations(t)
}
func Test_Resolve_Default_A_IpZero(t *testing.T) {
b := &MatcherMock{}
b.On("Match", "blocked1.com", []string{"gr0"}).Return(true, "gr1")
w := &MatcherMock{}
w.On("Match", mock.Anything, mock.Anything).Return(false, "gr1")
sut := BlockingResolver{
clientGroupsBlock: clientBlock,
blacklistMatcher: b,
whitelistMatcher: w,
}
req := util.NewMsgWithQuestion("blocked1.com.", dns.TypeA)
resp, err := sut.Resolve(&Request{
Req: req,
ClientNames: []string{"unknown"},
ClientIP: net.ParseIP("192.168.178.1"),
Log: logrus.NewEntry(logrus.New()),
})
assert.NoError(t, err)
assert.Equal(t, dns.RcodeSuccess, resp.Res.Rcode)
assert.Equal(t, "blocked1.com. 21600 IN A 0.0.0.0", resp.Res.Answer[0].String())
b.AssertExpectations(t)
}
func Test_Resolve_Default_Block_With_Whitelist(t *testing.T) {
b := &MatcherMock{}
b.On("Match", "blocked1.com", []string{"gr0"}).Return(true, "gr")
w := &MatcherMock{}
w.On("Match", "blocked1.com", []string{"gr0"}).Return(true, "gr1")
sut := BlockingResolver{
clientGroupsBlock: clientBlock,
blacklistMatcher: b,
whitelistMatcher: w,
}
m := &resolverMock{}
m.On("Resolve", mock.Anything).Return(new(Response), nil)
sut.Next(m)
req := util.NewMsgWithQuestion("blocked1.com.", dns.TypeA)
_, err := sut.Resolve(&Request{
Req: req,
ClientNames: []string{"unknown"},
ClientIP: net.ParseIP("192.168.178.1"),
Log: logrus.NewEntry(logrus.New()),
})
assert.NoError(t, err)
w.AssertExpectations(t)
assert.Equal(t, 0, len(b.Calls))
}
func Test_Resolve_Whitelist_Only(t *testing.T) {
b := &MatcherMock{}
w := &MatcherMock{}
w.On("Match", "whitelisted.com", []string{"gr0"}).Return(true, "gr0")
w.On("Match", mock.Anything, []string{"gr0"}).Return(false, "gr0")
sut := BlockingResolver{
clientGroupsBlock: clientBlock,
blacklistMatcher: b,
whitelistMatcher: w,
whitelistOnlyGroups: []string{"gr0"},
}
m := &resolverMock{}
m.On("Resolve", mock.Anything).Return(new(Response), nil)
sut.Next(m)
req := util.NewMsgWithQuestion("whitelisted.com.", dns.TypeA)
_, err := sut.Resolve(&Request{
Req: req,
ClientNames: []string{"unknown"},
ClientIP: net.ParseIP("192.168.178.1"),
Log: logrus.NewEntry(logrus.New()),
})
assert.NoError(t, err)
w.AssertExpectations(t)
assert.Equal(t, 0, len(b.Calls))
req = new(dns.Msg)
req.SetQuestion("google.com.", dns.TypeA)
resp, err := sut.Resolve(&Request{
Req: req,
ClientNames: []string{"unknown"},
ClientIP: net.ParseIP("192.168.178.1"),
Log: logrus.NewEntry(logrus.New()),
})
assert.NoError(t, err)
assert.Equal(t, dns.RcodeSuccess, resp.Res.Rcode)
assert.Equal(t, "google.com. 21600 IN A 0.0.0.0", resp.Res.Answer[0].String())
w.AssertExpectations(t)
b.AssertExpectations(t)
}
func Test_determineWhitelistOnlyGroups(t *testing.T) {
assert.Equal(t, []string{"w1"}, determineWhitelistOnlyGroups(&config.BlockingConfig{
BlackLists: map[string][]string{},
WhiteLists: map[string][]string{"w1": {"l1"}},
}))
assert.Equal(t, []string{"b1", "default"}, determineWhitelistOnlyGroups(&config.BlockingConfig{
BlackLists: map[string][]string{
"w1": {"y"},
},
WhiteLists: map[string][]string{
"w1": {"l1"},
"default": {"s1"},
"b1": {"x"}},
}))
}
func Test_Resolve_Default_A_NxRecord(t *testing.T) {
b := &MatcherMock{}
b.On("Match", "blocked1.com", []string{"gr0"}).Return(true, "gr1")
w := &MatcherMock{}
w.On("Match", mock.Anything, mock.Anything).Return(false, "gr1")
sut := BlockingResolver{
clientGroupsBlock: clientBlock,
blacklistMatcher: b,
blockType: NxDomain,
whitelistMatcher: w,
}
req := util.NewMsgWithQuestion("blocked1.com.", dns.TypeA)
resp, err := sut.Resolve(&Request{
Req: req,
ClientNames: []string{"unknown"},
ClientIP: net.ParseIP("192.168.178.1"),
Log: logrus.NewEntry(logrus.New()),
})
assert.NoError(t, err)
assert.Equal(t, dns.RcodeNameError, resp.Res.Rcode)
b.AssertExpectations(t)
}
func Test_Resolve_NoBlock(t *testing.T) {
b := &MatcherMock{}
b.On("Match", "example.com", []string{"gr0"}).Return(false, "")
w := &MatcherMock{}
w.On("Match", mock.Anything, mock.Anything).Return(false, "gr1")
sut := BlockingResolver{
clientGroupsBlock: clientBlock,
blacklistMatcher: b,
blockType: NxDomain,
whitelistMatcher: w,
}
m := &resolverMock{}
m.On("Resolve", mock.Anything).Return(new(Response), nil)
sut.Next(m)
req := util.NewMsgWithQuestion("example.com.", dns.TypeA)
_, err := sut.Resolve(&Request{
Req: req,
ClientNames: []string{"unknown"},
ClientIP: net.ParseIP("192.168.178.1"),
Log: logrus.NewEntry(logrus.New()),
})
assert.NoError(t, err)
b.AssertExpectations(t)
}

View File

@ -0,0 +1,120 @@
package resolver
import (
"blocky/util"
"fmt"
"time"
"github.com/miekg/dns"
"github.com/patrickmn/go-cache"
log "github.com/sirupsen/logrus"
)
// caches answers from dns queries with their TTL time, to avoid external resolver calls for recurrent queries
type CachingResolver struct {
NextResolver
cacheA *cache.Cache
cacheAAAA *cache.Cache
}
const minTTL = 250
type Type uint8
const (
A Type = iota
AAAA
)
func NewCachingResolver() ChainedResolver {
return &CachingResolver{
cacheA: cache.New(15*time.Minute, 5*time.Minute),
cacheAAAA: cache.New(15*time.Minute, 5*time.Minute),
}
}
func (r *CachingResolver) getCache(queryType uint16) *cache.Cache {
switch queryType {
case dns.TypeA:
return r.cacheA
case dns.TypeAAAA:
return r.cacheAAAA
default:
log.Error("unknown type: ", queryType)
}
return r.cacheA
}
func (r *CachingResolver) Configuration() (result []string) {
result = append(result, fmt.Sprintf("A cache items count = %d", r.cacheA.ItemCount()))
result = append(result, fmt.Sprintf("AAAA cache items count = %d", r.cacheAAAA.ItemCount()))
return
}
func (r *CachingResolver) Resolve(request *Request) (response *Response, err error) {
logger := withPrefix(request.Log, "caching_resolver")
resp := new(dns.Msg)
resp.SetReply(request.Req)
for _, question := range request.Req.Question {
domain := util.ExtractDomain(question)
logger := logger.WithField("domain", domain)
// we caching only A and AAAA queries
if question.Qtype == dns.TypeA || question.Qtype == dns.TypeAAAA {
val, expiresAt, found := r.getCache(question.Qtype).GetWithExpiration(domain)
if found {
logger.Debug("domain is cached")
// calculate remaining TTL
remainingTTL := uint32(time.Until(expiresAt).Seconds())
resp.Answer = val.([]dns.RR)
for _, rr := range resp.Answer {
rr.Header().Ttl = remainingTTL
}
return &Response{Res: resp, Reason: fmt.Sprintf("CACHED (ttl %d)", remainingTTL)}, nil
}
logger.WithField("next_resolver", r.next).Debug("not in cache: go to next resolver")
response, err = r.next.Resolve(request)
if err == nil {
var maxTTL uint32
for _, a := range response.Res.Answer {
// if TTL < mitTTL -> adjust the value, set minTTL
if a.Header().Ttl < minTTL {
logger.WithFields(log.Fields{
"TTL": a.Header().Ttl,
"min_TTL": minTTL,
}).Debugf("ttl is < than min TTL, using min value")
a.Header().Ttl = minTTL
}
if maxTTL < a.Header().Ttl {
maxTTL = a.Header().Ttl
}
}
// put value into cache
r.getCache(question.Qtype).Set(domain, response.Res.Answer, time.Duration(maxTTL)*time.Second)
}
} else {
logger.Debugf("not A/AAAA: go to next %s", r.next)
return r.next.Resolve(request)
}
}
return response, err
}
func (r CachingResolver) String() string {
return fmt.Sprintf("caching resolver")
}

View File

@ -0,0 +1,117 @@
package resolver
import (
"blocky/util"
"testing"
"time"
"github.com/miekg/dns"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func Test_Resolve_A_WithCachingAndMinTtl(t *testing.T) {
sut := NewCachingResolver()
m := &resolverMock{}
mockResp, err := util.NewMsgWithAnswer("example.com. 300 IN A 123.122.121.120")
if err != nil {
t.Error(err)
}
m.On("Resolve", mock.Anything).Return(&Response{Res: mockResp}, nil)
sut.Next(m)
request := &Request{
Req: util.NewMsgWithQuestion("example.com.", dns.TypeA),
Log: logrus.NewEntry(logrus.New()),
}
// first request
resp, err := sut.Resolve(request)
assert.NoError(t, err)
assert.Equal(t, dns.RcodeSuccess, resp.Res.Rcode)
assert.Equal(t, "example.com. 300 IN A 123.122.121.120", resp.Res.Answer[0].String())
assert.Equal(t, 1, len(m.Calls))
time.Sleep(500 * time.Millisecond)
// second request
resp, err = sut.Resolve(request)
assert.NoError(t, err)
assert.Equal(t, dns.RcodeSuccess, resp.Res.Rcode)
// ttl is smaler
assert.Equal(t, "example.com. 299 IN A 123.122.121.120", resp.Res.Answer[0].String())
// still one call to resolver
assert.Equal(t, 1, len(m.Calls))
m.AssertExpectations(t)
}
func Test_Resolve_AAAA_WithCachingAndMinTtl(t *testing.T) {
sut := NewCachingResolver()
m := &resolverMock{}
mockResp, err := util.NewMsgWithAnswer("example.com. 123 IN AAAA 2001:0db8:85a3:08d3:1319:8a2e:0370:7344")
if err != nil {
t.Error(err)
}
m.On("Resolve", mock.Anything).Return(&Response{Res: mockResp}, nil)
sut.Next(m)
request := &Request{
Req: util.NewMsgWithQuestion("example.com.", dns.TypeAAAA),
Log: logrus.NewEntry(logrus.New()),
}
// first request
resp, err := sut.Resolve(request)
assert.NoError(t, err)
assert.Equal(t, dns.RcodeSuccess, resp.Res.Rcode)
assert.Equal(t, "example.com. 250 IN AAAA 2001:db8:85a3:8d3:1319:8a2e:370:7344", resp.Res.Answer[0].String())
assert.Equal(t, 1, len(m.Calls))
time.Sleep(500 * time.Millisecond)
// second request
resp, err = sut.Resolve(request)
assert.NoError(t, err)
assert.Equal(t, dns.RcodeSuccess, resp.Res.Rcode)
// ttl is smaler
assert.Equal(t, "example.com. 249 IN AAAA 2001:db8:85a3:8d3:1319:8a2e:370:7344", resp.Res.Answer[0].String())
// still one call to resolver
assert.Equal(t, 1, len(m.Calls))
m.AssertExpectations(t)
}
func Test_Resolve_MX(t *testing.T) {
sut := NewCachingResolver()
m := &resolverMock{}
mockResp, err := util.NewMsgWithAnswer("google.de.\t180\tIN\tMX\t20\talt1.aspmx.l.google.com.")
if err != nil {
t.Error(err)
}
m.On("Resolve", mock.Anything).Return(&Response{Res: mockResp}, nil)
sut.Next(m)
request := &Request{
Req: util.NewMsgWithQuestion("google.de.", dns.TypeMX),
Log: logrus.NewEntry(logrus.New()),
}
resp, err := sut.Resolve(request)
assert.NoError(t, err)
assert.Equal(t, dns.RcodeSuccess, resp.Res.Rcode)
assert.Equal(t, "google.de.\t180\tIN\tMX\t20 alt1.aspmx.l.google.com.", resp.Res.Answer[0].String())
assert.Equal(t, 1, len(m.Calls))
}

View File

@ -0,0 +1,135 @@
package resolver
import (
"blocky/config"
"blocky/util"
"fmt"
"net"
"strings"
"time"
"github.com/miekg/dns"
"github.com/patrickmn/go-cache"
"github.com/sirupsen/logrus"
)
// ClientNamesResolver tries to determine client name by asking responsible DNS server vie rDNS (reverse lookup)
type ClientNamesResolver struct {
cache *cache.Cache
externalResolver Resolver
singleNameOrder []uint
NextResolver
}
func NewClientNamesResolver(cfg config.ClientLookupConfig) ChainedResolver {
var r Resolver
if (config.Upstream{}) != cfg.Upstream {
r = NewUpstreamResolver(cfg.Upstream)
}
return &ClientNamesResolver{
cache: cache.New(1*time.Hour, 1*time.Hour),
externalResolver: r,
singleNameOrder: cfg.SingleNameOrder,
}
}
func (r *ClientNamesResolver) Configuration() (result []string) {
if r.externalResolver != nil {
result = append(result, fmt.Sprintf("singleNameOrder = \"%v\"", r.singleNameOrder))
result = append(result, fmt.Sprintf("externalResolver = \"%s\"", r.externalResolver))
result = append(result, fmt.Sprintf("cache item count = %d", r.cache.ItemCount()))
} else {
result = []string{"deactivated, use only IP address"}
}
return
}
func (r *ClientNamesResolver) Resolve(request *Request) (*Response, error) {
clientNames := r.getClientNames(request)
request.ClientNames = clientNames
request.Log = request.Log.WithField("client_names", strings.Join(clientNames, "; "))
return r.next.Resolve(request)
}
// returns names of client
func (r *ClientNamesResolver) getClientNames(request *Request) []string {
ip := request.ClientIP
c, found := r.cache.Get(ip.String())
if found {
if t, ok := c.([]string); ok {
return t
}
}
names := r.resolveClientNames(ip, withPrefix(request.Log, "client_names_resolver"))
r.cache.Set(ip.String(), names, cache.DefaultExpiration)
return names
}
// performs reverse DNS lookup
func (r *ClientNamesResolver) resolveClientNames(ip net.IP, logger *logrus.Entry) (result []string) {
if r.externalResolver != nil {
reverse, err := dns.ReverseAddr(ip.String())
if err != nil {
logger.Warnf("can't create reverse address for %s", ip.String())
return
}
resp, err := r.externalResolver.Resolve(&Request{
Req: util.NewMsgWithQuestion(reverse, dns.TypePTR),
Log: logger,
})
if err != nil {
logger.Error("can't resolve client name", err)
return
}
var clientNames []string
for _, answer := range resp.Res.Answer {
if t, ok := answer.(*dns.PTR); ok {
hostName := strings.TrimSuffix(t.Ptr, ".")
clientNames = append(clientNames, hostName)
}
}
if len(clientNames) == 0 {
clientNames = []string{ip.String()}
}
// optional: if singleNameOrder is set, use only one name in the defined order
if len(r.singleNameOrder) > 0 {
for _, i := range r.singleNameOrder {
if i > 0 && int(i) <= len(clientNames) {
result = []string{clientNames[i-1]}
break
}
}
} else {
result = clientNames
}
logger.WithField("client_names", strings.Join(result, "; ")).Debug("resolved client name(s)")
} else {
result = []string{ip.String()}
}
return result
}
func (r ClientNamesResolver) String() string {
return fmt.Sprintf("client names resolver")
}
// reset client name cache
func (r *ClientNamesResolver) FlushCache() {
r.cache.Flush()
}

View File

@ -0,0 +1,208 @@
package resolver
import (
"blocky/config"
"blocky/util"
"fmt"
"net"
"testing"
"github.com/miekg/dns"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestClientNamesFromUpstream(t *testing.T) {
callCount := 0
upstream := TestUDPUpstream(func(request *dns.Msg) *dns.Msg {
callCount++
r, err := dns.ReverseAddr("192.168.178.25")
assert.NoError(t, err)
response, err := util.NewMsgWithAnswer(fmt.Sprintf("%s 300 IN PTR myhost", r))
assert.NoError(t, err)
return response
})
sut := NewClientNamesResolver(config.ClientLookupConfig{Upstream: upstream})
m := &resolverMock{}
m.On("Resolve", mock.Anything).Return(&Response{Res: new(dns.Msg)}, nil)
sut.Next(m)
// first request
request := &Request{
ClientIP: net.ParseIP("192.168.178.25"),
Log: logrus.NewEntry(logrus.New())}
_, err := sut.Resolve(request)
assert.Equal(t, 1, callCount)
m.AssertExpectations(t)
assert.NoError(t, err)
assert.Equal(t, "myhost", request.ClientNames[0])
// second request
request = &Request{ClientIP: net.ParseIP("192.168.178.25"),
Log: logrus.NewEntry(logrus.New())}
_, err = sut.Resolve(request)
// use cache -> call count 1
assert.Equal(t, 1, callCount)
m.AssertExpectations(t)
assert.NoError(t, err)
assert.Len(t, request.ClientNames, 1)
assert.Equal(t, "myhost", request.ClientNames[0])
}
func TestClientInfoFromUpstreamSingleNameWithOrder(t *testing.T) {
callCount := 0
upstream := TestUDPUpstream(func(request *dns.Msg) *dns.Msg {
callCount++
r, err := dns.ReverseAddr("192.168.178.25")
assert.NoError(t, err)
response, err := util.NewMsgWithAnswer(fmt.Sprintf("%s 300 IN PTR myhost", r))
assert.NoError(t, err)
return response
})
sut := NewClientNamesResolver(config.ClientLookupConfig{
Upstream: upstream,
SingleNameOrder: []uint{2, 1}})
m := &resolverMock{}
m.On("Resolve", mock.Anything).Return(&Response{Res: new(dns.Msg)}, nil)
sut.Next(m)
// first request
request := &Request{
ClientIP: net.ParseIP("192.168.178.25"),
Log: logrus.NewEntry(logrus.New())}
_, err := sut.Resolve(request)
assert.Equal(t, 1, callCount)
m.AssertExpectations(t)
assert.NoError(t, err)
assert.Equal(t, "myhost", request.ClientNames[0])
// second request
request = &Request{ClientIP: net.ParseIP("192.168.178.25"),
Log: logrus.NewEntry(logrus.New())}
_, err = sut.Resolve(request)
// use cache -> call count 1
assert.Equal(t, 1, callCount)
m.AssertExpectations(t)
assert.NoError(t, err)
assert.Len(t, request.ClientNames, 1)
assert.Equal(t, "myhost", request.ClientNames[0])
}
func TestClientInfoFromUpstreamMultipleNames(t *testing.T) {
upstream := TestUDPUpstream(func(request *dns.Msg) *dns.Msg {
r, err := dns.ReverseAddr("192.168.178.25")
assert.NoError(t, err)
rr1, err := dns.NewRR(fmt.Sprintf("%s 300 IN PTR myhost1", r))
assert.NoError(t, err)
rr2, err := dns.NewRR(fmt.Sprintf("%s 300 IN PTR myhost2", r))
assert.NoError(t, err)
msg := new(dns.Msg)
msg.Answer = []dns.RR{rr1, rr2}
return msg
})
sut := NewClientNamesResolver(config.ClientLookupConfig{Upstream: upstream})
m := &resolverMock{}
m.On("Resolve", mock.Anything).Return(&Response{Res: new(dns.Msg)}, nil)
sut.Next(m)
request := &Request{
ClientIP: net.ParseIP("192.168.178.25"),
Log: logrus.NewEntry(logrus.New())}
_, err := sut.Resolve(request)
m.AssertExpectations(t)
assert.NoError(t, err)
assert.Len(t, request.ClientNames, 2)
assert.Equal(t, "myhost1", request.ClientNames[0])
assert.Equal(t, "myhost2", request.ClientNames[1])
}
func TestClientInfoFromUpstreamMultipleNamesSingleNameOrder(t *testing.T) {
upstream := TestUDPUpstream(func(request *dns.Msg) *dns.Msg {
r, err := dns.ReverseAddr("192.168.178.25")
assert.NoError(t, err)
rr1, err := dns.NewRR(fmt.Sprintf("%s 300 IN PTR myhost1", r))
assert.NoError(t, err)
rr2, err := dns.NewRR(fmt.Sprintf("%s 300 IN PTR myhost2", r))
assert.NoError(t, err)
msg := new(dns.Msg)
msg.Answer = []dns.RR{rr1, rr2}
return msg
})
sut := NewClientNamesResolver(config.ClientLookupConfig{
Upstream: upstream,
SingleNameOrder: []uint{2, 1}})
m := &resolverMock{}
m.On("Resolve", mock.Anything).Return(&Response{Res: new(dns.Msg)}, nil)
sut.Next(m)
request := &Request{
ClientIP: net.ParseIP("192.168.178.25"),
Log: logrus.NewEntry(logrus.New())}
_, err := sut.Resolve(request)
m.AssertExpectations(t)
assert.NoError(t, err)
assert.Len(t, request.ClientNames, 1)
assert.Equal(t, "myhost2", request.ClientNames[0])
}
func TestClientInfoFromUpstreamNotFound(t *testing.T) {
upstream := TestUDPUpstream(func(request *dns.Msg) *dns.Msg {
msg := new(dns.Msg)
msg.SetRcode(request, dns.RcodeNameError)
return msg
})
sut := NewClientNamesResolver(config.ClientLookupConfig{Upstream: upstream})
m := &resolverMock{}
m.On("Resolve", mock.Anything).Return(&Response{Res: new(dns.Msg)}, nil)
sut.Next(m)
request := &Request{ClientIP: net.ParseIP("192.168.178.25"),
Log: logrus.NewEntry(logrus.New())}
_, err := sut.Resolve(request)
assert.NoError(t, err)
assert.Len(t, request.ClientNames, 1)
assert.Equal(t, "192.168.178.25", request.ClientNames[0])
}
func TestClientInfoWithoutUpstream(t *testing.T) {
sut := NewClientNamesResolver(config.ClientLookupConfig{})
m := &resolverMock{}
m.On("Resolve", mock.Anything).Return(&Response{Res: new(dns.Msg)}, nil)
sut.Next(m)
request := &Request{ClientIP: net.ParseIP("192.168.178.25"),
Log: logrus.NewEntry(logrus.New())}
_, err := sut.Resolve(request)
assert.NoError(t, err)
assert.Len(t, request.ClientNames, 1)
assert.Equal(t, "192.168.178.25", request.ClientNames[0])
}

View File

@ -0,0 +1,80 @@
package resolver
import (
"blocky/config"
"blocky/util"
"fmt"
"strings"
"github.com/sirupsen/logrus"
)
// ConditionalUpstreamResolver delegates DNS question to other DNS resolver dependent on domain name in question
type ConditionalUpstreamResolver struct {
NextResolver
mapping map[string]Resolver
}
func NewConditionalUpstreamResolver(cfg config.ConditionalUpstreamConfig) ChainedResolver {
m := make(map[string]Resolver)
for domain, upstream := range cfg.Mapping {
m[strings.ToLower(domain)] = NewUpstreamResolver(upstream)
}
return &ConditionalUpstreamResolver{mapping: m}
}
func (r *ConditionalUpstreamResolver) Configuration() (result []string) {
if len(r.mapping) > 0 {
for key, val := range r.mapping {
result = append(result, fmt.Sprintf("%s = \"%s\"", key, val))
}
} else {
result = []string{"deactivated"}
}
return
}
func (r *ConditionalUpstreamResolver) Resolve(request *Request) (*Response, error) {
logger := withPrefix(request.Log, "conditional_resolver")
if len(r.mapping) > 0 {
for _, question := range request.Req.Question {
domain := util.ExtractDomain(question)
// try with domain with and without sub-domains
for len(domain) > 0 {
r, found := r.mapping[domain]
if found {
response, err := r.Resolve(request)
if err == nil {
response.Reason = "CONDITIONAL"
}
logger.WithFields(logrus.Fields{
"answer": util.AnswerToString(response.Res.Answer),
"domain": domain,
"upstream": r,
}).Debugf("received response from conditional upstream")
return response, err
}
if i := strings.Index(domain, "."); i >= 0 {
domain = domain[i+1:]
} else {
break
}
}
}
}
logger.WithField("next_resolver", r.next).Trace("go to next resolver")
return r.next.Resolve(request)
}
func (r ConditionalUpstreamResolver) String() string {
return fmt.Sprintf("conditional resolver")
}

View File

@ -0,0 +1,86 @@
package resolver
import (
"blocky/util"
"testing"
"github.com/miekg/dns"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func setup() (sut *ConditionalUpstreamResolver, cond *resolverMock, next *resolverMock) {
cond = &resolverMock{}
next = &resolverMock{}
sut = &ConditionalUpstreamResolver{
mapping: map[string]Resolver{
"fritz.box": cond,
"other.box": cond,
},
}
cond.On("Resolve", mock.Anything).Return(&Response{Res: new(dns.Msg)}, nil)
next.On("Resolve", mock.Anything).Return(&Response{Res: new(dns.Msg)}, nil)
sut.Next(next)
return
}
func Test_Resolve_Conditional_Exact(t *testing.T) {
sut, conditionalResolver, nextResolver := setup()
request := &Request{
Req: util.NewMsgWithQuestion("fritz.box.", dns.TypeA),
Log: logrus.NewEntry(logrus.New()),
}
resp, err := sut.Resolve(request)
assert.NoError(t, err)
assert.Equal(t, "CONDITIONAL", resp.Reason)
conditionalResolver.AssertExpectations(t)
nextResolver.AssertNotCalled(t, "Resolve", mock.Anything)
}
func Test_Resolve_Conditional_ExactLast(t *testing.T) {
sut, conditionalResolver, nextResolver := setup()
request := &Request{
Req: util.NewMsgWithQuestion("other.box.", dns.TypeA),
Log: logrus.NewEntry(logrus.New()),
}
resp, err := sut.Resolve(request)
assert.NoError(t, err)
assert.Equal(t, "CONDITIONAL", resp.Reason)
conditionalResolver.AssertExpectations(t)
nextResolver.AssertNotCalled(t, "Resolve", mock.Anything)
}
func Test_Resolve_Conditional_Subdomain(t *testing.T) {
sut, conditionalResolver, nextResolver := setup()
request := &Request{
Req: util.NewMsgWithQuestion("test.fritz.box.", dns.TypeA),
Log: logrus.NewEntry(logrus.New()),
}
_, err := sut.Resolve(request)
assert.NoError(t, err)
conditionalResolver.AssertExpectations(t)
nextResolver.AssertNotCalled(t, "Resolve", mock.Anything)
}
func Test_Resolve_Conditional_Not_Match(t *testing.T) {
sut, conditionalResolver, nextResolver := setup()
request := &Request{
Req: util.NewMsgWithQuestion("example.com.", dns.TypeA),
Log: logrus.NewEntry(logrus.New()),
}
_, err := sut.Resolve(request)
assert.NoError(t, err)
nextResolver.AssertExpectations(t)
conditionalResolver.AssertNotCalled(t, "Resolve", mock.Anything)
}

View File

@ -0,0 +1,94 @@
package resolver
import (
"blocky/config"
"blocky/util"
"fmt"
"net"
"strings"
"github.com/miekg/dns"
"github.com/sirupsen/logrus"
)
const customDNSTTL = 60 * 60
// CustomDNSResolver resolves passed domain name to ip address defined in domain-IP map
type CustomDNSResolver struct {
NextResolver
mapping map[string]net.IP
}
func NewCustomDNSResolver(cfg config.CustomDNSConfig) ChainedResolver {
m := make(map[string]net.IP)
for url, ip := range cfg.Mapping {
m[strings.ToLower(url)] = ip
}
return &CustomDNSResolver{mapping: m}
}
func (r *CustomDNSResolver) Configuration() (result []string) {
if len(r.mapping) > 0 {
for key, val := range r.mapping {
result = append(result, fmt.Sprintf("%s = \"%s\"", key, val))
}
} else {
result = []string{"deactivated"}
}
return
}
func (r *CustomDNSResolver) Resolve(request *Request) (*Response, error) {
logger := withPrefix(request.Log, "custom_dns_resolver")
if len(r.mapping) > 0 {
for _, question := range request.Req.Question {
domain := util.ExtractDomain(question)
for len(domain) > 0 {
ip, found := r.mapping[domain]
if found {
response := new(dns.Msg)
response.SetReply(request.Req)
if (ip.To4() != nil && question.Qtype == dns.TypeA) ||
(strings.Contains(ip.String(), ":") && question.Qtype == dns.TypeAAAA) {
rr, err := util.CreateAnswerFromQuestion(question, ip, customDNSTTL)
if err == nil {
response.Answer = append(response.Answer, rr)
logger.WithFields(logrus.Fields{
"answer": util.AnswerToString(response.Answer),
"domain": domain,
}).Debugf("returning custom dns entry")
return &Response{Res: response, Reason: "CUSTOM DNS"}, nil
}
return nil, err
}
response.Rcode = dns.RcodeNameError
return &Response{Res: response, Reason: "CUSTOM DNS"}, nil
}
if i := strings.Index(domain, "."); i >= 0 {
domain = domain[i+1:]
} else {
break
}
}
}
}
logger.WithField("resolver", r.next).Trace("go to next resolver")
return r.next.Resolve(request)
}
func (r CustomDNSResolver) String() string {
return fmt.Sprintf("custom resolver")
}

View File

@ -0,0 +1,101 @@
package resolver
import (
"blocky/config"
"blocky/util"
"net"
"testing"
"github.com/miekg/dns"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func Test_Resolve_Custom_Name_Ip4_A(t *testing.T) {
sut := NewCustomDNSResolver(config.CustomDNSConfig{
Mapping: map[string]net.IP{"custom.domain": net.ParseIP("192.168.143.123")}})
m := &resolverMock{}
sut.Next(m)
request := &Request{
Req: util.NewMsgWithQuestion("custom.domain.", dns.TypeA),
Log: logrus.NewEntry(logrus.New()),
}
resp, err := sut.Resolve(request)
assert.NoError(t, err)
assert.Equal(t, dns.RcodeSuccess, resp.Res.Rcode)
assert.Equal(t, "custom.domain. 3600 IN A 192.168.143.123", resp.Res.Answer[0].String())
m.AssertNotCalled(t, "Resolve", mock.Anything)
}
func Test_Resolve_Custom_Name_Ip4_AAAA(t *testing.T) {
sut := NewCustomDNSResolver(config.CustomDNSConfig{
Mapping: map[string]net.IP{"custom.domain": net.ParseIP("192.168.143.123")}})
m := &resolverMock{}
sut.Next(m)
request := &Request{
Req: util.NewMsgWithQuestion("custom.domain.", dns.TypeAAAA),
Log: logrus.NewEntry(logrus.New()),
}
resp, err := sut.Resolve(request)
assert.NoError(t, err)
assert.Equal(t, dns.RcodeNameError, resp.Res.Rcode)
m.AssertNotCalled(t, "Resolve", mock.Anything)
}
func Test_Resolve_Custom_Name_Ip6_AAAA(t *testing.T) {
sut := NewCustomDNSResolver(config.CustomDNSConfig{
Mapping: map[string]net.IP{"custom.domain": net.ParseIP("2001:0db8:85a3:0000:0000:8a2e:0370:7334")}})
m := &resolverMock{}
sut.Next(m)
request := &Request{
Req: util.NewMsgWithQuestion("custom.domain.", dns.TypeAAAA),
Log: logrus.NewEntry(logrus.New()),
}
resp, err := sut.Resolve(request)
assert.NoError(t, err)
assert.Equal(t, dns.RcodeSuccess, resp.Res.Rcode)
assert.Equal(t, "custom.domain. 3600 IN AAAA 2001:db8:85a3::8a2e:370:7334", resp.Res.Answer[0].String())
m.AssertNotCalled(t, "Resolve", mock.Anything)
}
func Test_Resolve_Custom_Name_Subdomain(t *testing.T) {
sut := NewCustomDNSResolver(config.CustomDNSConfig{
Mapping: map[string]net.IP{"custom.domain": net.ParseIP("192.168.143.123")}})
m := &resolverMock{}
m.On("Resolve", mock.Anything).Return(&Response{Res: new(dns.Msg)}, nil)
sut.Next(m)
request := &Request{
Req: util.NewMsgWithQuestion("ABC.CUSTOM.DOMAIN.", dns.TypeA),
Log: logrus.NewEntry(logrus.New()),
}
resp, err := sut.Resolve(request)
assert.NoError(t, err)
assert.Equal(t, dns.RcodeSuccess, resp.Res.Rcode)
assert.Equal(t, "ABC.CUSTOM.DOMAIN. 3600 IN A 192.168.143.123", resp.Res.Answer[0].String())
m.AssertNotCalled(t, "Resolve", mock.Anything)
}
func Test_Resolve_Delegate_Next(t *testing.T) {
sut := NewCustomDNSResolver(config.CustomDNSConfig{
Mapping: map[string]net.IP{"custom.domain": net.ParseIP("192.168.143.123")}})
m := &resolverMock{}
m.On("Resolve", mock.Anything).Return(&Response{Res: new(dns.Msg)}, nil)
sut.Next(m)
request := &Request{
Req: util.NewMsgWithQuestion("example.com.", dns.TypeA),
Log: logrus.NewEntry(logrus.New()),
}
_, _ = sut.Resolve(request)
m.AssertExpectations(t)
}

85
resolver/mocks.go Normal file
View File

@ -0,0 +1,85 @@
package resolver
import (
"blocky/config"
"net"
"strconv"
"strings"
"github.com/miekg/dns"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/mock"
)
type resolverMock struct {
mock.Mock
NextResolver
}
func (r *resolverMock) Configuration() (result []string) {
return
}
func (r *resolverMock) Resolve(req *Request) (*Response, error) {
args := r.Called(req)
return args.Get(0).(*Response), args.Error(1)
}
func TestUDPUpstreamWithResponse(response *dns.Msg) config.Upstream {
return TestUDPUpstream(func(request *dns.Msg) *dns.Msg {
return response
})
}
func TestUDPUpstream(fn func(request *dns.Msg) (response *dns.Msg)) config.Upstream {
a, err := net.ResolveUDPAddr("udp4", ":0")
if err != nil {
log.Fatal("can't resolve address: ", err)
}
ln, err := net.ListenUDP("udp4", a)
if err != nil {
log.Fatal("can't create connection: ", err)
}
ladr := ln.LocalAddr().String()
host := strings.Split(ladr, ":")[0]
p, err := strconv.Atoi(strings.Split(ladr, ":")[1])
if err != nil {
log.Fatal("can't convert port: ", err)
}
port := uint16(p)
go func() {
for {
buffer := make([]byte, 1024)
n, addr, err := ln.ReadFromUDP(buffer)
if err != nil {
log.Fatal("error on reading from udp: ", err)
}
msg := new(dns.Msg)
err = msg.Unpack(buffer[0 : n-1])
if err != nil {
log.Fatal("can't deserialize message: ", err)
}
response := fn(msg)
response.SetReply(msg)
b, err := response.Pack()
if err != nil {
log.Fatal("can't serialize message: ", err)
}
_, err = ln.WriteToUDP(b, addr)
if err != nil {
log.Fatal("can't write to UDP: ", err)
}
}
}()
return config.Upstream{Net: "udp", Host: host, Port: port}
}

View File

@ -0,0 +1,113 @@
package resolver
import (
"blocky/util"
"fmt"
"math/rand"
"github.com/sirupsen/logrus"
)
// ParallelBestResolver delegates the DNS message to 2 upstream resolvers and returns the fastest answer
type ParallelBestResolver struct {
resolvers []Resolver
}
func NewParallelBestResolver(resolvers []Resolver) Resolver {
return &ParallelBestResolver{resolvers: resolvers}
}
func (r *ParallelBestResolver) Configuration() (result []string) {
result = append(result, "upstream resolvers:")
for _, res := range r.resolvers {
result = append(result, fmt.Sprintf("- %s", res))
}
return
}
func (r *ParallelBestResolver) Resolve(request *Request) (*Response, error) {
logger := request.Log.WithField("prefix", "parallel_best_resolver")
r1, r2 := r.pickRandom()
logger.Debugf("using %s and %s as resolver", r1, r2)
ch1 := make(chan struct {
*Response
error
})
ch2 := make(chan struct {
*Response
error
})
var err1, err2 error
logger.WithField("resolver", r1).Debug("delegating to resolver")
go resolve(request, r1, ch1)
logger.WithField("resolver", r2).Debug("delegating to resolver")
go resolve(request, r2, ch2)
for err1 == nil || err2 == nil {
select {
case msg1 := <-ch1:
if msg1.error != nil {
err1 = msg1.error
ch1 = nil
logger.WithField("resolver", r1).Debug("resolution failed from resolver, cause: ", msg1.error)
} else {
logger.WithFields(logrus.Fields{
"resolver": r1,
"answer": util.AnswerToString(msg1.Response.Res.Answer),
}).Debug("using response from resolver")
return msg1.Response, nil
}
case msg2 := <-ch2:
if msg2.error != nil {
err2 = msg2.error
ch2 = nil
logger.WithField("resolver", r2).Debug("resolution failed from resolver, cause: ", msg2.error)
} else {
logger.WithFields(logrus.Fields{
"resolver": r2,
"answer": util.AnswerToString(msg2.Response.Res.Answer),
}).Debug("using response from resolver")
return msg2.Response, nil
}
}
}
return nil, fmt.Errorf("resolution was not successful, errors: '%v', '%v'", err1, err2)
}
// pick 2 different random resolvers from the resolver pool
func (r *ParallelBestResolver) pickRandom() (resolver1, resolver2 Resolver) {
resolver1 = r.resolvers[rand.Intn(len(r.resolvers))]
for resolver2 == resolver1 || resolver2 == nil {
resolver2 = r.resolvers[rand.Intn(len(r.resolvers))]
}
return
}
func resolve(req *Request, resolver Resolver, ch chan struct {
*Response
error
}) {
defer close(ch)
resp, err := resolver.Resolve(req)
ch <- struct {
*Response
error
}{resp, err}
}
func (r ParallelBestResolver) String() string {
return fmt.Sprintf("parallel best resolver '%s'", r.resolvers)
}

View File

@ -0,0 +1,39 @@
package resolver
import (
"blocky/util"
"testing"
"time"
"github.com/miekg/dns"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func Test_Resolve_Best_Result(t *testing.T) {
fast := &resolverMock{}
mockResp, err := util.NewMsgWithAnswer("example.com. 123 IN A 192.168.178.44")
if err != nil {
t.Error(err)
}
fast.On("Resolve", mock.Anything).Return(&Response{Res: mockResp}, nil)
slow := &resolverMock{}
slow.On("Resolve", mock.Anything).WaitUntil(time.After(50*time.Millisecond)).Return(&Response{Res: new(dns.Msg)}, nil)
sut := NewParallelBestResolver([]Resolver{slow, fast})
resp, err := sut.Resolve(&Request{
Req: util.NewMsgWithQuestion("example.com.", dns.TypeA),
Log: logrus.NewEntry(logrus.New()),
})
assert.NoError(t, err)
assert.Equal(t, dns.RcodeSuccess, resp.Res.Rcode)
assert.Equal(t, "example.com. 123 IN A 192.168.178.44", resp.Res.Answer[0].String())
fast.AssertExpectations(t)
slow.AssertExpectations(t)
}

View File

@ -0,0 +1,229 @@
package resolver
import (
"blocky/config"
"blocky/util"
"encoding/csv"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"strings"
"time"
"github.com/miekg/dns"
"github.com/sirupsen/logrus"
"golang.org/x/sys/unix"
)
const (
cleanUpRunPeriod = 12 * time.Hour
queryLoggingResolverPrefix = "query_logging_resolver"
logChanCap = 1000
)
// QueryLoggingResolver writes query information (question, answer, duration, ...) into
// log file or as log entry (if log directory is not configured)
type QueryLoggingResolver struct {
NextResolver
logDir string
perClient bool
logRetentionDays uint64
logChan chan *queryLogEntry
}
type queryLogEntry struct {
request *Request
response *Response
start time.Time
durationMs int64
logger *logrus.Entry
}
func NewQueryLoggingResolver(cfg config.QueryLogConfig) ChainedResolver {
if cfg.Dir != "" && unix.Access(cfg.Dir, unix.W_OK) != nil {
logger(queryLoggingResolverPrefix).Fatalf("query log directory '%s' does not exist or is not writable", cfg.Dir)
}
logChan := make(chan *queryLogEntry, logChanCap)
resolver := QueryLoggingResolver{
logDir: cfg.Dir,
perClient: cfg.PerClient,
logRetentionDays: cfg.LogRetentionDays,
logChan: logChan,
}
go resolver.writeLog()
if cfg.LogRetentionDays > 0 {
go resolver.periodicCleanUp()
}
return &resolver
}
// triggers periodically cleanup of old log files
func (r *QueryLoggingResolver) periodicCleanUp() {
ticker := time.NewTicker(cleanUpRunPeriod)
defer ticker.Stop()
for {
<-ticker.C
r.doCleanUp()
}
}
// deletes old log files
func (r *QueryLoggingResolver) doCleanUp() {
logger := logger(queryLoggingResolverPrefix)
logger.Trace("starting clean up")
files, err := ioutil.ReadDir(r.logDir)
if err != nil {
logger.WithField("log_dir", r.logDir).Error("can't list log directory: ", err)
}
// search for log files, which names starts with date
for _, f := range files {
if strings.HasSuffix(f.Name(), ".log") && len(f.Name()) > 10 {
t, err := time.Parse("2006-01-02", f.Name()[:10])
if err == nil {
differenceDays := uint64(time.Since(t).Hours() / 24)
if r.logRetentionDays > 0 && differenceDays > r.logRetentionDays {
logger.WithFields(logrus.Fields{
"file": f.Name(),
"ageInDays": differenceDays,
"logRetentionDays": r.logRetentionDays,
}).Info("existing log file is older than retention time and will be deleted")
err := os.Remove(filepath.Join(r.logDir, f.Name()))
if err != nil {
logger.WithField("file", f.Name()).Error("can't remove file: ", err)
}
}
}
}
}
}
func (r *QueryLoggingResolver) Resolve(request *Request) (*Response, error) {
logger := withPrefix(request.Log, queryLoggingResolverPrefix)
start := time.Now()
resp, err := r.next.Resolve(request)
duration := time.Since(start).Milliseconds()
if err == nil {
select {
case r.logChan <- &queryLogEntry{
request: request,
response: resp,
start: start,
durationMs: duration,
logger: logger}:
default:
logger.Error("query log writer is too slow, log entry will be dropped")
}
}
return resp, err
}
// write entry: if log directory is configured, write to log file
func (r *QueryLoggingResolver) writeLog() {
for logEntry := range r.logChan {
if r.logDir != "" {
var clientPrefix string
start := time.Now()
dateString := logEntry.start.Format("2006-01-02")
if r.perClient {
clientPrefix = strings.Join(logEntry.request.ClientNames, "-")
} else {
clientPrefix = "ALL"
}
writePath := filepath.Join(r.logDir, fmt.Sprintf("%s_%s.log", dateString, clientPrefix))
file, err := os.OpenFile(writePath, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0666)
if err != nil {
logEntry.logger.WithField("file_name", writePath).Error("can't create/open file", err)
} else {
writer := createCsvWriter(file)
err := writer.Write(createQueryLogRow(logEntry))
if err != nil {
logEntry.logger.WithField("file_name", writePath).Error("can't write to file", err)
}
writer.Flush()
}
halfCap := cap(r.logChan) / 2
// if log channel is > 50% full, this could be a problem with slow writer (external storage over network etc.)
if len(r.logChan) > halfCap {
logEntry.logger.WithField("channel_len",
len(r.logChan)).Warnf("query log writer is too slow, write duration: %d ms", time.Since(start).Milliseconds())
}
} else {
logEntry.logger.WithFields(
logrus.Fields{
"response_reason": logEntry.response.Reason,
"answer": util.AnswerToString(logEntry.response.Res.Answer),
"duration_ms": logEntry.durationMs,
},
).Infof("query resolved")
}
}
}
func createCsvWriter(file io.Writer) *csv.Writer {
writer := csv.NewWriter(file)
writer.Comma = '\t'
return writer
}
func createQueryLogRow(logEntry *queryLogEntry) []string {
request := logEntry.request
response := logEntry.response
return []string{
logEntry.start.Format("2006-01-02 15:04:05"),
request.ClientIP.String(),
strings.Join(request.ClientNames, "; "),
fmt.Sprintf("%d", logEntry.durationMs),
response.Reason,
util.QuestionToString(request.Req.Question),
util.AnswerToString(response.Res.Answer),
dns.RcodeToString[response.Res.Rcode],
}
}
func (r *QueryLoggingResolver) Configuration() (result []string) {
if r.logDir != "" {
result = append(result, fmt.Sprintf("logDir= \"%s\"", r.logDir))
result = append(result, fmt.Sprintf("perClient = %t", r.perClient))
result = append(result, fmt.Sprintf("logRetentionDays= %d", r.logRetentionDays))
if r.logRetentionDays == 0 {
result = append(result, "log cleanup deactivated")
}
} else {
result = []string{"deactivated"}
}
return
}
func (r QueryLoggingResolver) String() string {
return fmt.Sprintf("query logging resolver")
}

View File

@ -0,0 +1,211 @@
package resolver
import (
"blocky/config"
"blocky/util"
"bufio"
"encoding/csv"
"fmt"
"io"
"io/ioutil"
"log"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/miekg/dns"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func Test_doCleanUp(t *testing.T) {
tmpDir, err := ioutil.TempDir("", "queryLoggingResolver")
defer os.RemoveAll(tmpDir)
assert.NoError(t, err)
// create 2 files, 7 and 8 days old
dateBefore7Days := time.Now().AddDate(0, 0, -7)
dateBefore8Days := time.Now().AddDate(0, 0, -8)
f1, err := os.Create(filepath.Join(tmpDir, fmt.Sprintf("%s-test.log", dateBefore7Days.Format("2006-01-02"))))
assert.NoError(t, err)
f2, err := os.Create(filepath.Join(tmpDir, fmt.Sprintf("%s-test.log", dateBefore8Days.Format("2006-01-02"))))
assert.NoError(t, err)
sut := NewQueryLoggingResolver(config.QueryLogConfig{
Dir: tmpDir,
LogRetentionDays: 7,
})
sut.(*QueryLoggingResolver).doCleanUp()
// file 1 exist
_, err = os.Stat(f1.Name())
assert.NoError(t, err)
// file 2 was deleted
_, err = os.Stat(f2.Name())
assert.Error(t, err)
assert.True(t, os.IsNotExist(err))
}
func Test_Resolve_WithEmptyConfig(t *testing.T) {
sut := NewQueryLoggingResolver(config.QueryLogConfig{})
m := &resolverMock{}
resp, err := util.NewMsgWithAnswer("example.com. 300 IN A 123.122.121.120")
assert.NoError(t, err)
m.On("Resolve", mock.Anything).Return(&Response{Res: resp, Reason: "reason"}, nil)
sut.Next(m)
_, err = sut.Resolve(&Request{
ClientIP: net.ParseIP("192.168.178.25"),
ClientNames: []string{"client1"},
Req: util.NewMsgWithQuestion("google.de.", dns.TypeA),
Log: logrus.NewEntry(logrus.New())})
assert.NoError(t, err)
m.AssertExpectations(t)
}
func Test_Resolve_WithLoggingPerClient(t *testing.T) {
tmpDir, err := ioutil.TempDir("", "queryLoggingResolver")
assert.NoError(t, err)
defer os.RemoveAll(tmpDir)
sut := NewQueryLoggingResolver(config.QueryLogConfig{
Dir: tmpDir,
PerClient: true,
})
m := &resolverMock{}
resp, err := util.NewMsgWithAnswer("example.com. 300 IN A 123.122.121.120")
assert.NoError(t, err)
m.On("Resolve", mock.Anything).Return(&Response{Res: resp, Reason: "reason"}, nil)
sut.Next(m)
// request client1
_, err = sut.Resolve(&Request{
ClientIP: net.ParseIP("192.168.178.25"),
ClientNames: []string{"client1"},
Req: util.NewMsgWithQuestion("google.de.", dns.TypeA),
Log: logrus.NewEntry(logrus.New())})
assert.NoError(t, err)
// request client2
_, err = sut.Resolve(&Request{
ClientIP: net.ParseIP("192.168.178.26"),
ClientNames: []string{"client2"},
Req: util.NewMsgWithQuestion("google.de.", dns.TypeA),
Log: logrus.NewEntry(logrus.New())})
assert.NoError(t, err)
time.Sleep(100 * time.Millisecond)
m.AssertExpectations(t)
// client1
csvLines := readCsv(filepath.Join(tmpDir, fmt.Sprintf("%s_client1.log", time.Now().Format("2006-01-02"))))
assert.Len(t, csvLines, 1)
assert.Equal(t, "192.168.178.25", csvLines[0][1])
assert.Equal(t, "client1", csvLines[0][2])
assert.Equal(t, "reason", csvLines[0][4])
assert.Equal(t, "A (google.de.)", csvLines[0][5])
assert.Equal(t, "A (123.122.121.120)", csvLines[0][6])
// client2
csvLines = readCsv(filepath.Join(tmpDir, fmt.Sprintf("%s_client2.log", time.Now().Format("2006-01-02"))))
assert.Len(t, csvLines, 1)
assert.Equal(t, "192.168.178.26", csvLines[0][1])
assert.Equal(t, "client2", csvLines[0][2])
assert.Equal(t, "reason", csvLines[0][4])
assert.Equal(t, "A (google.de.)", csvLines[0][5])
assert.Equal(t, "A (123.122.121.120)", csvLines[0][6])
}
func Test_Resolve_WithLoggingAll(t *testing.T) {
tmpDir, err := ioutil.TempDir("", "queryLoggingResolver")
assert.NoError(t, err)
defer os.RemoveAll(tmpDir)
sut := NewQueryLoggingResolver(config.QueryLogConfig{
Dir: tmpDir,
PerClient: false,
})
m := &resolverMock{}
resp, err := util.NewMsgWithAnswer("example.com. 300 IN A 123.122.121.120")
assert.NoError(t, err)
m.On("Resolve", mock.Anything).Return(&Response{Res: resp, Reason: "reason"}, nil)
sut.Next(m)
// request client1
_, err = sut.Resolve(&Request{
ClientIP: net.ParseIP("192.168.178.25"),
ClientNames: []string{"client1"},
Req: util.NewMsgWithQuestion("google.de.", dns.TypeA),
Log: logrus.NewEntry(logrus.New())})
assert.NoError(t, err)
// request client2
_, err = sut.Resolve(&Request{
ClientIP: net.ParseIP("192.168.178.26"),
ClientNames: []string{"client2"},
Req: util.NewMsgWithQuestion("google.de.", dns.TypeA),
Log: logrus.NewEntry(logrus.New())})
assert.NoError(t, err)
time.Sleep(100 * time.Millisecond)
m.AssertExpectations(t)
csvLines := readCsv(filepath.Join(tmpDir, fmt.Sprintf("%s_ALL.log", time.Now().Format("2006-01-02"))))
assert.Len(t, csvLines, 2)
// client1 -> first line
assert.Equal(t, "192.168.178.25", csvLines[0][1])
assert.Equal(t, "client1", csvLines[0][2])
assert.Equal(t, "reason", csvLines[0][4])
assert.Equal(t, "A (google.de.)", csvLines[0][5])
assert.Equal(t, "A (123.122.121.120)", csvLines[0][6])
// client2 -> second line
assert.Equal(t, "192.168.178.26", csvLines[1][1])
assert.Equal(t, "client2", csvLines[1][2])
assert.Equal(t, "reason", csvLines[1][4])
assert.Equal(t, "A (google.de.)", csvLines[1][5])
assert.Equal(t, "A (123.122.121.120)", csvLines[1][6])
}
func readCsv(file string) [][]string {
var result [][]string
csvFile, err := os.Open(file)
if err != nil {
log.Fatal("can't open file", err)
}
reader := csv.NewReader(bufio.NewReader(csvFile))
reader.Comma = '\t'
for {
line, err := reader.Read()
if err == io.EOF {
break
} else if err != nil {
log.Fatal("can't read line", err)
}
result = append(result, line)
}
return result
}

57
resolver/resolver.go Normal file
View File

@ -0,0 +1,57 @@
package resolver
import (
"net"
"github.com/miekg/dns"
"github.com/sirupsen/logrus"
)
type Request struct {
ClientIP net.IP
ClientNames []string
Req *dns.Msg
Log *logrus.Entry
}
type ResponseType uint8
const (
Resolved ResponseType = iota
Blocked
)
type Response struct {
Res *dns.Msg
Reason string
}
type Resolver interface {
Resolve(req *Request) (*Response, error)
Configuration() []string
}
type ChainedResolver interface {
Resolver
Next(n Resolver)
GetNext() Resolver
}
type NextResolver struct {
next Resolver
}
func (r *NextResolver) Next(n Resolver) {
r.next = n
}
func (r *NextResolver) GetNext() Resolver {
return r.next
}
func logger(prefix string) *logrus.Entry {
return logrus.WithField("prefix", prefix)
}
func withPrefix(logger *logrus.Entry, prefix string) *logrus.Entry {
return logger.WithField("prefix", prefix)
}

View File

@ -0,0 +1,69 @@
package resolver
import (
"blocky/config"
"blocky/util"
"fmt"
"net"
"strconv"
"time"
"github.com/miekg/dns"
"github.com/sirupsen/logrus"
)
// UpstreamResolver sends request to external DNS server
type UpstreamResolver struct {
NextResolver
client *dns.Client
upstream string
}
func NewUpstreamResolver(upstream config.Upstream) Resolver {
client := new(dns.Client)
client.Net = upstream.Net
return &UpstreamResolver{
client: client,
upstream: net.JoinHostPort(upstream.Host, strconv.Itoa(int(upstream.Port)))}
}
func (r *UpstreamResolver) Configuration() (result []string) {
return
}
func (r *UpstreamResolver) Resolve(request *Request) (response *Response, err error) {
logger := withPrefix(request.Log, "upstream_resolver")
attempt := 1
var rtt time.Duration
var resp *dns.Msg
for attempt <= 3 {
if resp, rtt, err = r.client.Exchange(request.Req, r.upstream); err == nil {
logger.WithFields(logrus.Fields{
"answer": util.AnswerToString(resp.Answer),
"return_code": dns.RcodeToString[resp.Rcode],
"upstream": r.upstream,
"response_time_ms": rtt.Milliseconds(),
}).Debugf("received response from upstream")
return &Response{Res: resp, Reason: fmt.Sprintf("RESOLVED (%s) in %d ms", r.upstream, rtt.Milliseconds())}, err
}
if errNet, ok := err.(net.Error); ok && (errNet.Timeout() || errNet.Temporary()) {
logger.WithField("attempt", attempt).Debugf("Temporary network error / Timeout occurred, retrying...")
attempt++
} else {
return nil, err
}
}
return
}
func (r UpstreamResolver) String() string {
return fmt.Sprintf("upstream '%s'", r.upstream)
}

View File

@ -0,0 +1,79 @@
package resolver
import (
"blocky/util"
"fmt"
"strings"
"testing"
"time"
"github.com/miekg/dns"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
func Test_Resolve_Upstream(t *testing.T) {
upstream := TestUDPUpstream(func(request *dns.Msg) *dns.Msg {
response, err := util.NewMsgWithAnswer("example.com 123 IN A 123.124.122.122")
assert.NoError(t, err)
return response
})
sut := NewUpstreamResolver(upstream)
request := &Request{
Req: util.NewMsgWithQuestion("example.com.", dns.TypeA),
Log: logrus.NewEntry(logrus.New()),
}
resp, err := sut.Resolve(request)
assert.NoError(t, err)
assert.Equal(t, dns.RcodeSuccess, resp.Res.Rcode)
assert.Equal(t, "example.com. 123 IN A 123.124.122.122", resp.Res.Answer[0].String())
}
func TestUpstreamTimeout(t *testing.T) {
counter := 0
attemptsWithTimeout := 2
upstream := TestUDPUpstream(func(request *dns.Msg) (response *dns.Msg) {
counter++
// timeout on first x attempts
if counter <= attemptsWithTimeout {
fmt.Print("timeout")
time.Sleep(110 * time.Millisecond)
}
response, err := util.NewMsgWithAnswer("example.com 123 IN A 123.124.122.122")
assert.NoError(t, err)
return response
})
sut := NewUpstreamResolver(upstream).(*UpstreamResolver)
sut.client.ReadTimeout = 100 * time.Millisecond
request := &Request{
Req: util.NewMsgWithQuestion("example.com.", dns.TypeA),
Log: logrus.NewEntry(logrus.New()),
}
// first request -> after 2 timeouts success
response, err := sut.Resolve(request)
assert.NoError(t, err)
if response != nil {
assert.Equal(t, dns.RcodeSuccess, response.Res.Rcode)
assert.Equal(t, "example.com.\t123\tIN\tA\t123.124.122.122", response.Res.Answer[0].String())
}
attemptsWithTimeout = 3
counter = 0
// second request
// all 3 attempts with timeout
response, err = sut.Resolve(request)
assert.Error(t, err)
assert.True(t, strings.HasSuffix(err.Error(), "i/o timeout"))
assert.Nil(t, response)
}

189
server/server.go Normal file
View File

@ -0,0 +1,189 @@
package server
import (
"blocky/config"
"blocky/resolver"
"os"
"os/signal"
"syscall"
"blocky/util"
"fmt"
"net"
"github.com/miekg/dns"
"github.com/sirupsen/logrus"
)
type Server struct {
udpServer *dns.Server
tcpServer *dns.Server
queryResolver resolver.Resolver
}
func logger() *logrus.Entry {
return logrus.WithField("prefix", "server")
}
func NewServer(cfg *config.Config) (*Server, error) {
udpHandler := dns.NewServeMux()
tcpHandler := dns.NewServeMux()
udpServer := &dns.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Net: "udp",
Handler: udpHandler,
NotifyStartedFunc: func() {
logger().Infof("udp server is up and running")
},
UDPSize: 65535}
tcpServer := &dns.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Net: "tcp",
Handler: tcpHandler,
NotifyStartedFunc: func() {
logger().Infof("tcp server is up and running")
},
}
var queryResolver resolver.Resolver
clientNamesResolver := resolver.NewClientNamesResolver(cfg.ClientLookup)
queryLoggingResolver := resolver.NewQueryLoggingResolver(cfg.QueryLog)
conditionalUpstreamResolver := resolver.NewConditionalUpstreamResolver(cfg.Conditional)
customDNSResolver := resolver.NewCustomDNSResolver(cfg.CustomDNS)
blacklistResolver := resolver.NewBlockingResolver(cfg.Blocking)
cachingResolver := resolver.NewCachingResolver()
parallelUpstreamResolver := createParallelUpstreamResolver(cfg.Upstream.ExternalResolvers)
clientNamesResolver.Next(queryLoggingResolver)
queryLoggingResolver.Next(conditionalUpstreamResolver)
conditionalUpstreamResolver.Next(customDNSResolver)
customDNSResolver.Next(blacklistResolver)
blacklistResolver.Next(cachingResolver)
cachingResolver.Next(parallelUpstreamResolver)
queryResolver = clientNamesResolver
server := Server{
udpServer: udpServer,
tcpServer: tcpServer,
queryResolver: queryResolver,
}
server.printConfiguration()
udpHandler.HandleFunc(".", server.OnRequest)
tcpHandler.HandleFunc(".", server.OnRequest)
return &server, nil
}
func (s *Server) printConfiguration() {
logger().Info("current configuration:")
res := s.queryResolver
for res != nil {
logger().Infof("-> resolver: '%s'", res)
for _, c := range res.Configuration() {
logger().Infof(" %s", c)
}
if c, ok := res.(resolver.ChainedResolver); ok {
res = c.GetNext()
} else {
break
}
}
}
func createParallelUpstreamResolver(upstream []config.Upstream) resolver.Resolver {
if len(upstream) == 1 {
return resolver.NewUpstreamResolver(upstream[0])
}
resolvers := make([]resolver.Resolver, len(upstream))
for i, u := range upstream {
resolvers[i] = resolver.NewUpstreamResolver(u)
}
return resolver.NewParallelBestResolver(resolvers)
}
func (s *Server) Start() {
logger().Info("Starting server")
go func() {
if err := s.udpServer.ListenAndServe(); err != nil {
logger().Fatalf("start %s listener failed: %v", s.udpServer.Net, err)
}
}()
go func() {
if err := s.tcpServer.ListenAndServe(); err != nil {
logger().Fatalf("start %s listener failed: %v", s.tcpServer.Net, err)
}
}()
signals := make(chan os.Signal)
signal.Notify(signals, syscall.SIGUSR1)
go func() {
for {
<-signals
s.printConfiguration()
}
}()
}
func (s *Server) Stop() {
logger().Info("Stopping server")
if err := s.udpServer.Shutdown(); err != nil {
logger().Fatalf("stop %s listener failed: %v", s.udpServer.Net, err)
}
if err := s.tcpServer.Shutdown(); err != nil {
logger().Fatalf("stop %s listener failed: %v", s.tcpServer.Net, err)
}
}
func (s *Server) OnRequest(w dns.ResponseWriter, request *dns.Msg) {
logger().Debug("new request")
clientIP := resolveClientIP(w.RemoteAddr())
r := &resolver.Request{
ClientIP: clientIP,
Req: request,
Log: logrus.WithFields(logrus.Fields{
"question": util.QuestionToString(request.Question),
"client_ip": clientIP,
}),
}
response, err := s.queryResolver.Resolve(r)
if err != nil {
logger().Errorf("error on processing request: %v", err)
dns.HandleFailed(w, request)
} else {
response.Res.MsgHdr.RecursionAvailable = request.MsgHdr.RecursionDesired
if err := w.WriteMsg(response.Res); err != nil {
logger().Error("can't write message: ", err)
}
}
}
func resolveClientIP(addr net.Addr) net.IP {
var clientIP net.IP
if t, ok := addr.(*net.UDPAddr); ok {
clientIP = t.IP
} else if t, ok := addr.(*net.TCPAddr); ok {
clientIP = t.IP
}
return clientIP
}

323
server/server_test.go Normal file
View File

@ -0,0 +1,323 @@
package server
import (
"blocky/config"
"blocky/resolver"
"blocky/util"
"fmt"
"log"
"net"
"testing"
"time"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
)
var mockClientName string
// test case definition
var tests = []struct {
name string
request *dns.Msg
mockClientName string
respValidator func(*testing.T, *dns.Msg)
}{
{
// resolve query via external dns
name: "resolveWithUpstream",
request: util.NewMsgWithQuestion("google.de.", dns.TypeA),
respValidator: func(t *testing.T, resp *dns.Msg) {
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
assert.Equal(t, "google.de.\t250\tIN\tA\t123.124.122.122", resp.Answer[0].String())
},
},
{
// custom dnd entry with exact match
name: "customDns",
request: util.NewMsgWithQuestion("custom.lan.", dns.TypeA),
respValidator: func(t *testing.T, resp *dns.Msg) {
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
assert.Equal(t, "custom.lan.\t3600\tIN\tA\t192.168.178.55", resp.Answer[0].String())
},
},
{
// sub domain custom dns
name: "customDnsWithSubdomain",
request: util.NewMsgWithQuestion("host.lan.home.", dns.TypeA),
respValidator: func(t *testing.T, resp *dns.Msg) {
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
assert.Equal(t, "host.lan.home.\t3600\tIN\tA\t192.168.178.56", resp.Answer[0].String())
},
},
{
// delegate to special dns upstream
name: "conditional",
request: util.NewMsgWithQuestion("host.fritz.box.", dns.TypeA),
respValidator: func(t *testing.T, resp *dns.Msg) {
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
assert.Equal(t, "host.fritz.box.\t3600\tIN\tA\t192.168.178.2", resp.Answer[0].String())
},
},
{
// blocking default group
name: "blockDefault",
request: util.NewMsgWithQuestion("doubleclick.net.", dns.TypeA),
respValidator: func(t *testing.T, resp *dns.Msg) {
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
assert.Equal(t, "doubleclick.net.\t21600\tIN\tA\t0.0.0.0", resp.Answer[0].String())
},
},
{
// blocking default group with sub domain
name: "blockDefaultWithSubdomain",
request: util.NewMsgWithQuestion("www.bild.de.", dns.TypeA),
respValidator: func(t *testing.T, resp *dns.Msg) {
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
assert.Equal(t, "www.bild.de.\t21600\tIN\tA\t0.0.0.0", resp.Answer[0].String())
},
},
{
// no blocking default group with sub domain
name: "noBlockDefaultWithSubdomain",
request: util.NewMsgWithQuestion("bild.de.", dns.TypeA),
respValidator: func(t *testing.T, resp *dns.Msg) {
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
assert.Equal(t, "bild.de.\t250\tIN\tA\t123.124.122.122", resp.Answer[0].String())
},
},
{
// white and block default group
name: "whiteBlackDefault",
request: util.NewMsgWithQuestion("heise.de.", dns.TypeA),
respValidator: func(t *testing.T, resp *dns.Msg) {
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
assert.Equal(t, "heise.de.\t250\tIN\tA\t123.124.122.122", resp.Answer[0].String())
},
},
{
// no block client whitelist only
name: "noBlockWhitelistOnly",
mockClientName: "clWhitelistOnly",
request: util.NewMsgWithQuestion("heise.de.", dns.TypeA),
respValidator: func(t *testing.T, resp *dns.Msg) {
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
assert.Equal(t, "123.124.122.122", resp.Answer[0].(*dns.A).A.String())
},
},
{
// block client whitelist only
name: "blockWhitelistOnly",
mockClientName: "clWhitelistOnly",
request: util.NewMsgWithQuestion("google.de.", dns.TypeA),
respValidator: func(t *testing.T, resp *dns.Msg) {
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
assert.Equal(t, "0.0.0.0", resp.Answer[0].(*dns.A).A.String())
},
},
{
// block client with 2 groups
name: "block2groups1",
mockClientName: "clAdsAndYoutube",
request: util.NewMsgWithQuestion("www.bild.de.", dns.TypeA),
respValidator: func(t *testing.T, resp *dns.Msg) {
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
assert.Equal(t, "0.0.0.0", resp.Answer[0].(*dns.A).A.String())
},
},
{
// block client with 2 groups
name: "block2groups2",
mockClientName: "clAdsAndYoutube",
request: util.NewMsgWithQuestion("youtube.com.", dns.TypeA),
respValidator: func(t *testing.T, resp *dns.Msg) {
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
assert.Equal(t, "0.0.0.0", resp.Answer[0].(*dns.A).A.String())
},
},
{
// lient with 1 group: no block if domain in other group
name: "noBlockBlacklistOtherGroup",
mockClientName: "clYoutubeOnly",
request: util.NewMsgWithQuestion("www.bild.de.", dns.TypeA),
respValidator: func(t *testing.T, resp *dns.Msg) {
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
assert.Equal(t, "123.124.122.122", resp.Answer[0].(*dns.A).A.String())
},
},
{
// block client with 1 group
name: "blockBlacklist",
mockClientName: "clYoutubeOnly",
request: util.NewMsgWithQuestion("youtube.com.", dns.TypeA),
respValidator: func(t *testing.T, resp *dns.Msg) {
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
assert.Equal(t, "0.0.0.0", resp.Answer[0].(*dns.A).A.String())
},
},
}
//nolint:funlen
func TestDnsRequest(t *testing.T) {
upstreamGoogle := resolver.TestUDPUpstream(func(request *dns.Msg) *dns.Msg {
response, err := util.NewMsgWithAnswer(fmt.Sprintf("%s %d %s %s %s",
util.ExtractDomain(request.Question[0]), 123, "IN", "A", "123.124.122.122"))
assert.NoError(t, err)
return response
})
upstreamFritzbox := resolver.TestUDPUpstream(func(request *dns.Msg) *dns.Msg {
response, err := util.NewMsgWithAnswer(fmt.Sprintf("%s %d %s %s %s",
util.ExtractDomain(request.Question[0]), 3600, "IN", "A", "192.168.178.2"))
assert.NoError(t, err)
return response
})
upstreamClient := resolver.TestUDPUpstream(func(request *dns.Msg) *dns.Msg {
response, err := util.NewMsgWithAnswer(fmt.Sprintf("%s %d %s %s %s",
util.ExtractDomain(request.Question[0]), 3600, "IN", "PTR", mockClientName))
assert.NoError(t, err)
return response
})
// create server
server, err := NewServer(&config.Config{
CustomDNS: config.CustomDNSConfig{
Mapping: map[string]net.IP{
"custom.lan": net.ParseIP("192.168.178.55"),
"lan.home": net.ParseIP("192.168.178.56"),
},
},
Conditional: config.ConditionalUpstreamConfig{
Mapping: map[string]config.Upstream{"fritz.box": upstreamFritzbox},
},
Blocking: config.BlockingConfig{
BlackLists: map[string][]string{
"ads": {
"../testdata/doubleclick.net.txt",
"../testdata/www.bild.de.txt",
"../testdata/heise.de.txt"},
"youtube": {"../testdata/youtube.com.txt"}},
WhiteLists: map[string][]string{
"ads": {"../testdata/heise.de.txt"},
"whitelist": {"../testdata/heise.de.txt"},
},
ClientGroupsBlock: map[string][]string{
"default": {"ads"},
"clWhitelistOnly": {"whitelist"},
"clAdsAndYoutube": {"ads", "youtube"},
"clYoutubeOnly": {"youtube"},
},
},
Upstream: config.UpstreamConfig{
ExternalResolvers: []config.Upstream{upstreamGoogle},
},
ClientLookup: config.ClientLookupConfig{
Upstream: upstreamClient,
},
Port: 55555,
})
assert.NoError(t, err)
// start server
go func() {
server.Start()
}()
defer server.Stop()
time.Sleep(100 * time.Millisecond)
for _, tt := range tests {
tst := tt
t.Run(tt.name, func(t *testing.T) {
res := server.queryResolver
for res != nil {
if t, ok := res.(*resolver.ClientNamesResolver); ok {
t.FlushCache()
break
}
if c, ok := res.(resolver.ChainedResolver); ok {
res = c.GetNext()
} else {
break
}
}
mockClientName = tst.mockClientName
response := requestServer(tst.request)
tst.respValidator(t, response)
})
}
}
// 26106 43273 ns/op 12518 B/op 137 allocs/op
func BenchmarkServerExternalResolver(b *testing.B) {
msg, _ := util.NewMsgWithAnswer(fmt.Sprintf("example.com IN A 123.124.122.122"))
upstreamExternal := resolver.TestUDPUpstreamWithResponse(msg)
// create server
server, err := NewServer(&config.Config{
Upstream: config.UpstreamConfig{
ExternalResolvers: []config.Upstream{upstreamExternal},
},
Port: 55555,
})
assert.NoError(b, err)
// start server
go func() {
server.Start()
}()
defer server.Stop()
time.Sleep(100 * time.Millisecond)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = requestServer(util.NewMsgWithQuestion("google.de.", dns.TypeA))
}
})
}
func requestServer(request *dns.Msg) *dns.Msg {
conn, err := net.Dial("udp", ":55555")
if err != nil {
log.Fatal("could not connect to server: ", err)
}
defer conn.Close()
msg, err := request.Pack()
if err != nil {
log.Fatal("can't pack request: ", err)
}
_, err = conn.Write(msg)
if err != nil {
log.Fatal("can't send request to server: ", err)
}
out := make([]byte, 1024)
if _, err := conn.Read(out); err == nil {
response := new(dns.Msg)
err := response.Unpack(out)
if err != nil {
log.Fatal("can't unpack response: ", err)
}
return response
}
log.Fatal("could not read from connection", err)
return nil
}

44
testdata/config.yml vendored Normal file
View File

@ -0,0 +1,44 @@
upstream:
externalResolvers:
- udp:8.8.8.8
- udp:8.8.4.4
- udp:1.1.1.1
customDNS:
mapping:
my.duckdns.org: 192.168.178.3
conditional:
mapping:
fritz.box: udp:192.168.178.1
blocking:
blackLists:
ads:
- https://s3.amazonaws.com/lists.disconnect.me/simple_ad.txt
- https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
- https://mirror1.malwaredomains.com/files/justdomains
- http://sysctl.org/cameleon/hosts
- https://zeustracker.abuse.ch/blocklist.php?download=domainblocklist
- https://s3.amazonaws.com/lists.disconnect.me/simple_tracking.txt
special:
- https://hosts-file.net/ad_servers.txt
whiteLists:
ads:
- whitelist.txt
clientGroupsBlock:
default:
- ads
- special
Laptop-D.fritz.box:
- ads
#blockMode: zeroIP
clientLookup:
upstream: udp:192.168.178.1
singleNameOrder:
- 2
- 1
queryLog:
dir: /opt/log
perClient: true
port: 55555
logLevel: debug

1
testdata/doubleclick.net.txt vendored Normal file
View File

@ -0,0 +1 @@
doubleclick.net

1
testdata/heise.de.txt vendored Normal file
View File

@ -0,0 +1 @@
heise.de

1
testdata/www.bild.de.txt vendored Normal file
View File

@ -0,0 +1 @@
www.bild.de

1
testdata/youtube.com.txt vendored Normal file
View File

@ -0,0 +1 @@
youtube.com

80
util/common.go Normal file
View File

@ -0,0 +1,80 @@
package util
import (
"fmt"
"net"
"strings"
"github.com/miekg/dns"
)
func qTypeToString() func(uint16) string {
innerMap := map[uint16]string{
dns.TypeA: "A",
dns.TypeAAAA: "AAAA",
dns.TypeCNAME: "CNAME",
dns.TypePTR: "PTR",
dns.TypeMX: "MX",
}
return func(key uint16) string {
return innerMap[key]
}
}
func AnswerToString(answer []dns.RR) string {
answers := make([]string, len(answer))
for i, record := range answer {
switch v := record.(type) {
case *dns.A:
answers[i] = fmt.Sprintf("A (%s)", v.A)
case *dns.AAAA:
answers[i] = fmt.Sprintf("AAAA (%s)", v.AAAA)
case *dns.CNAME:
answers[i] = fmt.Sprintf("CNAME (%s)", v.Target)
case *dns.PTR:
answers[i] = fmt.Sprintf("PTR (%s)", v.Ptr)
default:
answers[i] = fmt.Sprint(record)
}
}
return strings.Join(answers, ", ")
}
func QuestionToString(questions []dns.Question) string {
result := make([]string, len(questions))
for i, question := range questions {
result[i] = fmt.Sprintf("%s (%s)", qTypeToString()(question.Qtype), question.Name)
}
return strings.Join(result, ", ")
}
func CreateAnswerFromQuestion(question dns.Question, ip net.IP, remainingTTL uint32) (dns.RR, error) {
return dns.NewRR(fmt.Sprintf("%s %d %s %s %s", question.Name, remainingTTL, "IN", qTypeToString()(question.Qtype), ip))
}
func ExtractDomain(question dns.Question) string {
return strings.TrimSuffix(strings.ToLower(question.Name), ".")
}
func NewMsgWithQuestion(question string, mType uint16) *dns.Msg {
msg := new(dns.Msg)
msg.SetQuestion(question, mType)
return msg
}
func NewMsgWithAnswer(answer string) (*dns.Msg, error) {
rr, err := dns.NewRR(answer)
if err != nil {
return nil, err
}
msg := new(dns.Msg)
msg.Answer = []dns.RR{rr}
return msg, nil
}