├── .github ├── FUNDING.yml ├── workflows │ ├── push.yml │ ├── pull_request.yml │ ├── release.yml │ └── codeql-analysis.yml ├── run-tests.sh └── build ├── .gitignore ├── docker ├── worker ├── bridge └── enqueue ├── systemd ├── overseer-enqueue.timer ├── overseer-enqueue.service ├── overseer-worker.service └── README.md ├── Dockerfile.enqueue ├── Dockerfile.worker ├── Dockerfile.bridge ├── docker-compose.yml ├── main.go ├── go.mod ├── cmd_version.go ├── bridges ├── README.md ├── telegram-bridge │ └── main.go ├── email-bridge │ └── main.go └── purppura-bridge │ └── main.go ├── 0doc.go ├── cmd_dump.go ├── protocols ├── api.go ├── telnet_probe.go ├── vnc_probe.go ├── ssh_probe.go ├── rsync_probe.go ├── pop3_probe.go ├── xmpp_probe.go ├── imap_probe.go ├── psql_probe.go ├── ping_probe.go ├── mysql_probe.go ├── nntp_probe.go ├── pop3s_probe.go ├── finger_probe.go ├── tcp_probe.go ├── imaps_probe.go ├── smtp_probe.go ├── redis_probe.go ├── dns_probe.go └── ftp_probe.go ├── test └── type.go ├── cmd_examples.go ├── cmd_enqueue.go ├── parser ├── parser.go └── parser_test.go ├── go.sum ├── input.txt ├── README.md └── cmd_worker.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | github: skx 3 | custom: https://steve.fi/donate/ 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | overseer 2 | overseer-* 3 | bridges/email-bridge 4 | bridges/irc-bridge 5 | bridges/purppura-bridge 6 | *-bridge-* 7 | -------------------------------------------------------------------------------- /docker/worker: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Accept jobs from the queue, and process them. 4 | # 5 | 6 | overseer worker -redis-host=redis:6379 \ 7 | -verbose \ 8 | -verbose \ 9 | -4=true \ 10 | -6=false 11 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: Push Event 3 | jobs: 4 | test: 5 | name: Test 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@master 9 | - name: Test 10 | uses: skx/github-action-tester@master 11 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | on: pull_request 2 | name: Pull Request 3 | jobs: 4 | test: 5 | name: Test 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@master 9 | - name: Test 10 | uses: skx/github-action-tester@master 11 | -------------------------------------------------------------------------------- /systemd/overseer-enqueue.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Populate the overseer work-queue 3 | RefuseManualStart=no 4 | RefuseManualStop=no 5 | 6 | [Timer] 7 | Persistent=false 8 | OnCalendar=*:0/2 9 | Unit=overseer-enqueue.service 10 | 11 | [Install] 12 | WantedBy=default.target 13 | 14 | -------------------------------------------------------------------------------- /systemd/overseer-enqueue.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=add tests to be executed by oveseer 3 | RefuseManualStart=no 4 | RefuseManualStop=yes 5 | 6 | [Service] 7 | Type=oneshot 8 | ExecStart=/bin/sh -c '/opt/overseer/bin/overseer enqueue -redis-host=localhost:6379 /opt/overseer/tests.d/*.conf' 9 | 10 | -------------------------------------------------------------------------------- /systemd/overseer-worker.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=overseer worker-service 3 | 4 | [Service] 5 | User=root 6 | WorkingDirectory=/opt/overseer 7 | ExecStart=/opt/overseer/bin/overseer worker -redis-host=127.0.0.1:6379 8 | KillMode=process 9 | Restart=always 10 | StartLimitInterval=2 11 | StartLimitBurst=20 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /Dockerfile.enqueue: -------------------------------------------------------------------------------- 1 | # 2 | # Dockerfile for enqueue process 3 | # 4 | # docker build -t oversser:enqueue -f Dockerfile.enqueue . 5 | # 6 | 7 | FROM golang:1.15 8 | 9 | # Create a working directory 10 | WORKDIR /go/src/app 11 | 12 | # Copy our source and build it. 13 | COPY . . 14 | 15 | # Install 16 | RUN go install -v ./... 17 | 18 | # Entry-point 19 | CMD [ "docker/enqueue" ] 20 | -------------------------------------------------------------------------------- /Dockerfile.worker: -------------------------------------------------------------------------------- 1 | # 2 | # Dockerfile for the worker process 3 | # 4 | # docker build -t overseer:worker -f Dockerfile.worker . 5 | # 6 | 7 | FROM golang:1.15 8 | 9 | # Create a working directory 10 | WORKDIR /go/src/app 11 | 12 | # Copy our source and build it. 13 | COPY . . 14 | 15 | # Install 16 | RUN go install -v ./... 17 | 18 | # Entry-point 19 | CMD [ "docker/worker" ] 20 | -------------------------------------------------------------------------------- /docker/bridge: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Telegram notifier. 4 | # 5 | 6 | echo "*********************************" 7 | echo "Telegram token: ${TELEGRAM_TOKEN}" 8 | echo "Telegram user: ${TELEGRAM_NOTIFY}" 9 | echo "*********************************" 10 | 11 | 12 | telegram-bridge -redis-host=redis:6379 \ 13 | -token=${TELEGRAM_TOKEN} \ 14 | -recipient=${TELEGRAM_NOTIFY} 15 | -------------------------------------------------------------------------------- /Dockerfile.bridge: -------------------------------------------------------------------------------- 1 | # 2 | # Dockerfile for the bridge 3 | # 4 | # docker build -t overseer:bridge -f Dockerfile.bridge . 5 | # 6 | 7 | FROM golang:1.15 8 | 9 | # Create a working directory 10 | WORKDIR /go/src/app 11 | 12 | # Copy our source and build it. 13 | COPY . . 14 | 15 | # Install 16 | RUN cd /go/src/app/bridges/telegram-bridge/ && go install -v . 17 | 18 | # Entry-point 19 | CMD [ "docker/bridge" ] 20 | -------------------------------------------------------------------------------- /docker/enqueue: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Loop constantly adding the tests to the file 4 | # 5 | 6 | 7 | # 8 | # Loop forever 9 | # 10 | while true; do 11 | 12 | # 13 | # Add all tests 14 | # 15 | for file in /etc/overseer/*.cfg; do 16 | 17 | # 18 | # Redis comes from "redis" 19 | # 20 | echo "Adding tests from ${file}" 21 | overseer enqueue -redis-host=redis:6379 $file 22 | done 23 | 24 | # 25 | # Repeat in a minute 26 | # 27 | sleep 59 28 | 29 | done 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: release 2 | name: Handle Release 3 | jobs: 4 | upload: 5 | name: Upload 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout the repository 9 | uses: actions/checkout@master 10 | - name: Generate the artifacts 11 | uses: skx/github-action-build@master 12 | - name: Upload the artifacts 13 | uses: skx/github-action-publish-binaries@master 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | with: 17 | args: '*-*' 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | enqueue: 5 | restart: always 6 | image: overseer:enqueue 7 | volumes: 8 | - ./tests.d/:/etc/overseer 9 | links: 10 | - redis:redis 11 | worker: 12 | restart: always 13 | image: overseer:worker 14 | links: 15 | - redis:redis 16 | bridge: 17 | restart: always 18 | image: overseer:bridge 19 | links: 20 | - redis:redis 21 | environment: 22 | - TELEGRAM_TOKEN=xxxxxxxxxxxxxxxxxxxxx 23 | - TELEGRAM_NOTIFY=yyyyyyyy 24 | redis: 25 | restart: always 26 | image: redis 27 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Entry-Point to our code. 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "flag" 8 | "os" 9 | 10 | "github.com/google/subcommands" 11 | ) 12 | 13 | // 14 | // Open the named configuration file, and parse it 15 | // 16 | func main() { 17 | 18 | subcommands.Register(subcommands.HelpCommand(), "") 19 | subcommands.Register(subcommands.FlagsCommand(), "") 20 | subcommands.Register(subcommands.CommandsCommand(), "") 21 | subcommands.Register(&dumpCmd{}, "") 22 | subcommands.Register(&enqueueCmd{}, "") 23 | subcommands.Register(&examplesCmd{}, "") 24 | subcommands.Register(&versionCmd{}, "") 25 | subcommands.Register(&workerCmd{}, "") 26 | 27 | flag.Parse() 28 | ctx := context.Background() 29 | os.Exit(int(subcommands.Execute(ctx))) 30 | 31 | } 32 | -------------------------------------------------------------------------------- /.github/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Install tools to test our code-quality. 4 | go get -u golang.org/x/lint/golint 5 | go get -u golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow 6 | go get -u honnef.co/go/tools/cmd/staticcheck 7 | 8 | # Run the static-check tool; any output is a failure. 9 | t=$(mktemp) 10 | staticcheck -checks all ./... > $t 11 | if [ -s $t ]; then 12 | echo "Found errors via 'staticcheck'" 13 | cat $t 14 | rm $t 15 | exit 1 16 | fi 17 | rm $t 18 | 19 | # At this point failures cause aborts 20 | set -e 21 | 22 | # Run the linter 23 | echo "Launching linter .." 24 | golint -set_exit_status ./... 25 | echo "Completed linter .." 26 | 27 | # Run the shadow-checker 28 | echo "Launching shadowed-variable check .." 29 | go vet -vettool=$(which shadow) ./... 30 | echo "Completed shadowed-variable check .." 31 | 32 | # Run golang tests 33 | go test ./... 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/skx/overseer 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/emersion/go-imap v1.2.1 7 | github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // indirect 8 | github.com/go-redis/redis v6.15.9+incompatible 9 | github.com/go-sql-driver/mysql v1.7.1 10 | github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible 11 | github.com/golang/protobuf v1.3.1 // indirect 12 | github.com/google/subcommands v1.2.0 13 | github.com/hashicorp/errwrap v1.1.0 // indirect 14 | github.com/jlaffaye/ftp v0.2.0 15 | github.com/lib/pq v1.10.9 16 | github.com/marpaia/graphite-golang v0.0.0-20190519024811-caf161d2c2b1 17 | github.com/miekg/dns v1.1.55 18 | github.com/onsi/ginkgo v1.8.0 // indirect 19 | github.com/onsi/gomega v1.5.0 // indirect 20 | github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 21 | github.com/simia-tech/go-pop3 v0.0.0-20150626094726-c9c20550a244 22 | github.com/skx/golang-metrics v0.0.0-20190325085214-453332cf54e8 23 | github.com/technoweenie/multipartstreamer v1.0.1 // indirect 24 | golang.org/x/tools v0.12.0 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /cmd_version.go: -------------------------------------------------------------------------------- 1 | // Version 2 | // 3 | // The version sub-command reports our version and terminates. 4 | package main 5 | 6 | import ( 7 | "context" 8 | "flag" 9 | "fmt" 10 | "io" 11 | "os" 12 | 13 | "github.com/google/subcommands" 14 | ) 15 | 16 | // 17 | // modified during testing 18 | // 19 | var out io.Writer = os.Stdout 20 | 21 | var ( 22 | version = "master" 23 | ) 24 | 25 | type versionCmd struct { 26 | } 27 | 28 | // 29 | // Glue 30 | // 31 | func (*versionCmd) Name() string { return "version" } 32 | func (*versionCmd) Synopsis() string { return "Show our version." } 33 | func (*versionCmd) Usage() string { 34 | return `version : 35 | Report upon our version, and exit. 36 | ` 37 | } 38 | 39 | // 40 | // Flag setup. 41 | // 42 | func (p *versionCmd) SetFlags(f *flag.FlagSet) { 43 | } 44 | 45 | // 46 | // Show the version - using the "out"-writer. 47 | // 48 | func showVersion() { 49 | fmt.Fprintf(out, "%s\n", version) 50 | } 51 | 52 | // 53 | // Entry-point. 54 | // 55 | func (p *versionCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { 56 | 57 | showVersion() 58 | return subcommands.ExitSuccess 59 | } 60 | -------------------------------------------------------------------------------- /bridges/README.md: -------------------------------------------------------------------------------- 1 | # Bridges 2 | 3 | Overseer only submits the results of the tests it executes to a redis queue, 4 | in order to actually inform a human about a failure you need to process the 5 | result-queue, and pass the messages on. 6 | 7 | Note that each test `overseer` executes is stateless, so if you have a failing 8 | test the notification will be repeated. 9 | 10 | To give a concrete example, assume the following test: 11 | 12 | http://example.com/ must run http 13 | 14 | If the remote host is offline _every_ time that overseer executes that 15 | test it will record a fresh failure so if you're using the email bridge 16 | you'll receive a fresh email each time the test is executed. 17 | 18 | > (The purppura-bridge keeps local state, so it will ensure that humans are only notified once - even though it itself is updated at the end of every run.) 19 | 20 | The following bridges are distributed with `overseer`: 21 | 22 | * [email-bridge](email-bridge/) 23 | * Submits test-failures via email. 24 | * Test results which succeed are discarded. 25 | * [purppura-bridge](purppura-bridge/) 26 | * Posts test results to a [purppura](https://github.com/skx/purppura/)-instance. 27 | * [telegram-bridge](telegram-bridge/) 28 | * Posts test results to a telegram user. 29 | -------------------------------------------------------------------------------- /0doc.go: -------------------------------------------------------------------------------- 1 | // overseer is a remote protocol tester. 2 | // 3 | // It is designed to allow you to execute tests against remote hosts, 4 | // raising alerts when they are down, or failing. 5 | // 6 | // The application is written in an extensible fashion, allowing new 7 | // test-types to be added easily, and the notification of failures is 8 | // handled in a flexible fashion too via the use of a redis-server. 9 | // 10 | // The application is designed to run in a distributed fashion, although 11 | // it is equally happy to run upon a single node. 12 | // 13 | // Each of the tests to be executed is parsed and stored in a redis 14 | // queue, from where multiple workers can each fetch tests, and execute 15 | // them as they become available. 16 | // 17 | // To get started you'd first add your tests to the queue: 18 | // 19 | // overseer enqueue -redis-host=redis.example.com:6379 \ 20 | // test.file.1 test.file.2 .. test.file.N 21 | // 22 | // On a pool of machines you can await tests by starting: 23 | // 24 | // overseer worker -redis-host=redis.example.com:6379 25 | // 26 | // The workers will run the tests as they become available, raising and 27 | // clearing notifications as appropriaate. 28 | // 29 | // 30 | // Brief documentation for the available sub-commands now follows. 31 | // 32 | package main 33 | -------------------------------------------------------------------------------- /cmd_dump.go: -------------------------------------------------------------------------------- 1 | // Dump 2 | // 3 | // The dump sub-command dumps the (parsed) configuration file(s) 4 | package main 5 | 6 | import ( 7 | "context" 8 | "flag" 9 | "fmt" 10 | 11 | "github.com/google/subcommands" 12 | "github.com/skx/overseer/parser" 13 | "github.com/skx/overseer/test" 14 | ) 15 | 16 | type dumpCmd struct { 17 | } 18 | 19 | // 20 | // Glue 21 | // 22 | func (*dumpCmd) Name() string { return "dump" } 23 | func (*dumpCmd) Synopsis() string { return "Dump a parsed configuration file" } 24 | func (*dumpCmd) Usage() string { 25 | return `dump : 26 | Dump a parsed configuration file. 27 | 28 | This is particularly useful to show the result of macro-expansion. 29 | ` 30 | } 31 | 32 | // 33 | // Flag setup. 34 | // 35 | func (p *dumpCmd) SetFlags(f *flag.FlagSet) { 36 | } 37 | 38 | // 39 | // This is a callback invoked by the parser when a job 40 | // has been successfully parsed. 41 | // 42 | func dumpTest(tst test.Test) error { 43 | fmt.Printf("%s\n", tst.Input) 44 | return nil 45 | } 46 | 47 | // 48 | // Entry-point. 49 | // 50 | func (p *dumpCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { 51 | 52 | for _, file := range f.Args() { 53 | 54 | // 55 | // Create an object to parse our file. 56 | // 57 | helper := parser.New() 58 | 59 | // 60 | // For each parsed job call `dump_test` to show it 61 | // 62 | err := helper.ParseFile(file, dumpTest) 63 | if err != nil { 64 | fmt.Printf("Error parsing file: %s\n", err.Error()) 65 | } 66 | } 67 | 68 | return subcommands.ExitSuccess 69 | } 70 | -------------------------------------------------------------------------------- /systemd/README.md: -------------------------------------------------------------------------------- 1 | # Systemd Examples 2 | 3 | This directory contains some sample systemd configuration-files for running 4 | overseer upon a single host. 5 | 6 | The expectation is: 7 | 8 | * You have overseer deployed as `/opt/overseer/bin/overseer` 9 | * Your local host is running Redis 10 | * You wish to execute all the tests available as `/opt/overseer/tests.d/*.conf` 11 | 12 | The goal is to: 13 | 14 | * Start a single worker to execute tests 15 | * These tests will be pulled from redis running on localhost 16 | * Have a timer which will populate the redis-queue 17 | * This will refill the queue every two minutes 18 | 19 | 20 | ## Installation 21 | 22 | Copy the files into the correct location: 23 | 24 | cp oveseer* /lib/systemd/system/ 25 | 26 | Enable the worker: 27 | 28 | # systemctl daemon-reload 29 | # systemctl enable overseer-worker.service 30 | # systemctl start overseer-worker.service 31 | 32 | Now start the timer: 33 | 34 | # systemctl enable overseer-enqueue.timer 35 | # systemctl start overseer-enqueue.timer 36 | 37 | 38 | ## Sanity Checking 39 | 40 | You can see the state of the worker, and any output it produces, via: 41 | 42 | # systemctl status overseer-worker.service 43 | 44 | The cron-job to populate the queue is implemented as a (one-shot) service 45 | and a corresponding timer to trigger it. To view the status of the timer: 46 | 47 | # systemctl list-timers 48 | .. 49 | Tue 2018-05-15 09:42:00 UTC 17s left Tue 2018-05-15 09:40:00 UTC 1min 41s ago overseer-enqueue.timer overseer-enqueue.service 50 | .. 51 | 52 | Finally you can look for errors parsing the files via: 53 | 54 | # journalctl -u overseer-enqueue.service 55 | -------------------------------------------------------------------------------- /.github/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # The basename of our binary 4 | BASE="overseer" 5 | 6 | # Save our directory, since we build in child-directories later. 7 | D=$(pwd) 8 | 9 | # 10 | # We build on multiple platforms/archs 11 | # 12 | BUILD_PLATFORMS="linux windows darwin freebsd arm64" 13 | BUILD_ARCHS="amd64 386" 14 | 15 | # For each platform 16 | for OS in ${BUILD_PLATFORMS[@]}; do 17 | 18 | # For each arch 19 | for ARCH in ${BUILD_ARCHS[@]}; do 20 | 21 | cd ${D} 22 | 23 | # Setup a suffix for the binary 24 | SUFFIX="${OS}" 25 | 26 | # i386 is better than 386 27 | if [ "$ARCH" = "386" ]; then 28 | SUFFIX="${SUFFIX}-i386" 29 | else 30 | SUFFIX="${SUFFIX}-${ARCH}" 31 | fi 32 | 33 | # Windows binaries should end in .EXE 34 | if [ "$OS" = "windows" ]; then 35 | SUFFIX="${SUFFIX}.exe" 36 | fi 37 | 38 | echo "Building for ${OS} [${ARCH}] -> ${BASE}-${SUFFIX}" 39 | 40 | # Run the build 41 | export GOARCH=${ARCH} 42 | export GOOS=${OS} 43 | export CGO_ENABLED=0 44 | 45 | # hack for ARM 46 | if [ "${GOOS}" = "arm64" ]; then 47 | export GOOS="" 48 | export GOARCH=arm64 49 | export GOARM=7 50 | SUFFIX="arm64" 51 | fi 52 | 53 | # Build the main-binary 54 | go build -ldflags "-X main.version=$(git describe --tags 2>/dev/null || echo 'master')" -o "${BASE}-${SUFFIX}" 55 | 56 | # Build each bridge 57 | for br in ${D}/bridges/*/; do 58 | 59 | bridge=$(basename $br) 60 | 61 | # Build the bridge I use 62 | cd ${br} 63 | go build -o ../../${bridge}-${SUFFIX} 64 | 65 | done 66 | done 67 | done 68 | -------------------------------------------------------------------------------- /protocols/api.go: -------------------------------------------------------------------------------- 1 | // Package protocols is where the protocol-testers live. 2 | // 3 | // Tests are dynamically instantiated at run-time, via a class-factory 4 | // pattern, and due to their plugin nature they are simple to implement 5 | // as they require only implementing a single method. 6 | // 7 | package protocols 8 | 9 | import ( 10 | "sync" 11 | 12 | "github.com/skx/overseer/test" 13 | ) 14 | 15 | // ProtocolTest interface is the core of our code, it 16 | // defines the implementation methods which must be 17 | // implemented to add a new protocol-test. 18 | type ProtocolTest interface { 19 | 20 | // 21 | // Arguments return the arguments which this protocol-test accepts, along 22 | // with a regular expression which will be used to validate a non-empty 23 | // argument. 24 | // 25 | Arguments() map[string]string 26 | 27 | // Example should return a string describing how your protocol-test 28 | // works and is invoked. 29 | // 30 | // Optional arguments will automatically be documented. 31 | Example() string 32 | 33 | // 34 | // RunTest actually invokes the protocol-handler to run its 35 | // tests. 36 | // 37 | // Return a suitable error if the test fails, or nil to indicate 38 | // it passed. 39 | // 40 | RunTest(tst test.Test, target string, opts test.Options) error 41 | } 42 | 43 | // This is a map of known-tests. 44 | var handlers = struct { 45 | m map[string]TestCtor 46 | sync.RWMutex 47 | }{m: make(map[string]TestCtor)} 48 | 49 | // TestCtor is the signature of a constructor-function. 50 | type TestCtor func() ProtocolTest 51 | 52 | // Register a test-type with a constructor. 53 | func Register(id string, newfunc TestCtor) { 54 | handlers.Lock() 55 | handlers.m[id] = newfunc 56 | handlers.Unlock() 57 | } 58 | 59 | // ProtocolHandler is the factory-method which looks up and returns 60 | // an object of the given type - if possible. 61 | func ProtocolHandler(id string) (a ProtocolTest) { 62 | handlers.RLock() 63 | ctor, ok := handlers.m[id] 64 | handlers.RUnlock() 65 | if ok { 66 | a = ctor() 67 | } 68 | return 69 | } 70 | 71 | // Handlers returns the names of all the registered protocol-handlers. 72 | func Handlers() []string { 73 | var result []string 74 | 75 | // For each handler save the name 76 | handlers.RLock() 77 | for index := range handlers.m { 78 | result = append(result, index) 79 | } 80 | handlers.RUnlock() 81 | 82 | // And return the result 83 | return result 84 | 85 | } 86 | -------------------------------------------------------------------------------- /test/type.go: -------------------------------------------------------------------------------- 1 | // Package test contains details about a single parsed test which should be 2 | // executed against a remote host. 3 | // 4 | // Tests are parsed via the parser-module, and have the general form: 5 | // 6 | // HOST must run PROTOCOL with ARG_NAME1 ARG_VALUE1 .. 7 | // 8 | // For example a simple test might read: 9 | // 10 | // 1.2.3.4 must run ftp 11 | // 12 | // To change the port from the default the `port` argument could be 13 | // given: 14 | // 15 | // 1.2.3.4 must run ftp with port 2121 16 | // 17 | // 18 | package test 19 | 20 | import ( 21 | "fmt" 22 | "sort" 23 | "time" 24 | ) 25 | 26 | // Test contains a single test definition as identified by the parser. 27 | type Test struct { 28 | // Target of the test. 29 | // 30 | // In the example above this would be `1.2.3.4`. 31 | Target string 32 | 33 | // Type contains the type of the test. 34 | // 35 | // In the example above this would be `ftp`. 36 | Type string 37 | 38 | // Input contains a copy of the complete input-line the parser case. 39 | // 40 | // In the example above this would be `1.2.3.4 must run ftp`. 41 | Input string 42 | 43 | // MaxRetries overrides the global overseer setting for max test retries, if >= 0 44 | MaxRetries int 45 | 46 | // Arguments contains a map of any optional arguments supplied to 47 | // test test. 48 | // 49 | // In the example above the map would contain one key `port`, 50 | // with the value `2121` (as a string). 51 | // 52 | Arguments map[string]string 53 | } 54 | 55 | // Sanitize returns a copy of the input string, but with any password 56 | // removed 57 | func (obj *Test) Sanitize() string { 58 | 59 | // The basic test 60 | res := fmt.Sprintf("%s must run %s", obj.Target, obj.Type) 61 | 62 | // Arguments, sorted 63 | var keys []string 64 | for k := range obj.Arguments { 65 | keys = append(keys, k) 66 | } 67 | sort.Strings(keys) 68 | 69 | // Now append the arguments and their values. 70 | for _, k := range keys { 71 | tmp := "" 72 | 73 | // Censor passwords 74 | if k == "password" { 75 | tmp = " with password 'CENSORED'" 76 | } else { 77 | 78 | // Otherwise leave alone. 79 | tmp = fmt.Sprintf(" with %s '%s'", k, obj.Arguments[k]) 80 | } 81 | res += tmp 82 | } 83 | 84 | return res 85 | } 86 | 87 | // Options are options which are passed to every test-handler. 88 | // 89 | // The options might change the way the test operates. 90 | type Options struct { 91 | // Timeout for the single test, in seconds. 92 | Timeout time.Duration 93 | 94 | // Should the protocol-tests run verbosely? 95 | Verbose bool 96 | } 97 | -------------------------------------------------------------------------------- /cmd_examples.go: -------------------------------------------------------------------------------- 1 | // Examples 2 | // 3 | // Show information about our protocols. 4 | package main 5 | 6 | import ( 7 | "context" 8 | "flag" 9 | "fmt" 10 | "regexp" 11 | "sort" 12 | 13 | "github.com/google/subcommands" 14 | "github.com/skx/overseer/protocols" 15 | ) 16 | 17 | type examplesCmd struct { 18 | } 19 | 20 | // 21 | // Glue 22 | // 23 | func (*examplesCmd) Name() string { return "examples" } 24 | func (*examplesCmd) Synopsis() string { return "Show example protocol-tests." } 25 | func (*examplesCmd) Usage() string { 26 | return `examples : 27 | Provide sample usage of each of our protocol-tests. 28 | ` 29 | } 30 | 31 | // 32 | // Flag setup. 33 | // 34 | func (p *examplesCmd) SetFlags(f *flag.FlagSet) { 35 | } 36 | 37 | // 38 | // Show example output for any protocol-handler matching the 39 | // pattern specified. 40 | // 41 | // If the filter is empty then show all. 42 | // 43 | func showExamples(filter string) { 44 | 45 | re := regexp.MustCompile(filter) 46 | 47 | // For each (sorted) protocol-handler 48 | handlers := protocols.Handlers() 49 | sort.Strings(handlers) 50 | 51 | // Get the name 52 | for _, name := range handlers { 53 | 54 | // Skip unless this handler matches the filter. 55 | match := re.FindAllStringSubmatch(name, -1) 56 | if len(match) < 1 { 57 | continue 58 | } 59 | 60 | // Create an instance of it 61 | x := protocols.ProtocolHandler(name) 62 | 63 | // Show the output of that function 64 | out := x.Example() 65 | fmt.Printf("%s\n", out) 66 | 67 | fmt.Printf("Arguments which are supported are now shown:\n\n") 68 | 69 | fmt.Printf(" %10s|%s\n", "Name", "Valid Value") 70 | fmt.Printf(" ----------------------------------\n") 71 | 72 | // 73 | // The arguments this test supports 74 | // 75 | m := x.Arguments() 76 | 77 | // 78 | // Temporary structure to store the keys. 79 | // 80 | var keys []string 81 | for k := range m { 82 | keys = append(keys, k) 83 | } 84 | sort.Strings(keys) 85 | 86 | // 87 | // Now show the keys + values in sorted order 88 | // 89 | for _, k := range keys { 90 | fmt.Printf(" %10s|%s\n", k, m[k]) 91 | } 92 | fmt.Printf("\n\n") 93 | 94 | } 95 | } 96 | 97 | // 98 | // Entry-point. 99 | // 100 | func (p *examplesCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { 101 | 102 | if len(f.Args()) > 0 { 103 | for _, name := range f.Args() { 104 | showExamples(name) 105 | } 106 | } else { 107 | showExamples(".*") 108 | } 109 | return subcommands.ExitSuccess 110 | } 111 | -------------------------------------------------------------------------------- /protocols/telnet_probe.go: -------------------------------------------------------------------------------- 1 | // Telnet Tester 2 | // 3 | // The telnet tester connects to a remote host and does nothing else. 4 | // 5 | // This test is invoked via input like so: 6 | // 7 | // host.example.com must run telnet 8 | // 9 | 10 | package protocols 11 | 12 | import ( 13 | "fmt" 14 | "net" 15 | "strconv" 16 | "strings" 17 | 18 | "github.com/skx/overseer/test" 19 | ) 20 | 21 | // TELNETTest is our object 22 | type TELNETTest struct { 23 | } 24 | 25 | // Arguments returns the names of arguments which this protocol-test 26 | // understands, along with corresponding regular-expressions to validate 27 | // their values. 28 | func (s *TELNETTest) Arguments() map[string]string { 29 | known := map[string]string{ 30 | "port": "^[0-9]+$", 31 | } 32 | return known 33 | } 34 | 35 | // Example returns sample usage-instructions for self-documentation purposes. 36 | func (s *TELNETTest) Example() string { 37 | str := ` 38 | Telnet Tester 39 | ------------- 40 | The telnet tester connects to a remote host and does nothing else. 41 | 42 | This test is invoked via input like so: 43 | 44 | host.example.com must run telnet 45 | ` 46 | return str 47 | } 48 | 49 | // RunTest is the part of our API which is invoked to actually execute a 50 | // test against the given target. 51 | // 52 | // In this case we make a TCP connection to the specified port, and assume 53 | // that everything is OK if that succeeded. 54 | func (s *TELNETTest) RunTest(tst test.Test, target string, opts test.Options) error { 55 | var err error 56 | 57 | // 58 | // The default port to connect to. 59 | // 60 | port := 23 61 | 62 | // 63 | // If the user specified a different port update to use it. 64 | // 65 | if tst.Arguments["port"] != "" { 66 | port, err = strconv.Atoi(tst.Arguments["port"]) 67 | if err != nil { 68 | return err 69 | } 70 | } 71 | 72 | // 73 | // Set an explicit timeout 74 | // 75 | d := net.Dialer{Timeout: opts.Timeout} 76 | 77 | // 78 | // Default to connecting to an IPv4-address 79 | // 80 | address := fmt.Sprintf("%s:%d", target, port) 81 | 82 | // 83 | // If we find a ":" we know it is an IPv6 address though 84 | // 85 | if strings.Contains(target, ":") { 86 | address = fmt.Sprintf("[%s]:%d", target, port) 87 | } 88 | 89 | // 90 | // Make the TCP connection. 91 | // 92 | conn, err := d.Dial("tcp", address) 93 | if err != nil { 94 | return err 95 | } 96 | conn.Close() 97 | 98 | return nil 99 | } 100 | 101 | // 102 | // Register our protocol-tester. 103 | // 104 | func init() { 105 | Register("telnet", func() ProtocolTest { 106 | return &TELNETTest{} 107 | }) 108 | } 109 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [master] 14 | schedule: 15 | - cron: '0 18 * * 4' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['go'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # If this run was triggered by a pull request event, then checkout 40 | # the head of the pull request instead of the merge commit. 41 | - run: git checkout HEAD^2 42 | if: ${{ github.event_name == 'pull_request' }} 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /protocols/vnc_probe.go: -------------------------------------------------------------------------------- 1 | // VNC Tester 2 | // 3 | // The VNC tester connects to a remote host and ensures that a response 4 | // is received that looks like an VNC banner. 5 | // 6 | // This test is invoked via input like so: 7 | // 8 | // host.example.com must run vnc [with port 5900] 9 | // 10 | 11 | package protocols 12 | 13 | import ( 14 | "bufio" 15 | "errors" 16 | "fmt" 17 | "net" 18 | "strconv" 19 | "strings" 20 | 21 | "github.com/skx/overseer/test" 22 | ) 23 | 24 | // VNCTest is our object 25 | type VNCTest struct { 26 | } 27 | 28 | // Arguments returns the names of arguments which this protocol-test 29 | // understands, along with corresponding regular-expressions to validate 30 | // their values. 31 | func (s *VNCTest) Arguments() map[string]string { 32 | known := map[string]string{ 33 | "port": "^[0-9]+$", 34 | } 35 | return known 36 | } 37 | 38 | // Example returns sample usage-instructions for self-documentation purposes. 39 | func (s *VNCTest) Example() string { 40 | str := ` 41 | VNC Tester 42 | ---------- 43 | The VNC tester connects to a remote host and ensures that a response 44 | is received that looks like an VNC banner. 45 | 46 | This test is invoked via input like so: 47 | 48 | host.example.com must run vnc 49 | ` 50 | return str 51 | } 52 | 53 | // RunTest is the part of our API which is invoked to actually execute a 54 | // test against the given target. 55 | // 56 | // In this case we make a TCP connection, defaulting to port 5900, and 57 | // look for a response which appears to be an VNC-server. 58 | func (s *VNCTest) RunTest(tst test.Test, target string, opts test.Options) error { 59 | var err error 60 | 61 | // 62 | // The default port to connect to. 63 | // 64 | port := 5900 65 | 66 | // 67 | // If the user specified a different port update to use it. 68 | // 69 | if tst.Arguments["port"] != "" { 70 | port, err = strconv.Atoi(tst.Arguments["port"]) 71 | if err != nil { 72 | return err 73 | } 74 | } 75 | 76 | // 77 | // Set an explicit timeout 78 | // 79 | d := net.Dialer{Timeout: opts.Timeout} 80 | 81 | // 82 | // Default to connecting to an IPv4-address 83 | // 84 | address := fmt.Sprintf("%s:%d", target, port) 85 | 86 | // 87 | // If we find a ":" we know it is an IPv6 address though 88 | // 89 | if strings.Contains(target, ":") { 90 | address = fmt.Sprintf("[%s]:%d", target, port) 91 | } 92 | 93 | // 94 | // Make the TCP connection. 95 | // 96 | conn, err := d.Dial("tcp", address) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | // 102 | // Read the banner. 103 | // 104 | banner, err := bufio.NewReader(conn).ReadString('\n') 105 | if err != nil { 106 | return err 107 | } 108 | conn.Close() 109 | 110 | if !strings.Contains(banner, "RFB") { 111 | return errors.New("banner doesn't look like VNC") 112 | } 113 | 114 | return nil 115 | } 116 | 117 | // 118 | // Register our protocol-tester. 119 | // 120 | func init() { 121 | Register("vnc", func() ProtocolTest { 122 | return &VNCTest{} 123 | }) 124 | } 125 | -------------------------------------------------------------------------------- /protocols/ssh_probe.go: -------------------------------------------------------------------------------- 1 | // SSH Tester 2 | // 3 | // The SSH tester connects to a remote host and ensures that a response 4 | // is received that looks like an SSH-server banner. 5 | // 6 | // This test is invoked via input like so: 7 | // 8 | // host.example.com must run ssh [with port 22] 9 | // 10 | 11 | package protocols 12 | 13 | import ( 14 | "bufio" 15 | "errors" 16 | "fmt" 17 | "net" 18 | "strconv" 19 | "strings" 20 | 21 | "github.com/skx/overseer/test" 22 | ) 23 | 24 | // SSHTest is our object. 25 | type SSHTest struct { 26 | } 27 | 28 | // Arguments returns the names of arguments which this protocol-test 29 | // understands, along with corresponding regular-expressions to validate 30 | // their values. 31 | func (s *SSHTest) Arguments() map[string]string { 32 | known := map[string]string{ 33 | "port": "^[0-9]+$", 34 | } 35 | return known 36 | } 37 | 38 | // Example returns sample usage-instructions for self-documentation purposes. 39 | func (s *SSHTest) Example() string { 40 | str := ` 41 | SSH Tester 42 | ---------- 43 | The ssh tester connects to a remote host and ensures that a response 44 | is received that looks like an ssh-server banner. 45 | 46 | This test is invoked via input like so: 47 | 48 | host.example.com must run ssh 49 | ` 50 | return str 51 | } 52 | 53 | // RunTest is the part of our API which is invoked to actually execute a 54 | // test against the given target. 55 | // 56 | // In this case we make a TCP connection, defaulting to port 22, and 57 | // look for a response which appears to be an SSH-server. 58 | func (s *SSHTest) RunTest(tst test.Test, target string, opts test.Options) error { 59 | var err error 60 | 61 | // 62 | // The default port to connect to. 63 | // 64 | port := 22 65 | 66 | // 67 | // If the user specified a different port update to use it. 68 | // 69 | if tst.Arguments["port"] != "" { 70 | port, err = strconv.Atoi(tst.Arguments["port"]) 71 | if err != nil { 72 | return err 73 | } 74 | } 75 | 76 | // 77 | // Set an explicit timeout 78 | // 79 | d := net.Dialer{Timeout: opts.Timeout} 80 | 81 | // 82 | // Default to connecting to an IPv4-address 83 | // 84 | address := fmt.Sprintf("%s:%d", target, port) 85 | 86 | // 87 | // If we find a ":" we know it is an IPv6 address though 88 | // 89 | if strings.Contains(target, ":") { 90 | address = fmt.Sprintf("[%s]:%d", target, port) 91 | } 92 | 93 | // 94 | // Make the TCP connection. 95 | // 96 | conn, err := d.Dial("tcp", address) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | // 102 | // Read the banner. 103 | // 104 | banner, err := bufio.NewReader(conn).ReadString('\n') 105 | if err != nil { 106 | return err 107 | } 108 | conn.Close() 109 | 110 | if !strings.Contains(banner, "SSH-") { 111 | return errors.New("banner doesn't look like an SSH server") 112 | } 113 | 114 | return nil 115 | } 116 | 117 | // 118 | // Register our protocol-tester. 119 | // 120 | func init() { 121 | Register("ssh", func() ProtocolTest { 122 | return &SSHTest{} 123 | }) 124 | } 125 | -------------------------------------------------------------------------------- /protocols/rsync_probe.go: -------------------------------------------------------------------------------- 1 | // Rsync Tester 2 | // 3 | // The Rsync tester connects to a remote host and ensures that a response 4 | // is received that looks like an rsync-server banner. 5 | // 6 | // This test is invoked via input like so: 7 | // 8 | // host.example.com must run rsync [with port 873] 9 | // 10 | 11 | package protocols 12 | 13 | import ( 14 | "bufio" 15 | "errors" 16 | "fmt" 17 | "net" 18 | "strconv" 19 | "strings" 20 | 21 | "github.com/skx/overseer/test" 22 | ) 23 | 24 | // RSYNCTest is our object. 25 | type RSYNCTest struct { 26 | } 27 | 28 | // Arguments returns the names of arguments which this protocol-test 29 | // understands, along with corresponding regular-expressions to validate 30 | // their values. 31 | func (s *RSYNCTest) Arguments() map[string]string { 32 | known := map[string]string{ 33 | "port": "^[0-9]+$", 34 | } 35 | return known 36 | } 37 | 38 | // Example returns sample usage-instructions for self-documentation purposes. 39 | func (s *RSYNCTest) Example() string { 40 | str := ` 41 | Rsync Tester 42 | ------------ 43 | The rsync tester connects to a remote host and ensures that a response 44 | is received that looks like an rsync-server banner. 45 | 46 | This test is invoked via input like so: 47 | 48 | host.example.com must run rsync 49 | ` 50 | return str 51 | } 52 | 53 | // RunTest is the part of our API which is invoked to actually execute a 54 | // test against the given target. 55 | // 56 | // In this case we make a TCP connection, defaulting to port 873, and 57 | // look for a response which appears to be an rsync-server. 58 | func (s *RSYNCTest) RunTest(tst test.Test, target string, opts test.Options) error { 59 | var err error 60 | 61 | // 62 | // The default port to connect to. 63 | // 64 | port := 873 65 | 66 | // 67 | // If the user specified a different port update to use it. 68 | // 69 | if tst.Arguments["port"] != "" { 70 | port, err = strconv.Atoi(tst.Arguments["port"]) 71 | if err != nil { 72 | return err 73 | } 74 | } 75 | 76 | // 77 | // Set an explicit timeout 78 | // 79 | d := net.Dialer{Timeout: opts.Timeout} 80 | 81 | // 82 | // Default to connecting to an IPv4-address 83 | // 84 | address := fmt.Sprintf("%s:%d", target, port) 85 | 86 | // 87 | // If we find a ":" we know it is an IPv6 address though 88 | // 89 | if strings.Contains(target, ":") { 90 | address = fmt.Sprintf("[%s]:%d", target, port) 91 | } 92 | 93 | // 94 | // Make the TCP connection. 95 | // 96 | conn, err := d.Dial("tcp", address) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | // 102 | // Read the banner. 103 | // 104 | banner, err := bufio.NewReader(conn).ReadString('\n') 105 | if err != nil { 106 | return err 107 | } 108 | conn.Close() 109 | 110 | if !strings.Contains(banner, "RSYNC") { 111 | return errors.New("banner doesn't look like a rsync-banner") 112 | } 113 | 114 | return nil 115 | } 116 | 117 | // 118 | // Register our protocol-tester. 119 | // 120 | func init() { 121 | Register("rsync", func() ProtocolTest { 122 | return &RSYNCTest{} 123 | }) 124 | } 125 | -------------------------------------------------------------------------------- /protocols/pop3_probe.go: -------------------------------------------------------------------------------- 1 | // POP3 Tester 2 | // 3 | // The POP3 tester connects to a remote host and ensures that this 4 | // succeeds. If you supply a username & password a login will be 5 | // made, and the test will fail if this login fails. 6 | // 7 | // This test is invoked via input like so: 8 | // 9 | // host.example.com must run pop3 [with username 'steve@steve' with password ] 10 | // 11 | 12 | package protocols 13 | 14 | import ( 15 | "fmt" 16 | "strconv" 17 | "strings" 18 | 19 | "github.com/simia-tech/go-pop3" 20 | "github.com/skx/overseer/test" 21 | ) 22 | 23 | // POP3Test is our object 24 | type POP3Test struct { 25 | } 26 | 27 | // Arguments returns the names of arguments which this protocol-test 28 | // understands, along with corresponding regular-expressions to validate 29 | // their values. 30 | func (s *POP3Test) Arguments() map[string]string { 31 | known := map[string]string{ 32 | "port": "^[0-9]+$", 33 | "tls": "insecure", 34 | "username": ".*", 35 | "password": ".*", 36 | } 37 | return known 38 | } 39 | 40 | // Example returns sample usage-instructions for self-documentation purposes. 41 | func (s *POP3Test) Example() string { 42 | str := ` 43 | POP3 Tester 44 | ----------- 45 | The POP3 tester connects to a remote host and ensures that this 46 | succeeds. If you supply a username & password a login will be 47 | made, and the test will fail if this login fails. 48 | 49 | This test is invoked via input like so: 50 | 51 | host.example.com must run pop3 52 | ` 53 | return str 54 | } 55 | 56 | // RunTest is the part of our API which is invoked to actually execute a 57 | // test against the given target. 58 | // 59 | // In this case we make a POP3 connection to the specified host, and if 60 | // a username + password were specified we then attempt to authenticate 61 | // to the remote host too. 62 | func (s *POP3Test) RunTest(tst test.Test, target string, opts test.Options) error { 63 | var err error 64 | 65 | // 66 | // The default port to connect to. 67 | // 68 | port := 110 69 | 70 | // 71 | // If the user specified a different port update to use it. 72 | // 73 | if tst.Arguments["port"] != "" { 74 | port, err = strconv.Atoi(tst.Arguments["port"]) 75 | if err != nil { 76 | return err 77 | } 78 | } 79 | 80 | // 81 | // Default to connecting to an IPv4-address 82 | // 83 | address := fmt.Sprintf("%s:%d", target, port) 84 | 85 | // 86 | // If we find a ":" we know it is an IPv6 address though 87 | // 88 | if strings.Contains(target, ":") { 89 | address = fmt.Sprintf("[%s]:%d", target, port) 90 | } 91 | 92 | // 93 | // Connect 94 | // 95 | c, err := pop3.Dial(address, pop3.UseTimeout(opts.Timeout)) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | // 101 | // Did we get a username/password? If so try to authenticate 102 | // with them 103 | // 104 | if (tst.Arguments["username"] != "") && (tst.Arguments["password"] != "") { 105 | err = c.Auth(tst.Arguments["username"], tst.Arguments["password"]) 106 | if err != nil { 107 | return err 108 | } 109 | } 110 | 111 | // 112 | // Quit and return 113 | // 114 | c.Quit() 115 | return nil 116 | } 117 | 118 | // 119 | // Register our protocol-tester. 120 | // 121 | func init() { 122 | Register("pop3", func() ProtocolTest { 123 | return &POP3Test{} 124 | }) 125 | } 126 | -------------------------------------------------------------------------------- /protocols/xmpp_probe.go: -------------------------------------------------------------------------------- 1 | // XMPP Tester 2 | // 3 | // The XMPP tester connects to a remote host and ensures that a response 4 | // is received that looks like an XMPP-server banner. 5 | // 6 | // This test is invoked via input like so: 7 | // 8 | // host.example.com must run xmpp [with port 5222] 9 | // 10 | 11 | package protocols 12 | 13 | import ( 14 | "bufio" 15 | "fmt" 16 | "net" 17 | "strconv" 18 | "strings" 19 | 20 | "github.com/skx/overseer/test" 21 | ) 22 | 23 | // XMPPTest is our object 24 | type XMPPTest struct { 25 | } 26 | 27 | // Arguments returns the names of arguments which this protocol-test 28 | // understands, along with corresponding regular-expressions to validate 29 | // their values. 30 | func (s *XMPPTest) Arguments() map[string]string { 31 | known := map[string]string{ 32 | "port": "^[0-9]+$", 33 | } 34 | return known 35 | } 36 | 37 | // Example returns sample usage-instructions for self-documentation purposes. 38 | func (s *XMPPTest) Example() string { 39 | str := ` 40 | XMPP Tester 41 | ----------- 42 | The XMPP tester connects to a remote host and ensures that a response 43 | is received that looks like an XMPP-server banner. 44 | 45 | This test is invoked via input like so: 46 | 47 | host.example.com must run xmpp 48 | ` 49 | return str 50 | } 51 | 52 | // RunTest is the part of our API which is invoked to actually execute a 53 | // test against the given target. 54 | // 55 | // In this case we make a TCP connection, defaulting to port 5222, and 56 | // look for a response which appears to be an XMPP-server. 57 | func (s *XMPPTest) RunTest(tst test.Test, target string, opts test.Options) error { 58 | var err error 59 | 60 | // 61 | // The default port to connect to. 62 | // 63 | port := 5222 64 | 65 | // 66 | // If the user specified a different port update to use it. 67 | // 68 | if tst.Arguments["port"] != "" { 69 | port, err = strconv.Atoi(tst.Arguments["port"]) 70 | if err != nil { 71 | return err 72 | } 73 | } 74 | 75 | // 76 | // Set an explicit timeout 77 | // 78 | d := net.Dialer{Timeout: opts.Timeout} 79 | 80 | // 81 | // Default to connecting to an IPv4-address 82 | // 83 | address := fmt.Sprintf("%s:%d", target, port) 84 | 85 | // 86 | // If we find a ":" we know it is an IPv6 address though 87 | // 88 | if strings.Contains(target, ":") { 89 | address = fmt.Sprintf("[%s]:%d", target, port) 90 | } 91 | 92 | // 93 | // Make the TCP connection. 94 | // 95 | conn, err := d.Dial("tcp", address) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | // 101 | // Send a (bogus) greeting 102 | // 103 | _, err = conn.Write([]byte("<>\n")) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | // 109 | // Read the response. 110 | // 111 | banner, err := bufio.NewReader(conn).ReadString('>') 112 | if err != nil { 113 | return err 114 | } 115 | 116 | // 117 | // Now close the connection 118 | // 119 | err = conn.Close() 120 | if err != nil { 121 | return err 122 | } 123 | 124 | if !strings.Contains(banner, " 0 { 64 | cfg, err := ioutil.ReadFile(os.Getenv("OVERSEER")) 65 | if err == nil { 66 | err = json.Unmarshal(cfg, &defaults) 67 | if err != nil { 68 | fmt.Printf("WARNING: Error loading overseer.json - %s\n", 69 | err.Error()) 70 | } 71 | } else { 72 | fmt.Printf("WARNING: Failed to read configuration-file - %s\n", err.Error()) 73 | } 74 | } 75 | 76 | f.IntVar(&p.RedisDB, "redis-db", defaults.RedisDB, "Specify the database-number for redis.") 77 | f.StringVar(&p.RedisHost, "redis-host", defaults.RedisHost, "Specify the address of the redis queue.") 78 | f.StringVar(&p.RedisPassword, "redis-pass", defaults.RedisPassword, "Specify the password for the redis queue.") 79 | f.StringVar(&p.RedisSocket, "redis-socket", defaults.RedisSocket, "If set, will be used for the redis connections.") 80 | } 81 | 82 | // 83 | // This is a callback invoked by the parser when a job 84 | // has been successfully parsed. 85 | // 86 | func (p *enqueueCmd) enqueueTest(tst test.Test) error { 87 | _, err := p._r.RPush("overseer.jobs", tst.Input).Result() 88 | return err 89 | } 90 | 91 | // 92 | // Entry-point. 93 | // 94 | func (p *enqueueCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { 95 | 96 | // 97 | // Connect to the redis-host. 98 | // 99 | if p.RedisSocket != "" { 100 | p._r = redis.NewClient(&redis.Options{ 101 | Network: "unix", 102 | Addr: p.RedisSocket, 103 | Password: p.RedisPassword, 104 | DB: p.RedisDB, 105 | }) 106 | } else { 107 | p._r = redis.NewClient(&redis.Options{ 108 | Addr: p.RedisHost, 109 | Password: p.RedisPassword, 110 | DB: p.RedisDB, 111 | DialTimeout: p.RedisDialTimeout, 112 | }) 113 | } 114 | 115 | // 116 | // And run a ping, just to make sure it worked. 117 | // 118 | _, err := p._r.Ping().Result() 119 | if err != nil { 120 | fmt.Printf("Redis connection failed: %s\n", err.Error()) 121 | return subcommands.ExitFailure 122 | } 123 | 124 | // 125 | // For each file on the command-line we can now parse and 126 | // enqueue the jobs 127 | // 128 | for _, file := range f.Args() { 129 | 130 | // 131 | // Create an object to parse our file. 132 | // 133 | helper := parser.New() 134 | 135 | // 136 | // For each parsed job call `enqueueTest`. 137 | // 138 | err := helper.ParseFile(file, p.enqueueTest) 139 | 140 | // 141 | // Did we see an error? 142 | // 143 | if err != nil { 144 | fmt.Printf("Error parsing file: %s\n", err.Error()) 145 | } 146 | 147 | // Did we read from stdin? 148 | if file == "-" { 149 | break 150 | } 151 | } 152 | 153 | return subcommands.ExitSuccess 154 | } 155 | -------------------------------------------------------------------------------- /bridges/telegram-bridge/main.go: -------------------------------------------------------------------------------- 1 | // 2 | // This is the telegram bridge, which reads test-results from redis, and submits 3 | // notices of failures to telegram, such that a human can be notified. 4 | // 5 | // The program should be built like so: 6 | // 7 | // go build . 8 | // 9 | // Once built launch it like so: 10 | // 11 | // $ ./telegram-bridge -token=xxxx -recipient=YYY 12 | // 13 | // Here `xxxx` is the token for the telegram bot API, and YYY is the UID 14 | // of the user to message. 15 | // 16 | // Steve 17 | // -- 18 | // 19 | 20 | package main 21 | 22 | import ( 23 | "encoding/json" 24 | "flag" 25 | "fmt" 26 | "os" 27 | "strconv" 28 | "strings" 29 | 30 | "github.com/go-redis/redis" 31 | tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api" 32 | ) 33 | 34 | // The redis handle 35 | var r *redis.Client 36 | 37 | // The telegram bot token 38 | var token *string 39 | 40 | // The recipient of the message 41 | var recipient *string 42 | 43 | // Given a JSON message decode it and post to telegram if it describes a 44 | // failure. 45 | func process(msg []byte) error { 46 | 47 | data := map[string]string{} 48 | 49 | if err := json.Unmarshal(msg, &data); err != nil { 50 | return err 51 | } 52 | 53 | // If the test passed we don't care 54 | if data["error"] == "" { 55 | return nil 56 | } 57 | 58 | testType := data["type"] 59 | testTarget := data["target"] 60 | input := data["input"] 61 | 62 | // Make the target a link, if it looks like one. 63 | if strings.HasPrefix(testTarget, "http") { 64 | testTarget = fmt.Sprintf("%s", testTarget, testTarget) 65 | } 66 | 67 | // The message we send to the user. 68 | text := fmt.Sprintf("The %s test failed against %s.\n\n%s\n\nThe test was:\n%s", testType, testTarget, data["error"], input) 69 | 70 | // 71 | // Create the bot 72 | // 73 | bot, err := tgbotapi.NewBotAPI(*token) 74 | if err != nil { 75 | return fmt.Errorf("error creating telegram bot with token '%s': %s", *token, err.Error()) 76 | } 77 | 78 | // 79 | // Convert the recipient string to a number. 80 | // 81 | n := 0 82 | n, err = strconv.Atoi(*recipient) 83 | if err != nil { 84 | return fmt.Errorf("error converting user to notify %s to integer: %s", *recipient, err.Error()) 85 | } 86 | 87 | // 88 | // Create the message. 89 | // 90 | message := tgbotapi.NewMessage(int64(n), text) 91 | message.ParseMode = tgbotapi.ModeHTML 92 | 93 | // 94 | // Send the message. 95 | // 96 | _, err = bot.Send(message) 97 | if err != nil { 98 | return fmt.Errorf("error sending message to user %s", err.Error()) 99 | } 100 | 101 | // 102 | // All done 103 | // 104 | return nil 105 | } 106 | 107 | // 108 | // Entry Point 109 | // 110 | func main() { 111 | 112 | // 113 | // Parse our flags 114 | // 115 | redisHost := flag.String("redis-host", "127.0.0.1:6379", "Specify the address of the redis queue.") 116 | redisPass := flag.String("redis-pass", "", "Specify the password of the redis queue.") 117 | token = flag.String("token", "", "The telegram bot token") 118 | recipient = flag.String("recipient", "", "The telegram user to notify") 119 | flag.Parse() 120 | 121 | // 122 | // Sanity-check 123 | // 124 | if *recipient == "" || *token == "" { 125 | fmt.Printf("Please set the telegram recipient and token.\n") 126 | os.Exit(1) 127 | 128 | } 129 | 130 | // 131 | // Create the redis client 132 | // 133 | r = redis.NewClient(&redis.Options{ 134 | Addr: *redisHost, 135 | Password: *redisPass, 136 | DB: 0, // use default DB 137 | }) 138 | 139 | // 140 | // And run a ping, just to make sure it worked. 141 | // 142 | _, err := r.Ping().Result() 143 | if err != nil { 144 | fmt.Printf("Redis connection failed: %s\n", err.Error()) 145 | os.Exit(1) 146 | } 147 | 148 | for { 149 | 150 | // 151 | // Get test-results 152 | // 153 | msg, _ := r.BLPop(0, "overseer.results").Result() 154 | 155 | // 156 | // If they were non-empty, process them. 157 | // 158 | // msg[0] will be "overseer.results" 159 | // 160 | // msg[1] will be the value removed from the list. 161 | // 162 | if len(msg) >= 1 { 163 | err := process([]byte(msg[1])) 164 | if err != nil { 165 | fmt.Printf("error notifying user: %s\n", err.Error()) 166 | return 167 | } 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /protocols/pop3s_probe.go: -------------------------------------------------------------------------------- 1 | // POP3S Tester 2 | // 3 | // The POP3S tester connects to a remote host and ensures that this 4 | // succeeds. If you supply a username & password a login will be 5 | // made, and the test will fail if this login fails. 6 | // 7 | // This test is invoked via input like so: 8 | // 9 | // host.example.com must run pop3 [with username 'steve@steve' with password 'secret'] 10 | // 11 | // Because POP3S uses TLS it will test the validity of the certificate as 12 | // part of the test, if you wish to disable this add `with tls insecure`. 13 | // 14 | 15 | package protocols 16 | 17 | import ( 18 | "crypto/tls" 19 | "fmt" 20 | "strconv" 21 | "strings" 22 | 23 | "github.com/simia-tech/go-pop3" 24 | "github.com/skx/overseer/test" 25 | ) 26 | 27 | // POP3STest is our object 28 | type POP3STest struct { 29 | } 30 | 31 | // Arguments returns the names of arguments which this protocol-test 32 | // understands, along with corresponding regular-expressions to validate 33 | // their values. 34 | func (s *POP3STest) Arguments() map[string]string { 35 | known := map[string]string{ 36 | "port": "^[0-9]+$", 37 | "tls": "insecure", 38 | "username": ".*", 39 | "password": ".*", 40 | } 41 | return known 42 | } 43 | 44 | // Example returns sample usage-instructions for self-documentation purposes. 45 | func (s *POP3STest) Example() string { 46 | str := ` 47 | POP3S Tester 48 | ------------ 49 | The POP3S tester connects to a remote host and ensures that this 50 | succeeds. If you supply a username & password a login will be 51 | made, and the test will fail if this login fails. 52 | 53 | This test is invoked via input like so: 54 | 55 | host.example.com must run pop3 56 | 57 | Because POP3S uses TLS it will test the validity of the certificate as 58 | part of the test, if you wish to disable this add 'with tls insecure'. 59 | ` 60 | return str 61 | } 62 | 63 | // RunTest is the part of our API which is invoked to actually execute a 64 | // test against the given target. 65 | // 66 | // In this case we make a POP3 connection to the specified host, and if 67 | // a username + password were specified we then attempt to authenticate 68 | // to the remote host too. 69 | func (s *POP3STest) RunTest(tst test.Test, target string, opts test.Options) error { 70 | var err error 71 | 72 | // 73 | // The default port to connect to. 74 | // 75 | port := 995 76 | 77 | // 78 | // If the user specified a different port update to use it. 79 | // 80 | if tst.Arguments["port"] != "" { 81 | port, err = strconv.Atoi(tst.Arguments["port"]) 82 | if err != nil { 83 | return err 84 | } 85 | } 86 | 87 | // 88 | // Should we skip validation of the SSL certificate? 89 | // 90 | insecure := false 91 | if tst.Arguments["tls"] == "insecure" { 92 | insecure = true 93 | } 94 | 95 | // 96 | // Default to connecting to an IPv4-address 97 | // 98 | address := fmt.Sprintf("%s:%d", target, port) 99 | 100 | // 101 | // If we find a ":" we know it is an IPv6 address though 102 | // 103 | if strings.Contains(target, ":") { 104 | address = fmt.Sprintf("[%s]:%d", target, port) 105 | } 106 | 107 | // 108 | // Setup the default TLS config. 109 | // 110 | // We need to setup the hostname that the TLS certificate 111 | // will verify upon, from our input-line. 112 | // 113 | data := strings.Fields(tst.Input) 114 | tlsSetup := &tls.Config{ServerName: data[0]} 115 | 116 | // 117 | // If we're being insecure then remove the verification 118 | // 119 | if insecure { 120 | tlsSetup = &tls.Config{ 121 | InsecureSkipVerify: true, 122 | } 123 | } 124 | 125 | // 126 | // Connect 127 | // 128 | c, err := pop3.Dial(address, pop3.UseTLS(tlsSetup), pop3.UseTimeout(opts.Timeout)) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | // 134 | // Did we get a username/password? If so try to authenticate 135 | // with them 136 | // 137 | if (tst.Arguments["username"] != "") && (tst.Arguments["password"] != "") { 138 | err = c.Auth(tst.Arguments["username"], tst.Arguments["password"]) 139 | if err != nil { 140 | return err 141 | } 142 | } 143 | 144 | // 145 | // Quit and return 146 | // 147 | c.Quit() 148 | 149 | return nil 150 | } 151 | 152 | // 153 | // Register our protocol-tester. 154 | // 155 | func init() { 156 | Register("pop3s", func() ProtocolTest { 157 | return &POP3STest{} 158 | }) 159 | } 160 | -------------------------------------------------------------------------------- /protocols/finger_probe.go: -------------------------------------------------------------------------------- 1 | // Finger Tester 2 | // 3 | // The finger tester connects to a remote host and ensures that a response 4 | // is received. 5 | // 6 | // This test is invoked via input like so: 7 | // 8 | // host.example.com must run finger with user 'skx' 9 | // 10 | // If you wish you can regard the fetch as a failure unless some specific 11 | // text is returned too: 12 | // 13 | // host.example.com must run finger with user 'skx' with content '2018' 14 | // 15 | // NOTE: The user-argument is mandatory. 16 | // 17 | 18 | package protocols 19 | 20 | import ( 21 | "bufio" 22 | "errors" 23 | "fmt" 24 | "net" 25 | "strconv" 26 | "strings" 27 | 28 | "github.com/skx/overseer/test" 29 | ) 30 | 31 | // FINGERTest is our object. 32 | type FINGERTest struct { 33 | } 34 | 35 | // Arguments returns the names of arguments which this protocol-test 36 | // understands, along with corresponding regular-expressions to validate 37 | // their values. 38 | func (s *FINGERTest) Arguments() map[string]string { 39 | known := map[string]string{ 40 | "content": ".*", 41 | "port": "^[0-9]+$", 42 | "user": ".*", 43 | } 44 | return known 45 | } 46 | 47 | // Example returns sample usage-instructions for self-documentation purposes. 48 | func (s *FINGERTest) Example() string { 49 | str := ` 50 | Finger Tester 51 | ------------- 52 | The finger tester connects to a remote host and ensures that a response 53 | is received. 54 | 55 | This test is invoked via input like so: 56 | 57 | host.example.com must run finger with user 'skx' 58 | 59 | If you wish you can regard the fetch as a failure unless some specific 60 | text is returned too: 61 | 62 | host.example.com must run finger with user 'skx' with content '2018' 63 | 64 | NOTE: The user-argument is mandatory. 65 | ` 66 | return str 67 | } 68 | 69 | // RunTest is the part of our API which is invoked to actually execute a 70 | // test against the given target. 71 | // 72 | // In this case we make a TCP connection, defaulting to port 79, and 73 | // look for a non-empty response. 74 | func (s *FINGERTest) RunTest(tst test.Test, target string, opts test.Options) error { 75 | var err error 76 | 77 | // 78 | // Ensure we have a username 79 | // 80 | if tst.Arguments["user"] == "" { 81 | return errors.New("a 'user' argument is mandatory") 82 | } 83 | 84 | // 85 | // The default port to connect to. 86 | // 87 | port := 79 88 | 89 | // 90 | // If the user specified a different port update to use it. 91 | // 92 | if tst.Arguments["port"] != "" { 93 | port, err = strconv.Atoi(tst.Arguments["port"]) 94 | if err != nil { 95 | return err 96 | } 97 | } 98 | 99 | // 100 | // Set an explicit timeout 101 | // 102 | d := net.Dialer{Timeout: opts.Timeout} 103 | 104 | // 105 | // Default to connecting to an IPv4-address 106 | // 107 | address := fmt.Sprintf("%s:%d", target, port) 108 | 109 | // 110 | // If we find a ":" we know it is an IPv6 address though 111 | // 112 | if strings.Contains(target, ":") { 113 | address = fmt.Sprintf("[%s]:%d", target, port) 114 | } 115 | 116 | // 117 | // Make the TCP connection. 118 | // 119 | conn, err := d.Dial("tcp", address) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | // 125 | // Send the username 126 | // 127 | _, err = fmt.Fprintf(conn, tst.Arguments["user"]+"\r\n") 128 | if err != nil { 129 | return err 130 | } 131 | 132 | // 133 | // Read the response from the finger-server 134 | // 135 | var output string 136 | output, err = bufio.NewReader(conn).ReadString('\n') 137 | if err != nil { 138 | return err 139 | } 140 | conn.Close() 141 | 142 | // 143 | // If we didn't get a response of some kind, (i.e. "~/.plan" contents) 144 | // then the test failed. 145 | // 146 | if output == "" { 147 | return errors.New("the server didn't send a response") 148 | } 149 | 150 | // 151 | // If we require some specific content in the response we should 152 | // test for that here. 153 | // 154 | content := tst.Arguments["content"] 155 | if content != "" { 156 | if !strings.Contains(output, content) { 157 | return fmt.Errorf("the finger-output did not contain the required text '%s'", content) 158 | } 159 | } 160 | 161 | return nil 162 | } 163 | 164 | // 165 | // Register our protocol-tester. 166 | // 167 | func init() { 168 | Register("finger", func() ProtocolTest { 169 | return &FINGERTest{} 170 | }) 171 | } 172 | -------------------------------------------------------------------------------- /protocols/tcp_probe.go: -------------------------------------------------------------------------------- 1 | // TCP Tester 2 | // 3 | // The TCP tester connects to a remote host and does nothing else. 4 | // 5 | // In short it determines whether a TCP-based service is reachable, 6 | // by excluding errors such as "host not found", or "connection refused". 7 | // 8 | // This test is invoked via input like so: 9 | // 10 | // host.example.com must run tcp with port 123 11 | // 12 | // The port-setting is mandatory, such that the tests knows what to connect to. 13 | // 14 | // Optionally you may specify a regular expression to match against a 15 | // banner the remote host sends on connection: 16 | // 17 | // host.example.com must run tcp with port 655 with banner '0 \S+ 17' 18 | // 19 | 20 | package protocols 21 | 22 | import ( 23 | "bufio" 24 | "errors" 25 | "fmt" 26 | "net" 27 | "regexp" 28 | "strconv" 29 | "strings" 30 | 31 | "github.com/skx/overseer/test" 32 | ) 33 | 34 | // TCPTest is our object 35 | type TCPTest struct { 36 | } 37 | 38 | // Arguments returns the names of arguments which this protocol-test 39 | // understands, along with corresponding regular-expressions to validate 40 | // their values. 41 | func (s *TCPTest) Arguments() map[string]string { 42 | known := map[string]string{ 43 | "port": "^[0-9]+$", 44 | "banner": ".*", 45 | } 46 | return known 47 | } 48 | 49 | // Example returns sample usage-instructions for self-documentation purposes. 50 | func (s *TCPTest) Example() string { 51 | str := ` 52 | TCP Tester 53 | ---------- 54 | The TCP tester connects to a remote host and does nothing else. 55 | 56 | In short it determines whether a TCP-based service is reachable, 57 | by excluding errors such as "host not found", or "connection refused". 58 | 59 | This test is invoked via input like so: 60 | 61 | host.example.com must run tcp with port 123 62 | 63 | The port-setting is mandatory, such that the tests knows what to connect to. 64 | 65 | Optionally you may specify a regular expression to match against a 66 | banner the remote host sends on connection: 67 | 68 | host.example.com must run tcp with port 655 with banner '0 \S+ 17' 69 | ` 70 | return str 71 | } 72 | 73 | // RunTest is the part of our API which is invoked to actually execute a 74 | // test against the given target. 75 | // 76 | // In this case we make a TCP connection to the specified port, and assume 77 | // that everything is OK if that succeeded. 78 | func (s *TCPTest) RunTest(tst test.Test, target string, opts test.Options) error { 79 | var err error 80 | 81 | // 82 | // The default port to connect to. 83 | // 84 | port := -1 85 | 86 | // 87 | // If the user specified a different port update to use it. 88 | // 89 | if tst.Arguments["port"] != "" { 90 | port, err = strconv.Atoi(tst.Arguments["port"]) 91 | if err != nil { 92 | return err 93 | } 94 | } 95 | 96 | // 97 | // If there was no port that's an error 98 | // 99 | if port == -1 { 100 | return errors.New("you must specify the port when running a TCP test") 101 | } 102 | 103 | // 104 | // Set an explicit timeout 105 | // 106 | d := net.Dialer{Timeout: opts.Timeout} 107 | 108 | // 109 | // Default to connecting to an IPv4-address 110 | // 111 | address := fmt.Sprintf("%s:%d", target, port) 112 | 113 | // 114 | // If we find a ":" we know it is an IPv6 address though 115 | // 116 | if strings.Contains(target, ":") { 117 | address = fmt.Sprintf("[%s]:%d", target, port) 118 | } 119 | 120 | // 121 | // Make the TCP connection. 122 | // 123 | conn, err := d.Dial("tcp", address) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | defer conn.Close() 129 | 130 | // 131 | // If we're going to do a banner match then we should read a line 132 | // from the host 133 | // 134 | if tst.Arguments["banner"] != "" { 135 | 136 | // Compile the regular expression 137 | re, error := regexp.Compile("(?ms)" + tst.Arguments["banner"]) 138 | if error != nil { 139 | return error 140 | } 141 | 142 | // Read a single line of input 143 | banner, err := bufio.NewReader(conn).ReadString('\n') 144 | if err != nil { 145 | return err 146 | } 147 | 148 | // 149 | // If the regexp doesn't match that's an error. 150 | // 151 | match := re.FindAllStringSubmatch(string(banner), -1) 152 | if len(match) < 1 { 153 | return fmt.Errorf("remote banner '%s' didn't match the regular expression '%s'", banner, tst.Arguments["banner"]) 154 | } 155 | } 156 | 157 | return nil 158 | } 159 | 160 | // 161 | // Register our protocol-tester. 162 | // 163 | func init() { 164 | Register("tcp", func() ProtocolTest { 165 | return &TCPTest{} 166 | }) 167 | } 168 | -------------------------------------------------------------------------------- /protocols/imaps_probe.go: -------------------------------------------------------------------------------- 1 | // IMAPS Tester 2 | // 3 | // The IMAPS tester connects to a remote host and ensures that this 4 | // succeeds. If you supply a username & password a login will be 5 | // made, and the test will fail if this login fails. 6 | // 7 | // This test is invoked via input like so: 8 | // 9 | // host.example.com must run imap [with username 'steve@steve' with password 'secret'] 10 | // 11 | // Because IMAPS uses TLS it will test the validity of the certificate as 12 | // part of the test, if you wish to disable this add `with tls insecure`. 13 | // 14 | 15 | package protocols 16 | 17 | import ( 18 | "crypto/tls" 19 | "fmt" 20 | "net" 21 | "strconv" 22 | "strings" 23 | 24 | client "github.com/emersion/go-imap/client" 25 | "github.com/skx/overseer/test" 26 | ) 27 | 28 | // IMAPSTest is our object 29 | type IMAPSTest struct { 30 | } 31 | 32 | // Arguments returns the names of arguments which this protocol-test 33 | // understands, along with corresponding regular-expressions to validate 34 | // their values. 35 | func (s *IMAPSTest) Arguments() map[string]string { 36 | known := map[string]string{ 37 | "port": "^[0-9]+$", 38 | "tls": "insecure", 39 | "username": ".*", 40 | "password": ".*", 41 | } 42 | return known 43 | } 44 | 45 | // Example returns sample usage-instructions for self-documentation purposes. 46 | func (s *IMAPSTest) Example() string { 47 | str := ` 48 | IMAPS Tester 49 | ------------ 50 | The IMAPS tester connects to a remote host and ensures that this succeeds. 51 | 52 | If you supply a username & password a login will be made, and the test will 53 | fail if this login does not succeed. 54 | 55 | This test is invoked via input like so: 56 | 57 | host.example.com must run imaps 58 | 59 | Because IMAPS uses TLS this test will ensure the validity of the certificate as 60 | part of the test, if you wish to disable this add "with tls insecure". 61 | ` 62 | 63 | return str 64 | } 65 | 66 | // RunTest is the part of our API which is invoked to actually execute a 67 | // test against the given target. 68 | // 69 | // In this case we make a IMAP connection to the specified host, and if 70 | // a username + password were specified we then attempt to authenticate 71 | // to the remote host too. 72 | func (s *IMAPSTest) RunTest(tst test.Test, target string, opts test.Options) error { 73 | var err error 74 | 75 | // 76 | // The default port to connect to. 77 | // 78 | port := 993 79 | 80 | // 81 | // If the user specified a different port update to use it. 82 | // 83 | if tst.Arguments["port"] != "" { 84 | port, err = strconv.Atoi(tst.Arguments["port"]) 85 | if err != nil { 86 | return err 87 | } 88 | } 89 | 90 | // 91 | // Should we skip validation of the SSL certificate? 92 | // 93 | insecure := false 94 | if tst.Arguments["tls"] == "insecure" { 95 | insecure = true 96 | } 97 | 98 | // 99 | // Default to connecting to an IPv4-address 100 | // 101 | address := fmt.Sprintf("%s:%d", target, port) 102 | 103 | // 104 | // If we find a ":" we know it is an IPv6 address though 105 | // 106 | if strings.Contains(target, ":") { 107 | address = fmt.Sprintf("[%s]:%d", target, port) 108 | } 109 | 110 | // 111 | // Setup a dialer so we can have a suitable timeout 112 | // 113 | var dial = &net.Dialer{ 114 | Timeout: opts.Timeout, 115 | } 116 | 117 | // 118 | // Setup the default TLS config. 119 | // 120 | // We need to setup the hostname that the TLS certificate 121 | // will verify upon, from our input-line. 122 | // 123 | data := strings.Fields(tst.Input) 124 | tlsSetup := &tls.Config{ServerName: data[0]} 125 | 126 | // 127 | // Disable verification if we're being insecure. 128 | // 129 | if insecure { 130 | tlsSetup = &tls.Config{ 131 | InsecureSkipVerify: true, 132 | } 133 | } 134 | 135 | // 136 | // Connect. 137 | // 138 | con, err := client.DialWithDialerTLS(dial, address, tlsSetup) 139 | if err != nil { 140 | return err 141 | 142 | } 143 | defer con.Close() 144 | 145 | // 146 | // If we got username/password then use them 147 | // 148 | if (tst.Arguments["username"] != "") && (tst.Arguments["password"] != "") { 149 | err = con.Login(tst.Arguments["username"], tst.Arguments["password"]) 150 | if err != nil { 151 | return err 152 | } 153 | 154 | // Logout so that we don't keep the handle open. 155 | err = con.Logout() 156 | if err != nil { 157 | return err 158 | } 159 | } 160 | 161 | return nil 162 | } 163 | 164 | // 165 | // Register our protocol-tester. 166 | // 167 | func init() { 168 | Register("imaps", func() ProtocolTest { 169 | return &IMAPSTest{} 170 | }) 171 | } 172 | -------------------------------------------------------------------------------- /bridges/email-bridge/main.go: -------------------------------------------------------------------------------- 1 | // 2 | // This is the email bridge, which should be built like so: 3 | // 4 | // go build . 5 | // 6 | // Once built launch it as follows: 7 | // 8 | // $ ./email-bridge -email=sysadmin@example.com 9 | // 10 | // When a test fails an email will sent, by executing /usr/sbin/sendmail. 11 | // 12 | // Steve 13 | // -- 14 | // 15 | 16 | package main 17 | 18 | import ( 19 | "bytes" 20 | "encoding/json" 21 | "flag" 22 | "fmt" 23 | "io/ioutil" 24 | "os" 25 | "os/exec" 26 | "text/template" 27 | 28 | "github.com/go-redis/redis" 29 | ) 30 | 31 | // The email we notify 32 | var email *string 33 | 34 | // The redis handle 35 | var r *redis.Client 36 | 37 | // Template is our text/template which is used to generate the email 38 | // notification to the user. 39 | var Template = `From: {{.From}} 40 | To: {{.To}} 41 | Subject: The {{.Type}} test failed against {{.Target}} 42 | 43 | The {{.Type}} test failed against {{.Target}}. 44 | 45 | The complete test was: 46 | 47 | {{.Input}} 48 | 49 | The failure was: 50 | 51 | {{.Failure}} 52 | 53 | ` 54 | 55 | // 56 | // Given a JSON string decode it and post it via email if it describes 57 | // a test-failure. 58 | // 59 | func process(msg []byte) { 60 | data := map[string]string{} 61 | 62 | if err := json.Unmarshal(msg, &data); err != nil { 63 | panic(err) 64 | } 65 | 66 | // 67 | // If the test passed then we don't care. 68 | // 69 | result := data["error"] 70 | if result == "" { 71 | return 72 | } 73 | 74 | // 75 | // Here is a temporary structure we'll use to popular our email 76 | // template. 77 | // 78 | type TemplateParms struct { 79 | To string 80 | From string 81 | Target string 82 | Type string 83 | Input string 84 | Failure string 85 | } 86 | 87 | // 88 | // Populate it appropriately. 89 | // 90 | var x TemplateParms 91 | x.To = *email 92 | x.From = *email 93 | x.Type = data["type"] 94 | x.Target = data["target"] 95 | x.Input = data["input"] 96 | x.Failure = result 97 | 98 | // 99 | // Render our template into a buffer. 100 | // 101 | src := string(Template) 102 | t := template.Must(template.New("tmpl").Parse(src)) 103 | buf := &bytes.Buffer{} 104 | err := t.Execute(buf, x) 105 | if err != nil { 106 | fmt.Printf("Failed to compile email-template %s\n", err.Error()) 107 | return 108 | } 109 | 110 | // 111 | // Prepare to run sendmail, with a pipe we can write our message to. 112 | // 113 | sendmail := exec.Command("/usr/sbin/sendmail", "-f", *email, *email) 114 | stdin, err := sendmail.StdinPipe() 115 | if err != nil { 116 | fmt.Printf("Error sending email: %s\n", err.Error()) 117 | return 118 | } 119 | 120 | // 121 | // Get the output pipe. 122 | // 123 | stdout, err := sendmail.StdoutPipe() 124 | if err != nil { 125 | fmt.Printf("Error sending email: %s\n", err.Error()) 126 | return 127 | } 128 | 129 | // 130 | // Run the command, and pipe in the rendered template-result 131 | // 132 | sendmail.Start() 133 | _, err = stdin.Write(buf.Bytes()) 134 | if err != nil { 135 | fmt.Printf("Failed to write to sendmail pipe: %s\n", err.Error()) 136 | } 137 | stdin.Close() 138 | 139 | // 140 | // Read the output of Sendmail. 141 | // 142 | _, err = ioutil.ReadAll(stdout) 143 | if err != nil { 144 | fmt.Printf("Error reading mail output: %s\n", err.Error()) 145 | return 146 | } 147 | 148 | err = sendmail.Wait() 149 | 150 | if err != nil { 151 | fmt.Printf("Waiting for process to terminate failed: %s\n", err.Error()) 152 | } 153 | } 154 | 155 | // 156 | // Entry Point 157 | // 158 | func main() { 159 | 160 | // 161 | // Parse our flags 162 | // 163 | redisHost := flag.String("redis-host", "127.0.0.1:6379", "Specify the address of the redis queue.") 164 | redisPass := flag.String("redis-pass", "", "Specify the password of the redis queue.") 165 | email = flag.String("email", "", "The email address to notify") 166 | flag.Parse() 167 | 168 | // 169 | // Sanity-check. 170 | // 171 | if *email == "" { 172 | fmt.Printf("Usage: email-bridge -email=sysadmin@example.com [-redis-host=127.0.0.1:6379] [-redis-pass=foo]\n") 173 | os.Exit(1) 174 | } 175 | 176 | // 177 | // Create the redis client 178 | // 179 | r = redis.NewClient(&redis.Options{ 180 | Addr: *redisHost, 181 | Password: *redisPass, 182 | DB: 0, // use default DB 183 | }) 184 | 185 | // 186 | // And run a ping, just to make sure it worked. 187 | // 188 | _, err := r.Ping().Result() 189 | if err != nil { 190 | fmt.Printf("Redis connection failed: %s\n", err.Error()) 191 | os.Exit(1) 192 | } 193 | 194 | for { 195 | 196 | // 197 | // Get test-results 198 | // 199 | msg, _ := r.BLPop(0, "overseer.results").Result() 200 | 201 | // 202 | // If they were non-empty, process them. 203 | // 204 | // msg[0] will be "overseer.results" 205 | // 206 | // msg[1] will be the value removed from the list. 207 | // 208 | if len(msg) >= 1 { 209 | process([]byte(msg[1])) 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /protocols/smtp_probe.go: -------------------------------------------------------------------------------- 1 | // SMTP Tester 2 | // 3 | // The SMTP tester checks on the status of a remote SMTP-server. 4 | // 5 | // This test is invoked via input like so: 6 | // 7 | // host.example.com must run smtp [with port 25] 8 | // 9 | // By default a connection will be attempted and nothing else. A more 10 | // complete test would be to specify a username & password and test that 11 | // authentication succeeds. 12 | // 13 | // Note that performing an authentication-request requires the use of 14 | // `STARTTLS`. If the TLS certificate is self-signed or otherwise 15 | // non-trusted you'll need to disable the validity checking by appending 16 | // `with tls insecure`. 17 | // 18 | // A complete example, testing a login, will look like this: 19 | // 20 | // host.example.com must run smtp [with port 587] with username 'steve@example.com' with password 'secret' [with tls insecure] 21 | // 22 | // 23 | 24 | package protocols 25 | 26 | import ( 27 | "crypto/tls" 28 | "errors" 29 | "fmt" 30 | "net" 31 | "net/smtp" 32 | "strconv" 33 | "strings" 34 | 35 | "github.com/skx/overseer/test" 36 | ) 37 | 38 | // SMTPTest is our object 39 | type SMTPTest struct { 40 | } 41 | 42 | // Arguments returns the names of arguments which this protocol-test 43 | // understands, along with corresponding regular-expressions to validate 44 | // their values. 45 | func (s *SMTPTest) Arguments() map[string]string { 46 | known := map[string]string{ 47 | "port": "^[0-9]+$", 48 | "username": ".*", 49 | "password": ".*", 50 | "tls": "insecure", 51 | } 52 | return known 53 | } 54 | 55 | // Example returns sample usage-instructions for self-documentation purposes. 56 | func (s *SMTPTest) Example() string { 57 | str := ` 58 | SMTP Tester 59 | ----------- 60 | The SMTP tester checks on the status of a remote SMTP-server. 61 | 62 | This test is invoked via input like so: 63 | 64 | host.example.com must run smtp [with port 25] 65 | 66 | By default a connection will be attempted and nothing else. A more 67 | complete test would be to specify a username & password and test that 68 | authentication succeeds. 69 | 70 | Note that performing an authentication-request requires the use of 71 | STARTTLS. If the TLS certificate is self-signed or otherwise 72 | non-trusted you'll need to disable the validity checking by appending 73 | 'with tls insecure'. 74 | 75 | A complete example, testing a login, will look like this: 76 | 77 | host.example.com must run smtp [with port 587] with username 'steve@example.com' with password 's3cr3t' [with tls insecure] 78 | ` 79 | return str 80 | } 81 | 82 | // RunTest is the part of our API which is invoked to actually execute a 83 | // test against the given target. 84 | // 85 | // In this case we make a TCP connection, defaulting to port 25, and 86 | // look for a response which appears to be an SMTP-server. 87 | func (s *SMTPTest) RunTest(tst test.Test, target string, opts test.Options) error { 88 | var err error 89 | 90 | // 91 | // The default port to connect to. 92 | // 93 | port := 25 94 | 95 | // 96 | // If the user specified a different port update to use it. 97 | // 98 | if tst.Arguments["port"] != "" { 99 | port, err = strconv.Atoi(tst.Arguments["port"]) 100 | if err != nil { 101 | return err 102 | } 103 | } 104 | 105 | // 106 | // Set an explicit timeout 107 | // 108 | d := net.Dialer{Timeout: opts.Timeout} 109 | 110 | // 111 | // Default to connecting to an IPv4-address 112 | // 113 | address := fmt.Sprintf("%s:%d", target, port) 114 | 115 | // 116 | // If we find a ":" we know it is an IPv6 address though 117 | // 118 | if strings.Contains(target, ":") { 119 | address = fmt.Sprintf("[%s]:%d", target, port) 120 | } 121 | 122 | // 123 | // Make the TCP connection. 124 | // 125 | conn, err := d.Dial("tcp", address) 126 | if err != nil { 127 | return err 128 | } 129 | 130 | // The default TLS configuration verifies the certificate 131 | // matches the hostname of our target. 132 | tlsconfig := &tls.Config{ 133 | ServerName: tst.Target, 134 | } 135 | 136 | // However if the user is being insecure then we'll validate 137 | // nothing - allowing self-signed certificates, and hostname 138 | // mismatches. 139 | if tst.Arguments["tls"] == "insecure" { 140 | tlsconfig = &tls.Config{ 141 | InsecureSkipVerify: true, 142 | } 143 | } 144 | 145 | // Create the SMTP-client 146 | client, err := smtp.NewClient(conn, tst.Target) 147 | if err != nil { 148 | return err 149 | } 150 | 151 | defer client.Close() 152 | 153 | if err = client.Hello(tst.Target); err != nil { 154 | return err 155 | } 156 | 157 | // 158 | // If we have a username & password then we have to 159 | // try them - but this will require TLS so we'll start 160 | // that first. 161 | // 162 | if tst.Arguments["username"] != "" && 163 | tst.Arguments["password"] != "" { 164 | 165 | hasStartTLS, _ := client.Extension("STARTTLS") 166 | if !hasStartTLS { 167 | return errors.New("we cannot login without STARTTLS, and that was not advertised") 168 | } 169 | 170 | if err = client.StartTLS(tlsconfig); err != nil { 171 | return err 172 | } 173 | 174 | // 175 | // In the future we might try more options 176 | // 177 | // CRAM MD5 is available in the net/smtp client at least. 178 | // 179 | auth := smtp.PlainAuth("", tst.Arguments["username"], 180 | tst.Arguments["password"], tst.Target) 181 | 182 | // 183 | // If auth failed then report that. 184 | // 185 | if err = client.Auth(auth); err != nil { 186 | return err 187 | } 188 | } 189 | 190 | // All done 191 | return nil 192 | } 193 | 194 | // 195 | // Register our protocol-tester. 196 | // 197 | func init() { 198 | Register("smtp", func() ProtocolTest { 199 | return &SMTPTest{} 200 | }) 201 | } 202 | -------------------------------------------------------------------------------- /protocols/redis_probe.go: -------------------------------------------------------------------------------- 1 | // Redis Tester 2 | // 3 | // The Redis tester connects to a remote host and ensures that this succeeds, 4 | // if a password is specified it will be used in the connection. 5 | // 6 | // This test is invoked via input like so: 7 | // 8 | // host.example.com must run redis [with port 6379] [with password 'password'] 9 | // 10 | // If you wish you can test the size of a set/list, this is not quite as 11 | // good as it might be as our argument-parsing is a little too strict. 12 | // 13 | // To make sure the list `steve` has no more than `1000` entries we 14 | // would write: 15 | // 16 | // localhost must run redis with list 'steve' with max_size '1000' 17 | // 18 | // Or the set `users`: 19 | // 20 | // localhost must run redis with set 'members' with max_size '1000' 21 | // 22 | // 23 | 24 | package protocols 25 | 26 | import ( 27 | "fmt" 28 | "strconv" 29 | "strings" 30 | 31 | "github.com/go-redis/redis" 32 | "github.com/skx/overseer/test" 33 | ) 34 | 35 | // REDISTest is our object 36 | type REDISTest struct { 37 | } 38 | 39 | // Arguments returns the names of arguments which this protocol-test 40 | // understands, along with corresponding regular-expressions to validate 41 | // their values. 42 | func (s *REDISTest) Arguments() map[string]string { 43 | known := map[string]string{ 44 | "list": ".*", 45 | "set": ".*", 46 | "max_size": "^[0-9]+$", 47 | "password": ".*", 48 | "port": "^[0-9]+$", 49 | } 50 | return known 51 | } 52 | 53 | // Example returns sample usage-instructions for self-documentation purposes. 54 | func (s *REDISTest) Example() string { 55 | str := ` 56 | Redis Tester 57 | ------------ 58 | The Redis tester connects to a remote host and ensures that this succeeds, 59 | if a password is specified it will be used in the connection. 60 | 61 | This test is invoked via input like so: 62 | 63 | host.example.com must run redis [with password 'secret'] [with port '6379'] 64 | 65 | If you wish you can test the size of a set/list, this is not quite as 66 | good as it might be as our argument-parsing is a little too strict. 67 | 68 | To make sure the list 'steve' has no more than 1000 entries we 69 | would write: 70 | 71 | localhost must run redis with list 'steve' with max_size '1000' 72 | 73 | Or the set 'users': 74 | 75 | localhost must run redis with set 'members' with max_size '1000' 76 | ` 77 | return str 78 | } 79 | 80 | // RunTest is the part of our API which is invoked to actually execute a 81 | // test against the given target. 82 | // 83 | // In this case we make a Redis-test against the given target. 84 | // 85 | func (s *REDISTest) RunTest(tst test.Test, target string, opts test.Options) error { 86 | 87 | // 88 | // Predeclare our error 89 | // 90 | var err error 91 | 92 | // 93 | // The default port to connect to. 94 | // 95 | port := 6379 96 | 97 | // 98 | // The default password to use. 99 | // 100 | password := "" 101 | 102 | // 103 | // If the user specified a different port update to use it. 104 | // 105 | if tst.Arguments["port"] != "" { 106 | port, err = strconv.Atoi(tst.Arguments["port"]) 107 | if err != nil { 108 | return err 109 | } 110 | } 111 | 112 | // 113 | // Maximum list-size, if specified 114 | // 115 | maxSize := 0 116 | if tst.Arguments["max_size"] != "" { 117 | maxSize, err = strconv.Atoi(tst.Arguments["max_size"]) 118 | if err != nil { 119 | return err 120 | } 121 | } 122 | 123 | // 124 | // If the user specified a password use it. 125 | // 126 | password = tst.Arguments["password"] 127 | 128 | // 129 | // Default to connecting to an IPv4-address 130 | // 131 | address := fmt.Sprintf("%s:%d", target, port) 132 | 133 | // 134 | // If we find a ":" we know it is an IPv6 address though 135 | // 136 | if strings.Contains(target, ":") { 137 | address = fmt.Sprintf("[%s]:%d", target, port) 138 | } 139 | 140 | // 141 | // Attempt to connect to the host with the optional password 142 | // 143 | client := redis.NewClient(&redis.Options{ 144 | Addr: address, 145 | Password: password, 146 | DB: 0, // use default DB 147 | }) 148 | 149 | // 150 | // Now test the connection by running a ping 151 | // 152 | // If the connection is refused, or the auth-details don't match 153 | // then we'll see that here. 154 | // 155 | _, err = client.Ping().Result() 156 | if err != nil { 157 | return err 158 | } 159 | 160 | // 161 | // If we have a `list` and a `max_size` then get the size of the 162 | // specified set. 163 | // 164 | if tst.Arguments["list"] != "" && maxSize > 0 { 165 | 166 | // 167 | // Get the length of the list. 168 | // 169 | res := client.LLen(tst.Arguments["list"]) 170 | if res.Err() != nil { 171 | return res.Err() 172 | } 173 | 174 | len := int(res.Val()) 175 | 176 | // 177 | // Raise an alert if the size is exceeded. 178 | // 179 | if len >= maxSize { 180 | return (fmt.Errorf("list %s has %d entries, more than the max size of %d", tst.Arguments["list"], len, maxSize)) 181 | } 182 | } 183 | 184 | // 185 | // If we have a `set` and a `max_size` then get the size of the 186 | // specified set. 187 | // 188 | if tst.Arguments["set"] != "" && maxSize > 0 { 189 | 190 | // 191 | // Get the count of set-members. 192 | // 193 | res := client.SCard(tst.Arguments["list"]) 194 | if res.Err() != nil { 195 | return res.Err() 196 | } 197 | 198 | len := int(res.Val()) 199 | 200 | // 201 | // Raise an alert if the size is exceeded. 202 | // 203 | if len >= maxSize { 204 | return (fmt.Errorf("set %s has %d members, more than the max size of %d", tst.Arguments["list"], len, maxSize)) 205 | } 206 | } 207 | 208 | // 209 | // If we reached here all is OK 210 | // 211 | return nil 212 | } 213 | 214 | // 215 | // Register our protocol-tester. 216 | // 217 | func init() { 218 | Register("redis", func() ProtocolTest { 219 | return &REDISTest{} 220 | }) 221 | } 222 | -------------------------------------------------------------------------------- /protocols/dns_probe.go: -------------------------------------------------------------------------------- 1 | // DNS Tester 2 | // 3 | // The DNS tester allows you to confirm that the specified DNS server 4 | // returns the results you expect. It is invoked with input like this: 5 | // 6 | // ns.example.com must run dns with lookup test.example.com with type A with result '1.2.3.4' 7 | // 8 | // This test ensures that the DNS lookup of an A record for `test.example.com` 9 | // returns the single value 1.2.3.4 10 | // 11 | // Lookups are supported for A, AAAA, MX, NS, and TXT records. 12 | // 13 | 14 | package protocols 15 | 16 | import ( 17 | "errors" 18 | "fmt" 19 | "sort" 20 | "strings" 21 | "time" 22 | 23 | "github.com/miekg/dns" 24 | "github.com/skx/overseer/test" 25 | ) 26 | 27 | // DNSTest is our object. 28 | type DNSTest struct { 29 | } 30 | 31 | var ( 32 | localm *dns.Msg 33 | localc *dns.Client 34 | ) 35 | 36 | // lookup will perform a DNS query, using the servername-specified. 37 | // It returns an array of maps of the response. 38 | func (s *DNSTest) lookup(server string, name string, ltype string, timeout time.Duration) ([]string, error) { 39 | 40 | var results []string 41 | 42 | var err error 43 | localm = &dns.Msg{ 44 | MsgHdr: dns.MsgHdr{ 45 | RecursionDesired: true, 46 | }, 47 | Question: make([]dns.Question, 1), 48 | } 49 | localc = &dns.Client{ 50 | ReadTimeout: timeout, 51 | } 52 | r, err := s.localQuery(server, dns.Fqdn(name), ltype) 53 | if err != nil || r == nil { 54 | return nil, err 55 | } 56 | if r.Rcode == dns.RcodeNameError { 57 | return nil, fmt.Errorf("no such domain %s", dns.Fqdn(name)) 58 | } 59 | 60 | for _, entry := range r.Answer { 61 | 62 | // 63 | // Lookup the value 64 | // 65 | switch ent := entry.(type) { 66 | case *dns.A: 67 | a := ent.A 68 | results = append(results, a.String()) 69 | case *dns.AAAA: 70 | aaaa := ent.AAAA 71 | results = append(results, aaaa.String()) 72 | case *dns.MX: 73 | mxName := ent.Mx 74 | mxPrio := ent.Preference 75 | results = append(results, fmt.Sprintf("%d %s", mxPrio, mxName)) 76 | case *dns.NS: 77 | nameserver := ent.Ns 78 | results = append(results, nameserver) 79 | case *dns.TXT: 80 | txt := ent.Txt 81 | results = append(results, txt[0]) 82 | } 83 | } 84 | return results, nil 85 | } 86 | 87 | // Given a name & type to lookup perform the request against the named 88 | // DNS-server. 89 | func (s *DNSTest) localQuery(server string, qname string, lookupType string) (*dns.Msg, error) { 90 | 91 | // Here we have a map of DNS type-names. 92 | var StringToType = map[string]uint16{ 93 | "A": dns.TypeA, 94 | "AAAA": dns.TypeAAAA, 95 | "MX": dns.TypeMX, 96 | "NS": dns.TypeNS, 97 | "TXT": dns.TypeTXT, 98 | } 99 | 100 | qtype := StringToType[lookupType] 101 | if qtype == 0 { 102 | return nil, fmt.Errorf("unsupported record to lookup '%s'", lookupType) 103 | } 104 | localm.SetQuestion(qname, qtype) 105 | 106 | // 107 | // Default to connecting to an IPv4-address 108 | // 109 | address := fmt.Sprintf("%s:%d", server, 53) 110 | 111 | // 112 | // If we find a ":" we know it is an IPv6 address though 113 | // 114 | if strings.Contains(server, ":") { 115 | address = fmt.Sprintf("[%s]:%d", server, 53) 116 | } 117 | 118 | // 119 | // Run the lookup 120 | // 121 | r, _, err := localc.Exchange(localm, address) 122 | if err != nil { 123 | return nil, err 124 | } 125 | if r == nil || r.Rcode == dns.RcodeNameError || r.Rcode == dns.RcodeSuccess { 126 | return r, err 127 | } 128 | return nil, nil 129 | } 130 | 131 | // Arguments returns the names of arguments which this protocol-test 132 | // understands, along with corresponding regular-expressions to validate 133 | // their values. 134 | func (s *DNSTest) Arguments() map[string]string { 135 | 136 | known := map[string]string{ 137 | "type": "A|AAAA|MX|NS|TXT", 138 | "lookup": ".*", 139 | "result": ".*", 140 | } 141 | return known 142 | } 143 | 144 | // Example returns sample usage-instructions for self-documentation purposes. 145 | func (s *DNSTest) Example() string { 146 | str := ` 147 | DNS Tester 148 | ---------- 149 | The DNS tester allows you to confirm that the specified DNS server 150 | returns the results you expect. It is invoked with input like this: 151 | 152 | ns.example.com must run dns with lookup test.example.com with type A with result '1.2.3.4' 153 | 154 | This test ensures that the DNS lookup of an A record for 'test.example.com' 155 | returns the single value 1.2.3.4 156 | 157 | Lookups are supported for A, AAAA, MX, NS, and TXT records. If you expect 158 | there to be zero returning records, perhaps because you're ensuring that a 159 | service is IPv4-only you can specify that you require an empty result: 160 | 161 | rache.ns.cloudflare.com must run dns with lookup alert.steve.fi with type AAAA with result '' 162 | ` 163 | return str 164 | } 165 | 166 | // RunTest is the part of our API which is invoked to actually execute a 167 | // test against the given target. 168 | // 169 | // In this case we make a DNS-lookup against the named host, and compare 170 | // the result with what the user specified. 171 | // look for a response which appears to be an FTP-server. 172 | func (s *DNSTest) RunTest(tst test.Test, target string, opts test.Options) error { 173 | 174 | if tst.Arguments["lookup"] == "" { 175 | return errors.New("no value to lookup specified") 176 | } 177 | if tst.Arguments["type"] == "" { 178 | return errors.New("no record-type to lookup") 179 | } 180 | 181 | // 182 | // NOTE: 183 | // "result" must also be specified, but it is valid to set that 184 | // to be empty. 185 | // 186 | 187 | // 188 | // Run the lookup 189 | // 190 | res, err := s.lookup(target, tst.Arguments["lookup"], tst.Arguments["type"], opts.Timeout) 191 | if err != nil { 192 | return err 193 | } 194 | 195 | // 196 | // If the results differ that's an error 197 | // 198 | // Sort the results and comma-join for comparison 199 | // 200 | sort.Strings(res) 201 | found := strings.Join(res, ",") 202 | 203 | if found != tst.Arguments["result"] { 204 | return fmt.Errorf("expected DNS result to be '%s', but found '%s'", tst.Arguments["result"], found) 205 | } 206 | 207 | return nil 208 | 209 | } 210 | 211 | // Register our protocol-tester. 212 | func init() { 213 | Register("dns", func() ProtocolTest { 214 | return &DNSTest{} 215 | }) 216 | } 217 | -------------------------------------------------------------------------------- /protocols/ftp_probe.go: -------------------------------------------------------------------------------- 1 | // FTP Tester 2 | // 3 | // The FTP tester allows you to make a connection to an FTP-server, 4 | // and optionally retrieve a file. 5 | // 6 | // A basic test can be invoked via input like so: 7 | // 8 | // host.example.com must run ftp [with port 21] 9 | // 10 | // A more complex test would involve actually retrieving a file. To make 11 | // the test-definition natural you do this by specifying an URI: 12 | // 13 | // ftp://ftp.cpan.org/pub/gnu/=README must run ftp 14 | // 15 | // Downloading a file requires a login, so by default we'll try an anonymous 16 | // one. If you need to specify real credentials you can do so by adding 17 | // the appropriate username & password: 18 | // 19 | // ftp://ftp.example.com/path/to/README must run ftp with username 'user@host.com' with password 'secret' 20 | // 21 | // Of course the URI could also be used to specify the login details: 22 | // 23 | // ftp://user@example.com:secret@ftp.cpan.org/pub/gnu/=README must run ftp 24 | // 25 | // To ensure that the remote-file contains content you expect you can 26 | // also verify a specific string is included within the response, via the 27 | // "content" parameter: 28 | // 29 | // ftp://ftp.example.com/path/to/README.md must run ftp with content '2018' 30 | 31 | package protocols 32 | 33 | import ( 34 | "fmt" 35 | "io/ioutil" 36 | "net/url" 37 | "strconv" 38 | "strings" 39 | 40 | "github.com/jlaffaye/ftp" 41 | "github.com/skx/overseer/test" 42 | ) 43 | 44 | // FTPTest is our object. 45 | type FTPTest struct { 46 | } 47 | 48 | // Arguments returns the names of arguments which this protocol-test 49 | // understands, along with corresponding regular-expressions to validate 50 | // their values. 51 | func (s *FTPTest) Arguments() map[string]string { 52 | known := map[string]string{ 53 | "content": ".*", 54 | "password": ".*", 55 | "port": "^[0-9]+$", 56 | "username": ".*", 57 | } 58 | return known 59 | } 60 | 61 | // Example returns sample usage-instructions for self-documentation purposes. 62 | func (s *FTPTest) Example() string { 63 | str := ` 64 | FTP Tester 65 | ---------- 66 | The FTP tester allows you to make a connection to an FTP-server, 67 | and optionally retrieve a file. 68 | 69 | A basic test can be invoked via input like so: 70 | 71 | host.example.com must run ftp [with port 21] 72 | 73 | A more complex test would involve actually retrieving a file. To make 74 | the test-definition natural you do this by specifying an URI: 75 | 76 | ftp://ftp.cpan.org/pub/gnu/=README must run ftp 77 | 78 | Downloading a file requires a login, so by default we'll try an anonymous 79 | one. If you need to specify real credentials you can do so by adding 80 | the appropriate username & password: 81 | 82 | ftp://ftp.example.com/path/to/README must run ftp with username 'user@host.com' with password 'secret' 83 | 84 | Of course the URI could also be used to specify the login details: 85 | 86 | ftp://user@example.com:secret@ftp.cpan.org/pub/gnu/=README must run ftp 87 | 88 | To ensure that the remote-file contains content you expect you can 89 | also verify a specific string is included within the response, via the 90 | "content" parameter: 91 | 92 | ftp://ftp.example.com/path/to/README.md must run ftp with content '2018' 93 | ` 94 | return str 95 | } 96 | 97 | // RunTest is the part of our API which is invoked to actually execute a 98 | // test against the given target. 99 | // 100 | // In this case we make a TCP connection, defaulting to port 21, and 101 | // look for a response which appears to be an FTP-server. 102 | func (s *FTPTest) RunTest(tst test.Test, target string, opts test.Options) error { 103 | // 104 | // Holder for any error we might encounter. 105 | // 106 | var err error 107 | 108 | // 109 | // The default port to connect to. 110 | // 111 | port := 21 112 | 113 | // 114 | // Our default credentials 115 | // 116 | username := "anonymous" 117 | password := "overseer@example.com" 118 | 119 | // 120 | // The target-file we're going to retrieve, if any 121 | // 122 | file := "/" 123 | 124 | // 125 | // If we've been given an URI then we should update the 126 | // port if it is non-standard, and possibly retrieve an 127 | // actual file too. 128 | // 129 | if strings.Contains(tst.Target, "://") { 130 | 131 | // Parse the URI. 132 | var u *url.URL 133 | u, err = url.Parse(tst.Target) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | // Record the path to fetch. 139 | file = u.Path 140 | 141 | // Update the default port, if a port-number was given. 142 | if u.Port() != "" { 143 | port, err = strconv.Atoi(u.Port()) 144 | if err != nil { 145 | return err 146 | } 147 | } 148 | 149 | // The URI might contain username/password 150 | if u.User.Username() != "" { 151 | username = u.User.Username() 152 | p, _ := u.User.Password() 153 | if p != "" { 154 | password = p 155 | } 156 | } 157 | } 158 | 159 | fmt.Printf("Username: %s -> %s\n", username, password) 160 | // 161 | // If the user specified a different port update to use it. 162 | // 163 | // Do this after the URI-parsing. 164 | // 165 | if tst.Arguments["port"] != "" { 166 | port, err = strconv.Atoi(tst.Arguments["port"]) 167 | if err != nil { 168 | return err 169 | } 170 | } 171 | 172 | // 173 | // Default to connecting to an IPv4-address 174 | // 175 | address := fmt.Sprintf("%s:%d", target, port) 176 | 177 | // 178 | // If we find a ":" we know it is an IPv6 address though 179 | // 180 | if strings.Contains(target, ":") { 181 | address = fmt.Sprintf("[%s]:%d", target, port) 182 | } 183 | 184 | // 185 | // Make the connection. 186 | // 187 | var conn *ftp.ServerConn 188 | conn, err = ftp.Dial(address, ftp.DialWithTimeout(opts.Timeout)) 189 | if err != nil { 190 | return err 191 | } 192 | defer conn.Quit() 193 | 194 | // 195 | // If the user specified different/real credentials, use them instead. 196 | // 197 | if tst.Arguments["username"] != "" { 198 | username = tst.Arguments["username"] 199 | } 200 | if tst.Arguments["password"] != "" { 201 | password = tst.Arguments["password"] 202 | } 203 | 204 | // 205 | // If we have been given a path/file to fetch, via an URI 206 | // input, then fetch it. 207 | // 208 | // Before attempting the fetch login. 209 | // 210 | if file != "/" { 211 | 212 | // 213 | // Login 214 | // 215 | err = conn.Login(username, password) 216 | if err != nil { 217 | return err 218 | } 219 | 220 | // 221 | // Retrieve the file. 222 | // 223 | resp, err := conn.Retr(file) 224 | if err != nil { 225 | return err 226 | } 227 | defer resp.Close() 228 | 229 | // 230 | // Actually fetch the contents of the file. 231 | // 232 | buf, err := ioutil.ReadAll(resp) 233 | if err != nil { 234 | return err 235 | } 236 | 237 | // 238 | // If we're doing a content-match then do that here 239 | // 240 | if tst.Arguments["content"] != "" { 241 | if !strings.Contains(string(buf), tst.Arguments["content"]) { 242 | return fmt.Errorf("body didn't contain '%s'", tst.Arguments["content"]) 243 | } 244 | } 245 | 246 | } 247 | 248 | return nil 249 | } 250 | 251 | // Register our protocol-tester. 252 | func init() { 253 | Register("ftp", func() ProtocolTest { 254 | return &FTPTest{} 255 | }) 256 | } 257 | -------------------------------------------------------------------------------- /bridges/purppura-bridge/main.go: -------------------------------------------------------------------------------- 1 | // 2 | // This is the Purppura bridge, which reads test-results from redis, and submits 3 | // them to purppura, such that a human can be notified of test failures. 4 | // 5 | // The program should be built like so: 6 | // 7 | // go build . 8 | // 9 | // Once built launch it like so: 10 | // 11 | // $ ./purppura-bridge -url="http://purppura.example.com/events" 12 | // 13 | // Every two minutes it will send a heartbeat to the purppura-server so 14 | // that you know it is working. 15 | // 16 | // Steve 17 | // -- 18 | // 19 | 20 | package main 21 | 22 | import ( 23 | "bytes" 24 | "crypto/sha1" 25 | "encoding/hex" 26 | "encoding/json" 27 | "flag" 28 | "fmt" 29 | "io/ioutil" 30 | "net/http" 31 | "os" 32 | "sync" 33 | "time" 34 | 35 | "github.com/go-redis/redis" 36 | "github.com/robfig/cron" 37 | _ "github.com/skx/golang-metrics" 38 | ) 39 | 40 | // Avoid threading issues with our last update-time 41 | var mutex sync.RWMutex 42 | 43 | // The last time we received an update 44 | var update int64 45 | 46 | // Should we be verbose? 47 | var verbose *bool 48 | 49 | // The redis handle 50 | var r *redis.Client 51 | 52 | // The URL of the purppura server 53 | var pURL *string 54 | 55 | // Given a JSON string decode it and post to the Purppura URL. 56 | func process(msg []byte) { 57 | 58 | // Update our last received time 59 | mutex.Lock() 60 | update = time.Now().Unix() 61 | mutex.Unlock() 62 | 63 | data := map[string]string{} 64 | 65 | if err := json.Unmarshal(msg, &data); err != nil { 66 | panic(err) 67 | } 68 | 69 | testType := data["type"] 70 | testTarget := data["target"] 71 | input := data["input"] 72 | 73 | // 74 | // We need a stable ID for each test - get one by hashing the 75 | // complete input-line and the target we executed against. 76 | // 77 | hasher := sha1.New() 78 | hasher.Write([]byte(testTarget)) 79 | hasher.Write([]byte(input)) 80 | hash := hex.EncodeToString(hasher.Sum(nil)) 81 | 82 | // 83 | // Populate the default fields. 84 | // 85 | values := map[string]string{ 86 | "detail": fmt.Sprintf("

The %s test against %s passed.

", testType, testTarget), 87 | "id": hash, 88 | "raise": "clear", 89 | "subject": input, 90 | } 91 | 92 | // 93 | // If the test failed we'll update the detail and trigger a raise 94 | // 95 | if data["error"] != "" { 96 | values["detail"] = 97 | fmt.Sprintf("

The %s test against %s failed:

%s

", 98 | testType, testTarget, data["error"]) 99 | values["raise"] = "now" 100 | } 101 | 102 | // 103 | // Export the fields to json to post. 104 | // 105 | jsonValue, err := json.Marshal(values) 106 | if err != nil { 107 | fmt.Printf("process: Failed to encode JSON:%s\n", err.Error()) 108 | os.Exit(1) 109 | } 110 | 111 | // 112 | // If we're being verbose show what we're going to POST 113 | // 114 | if *verbose { 115 | fmt.Printf("%s\n", jsonValue) 116 | } 117 | 118 | // 119 | // Post to purppura 120 | // 121 | res, err := http.Post(*pURL, 122 | "application/json", 123 | bytes.NewBuffer(jsonValue)) 124 | 125 | if err != nil { 126 | fmt.Printf("process: Failed to post to purppura:%s\n", err.Error()) 127 | os.Exit(1) 128 | } 129 | 130 | // 131 | // OK now we've submitted the post. 132 | // 133 | // We should retrieve the status-code + body, if the status-code 134 | // is "odd" then we'll show them. 135 | // 136 | defer res.Body.Close() 137 | body, err := ioutil.ReadAll(res.Body) 138 | if err != nil { 139 | fmt.Printf("process: Error reading response to post: %s\n", err.Error()) 140 | return 141 | } 142 | status := res.StatusCode 143 | 144 | if status != 200 { 145 | fmt.Printf("process: Error - Status code was not 200: %d\n", status) 146 | fmt.Printf("process: Response - %s\n", body) 147 | } 148 | } 149 | 150 | // CheckUpdates triggers an alert if we've not received anything recently 151 | func CheckUpdates() { 152 | 153 | // Get our last-received time 154 | mutex.Lock() 155 | then := update 156 | mutex.Unlock() 157 | 158 | // Get the current time 159 | now := time.Now().Unix() 160 | 161 | // 162 | // The alert we'll send to the purppura server 163 | // 164 | values := map[string]string{ 165 | "detail": fmt.Sprintf("The purppura-bridge last received an update %d seconds ago.", now-then), 166 | "subject": "No traffic seen recently", 167 | "id": "purppura-bridge-traffic", 168 | "source": "127.127.127.127", 169 | } 170 | 171 | // Raise or clear? 172 | if now-then > (60 * 5) { 173 | values["raise"] = "now" 174 | } else { 175 | values["raise"] = "clear" 176 | } 177 | 178 | // 179 | // Export the fields to json to post. 180 | // 181 | jsonValue, err := json.Marshal(values) 182 | if err != nil { 183 | fmt.Printf("Failed to export to JSON - %s\n", err.Error()) 184 | os.Exit(1) 185 | } 186 | 187 | // 188 | // Post to purppura 189 | // 190 | res, err := http.Post(*pURL, 191 | "application/json", 192 | bytes.NewBuffer(jsonValue)) 193 | 194 | if err != nil { 195 | fmt.Printf("CheckUpdates: Failed to post purppura-bridge to purppura:%s\n", err.Error()) 196 | os.Exit(1) 197 | } 198 | 199 | // 200 | // OK now we've submitted the post. 201 | // 202 | // We should retrieve the status-code + body, if the status-code 203 | // is "odd" then we'll show them. 204 | // 205 | defer res.Body.Close() 206 | body, err := ioutil.ReadAll(res.Body) 207 | if err != nil { 208 | fmt.Printf("CheckUpdates: Error reading response to post: %s\n", err.Error()) 209 | return 210 | } 211 | status := res.StatusCode 212 | 213 | if status != 200 { 214 | fmt.Printf("CheckUpdates: Error - Status code was not 200: %d\n", status) 215 | fmt.Printf("CheckUpdates: Response - %s\n", body) 216 | } 217 | } 218 | 219 | // SendHeartbeat updates the purppura server with a hearbeat alert. 220 | // This will ensure that you're alerted if this bridge fails, dies, or 221 | // isn't running 222 | func SendHeartbeat() { 223 | 224 | // 225 | // The alert we'll send to the purppura server 226 | // 227 | values := map[string]string{ 228 | "detail": "The purppura-bridge hasn't sent a heartbeat recently, which means that overseer test-results won't raise alerts.", 229 | "subject": "The purppura bridge isn't running!", 230 | "id": "purppura-bridge-heartbeat", 231 | "source": "127.127.127.127", 232 | "raise": "+5m", 233 | } 234 | 235 | // 236 | // Export the fields to json to post. 237 | // 238 | jsonValue, _ := json.Marshal(values) 239 | 240 | // 241 | // Post to purppura 242 | // 243 | res, err := http.Post(*pURL, 244 | "application/json", 245 | bytes.NewBuffer(jsonValue)) 246 | 247 | if err != nil { 248 | fmt.Printf("SendHeartbeat: Failed to post heartbeat to purppura:%s\n", err.Error()) 249 | os.Exit(1) 250 | } 251 | 252 | // 253 | // OK now we've submitted the post. 254 | // 255 | // We should retrieve the status-code + body, if the status-code 256 | // is "odd" then we'll show them. 257 | // 258 | defer res.Body.Close() 259 | body, err := ioutil.ReadAll(res.Body) 260 | if err != nil { 261 | fmt.Printf("SendHeartbeat: Error reading response to post: %s\n", err.Error()) 262 | return 263 | } 264 | status := res.StatusCode 265 | 266 | if status != 200 { 267 | fmt.Printf("SendHeartbeat: Error - Status code was not 200: %d\n", status) 268 | fmt.Printf("SendHeartbeat: Response - %s\n", body) 269 | } 270 | 271 | } 272 | 273 | // 274 | // Entry Point 275 | // 276 | func main() { 277 | 278 | // 279 | // Parse our flags 280 | // 281 | redisHost := flag.String("redis-host", "127.0.0.1:6379", "Specify the address of the redis queue.") 282 | redisPass := flag.String("redis-pass", "", "Specify the password of the redis queue.") 283 | pURL = flag.String("purppura", "", "The purppura-server URL") 284 | verbose = flag.Bool("verbose", false, "Be verbose?") 285 | flag.Parse() 286 | 287 | // 288 | // Sanity-check 289 | // 290 | if *pURL == "" { 291 | fmt.Printf("Usage: purppura-bridge -purppura=https://alert.steve.fi/events [-redis-host=127.0.0.1:6379] [-redis-pass=secret]\n") 292 | os.Exit(1) 293 | 294 | } 295 | 296 | // 297 | // Create the redis client 298 | // 299 | r = redis.NewClient(&redis.Options{ 300 | Addr: *redisHost, 301 | Password: *redisPass, 302 | DB: 0, // use default DB 303 | }) 304 | 305 | // 306 | // And run a ping, just to make sure it worked. 307 | // 308 | _, err := r.Ping().Result() 309 | if err != nil { 310 | fmt.Printf("Redis connection failed: %s\n", err.Error()) 311 | os.Exit(1) 312 | } 313 | 314 | c := cron.New() 315 | // Make sure we send a heartbeat so we're alerted if the bridge fails 316 | c.AddFunc("@every 30s", func() { SendHeartbeat() }) 317 | // Make sure we raise an alert if we don't have recent results. 318 | c.AddFunc("@every 5m", func() { CheckUpdates() }) 319 | c.Start() 320 | 321 | for { 322 | 323 | // 324 | // Get test-results 325 | // 326 | msg, _ := r.BLPop(0, "overseer.results").Result() 327 | 328 | // 329 | // If they were non-empty, process them. 330 | // 331 | // msg[0] will be "overseer.results" 332 | // 333 | // msg[1] will be the value removed from the list. 334 | // 335 | if len(msg) >= 1 { 336 | process([]byte(msg[1])) 337 | } 338 | 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /parser/parser.go: -------------------------------------------------------------------------------- 1 | // Package parser contain the configuration-file parser for `overseer`. 2 | // 3 | // Given either an input file of text, or a single line of text, 4 | // protocol-tests are parsed and returned as instances of the 5 | // test.Test class. 6 | // 7 | // Regardless of which sub-command of the main overseer application 8 | // is involved this parser is the sole place that tests are parsed. 9 | // 10 | // To make the code flexible the parser is invoked with a callback 11 | // function - this could be used to run the test, dump it, or store 12 | // it in a redis queue. 13 | // 14 | package parser 15 | 16 | import ( 17 | "bufio" 18 | "bytes" 19 | "errors" 20 | "fmt" 21 | "os" 22 | "os/exec" 23 | "regexp" 24 | "strconv" 25 | "strings" 26 | 27 | "github.com/skx/overseer/protocols" 28 | "github.com/skx/overseer/test" 29 | ) 30 | 31 | // Parser holds our parser-state. 32 | type Parser struct { 33 | // Storage for defined macros. 34 | // 35 | // Macros comprise of a name and a list of hostnames. 36 | MACROS map[string][]string 37 | } 38 | 39 | // ParsedTest is the function-signature of a callback function 40 | // that can be invoked when a valid test-case has been parsed. 41 | type ParsedTest func(x test.Test) error 42 | 43 | // New is the constructor to the parser. 44 | func New() *Parser { 45 | m := new(Parser) 46 | m.MACROS = make(map[string][]string) 47 | return m 48 | } 49 | 50 | // executable returns true if the given file is executable. 51 | func (s *Parser) executable(path string) (bool, error) { 52 | 53 | stat, err := os.Stat(path) 54 | if err != nil { 55 | return false, err 56 | } 57 | 58 | mode := stat.Mode() 59 | 60 | if !mode.IsRegular() { 61 | return false, errors.New("not regular") 62 | } 63 | 64 | if (mode & 0111) == 0 { 65 | return false, nil 66 | } 67 | 68 | return true, nil 69 | } 70 | 71 | // ParseFile processes the filename specified, invoking the supplied 72 | // callback for every test-case which has been successfully parsed. 73 | func (s *Parser) ParseFile(filename string, cb ParsedTest) error { 74 | 75 | // This is the scanner we'll use 76 | var scanner *bufio.Scanner 77 | 78 | // Read from stdin 79 | if filename == "-" { 80 | scanner = bufio.NewScanner(os.Stdin) 81 | } else { 82 | 83 | // 84 | // If the file is executable then parse the output of executing 85 | // it, rather than the literal contents. 86 | // 87 | e, err := s.executable(filename) 88 | if (err == nil) && (e) { 89 | cmd := exec.Command(filename) 90 | var outb, errb bytes.Buffer 91 | cmd.Stdout = &outb 92 | cmd.Stderr = &errb 93 | err = cmd.Run() 94 | if err != nil { 95 | return err 96 | } 97 | reader := bytes.NewReader(outb.Bytes()) 98 | scanner = bufio.NewScanner(reader) 99 | } else { 100 | // 101 | // Otherwise just read it 102 | // 103 | var file *os.File 104 | file, err = os.Open(filename) 105 | if err != nil { 106 | return fmt.Errorf("error opening %s - %s", filename, err.Error()) 107 | } 108 | defer file.Close() 109 | scanner = bufio.NewScanner(file) 110 | } 111 | } 112 | 113 | // 114 | // We read into this string. 115 | // 116 | line := "" 117 | 118 | // 119 | // Loop 120 | // 121 | for scanner.Scan() { 122 | 123 | // 124 | // Get the line, and strip leading/trailing space. 125 | // 126 | tmp := scanner.Text() 127 | tmp = strings.TrimSpace(tmp) 128 | 129 | // 130 | // Append to our existing line. 131 | // 132 | line += tmp 133 | 134 | // 135 | // If the line ends with "\" then we remove 136 | // that character, and repeat. 137 | // 138 | if strings.HasSuffix(line, "\\") { 139 | line = strings.TrimSuffix(line, "\\") 140 | continue 141 | } 142 | 143 | // 144 | // OK we've either got a line that doesn't end 145 | // with this, or we'll add 146 | line = strings.TrimSpace(line) 147 | 148 | // 149 | // If the line wasn't empty, and didn't start with 150 | // a comment then process it. 151 | // 152 | if (line != "") && (!strings.HasPrefix(line, "#")) { 153 | _, err := s.ParseLine(line, cb) 154 | if err != nil { 155 | return err 156 | } 157 | } 158 | 159 | // 160 | // OK we've processed the line. 161 | // 162 | line = "" 163 | } 164 | 165 | // 166 | // Was there an error with the scanner? If so catch it 167 | // here. To be honest I'm not sure if anything needs to 168 | // happen here 169 | // 170 | if err := scanner.Err(); err != nil { 171 | return err 172 | } 173 | 174 | // No error 175 | return nil 176 | } 177 | 178 | // ParseLine parses a single line of text, and invokes the supplied callback 179 | // function if a valid test was found. 180 | func (s *Parser) ParseLine(input string, cb ParsedTest) (test.Test, error) { 181 | 182 | // 183 | // The result for the caller 184 | // 185 | var result test.Test 186 | 187 | // 188 | // Our input will contain lines of two forms: 189 | // 190 | // MACRO are host1, host2, host3 191 | // 192 | // NOTE: Macro-names are UPPERCASE, and redefinining a macro 193 | // is an error - because it would be too confusing otherwise. 194 | // 195 | // 196 | // TARGET must run PROTOCOL [OPTIONAL EXTRA ARGS] 197 | // 198 | 199 | // 200 | // Is this a macro-definition? 201 | // 202 | macro := regexp.MustCompile(`^([A-Z0-9]+)\s+are\s+(.*)$`) 203 | match := macro.FindStringSubmatch(input) 204 | if len(match) == 3 { 205 | 206 | name := match[1] 207 | vals := match[2] 208 | 209 | // 210 | // If this macro-exists that is a fatal error 211 | // 212 | if s.MACROS[name] != nil { 213 | return result, fmt.Errorf("redeclaring an existing macro is a fatal-error, %s exists already", name) 214 | } 215 | 216 | // 217 | // The macro-value is a comma-separated list of hosts 218 | // 219 | hosts := strings.Split(vals, ",") 220 | 221 | // 222 | // Save each host away, under the name of the macro. 223 | // 224 | for _, ent := range hosts { 225 | s.MACROS[name] = append(s.MACROS[name], strings.TrimSpace(ent)) 226 | } 227 | return result, nil 228 | } 229 | 230 | // 231 | // Look to see if this line matches the testing line 232 | // 233 | re := regexp.MustCompile(`^([^ \t]+)\s+must\s+run\s+([^\s]+)`) 234 | out := re.FindStringSubmatch(input) 235 | 236 | // 237 | // If it didn't then we have a malformed line 238 | // 239 | if len(out) != 3 { 240 | return result, fmt.Errorf("unrecognized line - '%s'", input) 241 | } 242 | 243 | // 244 | // Save the type + target away 245 | // 246 | testTarget := out[1] 247 | testType := out[2] 248 | 249 | // 250 | // Lookup the handler. 251 | // 252 | handler := protocols.ProtocolHandler(testType) 253 | if handler == nil { 254 | return result, fmt.Errorf("unknown test-type '%s' in input '%s'", testType, input) 255 | } 256 | 257 | // 258 | // Is this target a macro? 259 | // 260 | // If so we expand for each host in the macro-definition and 261 | // execute those expanded versions in turn. 262 | // 263 | hosts := s.MACROS[testTarget] 264 | if len(hosts) > 0 { 265 | 266 | // 267 | // So we have a bunch of hosts that this macro-name 268 | // should be replaced with. 269 | // 270 | for _, i := range hosts { 271 | 272 | // 273 | // Reparse the line for each host by taking advantage 274 | // of the fact the first entry in the line is the 275 | // target. 276 | // 277 | // So we change: 278 | // 279 | // HOSTS must run xxx.. 280 | // 281 | // Into: 282 | // 283 | // host1 must run xxx. 284 | // host2 must run xxx. 285 | // .. 286 | // hostN must run xxx. 287 | // 288 | split := regexp.MustCompile(`^([^\s]+)\s+(.*)$`) 289 | line := split.FindStringSubmatch(input) 290 | 291 | // 292 | // Create a new test, with the macro-host 293 | // in-place of the original target. 294 | // 295 | new := fmt.Sprintf("%s %s", i, line[2]) 296 | 297 | // 298 | // Call ourselves to run the test. 299 | // 300 | s.ParseLine(new, cb) 301 | } 302 | 303 | // 304 | // We've called ourself (processLine) with the updated 305 | // line for each host in the macro-definition. 306 | // 307 | // So we can return here. 308 | // 309 | return result, nil 310 | } 311 | 312 | // 313 | // Create a temporary structure to hold our test 314 | // 315 | result.MaxRetries = -1 316 | result.Target = testTarget 317 | result.Type = testType 318 | result.Input = input 319 | result.Arguments = s.ParseArguments(input) 320 | 321 | // 322 | // See which arguments the object supports 323 | // 324 | expected := handler.Arguments() 325 | 326 | // 327 | // If there are arguments which are unknown then this is an error 328 | // 329 | // For each argument which was supplied.. 330 | // 331 | for arg, val := range result.Arguments { 332 | 333 | // Is there a custom per-test override? 334 | if arg == "retries" { 335 | maxRetries, err := strconv.ParseInt(val, 10, 32) 336 | if err != nil { 337 | return result, fmt.Errorf("non-numeric argument '%s' for test-type '%s' in input '%s'", arg, testType, input) 338 | } 339 | result.MaxRetries = int(maxRetries) 340 | 341 | // We don't want to pass a non-test var to the actual test 342 | delete(result.Arguments, arg) 343 | continue 344 | } 345 | 346 | // 347 | // Is that argument present in the arguments the 348 | // tester supports? 349 | // 350 | pattern := expected[arg] 351 | if pattern == "" { 352 | return result, fmt.Errorf("unsupported argument '%s' for test-type '%s' in input '%s'", arg, testType, input) 353 | } 354 | 355 | // 356 | // Otherwise we need to look for a match 357 | // 358 | expr := regexp.MustCompile(pattern) 359 | match := expr.FindStringSubmatch(val) 360 | 361 | if match == nil { 362 | return result, fmt.Errorf("unsupported argument '%s' for test-type '%s' in input '%s' - did not match pattern '%s'", arg, testType, input, pattern) 363 | } 364 | 365 | } 366 | 367 | // 368 | // Invoke the user-supplied callback on this parsed test. 369 | // 370 | // 371 | // Ensure that we have a callback. 372 | // 373 | if cb != nil { 374 | cb(result) 375 | } 376 | 377 | return result, nil 378 | } 379 | 380 | // TrimQuotes removes matching quotes from around a string, if present. 381 | // 382 | // For example `'steve'` becomes `steve`, but `'steve` stays unchanged, 383 | // as there are not matching single-quotes around the string. 384 | // 385 | func (s *Parser) TrimQuotes(in string, c byte) string { 386 | if len(in) >= 2 { 387 | if in[0] == c && in[len(in)-1] == c { 388 | return in[1 : len(in)-1] 389 | } 390 | } 391 | return in 392 | } 393 | 394 | // ParseArguments takes a string such as this: 395 | // 396 | // foo must run http with username 'steve' with password 'bob' 397 | // 398 | // And extracts the values of the named options. 399 | // 400 | // Any option that is wrapped in matching quotes has them removed. 401 | // 402 | func (s *Parser) ParseArguments(input string) map[string]string { 403 | res := make(map[string]string) 404 | 405 | // 406 | // Look for each option 407 | // 408 | expr := regexp.MustCompile(`^(.*)\s+with\s+([^\s]+)\s+('.+'|\".+\"|\S+)`) 409 | match := expr.FindStringSubmatch(input) 410 | 411 | for len(match) > 1 { 412 | prefix := match[1] 413 | name := match[2] 414 | value := match[3] 415 | 416 | // Strip quotes 417 | value = s.TrimQuotes(value, '\'') 418 | value = s.TrimQuotes(value, '"') 419 | 420 | // Store the value in our map - unless there is already a value 421 | // present. 422 | // 423 | // This works the way you'd expect because our regular expression 424 | // is parsing "backwards". So parsing: 425 | // 426 | // with foo bar with foo baz with foo steve 427 | // 428 | // We first store "steve", then we would store "baz" and 429 | // finally "bar". We skip this because of the non-empty 430 | // test here, which means the last value is kept. 431 | // 432 | if res[name] == "" { 433 | res[name] = value 434 | } 435 | 436 | // Continue matching the tail of the string. 437 | input = prefix 438 | match = expr.FindStringSubmatch(input) 439 | } 440 | return res 441 | } 442 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= 5 | github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= 6 | github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= 7 | github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= 8 | github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y= 9 | github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= 10 | github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= 11 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 12 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 13 | github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= 14 | github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= 15 | github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= 16 | github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= 17 | github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU= 18 | github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM= 19 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 20 | github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= 21 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 22 | github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= 23 | github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= 24 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 25 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 26 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 27 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 28 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 29 | github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= 30 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 31 | github.com/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg= 32 | github.com/jlaffaye/ftp v0.2.0/go.mod h1:is2Ds5qkhceAPy2xD6RLI6hmp/qysSoymZ+Z2uTnspI= 33 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 34 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 35 | github.com/marpaia/graphite-golang v0.0.0-20171231172105-134b9af18cf3/go.mod h1:llZw8JbFm5CvdRrtgdjaQNlZR1bQhAWsBKtb0HTX+sw= 36 | github.com/marpaia/graphite-golang v0.0.0-20190519024811-caf161d2c2b1 h1:lODGHy+2Namopi4v7AeiqW106eo4QMXqj9aE8jVXcO4= 37 | github.com/marpaia/graphite-golang v0.0.0-20190519024811-caf161d2c2b1/go.mod h1:llZw8JbFm5CvdRrtgdjaQNlZR1bQhAWsBKtb0HTX+sw= 38 | github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= 39 | github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= 40 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 41 | github.com/onsi/ginkgo v1.8.0 h1:VkHVNpR4iVnU8XQR6DBm8BqYjN7CRzw+xKUbVVbbW9w= 42 | github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 43 | github.com/onsi/gomega v1.5.0 h1:izbySO9zDPmjJ8rDjLvkA2zJHIo+HkYXHnf7eN7SSyo= 44 | github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 45 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 46 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 47 | github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 h1:x7xEyJDP7Hv3LVgvWhzioQqbC/KtuUhTigKlH/8ehhE= 48 | github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= 49 | github.com/simia-tech/go-pop3 v0.0.0-20150626094726-c9c20550a244 h1:izFQm9qRSp+dKUYciqiHfYnrpDNqDdiuecqGQryGlRU= 50 | github.com/simia-tech/go-pop3 v0.0.0-20150626094726-c9c20550a244/go.mod h1:3smecozaRWHAj4cDRRnlRPVb6O44N+3S7K45/C37HpU= 51 | github.com/skx/golang-metrics v0.0.0-20190325085214-453332cf54e8 h1:NVwRIqHO7J7vnKGbTz5dBwWjl5Wr6mR1U8JQ32tw7vk= 52 | github.com/skx/golang-metrics v0.0.0-20190325085214-453332cf54e8/go.mod h1:P+OUoQPrBQUZg9lbHEu7iJsZYTC5Na4qghTSs5ZmTA4= 53 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 54 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 55 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 56 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 57 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 58 | github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= 59 | github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 60 | github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM= 61 | github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= 62 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 63 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 64 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 65 | golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= 66 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 67 | golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 68 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 69 | golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= 70 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 71 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 72 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 73 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 74 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 75 | golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 76 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 77 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 78 | golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= 79 | golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= 80 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 81 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 82 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 83 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 84 | golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= 85 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 86 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 87 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 88 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 89 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 90 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 91 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 92 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 93 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 94 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 95 | golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= 96 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 97 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 98 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 99 | golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 100 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 101 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 102 | golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= 103 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 104 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 105 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 106 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 107 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 108 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 109 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 110 | golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= 111 | golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 112 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 113 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 114 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 115 | golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= 116 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 117 | golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss= 118 | golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= 119 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 120 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 121 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 122 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 123 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 124 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 125 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 126 | gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= 127 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 128 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 129 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 130 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 131 | -------------------------------------------------------------------------------- /input.txt: -------------------------------------------------------------------------------- 1 | ## 2 | # 3 | # Comments are supported and are prefixed with a leading '#'. 4 | # 5 | # NOTE: If an input file is executable it will be executed 6 | # and the output will be parsed, instead of the literal contents. 7 | # 8 | # Although we've not discussed how tests are written yet, consider 9 | # this example a simple demonstration: 10 | # 11 | # -- 12 | # #!/usr/bin/m4 13 | # # Define a macro for a tinc-protocol test 14 | # define(`tinc', `$1 must run tcp with port 655 with banner "^0 \S+ 17$"') 15 | # 16 | # tinc(`foo.example.com') 17 | # tinc(`bar.example.com') 18 | # 19 | # # When processed the output will be: 20 | # foo.example.com must run tcp with port 655 with banner "^0 \S+ 17$" 21 | # bar.example.com must run tcp with port 655 with banner "^0 \S+ 17$" 22 | # -- 23 | # 24 | # 25 | ## 26 | 27 | #### 28 | # 29 | # 30 | # The general form of our test definitions is: 31 | # 32 | # TARGET must run PROTOCOL [test-specific options] 33 | # 34 | # Where: 35 | # 36 | # `TARGET` is either the hostname or an URI of the target to be tested. 37 | # 38 | # `PROTOCOL` is one of the protocol-handlers implemented in the application. 39 | # 40 | # Test-specific options are always written like so: 41 | # 42 | # with $OPTION_NAME $OPTION_VALUE 43 | # 44 | # `OPTION_VALUE` may optionally be quoted with single or double-quotes, 45 | # this is necessary if the option-value contains whitespace. 46 | # 47 | #### 48 | 49 | 50 | # 51 | # A simple example of a test would be to ensure that a host is running 52 | # an FTP daemon. 53 | # 54 | # The basic way to write this would be: 55 | # 56 | # ftp.example.com must run ftp 57 | # 58 | # This validates that the remote host is running an FTP-server, but it doesn't 59 | # making a complete test. To do that you'd want to actually retrieve a file 60 | # via FTP. 61 | # 62 | # To specify the file to retrieve we replace the target with an URI pointing 63 | # to the file we wish to retrieve: 64 | # 65 | # ftp://ftp.cpan.org/pub/gnu/=README must run ftp 66 | # 67 | # Downloading a file requires a login, so by default we'll try an anonymous 68 | # one. If you need to specify real credentials you can do so by adding 69 | # the appropriate username & password: 70 | # 71 | # ftp://ftp.example.com/path/to/README must run ftp with username 'user@host.com' with password 'secret' 72 | # 73 | # Of course the URI could also be used to specify the login details: 74 | # 75 | # ftp://user@example.com:secret@ftp.cpan.org/pub/gnu/=README must run ftp 76 | # 77 | # Finally you can ensure that you retrieved sane contents by testing the 78 | # resulting-file contains a specific string: 79 | # 80 | # ftp://ftp.cpan.org/pub/gnu/=README must run ftp with content 'GNU' 81 | # 82 | 83 | 84 | # 85 | # More people probably run HTTP/HTTPS servers than FTP-servers, so 86 | # the next test-type to document is that one. Unlike the previous 87 | # case the target of the test is an an URL, rather than a hostname. 88 | # 89 | # The most basic HTTP test would be: 90 | # 91 | # http://example.com/ must run http 92 | # 93 | # A HTTP status-code of 200 is regarded as a pass, and anything else 94 | # as a failure. You can choose to regard any other return-code as a 95 | # success by setting your preferred result: 96 | # 97 | # with status 302 98 | # 99 | # Or you might regard any response as valid, which could be specified by 100 | # writing: 101 | # 102 | # with status any 103 | # 104 | # In short `with status any` tests: 105 | # 106 | # * You could resolve the target's hostname. 107 | # * Any returned IPv4 and IPv6 address will be tested in turn. 108 | # * You could make a HTTP-request. 109 | # * You received some kind of response. 110 | # 111 | # If you wanted a more thorough test you might wish to look for 112 | # some specific text in body of the response, which is possible 113 | # via the `content` argument. 114 | # 115 | # For example I might wish to ensure my website has my name in it: 116 | # 117 | # https://steve.fi/ must run http with content 'Steve Kemp' 118 | # 119 | # You can of course combine the status & content options: 120 | # 121 | # https://steve.fi/ must run http with status any with content 'Kemp' 122 | # 123 | # Looking for a literal text-match in the body is usually sufficient 124 | # for ensuring that your site is available, however if it is not you 125 | # can also test that a specific regular-expression matches the content 126 | # of the response. 127 | # 128 | # Rather than using `content` we specify our pattern via `pattern`: 129 | # 130 | # https://steve.fi/ must run http with pattern 'Steve\s+Kemp' 131 | # 132 | # If you need to make a HTTP POST request, rather than a GET, you can 133 | # do that by specifying the data to POST like so: 134 | # 135 | # https://steve.fi/Security/XSS/Tutorial/filter.cgi must run http with data "text=test%20me" with content "test me" 136 | # 137 | # The CGI script in that example just echos arguments back, so it is 138 | # a simple tset that the POSTed data was received. 139 | # 140 | # The HTTP-protocol tester sets a custom user-agent to allow filtering 141 | # on the server-side - which might be required to remove noise if tests 142 | # are repeated often. 143 | # 144 | # You can see this in action via: 145 | # 146 | # http://httpbin.org/user-agent must run http with content 'overseer/' 147 | # 148 | 149 | # 150 | # My website is at https://steve.fi/, there are redirections 151 | # in place for HTTP and for www-prefixed access. 152 | # 153 | # Test that the HTTP versions redirect to the secure version. 154 | # 155 | # We look for redirections via: 156 | # 157 | # 1. The status-code. 158 | # 2. The URL of the target in the body (which Apache does automatically). 159 | # 160 | # This works because our HTTP-probe does NOT follow HTTP-Redirection 161 | # requests, as doing so would limit the kind of tests we could write. 162 | # 163 | http://steve.fi/ must run http with status 301 with content 'https://steve.fi' 164 | http://www.steve.fi/ must run http with status 302 with content 'https://steve.fi' 165 | 166 | # 167 | # I prefer to avoid www.-names: 168 | # 169 | https://www.steve.fi/ must run http with status 302 with content 'https://steve.fi' 170 | 171 | # 172 | # So the final test is that we have decent content on the single "real" site. 173 | # 174 | https://steve.fi/ must run http with status 200 with content 'Steve Kemp' 175 | 176 | 177 | 178 | # 179 | # If your webserver uses HTTP basic-authentication you can submit the 180 | # appropriate username/password as you would expect: 181 | # 182 | # https://jigsaw.w3.org/HTTP/Digest/ must run http with username 'guest' with password 'guest' with content "Your browser made it" 183 | # 184 | 185 | # 186 | # Macros are shortcuts for repeating tests against multiple hosts. 187 | # 188 | # Here we define the macro "REDIS" to have two IPs: 189 | # 190 | REDIS are 127.0.0.1, ::1 191 | 192 | # 193 | # Macro-names are always written in upper-case, and it is a fatal error 194 | # to set the value of an existing macro. Which means this is invalid: 195 | # 196 | # HOSTS are 1.2.3.4, 1.2.3.5,... 197 | # HOSTS are 10.0.0.1, 10.0.0.2 198 | # 199 | 200 | # 201 | # Although the examples above used IP-addresses using hostnames is fine 202 | # too: 203 | # 204 | # HOSTS are host1.example.com, host2.example.com 205 | # 206 | # We'll see that later on when we run a bunch of DNS-tests against a 207 | # pair of nameservers. 208 | # 209 | 210 | 211 | # 212 | # Now we use the macro we defined, meaning that this single test will 213 | # be applied against both hosts used in the definition. 214 | # 215 | REDIS must run redis 216 | 217 | 218 | # 219 | # The redis probe, used above, tested that Redis responded on port 6379. 220 | # Rather than using the redis-specific protocol-test you could have instead 221 | # used the generic TCP-based test: 222 | # 223 | # REDIS must run tcp on 6379 224 | # 225 | # Using the redis-test is better, because it lets you specify an optional 226 | # password, and really connects. But as an example using the TCP-connection 227 | # test allows you to test protocols that don't have specific handlers defined 228 | # for them in overseer - please report this as a bug! 229 | # 230 | 231 | 232 | # 233 | # Of course nobody could reach my website if there were no DNS entries 234 | # present for it. So we should test they exist too! 235 | # 236 | # The DNS lookup test requires you to specify several things, beyond the 237 | # DNS-server to query (which is the target of the test and thus implicit): 238 | # 239 | # * The name to lookup. 240 | # * The type of record to lookup. 241 | # * The expected result 242 | # 243 | # For consistency the DNS test uses the general mechanism already 244 | # demonstrated to allow you to set those via text like this: 245 | # 246 | # with lookup "example.com" 247 | # with type "A" 248 | # with result "127.0.0.1" 249 | # 250 | 251 | # 252 | # First of all we define a pair of nameservers, using our macro-facility: 253 | # 254 | NAMESERVERS are rachel.ns.cloudflare.com, clark.ns.cloudflare.com 255 | 256 | # 257 | # Now we run some basic tests 258 | # 259 | NAMESERVERS must run dns with lookup steve.fi with type A with result '176.9.183.100' 260 | NAMESERVERS must run dns with lookup steve.fi with type AAAA with result '2a01:4f8:151:6083::100' 261 | NAMESERVERS must run dns with lookup www.steve.fi with type A with result '176.9.183.100' 262 | NAMESERVERS must run dns with lookup www.steve.fi with type AAAA with result '2a01:4f8:151:6083::100' 263 | 264 | 265 | # 266 | # You can confirm that a record shouldn't exist by looking for an empty 267 | # result (i.e. "", or ''). 268 | # 269 | # The following host, alert.steve.fi, is deliberately setup as IPv4 only, 270 | # so finding an AAAA record in DNS would indicate a mistake: 271 | # 272 | NAMESERVERS must run dns with lookup alert.steve.fi with type A with result '176.9.183.100' 273 | NAMESERVERS must run dns with lookup alert.steve.fi with type AAAA with result '' 274 | 275 | 276 | # 277 | # Now we should do more testing! 278 | # 279 | # Run our OpenSSH probe against localhost, on the non-standard port 2222. 280 | # 281 | localhost must run ssh with port 2222 282 | 283 | # 284 | # If you didn't want to use a non-standard port you'd just write: 285 | # 286 | # localhost must run ssh 287 | # 288 | 289 | # 290 | # Redis should run on localhost. 291 | # 292 | localhost must run redis 293 | 294 | # 295 | # If a password is required to connect to redis then set it like so: 296 | # 297 | # localhost must run redis with password 'secrit!' 298 | # 299 | # If a non-standard port is used: 300 | # 301 | # localhost must run redis with port 1234 302 | # 303 | # Of course these can be combined: 304 | # 305 | # localhost must run redis with port 1234 with password 'p4ssw0rd' 306 | # 307 | 308 | # 309 | # Now we can test that we get a response from a remote SMTP server 310 | # 311 | mail.steve.org.uk must run smtp 312 | mail.steve.org.uk must run smtp with port 587 313 | 314 | 315 | 316 | # 317 | # Similarly you might wish to test SSH against a whole bunch of related 318 | # hosts, so you might try this: 319 | # 320 | # SSH_HOSTS are host1.example.com, host2.example.com, host3.example.com 321 | # SSH_HOSTS must run ssh 322 | # 323 | # NOTE: 324 | # 325 | # All of the protocol-tests allow this expansion __EXCEPT__ for 326 | # the http-test, because the target of a HTTP-test is an URL, not a host. 327 | # 328 | 329 | 330 | 331 | # 332 | # IMAPS is a good thing. 333 | # 334 | # In this context "insecure" means "don't validate the SSL certificate", 335 | # in my case the SSL certificate is for "mail.steve.org.uk", but here you'll 336 | # notice I'm testing a different name (which points to the same host). 337 | # 338 | # So here I'm disabling the strict validation here: 339 | # 340 | ssh.steve.org.uk must run imaps with tls insecure 341 | 342 | # 343 | # Without the disabling we'd see: 344 | # 345 | # Test failed: x509: certificate is valid for 346 | # mail.steve.org.uk, webmail.steve.org.uk, not ssh.steve.org.uk 347 | # 348 | 349 | # 350 | # But if I connect to the correct hostname it is fine to leave TLS alone: 351 | # 352 | mail.steve.org.uk must run imaps 353 | 354 | 355 | ## 356 | ## Further Examples 357 | ## 358 | 359 | 360 | # 361 | # To see the complete list of available protocol-tests, along with sample 362 | # usage and supported arguments please run: 363 | # 364 | # ./overseer examples 365 | # 366 | # This will show you test-types we've not covered here, including finger, 367 | # telnet, NTTP, posgres, and MySQL. 368 | # 369 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go Report Card](https://goreportcard.com/badge/github.com/skx/overseer)](https://goreportcard.com/report/github.com/skx/overseer) 2 | [![license](https://img.shields.io/github/license/skx/overseer.svg)](https://github.com/skx/overseer/blob/master/LICENSE) 3 | [![Release](https://img.shields.io/github/release/skx/overseer.svg)](https://github.com/skx/overseer/releases/latest) 4 | 5 | 6 | Table of Contents 7 | ================= 8 | 9 | * [Overseer](#overseer) 10 | * [Installation & Dependencies](#installation--dependencies) 11 | * [Source Installation go <= 1.11](#source-installation-go---111) 12 | * [Source installation go >= 1.12](#source-installation-go---112) 13 | * [Dependencies](#dependencies) 14 | * [Executing Tests](#executing-tests) 15 | * [Running Automatically](#running-automatically) 16 | * [Smoothing Test Failures](#smoothing-test-failures) 17 | * [Notifications](#notifications) 18 | * [Metrics](#metrics) 19 | * [Redis Specifics](#redis-specifics) 20 | * [Docker](#docker) 21 | * [Github Setup](#github-setup) 22 | 23 | 24 | # Overseer 25 | 26 | Overseer is a simple and scalable [golang](https://golang.org/)-based remote protocol tester, which allows you to monitor the state of your network, and the services running upon it. 27 | 28 | "Remote Protocol Tester" sounds a little vague, so to be more concrete this application lets you test that (remote) services are running, and has built-in support for performing testing against: 29 | 30 | * DNS-servers 31 | * Test lookups of A, AAAA, MX, NS, and TXT records. 32 | * Finger 33 | * FTP 34 | * HTTP & HTTPS fetches. 35 | * HTTP basic-authentication is supported. 36 | * Requests may be DELETE, GET, HEAD, POST, PATCH, POST, & etc. 37 | * SSL certificate validation and expiration warnings are supported. 38 | * IMAP & IMAPS 39 | * MySQL 40 | * NNTP 41 | * ping / ping6 42 | * POP3 & POP3S 43 | * Postgres 44 | * redis 45 | * rsync 46 | * SMTP 47 | * SSH 48 | * Telnet 49 | * VNC 50 | * XMPP 51 | 52 | (The implementation of the protocol-handlers can be found beneath the top-level [protocols/](protocols/) directory in this repository.) 53 | 54 | Tests to be executed are defined in a simple text-based format which has the general form: 55 | 56 | $TARGET must run $SERVICE [with $OPTION_NAME $VALUE] .. 57 | 58 | You can see what the available tests look like in [the sample test-file](input.txt), and each of the included protocol-handlers are self-documenting which means you can view example usage via: 59 | 60 | ~$ overseer examples [pattern] 61 | 62 | All protocol-tests transparently support testing IPv4 and IPv6 targets, although you may globally disable either address family if you wish. 63 | 64 | 65 | 66 | ## Installation & Dependencies 67 | 68 | There are two ways to install this project from source, which depend on the version of the [go](https://golang.org/) version you're using. 69 | 70 | If you just need the binaries you can find them upon the [project release page](https://github.com/skx/overseer/releases). 71 | 72 | 73 | ### Source Installation go <= 1.11 74 | 75 | If you're using `go` before 1.11 then the following command should fetch/update `overseer`, and install it upon your system: 76 | 77 | $ go get -u github.com/skx/overseer 78 | 79 | ### Source installation go >= 1.12 80 | 81 | If you're using a more recent version of `go` (which is _highly_ recommended), you need to clone to a directory which is not present upon your `GOPATH`: 82 | 83 | git clone https://github.com/skx/overseer 84 | cd overseer 85 | go install 86 | 87 | 88 | ### Dependencies 89 | 90 | Beyond the compile-time dependencies overseer requires a [redis](https://redis.io/) server which is used for two things: 91 | 92 | * As the storage-queue for parsed-jobs. 93 | * As the storage-queue for test-results. 94 | 95 | Because overseer is executed in a distributed fashion tests are not executed 96 | as they are parsed/read, instead they are inserted into a redis-queue. A worker, 97 | or number of workers, poll the queue fetching & executing jobs as they become 98 | available. 99 | 100 | In small-scale deployments it is probably sufficient to have a single worker, 101 | and all the software running upon a single host. For a larger number of 102 | tests (1000+) it might make more sense to have a pool of hosts each running 103 | a worker. 104 | 105 | Because we don't want to be tied to a specific notification-system results 106 | of each test are also posted to the same redis-host, which allows results to be retrieved and transmitted to your preferred notifier. 107 | 108 | More details about [notifications](#notifications) are available later in this document. 109 | 110 | 111 | 112 | ## Executing Tests 113 | 114 | As mentioned already executing tests a two-step process: 115 | 116 | * First of all tests are parsed and inserted into a redis-based queue. 117 | * Secondly the tests are pulled from that queue and executed. 118 | 119 | This might seem a little convoluted, however it is a great design if you 120 | have a lot of tests to be executed, because it allows you to deploy multiple 121 | workers. Instead of having a single host executing all the tests you can 122 | can have 10 hosts, each watching the same redis-queue pulling jobs, & executing 123 | them as they become available. 124 | 125 | In short using a central queue allows you to scale out the testing horizontally. 126 | 127 | To add your tests to the queue you should run: 128 | 129 | $ overseer enqueue \ 130 | -redis-host=queue.example.com:6379 [-redis-pass='secret.here'] \ 131 | test.file.1 test.file.2 .. test.file.N 132 | 133 | This will parse the tests contained in the specified files, adding each of them to the (shared) redis queue. Once all of the jobs have been parsed and inserted into the queue the process will terminate. 134 | 135 | To drain the queue you can should now start a worker, which will fetch the tests and process them: 136 | 137 | $ overseer worker -verbose \ 138 | -redis-host=queue.example.com:6379 [-redis-pass='secret'] 139 | 140 | The worker will run constantly, not terminating unless manually killed. With 141 | the worker running you can add more jobs by re-running the `overseer enqueue` 142 | command. 143 | 144 | To run tests in parallel simply launch more instances of the worker, on the same host, or on different hosts. 145 | 146 | 147 | 148 | ### Running Automatically 149 | 150 | Beneath [systemd/](systemd/) you will find some sample service-files which can be used to deploy overseer upon a single host: 151 | 152 | * A service to start a single worker, fetching jobs from a redis server. 153 | * The redis-server is assumed to be running on `localhost`. 154 | * A service & timer to regularly populate the queue with fresh jobs to be executed. 155 | * i.e. The first service is the worker, this second one feeds the worker. 156 | 157 | 158 | 159 | ### Smoothing Test Failures 160 | 161 | To avoid triggering false alerts due to transient (network/host) failures 162 | tests which fail are retried several times before triggering a notification. 163 | 164 | This _smoothing_ is designed to avoid raising an alert, which then clears 165 | upon the next overseer run, but the downside is that flapping services might 166 | not necessarily become visible. 167 | 168 | If you're absolutely certain that your connectivity is good, and that 169 | alerts should always be raised for failing services you can disable this 170 | retry-logic via the command-line flag `-retry=false`. 171 | 172 | 173 | 174 | ## Notifications 175 | 176 | The result of each test is submitted to the central redis-host, from where it can be pulled and used to notify a human of a problem. 177 | 178 | Sample result-processors are [included](bridges/) in this repository which post 179 | test-results to Telegram, a [purppura instance](https://github.com/skx/purppura), or via email. 180 | 181 | The sample bridges are primarily included for demonstration purposes, the 182 | expectation is you'll prefer to process the results and issue notifications to 183 | humans via your favourite in-house tool - be it pagerduty, or something similar. 184 | 185 | The results themselves are published as JSON objects to the `overseer.results` set. Your notifier should remove the results from this set, as it generates alerts to prevent it from growing indefinitely. 186 | 187 | You can check the size of the results set at any time via `redis-cli` like so: 188 | 189 | $ redis-cli llen overseer.results 190 | (integer) 0 191 | 192 | The JSON object used to describe each test-result has the following fields: 193 | 194 | | Field Name | Field Value | 195 | | ---------- | --------------------------------------------------------------- | 196 | | `input` | The input as read from the configuration-file. | 197 | | `result` | Either `passed` or `failed`. | 198 | | `error` | If the test failed this will explain why. | 199 | | `time` | The time the result was posted, in seconds past the epoch. | 200 | | `target` | The target of the test, either an IPv4 address or an IPv6 one. | 201 | | `type` | The type of test (ssh, ftp, etc). | 202 | 203 | **NOTE**: The `input` field will be updated to mask any password options which have been submitted with the tests. 204 | 205 | As mentioned this repository contains some demonstration "[bridges](bridges/)", which poll the results from Redis, and forward them to more useful systems: 206 | 207 | * `email-bridge/main.go` 208 | * This posts test-failures via email. 209 | * Tests which pass are not reported. 210 | * `purppura-bridge/main.go` 211 | * This forwards each test-result to a [purppura host](https://github.com/skx/purppura/). 212 | * From there alerts will reach a human via pushover. 213 | * `telegram-bridge/main.go` 214 | * This forwards each test-failure as a message to a Telegram user. 215 | 216 | 217 | 218 | ## Metrics 219 | 220 | Overseer has built-in support for exporting metrics to a remote carbon-server: 221 | 222 | * Details of the system itself. 223 | * Via the [go-metrics](https://github.com/skx/golang-metrics) package. 224 | * Details of the tests executed. 225 | * Including the time to run tests, perform DNS lookups, and retry-counts. 226 | 227 | To enable this support simply export the environmental variable `METRICS` 228 | with the hostname of your remote metrics-host prior to launching the worker. 229 | 230 | 231 | 232 | ## Redis Specifics 233 | 234 | We use Redis as a queue as it is simple to deploy, stable, and well-known. 235 | 236 | Redis doesn't natively operate as a queue, so we replicate this via the "list" 237 | primitives. Adding a job to a queue is performed via a "[rpush](https://redis.io/commands/rpush)" operation, and pulling a job from the queue is achieved via an "[blpop](https://redis.io/commands/blpop)" command. 238 | 239 | We use the following two lists as queues: 240 | 241 | * `overseer.jobs` 242 | * For storing tests to be executed by a worker. 243 | * `overseer.results` 244 | * For storing results, to be processed by a notifier. 245 | 246 | You can examine the length of either queue via the [llen](https://redis.io/commands/llen) operation. 247 | 248 | * To view jobs pending execution: 249 | * `redis-cli lrange overseer.jobs 0 -1` 250 | * Or to view just the count 251 | * `redis-cli llen overseer.jobs` 252 | * To view test-results which have yet to be notified: 253 | * `redis-cli lrange overseer.results 0 -1` 254 | * Or to view just the count 255 | * `redis-cli llen overseer.results` 256 | 257 | 258 | 259 | 260 | ## Docker 261 | 262 | There are a series of Dockerfiles contained within this repository, they're designed to allow you to test things in a simple fashion. However they do have the notification bridge hardcoded. 263 | 264 | You can build the images like so: 265 | 266 | ``` 267 | docker build -t overseer:bridge -f Dockerfile.bridge . 268 | docker build -t oversser:enqueue -f Dockerfile.enqueue . 269 | docker build -t overseer:worker -f Dockerfile.worker . 270 | ``` 271 | 272 | Once built the supplied [docker-compose.yml](docker-compose.yml) file will let you launch them, using a shared redis instance. The notifications will go via telegram by default, so you'll need to populate a token for a bot and setup your recipient user-ID. 273 | 274 | 275 | 276 | ## Github Setup 277 | 278 | This repository is configured to run tests upon every commit, and when pull-requests are created/updated. The testing is carried out via [.github/run-tests.sh](.github/run-tests.sh) which is used by the [github-action-tester](https://github.com/skx/github-action-tester) action. 279 | Releases are automated in a similar fashion via [.github/build](.github/build), and the [github-action-publish-binaries](https://github.com/skx/github-action-publish-binaries) action. 280 | 281 | Steve 282 | -- 283 | -------------------------------------------------------------------------------- /parser/parser_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/skx/overseer/test" 10 | ) 11 | 12 | // Test that parsing a missing file returns an error 13 | func TestMissingFile(t *testing.T) { 14 | 15 | p := New() 16 | err := p.ParseFile("/path/is/not/found", nil) 17 | 18 | if err == nil { 19 | t.Errorf("Parsing a missing file didn't raise an error!") 20 | } 21 | } 22 | 23 | // Test reading samples from a file 24 | func TestFile(t *testing.T) { 25 | file, err := ioutil.TempFile(os.TempDir(), "prefix") 26 | if err != nil { 27 | t.Errorf("Error creating temporary-directory %s", err.Error()) 28 | } 29 | defer os.Remove(file.Name()) 30 | 31 | // Write to the file 32 | lines := ` 33 | http://example.com/ must run http 34 | # This is fine 35 | http://example.com/ must run http with content 'moi' 36 | ` 37 | // 38 | err = ioutil.WriteFile(file.Name(), []byte(lines), 0644) 39 | if err != nil { 40 | t.Errorf("Error writing our test-case") 41 | } 42 | 43 | // 44 | // Now parse the file 45 | // 46 | p := New() 47 | err = p.ParseFile(file.Name(), nil) 48 | 49 | if err != nil { 50 | t.Errorf("Error parsing our valid file") 51 | } 52 | } 53 | 54 | // Test reading macro-based samples from a file 55 | func TestFileMacro(t *testing.T) { 56 | file, err := ioutil.TempFile(os.TempDir(), "prefix") 57 | if err != nil { 58 | t.Errorf("Error creating temporary-directory %s", err.Error()) 59 | } 60 | 61 | defer os.Remove(file.Name()) 62 | 63 | // Write to the file 64 | lines := ` 65 | FOO are host1.example.com, host2.example.com 66 | FOO must run ssh 67 | ` 68 | // 69 | err = ioutil.WriteFile(file.Name(), []byte(lines), 0644) 70 | if err != nil { 71 | t.Errorf("Error writing our test-case") 72 | } 73 | 74 | // 75 | // Now parse the file 76 | // 77 | p := New() 78 | err = p.ParseFile(file.Name(), nil) 79 | 80 | if err != nil { 81 | t.Errorf("Error parsing our valid file") 82 | } 83 | } 84 | 85 | // Test redefinining macros is a bug. 86 | func TestFileMacroRedefined(t *testing.T) { 87 | file, err := ioutil.TempFile(os.TempDir(), "prefix") 88 | if err != nil { 89 | t.Errorf("Error creating temporary-directory %s", err.Error()) 90 | } 91 | defer os.Remove(file.Name()) 92 | 93 | // Write to the file 94 | lines := ` 95 | FOO are host1.example.com, host2.example.com 96 | FOO must run ssh 97 | FOO are host3.example.com, host4.example.com 98 | FOO must run ftp 99 | ` 100 | // 101 | err = ioutil.WriteFile(file.Name(), []byte(lines), 0644) 102 | if err != nil { 103 | t.Errorf("Error writing our test-case") 104 | } 105 | 106 | // 107 | // Now parse the file 108 | // 109 | p := New() 110 | err = p.ParseFile(file.Name(), nil) 111 | 112 | if err == nil { 113 | t.Errorf("Expected error parsing file, didn't see one!") 114 | } 115 | if !strings.Contains(err.Error(), "redeclaring an existing macro") { 116 | t.Errorf("The expected error differed from what we received") 117 | } 118 | } 119 | 120 | // Test some valid input 121 | func TestValidLines(t *testing.T) { 122 | 123 | var inputs = []string{ 124 | "foo must run http", 125 | "bar must run http", 126 | "baz must run ftp"} 127 | 128 | for _, line := range inputs { 129 | 130 | p := New() 131 | _, err := p.ParseLine(line, nil) 132 | 133 | if err != nil { 134 | t.Errorf("Found error parsing valid line: %s\n", err.Error()) 135 | } 136 | } 137 | } 138 | 139 | // Test some malformed lines 140 | func TestUnknownInput(t *testing.T) { 141 | 142 | var inputs = []string{ 143 | "foo must RAN blah", 144 | "bar mustn't exist", 145 | "baz must ping"} 146 | 147 | for _, line := range inputs { 148 | 149 | p := New() 150 | _, err := p.ParseLine(line, nil) 151 | 152 | if err == nil { 153 | t.Errorf("Should have found error parsing line: %s\n", line) 154 | } 155 | if !strings.Contains(err.Error(), "unrecognized line") { 156 | t.Errorf("Received unexpected error: %s\n", err.Error()) 157 | } 158 | } 159 | } 160 | 161 | // Test some invalid inputs 162 | func TestUnknownProtocols(t *testing.T) { 163 | 164 | var inputs = []string{ 165 | "foo must run blah", 166 | "bar must run moi", 167 | "baz must run kiss"} 168 | 169 | for _, line := range inputs { 170 | 171 | p := New() 172 | _, err := p.ParseLine(line, nil) 173 | 174 | if err == nil { 175 | t.Errorf("Should have found error parsing line: %s\n", line) 176 | } 177 | if !strings.Contains(err.Error(), "unknown test-type") { 178 | t.Errorf("Received unexpected error: %s\n", err.Error()) 179 | } 180 | } 181 | } 182 | 183 | // Test parsing things that should return no options 184 | func TestNoArguments(t *testing.T) { 185 | 186 | tests := []string{ 187 | "127.0.0.1 must run ping", 188 | "127.0.0.1 must run ssh", 189 | } 190 | 191 | // Create a parser 192 | p := New() 193 | 194 | // Parse each line 195 | for _, input := range tests { 196 | 197 | out, err := p.ParseLine(input, nil) 198 | if err != nil { 199 | t.Errorf("Error parsing %s - %s", input, err.Error()) 200 | } 201 | if len(out.Arguments) != 0 { 202 | t.Errorf("Surprising output") 203 | } 204 | } 205 | } 206 | 207 | // Test parsing a multi-line statement 208 | func TestContinuation(t *testing.T) { 209 | 210 | file, err := ioutil.TempFile(os.TempDir(), "prefix") 211 | if err != nil { 212 | t.Errorf("Error creating temporary-directory %s", err.Error()) 213 | } 214 | defer os.Remove(file.Name()) 215 | 216 | // Write to the file 217 | lines := ` 218 | 127.0.\ 219 | 0.1 \ 220 | must \ 221 | run redis 222 | 223 | 224 | ` 225 | // 226 | err = ioutil.WriteFile(file.Name(), []byte(lines), 0644) 227 | if err != nil { 228 | t.Errorf("Error writing our test-case") 229 | } 230 | 231 | // 232 | // Count of parsed lines. 233 | // 234 | count := 0 235 | 236 | // 237 | // Now parse the file 238 | // 239 | p := New() 240 | err = p.ParseFile(file.Name(), func(tst test.Test) error { 241 | count++ 242 | if tst.Type != "redis" { 243 | t.Errorf("Our parser was broken!") 244 | } 245 | if tst.Target != "127.0.0.1" { 246 | t.Errorf("Our parser was broken!") 247 | } 248 | if tst.Sanitize() != "127.0.0.1 must run redis" { 249 | t.Errorf("Our parser resulted in a mismatched result!") 250 | } 251 | return nil 252 | }) 253 | 254 | if err != nil { 255 | t.Errorf("Expected no error, but found %s", err.Error()) 256 | } 257 | if count != 1 { 258 | t.Errorf("Expected a single valid line, found %d", count) 259 | } 260 | } 261 | 262 | // Test parsing a continued-comment. 263 | func TestCommentContinuation(t *testing.T) { 264 | 265 | file, err := ioutil.TempFile(os.TempDir(), "prefix") 266 | if err != nil { 267 | t.Errorf("Error creating temporary-directory %s", err.Error()) 268 | } 269 | defer os.Remove(file.Name()) 270 | 271 | // Write to the file 272 | lines := ` 273 | # This is a comment \ 274 | comment must run http \ 275 | This is still a comment. 276 | ` 277 | // 278 | err = ioutil.WriteFile(file.Name(), []byte(lines), 0644) 279 | if err != nil { 280 | t.Errorf("Error writing our test-case") 281 | } 282 | 283 | // 284 | // Count of parsed lines. 285 | // 286 | count := 0 287 | 288 | // 289 | // Now parse the file 290 | // 291 | p := New() 292 | err = p.ParseFile(file.Name(), func(tst test.Test) error { 293 | count++ 294 | return nil 295 | }) 296 | 297 | if err != nil { 298 | t.Errorf("Expected no error, but found %s", err.Error()) 299 | } 300 | if count != 0 { 301 | t.Errorf("Expected zero valid lines, found %d", count) 302 | } 303 | } 304 | 305 | // Test parsing an argument that fails validation 306 | func TestInvalidArgument(t *testing.T) { 307 | 308 | tests := []string{ 309 | "http://example.com/ must run http with status moi", 310 | "http://example.com/ must run http with expiration 12s", 311 | } 312 | 313 | // Create a parser 314 | p := New() 315 | 316 | // Parse each line 317 | for _, input := range tests { 318 | 319 | _, err := p.ParseLine(input, nil) 320 | if err == nil { 321 | t.Errorf("Expected an error parsing input, received none. Input was %s", input) 322 | } 323 | if !strings.Contains(err.Error(), "did not match pattern") { 324 | t.Errorf("Received unexpected error: %s\n", err.Error()) 325 | } 326 | 327 | } 328 | } 329 | 330 | // Test parsing some common HTTP options 331 | func TestHTTPOptions(t *testing.T) { 332 | 333 | tests := []string{ 334 | "http://example.com/ must run http with content 'moi' and ..", 335 | "http://example.com/ must run http with content moi", 336 | "http://example.com/ must run http with status '200'", 337 | "http://example.com/ must run http with status 200", 338 | } 339 | 340 | // Create a parser 341 | p := New() 342 | 343 | // Parse each line 344 | for _, input := range tests { 345 | 346 | // Parse the line 347 | out, err := p.ParseLine(input, nil) 348 | if err != nil { 349 | t.Errorf("Error parsing %s - %s", input, err.Error()) 350 | } 351 | 352 | // We should have a single argument in each case 353 | if len(out.Arguments) != 1 { 354 | t.Errorf("Surprising output - we expected 1 option but found %d", len(out.Arguments)) 355 | } 356 | } 357 | } 358 | 359 | // Test quotation-removal 360 | func TestQuoteRemoval(t *testing.T) { 361 | 362 | tests := []string{ 363 | "http://example.com/ must run http with content 'moi' and ..", 364 | "http://example.com/ must run http with content \"moi\"", 365 | "http://example.com/ must run http with content moi", 366 | } 367 | 368 | // Create a parser 369 | p := New() 370 | 371 | // Parse each line 372 | for _, input := range tests { 373 | 374 | out, err := p.ParseLine(input, nil) 375 | if err != nil { 376 | t.Errorf("Error parsing %s - %s", input, err.Error()) 377 | } 378 | 379 | // We expect one parameter: content 380 | if len(out.Arguments) != 1 { 381 | t.Errorf("Surprising output - we expected 1 option but found %d", len(out.Arguments)) 382 | } 383 | 384 | // The value should be 'moi' 385 | if out.Arguments["content"] != "moi" { 386 | t.Errorf("We expected the key 'content' to have the value 'moi', but found %s", out.Arguments["content"]) 387 | } 388 | } 389 | } 390 | 391 | // Test quotation-removal doesn't modify the content of a string 392 | func TestQuoteRemovalSanity(t *testing.T) { 393 | 394 | tests := []string{ 395 | "http://example.com/ must run http with content 'm\"'oi' and ..", 396 | "http://example.com/ must run http with content \"m\"'oi\"", 397 | "http://example.com/ must run http with content m\"'oi", 398 | } 399 | 400 | // Create a parser 401 | p := New() 402 | 403 | // Parse each line 404 | for _, input := range tests { 405 | 406 | out, err := p.ParseLine(input, nil) 407 | if err != nil { 408 | t.Errorf("Error parsing %s - %s", input, err.Error()) 409 | } 410 | 411 | // We expect one parameter: content 412 | if len(out.Arguments) != 1 { 413 | t.Errorf("Surprising output - we expected 1 option but found %d", len(out.Arguments)) 414 | } 415 | 416 | // The value should have a single quote and double-quote 417 | single := 0 418 | double := 0 419 | for _, c := range out.Arguments["content"] { 420 | if c == '"' { 421 | double++ 422 | } 423 | if c == '\'' { 424 | single++ 425 | } 426 | } 427 | 428 | if single != 1 { 429 | t.Errorf("We found the wrong number of single-quotes: %d != 1", single) 430 | 431 | } 432 | if double != 1 { 433 | t.Errorf("We found the wrong number of double-quotes: %d != 1", double) 434 | } 435 | } 436 | } 437 | 438 | // Test a real line 439 | func TestReal(t *testing.T) { 440 | in := "http://steve.fi/ must run http with status 301 with content 'Steve Kemp'" 441 | 442 | // Create a parser 443 | p := New() 444 | 445 | out, err := p.ParseLine(in, nil) 446 | if err != nil { 447 | t.Errorf("Error parsing %s - %s", in, err.Error()) 448 | } 449 | 450 | // We expect two parameter: content + status 451 | if len(out.Arguments) != 2 { 452 | t.Errorf("Received the wrong number of parameters") 453 | } 454 | if out.Arguments["status"] != "301" { 455 | t.Errorf("Failed to get the correct status-value") 456 | } 457 | if out.Arguments["content"] != "Steve Kemp" { 458 | t.Errorf("Failed to get the correct content-value") 459 | } 460 | 461 | } 462 | 463 | // Test that later arguments replace earlier ones. 464 | func TestDuplicateArguments(t *testing.T) { 465 | in := "http://steve.fi/ must run http with status 301 with status 302 with status any" 466 | 467 | // Create a parser 468 | p := New() 469 | 470 | out, err := p.ParseLine(in, nil) 471 | if err != nil { 472 | t.Errorf("Error parsing %s - %s", in, err.Error()) 473 | } 474 | 475 | // We expect one parameter: status 476 | if len(out.Arguments) != 1 { 477 | t.Errorf("Received the wrong number of parameters") 478 | } 479 | if out.Arguments["status"] != "any" { 480 | t.Errorf("Failed to get the correct status-value") 481 | } 482 | } 483 | 484 | // Test some invalid options 485 | func TestInvalidOptions(t *testing.T) { 486 | tests := []string{ 487 | "http://example.com/ must run http with CONTENT 'moi'", 488 | "http://example.com/ must run http with header 'foo: bar'", 489 | "http://example.com/ must run http with statsu 300 ", 490 | } 491 | 492 | // Create a parser 493 | p := New() 494 | 495 | // Parse each line 496 | for _, input := range tests { 497 | 498 | _, err := p.ParseLine(input, nil) 499 | if err == nil { 500 | t.Errorf("We expected an error parsing %s, but found none!", input) 501 | } 502 | 503 | if !strings.Contains(err.Error(), "unsupported argument") { 504 | t.Errorf("The error we received was the wrong error: %s", err.Error()) 505 | 506 | } 507 | } 508 | } 509 | 510 | func TestMaxRetries(t *testing.T) { 511 | tests := []string{ 512 | "http://example.com/ must run http with retries 0", 513 | "http://example.com/ must run http with retries 1", 514 | "http://example.com/ must run http with retries 2", 515 | } 516 | 517 | // Create a parser 518 | p := New() 519 | 520 | // Parse each line 521 | for idx, input := range tests { 522 | 523 | tst, err := p.ParseLine(input, nil) 524 | if err != nil { 525 | t.Errorf("We did not expect an error parsing %s - got %s!", input, err) 526 | 527 | continue 528 | } 529 | 530 | if tst.MaxRetries != idx { 531 | t.Errorf("Invalid retries number. Expected %d, got %d", idx, tst.MaxRetries) 532 | 533 | } 534 | } 535 | } 536 | 537 | // Test invoking a callback. 538 | func TestCallback(t *testing.T) { 539 | file, err := ioutil.TempFile(os.TempDir(), "prefix") 540 | if err != nil { 541 | t.Errorf("Error creating temporary-directory %s", err.Error()) 542 | } 543 | defer os.Remove(file.Name()) 544 | 545 | // Content to write to a file 546 | lines := ` 547 | http://example.com/ must run http 548 | # This is fine 549 | http://example.com/ must run http with content 'moi' 550 | ` 551 | // Write it out 552 | err = ioutil.WriteFile(file.Name(), []byte(lines), 0644) 553 | if err != nil { 554 | t.Errorf("Error writing our test-case") 555 | } 556 | 557 | // 558 | // Count of how many times we were calledback 559 | // 560 | i := 0 561 | 562 | // callback 563 | 564 | // 565 | // Now parse the file - using the callback 566 | // 567 | p := New() 568 | err = p.ParseFile(file.Name(), func(tst test.Test) error { 569 | i = i + 1 570 | return nil 571 | }) 572 | 573 | // 574 | // We'll test that worked. 575 | // 576 | if err != nil { 577 | t.Errorf("Error parsing our valid file") 578 | } 579 | if i != 2 { 580 | t.Errorf("Callback invoked the wrong number of times: %d", i) 581 | } 582 | } 583 | 584 | // Test sanitise 585 | func TestSanitize(t *testing.T) { 586 | file, err := ioutil.TempFile(os.TempDir(), "prefix") 587 | if err != nil { 588 | t.Errorf("Error creating temporary-directory %s", err.Error()) 589 | } 590 | defer os.Remove(file.Name()) 591 | 592 | // Content to write to a file 593 | lines := ` 594 | http://example.com/ must run http with username 'steve' with password 'ke'mp' 595 | ` 596 | // Write it out 597 | err = ioutil.WriteFile(file.Name(), []byte(lines), 0644) 598 | if err != nil { 599 | t.Errorf("Error writing our test-case") 600 | } 601 | 602 | // 603 | // The test 604 | // 605 | var tmp test.Test 606 | 607 | // 608 | // Now parse the file - using the callback 609 | // 610 | p := New() 611 | err = p.ParseFile(file.Name(), func(tst test.Test) error { 612 | tmp = tst 613 | return nil 614 | }) 615 | 616 | // 617 | // We'll test that worked. 618 | // 619 | if err != nil { 620 | t.Errorf("Error parsing our valid file") 621 | } 622 | 623 | // 624 | // So now we have a parsed test.Test object. 625 | // 626 | // Check the fields 627 | // 628 | if tmp.Target != "http://example.com/" { 629 | t.Errorf("Parsed test had wrong target!") 630 | } 631 | if tmp.Arguments["username"] != "steve" { 632 | t.Errorf("Parsed test had wrong username!") 633 | } 634 | if tmp.Arguments["password"] != "ke'mp" { 635 | t.Errorf("Parsed test had wrong password!") 636 | } 637 | 638 | // 639 | // Sanitize 640 | // 641 | safe := tmp.Sanitize() 642 | 643 | if strings.Contains(safe, "ke'mp") { 644 | t.Errorf("Password is still visible") 645 | } 646 | if !strings.Contains(safe, "CENSOR") { 647 | t.Errorf("We see no evidence of censorship") 648 | } 649 | } 650 | -------------------------------------------------------------------------------- /cmd_worker.go: -------------------------------------------------------------------------------- 1 | // Worker 2 | // 3 | // The worker sub-command executes tests pulled from a central redis queue. 4 | package main 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "flag" 10 | "fmt" 11 | "io/ioutil" 12 | "net" 13 | "net/url" 14 | "os" 15 | "regexp" 16 | "strconv" 17 | "strings" 18 | "time" 19 | 20 | "github.com/go-redis/redis" 21 | "github.com/google/subcommands" 22 | graphite "github.com/marpaia/graphite-golang" 23 | _ "github.com/skx/golang-metrics" 24 | "github.com/skx/overseer/parser" 25 | "github.com/skx/overseer/protocols" 26 | "github.com/skx/overseer/test" 27 | ) 28 | 29 | // This is our structure, largely populated by command-line arguments 30 | type workerCmd struct { 31 | // Should we run tests against IPv4 addresses? 32 | IPv4 bool 33 | 34 | // Should we run tests against IPv6 addresses? 35 | IPv6 bool 36 | 37 | // Should we retry failed tests a number of times to smooth failures? 38 | Retry bool 39 | 40 | // If we should retry failed tests, how many times before we give up? 41 | RetryCount int 42 | 43 | // Prior to retrying a failed test how long should we pause? 44 | RetryDelay time.Duration 45 | 46 | // The redis-host we're going to connect to for our queues. 47 | RedisHost string 48 | 49 | // The redis-database we're going to use. 50 | RedisDB int 51 | 52 | // The (optional) redis-password we'll use. 53 | RedisPassword string 54 | 55 | // The redis-socket we're going to use. 56 | // (If used, we ignore the specified host / port) 57 | RedisSocket string 58 | 59 | // Redis connection timeout 60 | RedisDialTimeout time.Duration 61 | 62 | // Tag applied to all results 63 | Tag string 64 | 65 | // How long should tests run for? 66 | Timeout time.Duration 67 | 68 | // Should the testing, and the tests, be verbose? 69 | Verbose bool 70 | 71 | // The handle to our redis-server 72 | _r *redis.Client 73 | 74 | // The handle to our graphite-server 75 | _g *graphite.Graphite 76 | } 77 | 78 | // Glue 79 | func (*workerCmd) Name() string { return "worker" } 80 | func (*workerCmd) Synopsis() string { return "Fetch jobs from the central queue and execute them" } 81 | func (*workerCmd) Usage() string { 82 | return `worker : 83 | Execute tests pulled from the central redis queue, until terminated. 84 | ` 85 | } 86 | 87 | // MetricsFromEnvironment sets up a carbon connection from the environment 88 | // if suitable values are found 89 | func (p *workerCmd) MetricsFromEnvironment() { 90 | 91 | // 92 | // Get the hostname to connect to. 93 | // 94 | host := os.Getenv("METRICS_HOST") 95 | if host == "" { 96 | host = os.Getenv("METRICS") 97 | } 98 | 99 | // No host then we'll return 100 | if host == "" { 101 | return 102 | } 103 | 104 | // Split the into Host + Port 105 | ho, pr, err := net.SplitHostPort(host) 106 | if err != nil { 107 | // If that failed we assume the port was missing 108 | ho = host 109 | pr = "2003" 110 | } 111 | 112 | // Setup the protocol to use 113 | protocol := os.Getenv("METRICS_PROTOCOL") 114 | if protocol == "" { 115 | protocol = "udp" 116 | } 117 | 118 | // Ensure that the port is an integer 119 | port, err := strconv.Atoi(pr) 120 | if err == nil { 121 | p._g, err = graphite.GraphiteFactory(protocol, ho, port, "") 122 | 123 | if err != nil { 124 | fmt.Printf("Error setting up metrics - skipping - %s\n", err.Error()) 125 | } 126 | } else { 127 | fmt.Printf("Error setting up metrics - failed to convert port to number - %s\n", err.Error()) 128 | 129 | } 130 | } 131 | 132 | // verbose shows a message only if we're running verbosely 133 | func (p *workerCmd) verbose(txt string) { 134 | if p.Verbose { 135 | fmt.Print(txt) 136 | } 137 | } 138 | 139 | // Flag setup. 140 | func (p *workerCmd) SetFlags(f *flag.FlagSet) { 141 | 142 | // 143 | // Setup the default options here, these can be loaded/replaced 144 | // via a configuration-file if it is present. 145 | // 146 | var defaults workerCmd 147 | defaults.IPv4 = true 148 | defaults.IPv6 = true 149 | defaults.Retry = true 150 | defaults.RetryCount = 5 151 | defaults.RetryDelay = 5 * time.Second 152 | defaults.Tag = "" 153 | defaults.Timeout = 10 * time.Second 154 | defaults.Verbose = false 155 | defaults.RedisHost = "localhost:6379" 156 | defaults.RedisDB = 0 157 | defaults.RedisPassword = "" 158 | defaults.RedisDialTimeout = 5 * time.Second 159 | 160 | // 161 | // If we have a configuration file then load it 162 | // 163 | if len(os.Getenv("OVERSEER")) > 0 { 164 | cfg, err := ioutil.ReadFile(os.Getenv("OVERSEER")) 165 | if err == nil { 166 | err = json.Unmarshal(cfg, &defaults) 167 | if err != nil { 168 | fmt.Printf("WARNING: Error loading overseer.json - %s\n", 169 | err.Error()) 170 | } 171 | } else { 172 | fmt.Printf("WARNING: Failed to read configuration-file - %s\n", 173 | err.Error()) 174 | } 175 | } 176 | 177 | // 178 | // Allow these defaults to be changed by command-line flags 179 | // 180 | // Verbose 181 | f.BoolVar(&p.Verbose, "verbose", defaults.Verbose, "Show more output.") 182 | 183 | // Protocols 184 | f.BoolVar(&p.IPv4, "4", defaults.IPv4, "Enable IPv4 tests.") 185 | f.BoolVar(&p.IPv6, "6", defaults.IPv6, "Enable IPv6 tests.") 186 | 187 | // Timeout 188 | f.DurationVar(&p.Timeout, "timeout", defaults.Timeout, "The global timeout for all tests, in seconds.") 189 | 190 | // Retry 191 | f.BoolVar(&p.Retry, "retry", defaults.Retry, "Should failing tests be retried a few times before raising a notification.") 192 | f.IntVar(&p.RetryCount, "retry-count", defaults.RetryCount, "How many times to retry a test, before regarding it as a failure.") 193 | f.DurationVar(&p.RetryDelay, "retry-delay", defaults.RetryDelay, "The time to sleep between failing tests.") 194 | 195 | // Redis 196 | f.StringVar(&p.RedisHost, "redis-host", defaults.RedisHost, "Specify the address of the redis queue.") 197 | f.IntVar(&p.RedisDB, "redis-db", defaults.RedisDB, "Specify the database-number for redis.") 198 | f.StringVar(&p.RedisPassword, "redis-pass", defaults.RedisPassword, "Specify the password for the redis queue.") 199 | f.StringVar(&p.RedisSocket, "redis-socket", defaults.RedisSocket, "If set, will be used for the redis connections.") 200 | 201 | // Tag 202 | f.StringVar(&p.Tag, "tag", defaults.Tag, "Specify the tag to add to all test-results.") 203 | } 204 | 205 | // notify is used to store the result of a test in our redis queue. 206 | func (p *workerCmd) notify(test test.Test, result error) error { 207 | 208 | // 209 | // If we don't have a redis-server then return immediately. 210 | // 211 | // (This shouldn't happen, as without a redis-handle we can't 212 | // fetch jobs to execute.) 213 | // 214 | if p._r == nil { 215 | return nil 216 | } 217 | 218 | // 219 | // The message we'll publish will be a JSON hash 220 | // 221 | msg := map[string]string{ 222 | "input": test.Input, 223 | "result": "passed", 224 | "target": test.Target, 225 | "time": fmt.Sprintf("%d", time.Now().Unix()), 226 | "type": test.Type, 227 | "tag": p.Tag, 228 | } 229 | 230 | // 231 | // Was the test result a failure? If so update the object 232 | // to contain the failure-message, and record that it was 233 | // a failure rather than a pass. 234 | // 235 | if result != nil { 236 | msg["result"] = "failed" 237 | msg["error"] = result.Error() 238 | } 239 | 240 | // 241 | // Convert the result-object to a JSON string we can add to 242 | // the redis-queue for the notifier to work with. 243 | // 244 | j, err := json.Marshal(msg) 245 | if err != nil { 246 | fmt.Printf("Failed to encode test-result to JSON: %s", err.Error()) 247 | return err 248 | } 249 | 250 | // 251 | // Publish the message to the queue. 252 | // 253 | _, err = p._r.RPush("overseer.results", j).Result() 254 | if err != nil { 255 | fmt.Printf("Result addition failed: %s\n", err) 256 | return err 257 | } 258 | 259 | return nil 260 | } 261 | 262 | // alphaNumeric removes all non alpha-numeric characters from the 263 | // given string, and returns it. We replace the characters that 264 | // are invalid with `_`. 265 | func (p *workerCmd) alphaNumeric(input string) string { 266 | // 267 | // Remove non alphanumeric 268 | // 269 | reg, err := regexp.Compile("[^A-Za-z0-9]+") 270 | if err != nil { 271 | panic(err) 272 | } 273 | return (reg.ReplaceAllString(input, "_")) 274 | } 275 | 276 | // formatMetrics Format a test for metrics submission. 277 | // 278 | // This is a little weird because ideally we'd want to submit to the 279 | // metrics-host : 280 | // 281 | // overseer.$testType.$testTarget.$key => value 282 | // 283 | // But of course the target might not be what we think it is for all 284 | // cases - i.e. A DNS test the target is the name of the nameserver rather 285 | // than the thing to lookup, which is the natural target. 286 | func (p *workerCmd) formatMetrics(tst test.Test, key string) string { 287 | 288 | prefix := "overseer.test." 289 | 290 | // 291 | // Special-case for the DNS-test 292 | // 293 | if tst.Type == "dns" { 294 | return (prefix + ".dns." + p.alphaNumeric(tst.Arguments["lookup"]) + "." + key) 295 | } 296 | 297 | // 298 | // Otherwise we have a normal test. 299 | // 300 | return (prefix + tst.Type + "." + p.alphaNumeric(tst.Target) + "." + key) 301 | } 302 | 303 | // runTest is really the core of our application, as it is responsible 304 | // for receiving a test to execute, executing it, and then issuing 305 | // the notification with the result. 306 | func (p *workerCmd) runTest(tst test.Test, opts test.Options) error { 307 | 308 | // Create a map for metric-recording. 309 | metrics := map[string]string{} 310 | 311 | // 312 | // Setup our local state. 313 | // 314 | testType := tst.Type 315 | testTarget := tst.Target 316 | 317 | // 318 | // Look for a suitable protocol handler 319 | // 320 | tmp := protocols.ProtocolHandler(testType) 321 | 322 | // 323 | // Each test will be executed for each address-family, so we need to 324 | // keep track of the IPs of the real test-target. 325 | // 326 | var targets []string 327 | 328 | // 329 | // If the first argument looks like an URI then get the host 330 | // out of it. 331 | // 332 | if strings.Contains(testTarget, "://") { 333 | u, err := url.Parse(testTarget) 334 | if err != nil { 335 | return err 336 | } 337 | testTarget = u.Hostname() 338 | } 339 | 340 | // Record the time before we lookup our targets IPs. 341 | timeA := time.Now() 342 | 343 | // Now resolve the target to IPv4 & IPv6 addresses. 344 | ips, err := net.LookupIP(testTarget) 345 | if err != nil { 346 | 347 | // 348 | // We failed to resolve the target, so we have to raise 349 | // a failure. But before we do that we need to sanitize 350 | // the test. 351 | // 352 | tst.Input = tst.Sanitize() 353 | 354 | // 355 | // Notify the world about our DNS-failure. 356 | // 357 | p.notify(tst, fmt.Errorf("failed to resolve name %s", testTarget)) 358 | 359 | // 360 | // Otherwise we're done. 361 | // 362 | fmt.Printf("WARNING: Failed to resolve %s for %s test!\n", testTarget, testType) 363 | return err 364 | } 365 | 366 | // Calculate the time the DNS-resolution took - in milliseconds. 367 | timeB := time.Now() 368 | duration := timeB.Sub(timeA) 369 | diff := fmt.Sprintf("%f", float64(duration)/float64(time.Millisecond)) 370 | 371 | // Record time in our metric hash 372 | metrics["overseer.dns."+p.alphaNumeric(testTarget)+".duration"] = diff 373 | 374 | // 375 | // We'll run the test against each of the resulting IPv4 and 376 | // IPv6 addresess - ignoring any IP-protocol which is disabled. 377 | // 378 | // Save the results in our `targets` array, unless disabled. 379 | // 380 | for _, ip := range ips { 381 | if ip.To4() != nil { 382 | if p.IPv4 { 383 | targets = append(targets, ip.String()) 384 | } 385 | } 386 | if ip.To16() != nil && ip.To4() == nil { 387 | if p.IPv6 { 388 | targets = append(targets, ip.String()) 389 | } 390 | } 391 | } 392 | 393 | // 394 | // Now for each target, run the test. 395 | // 396 | for _, target := range targets { 397 | 398 | // 399 | // Show what we're doing. 400 | // 401 | p.verbose(fmt.Sprintf("Running '%s' test against %s (%s)\n", testType, testTarget, target)) 402 | 403 | // 404 | // We'll repeat failing tests up to five times by default 405 | // 406 | attempt := 0 407 | maxAttempts := p.RetryCount 408 | 409 | // 410 | // If retrying is disabled then don't retry. 411 | // 412 | if !p.Retry { 413 | maxAttempts = attempt + 1 414 | } 415 | 416 | if tst.MaxRetries >= 0 { 417 | maxAttempts = tst.MaxRetries + 1 418 | } 419 | 420 | // 421 | // The result of the test. 422 | // 423 | var result error 424 | 425 | // 426 | // Record the start-time of the test. 427 | // 428 | timeA = time.Now() 429 | 430 | // 431 | // Start the count here for graphing execution attempts. 432 | // 433 | // We start at minus-one so that most case will show only 434 | // zero attempts total. 435 | // 436 | c := -1 437 | 438 | // 439 | // Prepare to repeat the test. 440 | // 441 | // We only repeat tests that fail, if the test passes then 442 | // it will only be executed once. 443 | // 444 | // This is designed to cope with transient failures, at a 445 | // cost that flapping services might be missed. 446 | // 447 | for attempt < maxAttempts { 448 | attempt++ 449 | c++ 450 | 451 | // 452 | // Run the test 453 | // 454 | result = tmp.RunTest(tst, target, opts) 455 | 456 | // 457 | // If the test passed then we're good. 458 | // 459 | if result == nil { 460 | p.verbose(fmt.Sprintf("\t[%d/%d] - Test passed.\n", attempt, maxAttempts)) 461 | 462 | // break out of loop 463 | attempt = maxAttempts + 1 464 | 465 | } else { 466 | 467 | // 468 | // The test failed. 469 | // 470 | // It will be repeated before a notifier 471 | // is invoked. 472 | // 473 | p.verbose(fmt.Sprintf("\t[%d/%d] Test failed: %s\n", attempt, maxAttempts, result.Error())) 474 | 475 | // 476 | // Sleep before retrying the failing test. 477 | // 478 | p.verbose(fmt.Sprintf("\t\tSleeping for %s before retrying\n", p.RetryDelay.String())) 479 | time.Sleep(p.RetryDelay) 480 | } 481 | } 482 | 483 | // 484 | // Now the test is complete we can record the time it 485 | // took to carry out, and the number of attempts it 486 | // took to complete. 487 | // 488 | timeB = time.Now() 489 | duration := timeB.Sub(timeA) 490 | diff = fmt.Sprintf("%f", float64(duration)/float64(time.Millisecond)) 491 | metrics[p.formatMetrics(tst, "duration")] = diff 492 | metrics[p.formatMetrics(tst, "attempts")] = fmt.Sprintf("%d", c) 493 | 494 | // 495 | // Post the result of the test to the notifier. 496 | // 497 | // Before we trigger the notification we need to 498 | // update the target to the thing we probed, which might 499 | // not necessarily be that which was originally submitted. 500 | // 501 | // i.e. "mail.steve.org.uk must run ssh" might become 502 | // "1.2.3.4 must run ssh" as a result of the DNS lookup. 503 | // 504 | // However because we might run the same test against 505 | // multiple hosts we need to do this with a copy so that 506 | // we don't lose the original target. 507 | // 508 | copy := tst 509 | copy.Target = target 510 | 511 | // 512 | // We also want to filter out any password which was found 513 | // on the input-line. 514 | // 515 | copy.Input = tst.Sanitize() 516 | 517 | // 518 | // Now we can trigger the notification with our updated 519 | // copy of the test. 520 | // 521 | p.notify(copy, result) 522 | } 523 | 524 | // 525 | // If we have a metric-host we can now submit each of the values 526 | // to it. 527 | // 528 | // There will be three results for each test: 529 | // 530 | // 1. The DNS-lookup-time of the target. 531 | // 532 | // 2. The time taken to run the test. 533 | // 534 | // 3. The number of attempts (retries, really) before the 535 | // test was completed. 536 | // 537 | if p._g != nil { 538 | for key, val := range metrics { 539 | v := os.Getenv("METRICS_VERBOSE") 540 | if v != "" { 541 | fmt.Printf("%s %s\n", key, val) 542 | } 543 | 544 | p._g.SimpleSend(key, val) 545 | } 546 | } 547 | 548 | return nil 549 | } 550 | 551 | // Entry-point. 552 | func (p *workerCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { 553 | 554 | // 555 | // Connect to the redis-host. 556 | // 557 | if p.RedisSocket != "" { 558 | p._r = redis.NewClient(&redis.Options{ 559 | Network: "unix", 560 | Addr: p.RedisSocket, 561 | Password: p.RedisPassword, 562 | DB: p.RedisDB, 563 | }) 564 | } else { 565 | p._r = redis.NewClient(&redis.Options{ 566 | Addr: p.RedisHost, 567 | Password: p.RedisPassword, 568 | DB: p.RedisDB, 569 | DialTimeout: p.RedisDialTimeout, 570 | }) 571 | } 572 | 573 | // 574 | // And run a ping, just to make sure it worked. 575 | // 576 | _, err := p._r.Ping().Result() 577 | if err != nil { 578 | fmt.Printf("Redis connection failed: %s\n", err.Error()) 579 | return subcommands.ExitFailure 580 | } 581 | 582 | // 583 | // Setup our metrics-connection, if enabled 584 | // 585 | p.MetricsFromEnvironment() 586 | 587 | // 588 | // Setup the options passed to each test, by copying our 589 | // global ones. 590 | // 591 | var opts test.Options 592 | opts.Verbose = p.Verbose 593 | opts.Timeout = p.Timeout 594 | 595 | // 596 | // Create a parser for our input 597 | // 598 | parse := parser.New() 599 | 600 | // 601 | // Wait for jobs, in a blocking-manner. 602 | // 603 | for { 604 | 605 | // 606 | // Get a job. 607 | // 608 | test, _ := p._r.BLPop(0, "overseer.jobs").Result() 609 | 610 | // 611 | // Parse it 612 | // 613 | // test[0] will be the list-name (i.e. "overseer.jobs") 614 | // 615 | // test[1] will be the value removed from the list. 616 | // 617 | if len(test) >= 1 { 618 | job, err := parse.ParseLine(test[1], nil) 619 | 620 | if err == nil { 621 | p.runTest(job, opts) 622 | } else { 623 | fmt.Printf("Error parsing job from queue: %s - %s\n", test[1], err.Error()) 624 | } 625 | } 626 | 627 | } 628 | 629 | // Not reached: 630 | // return subcommands.ExitSuccess 631 | } 632 | --------------------------------------------------------------------------------