├── .github └── workflows │ └── goreleaser.yml ├── .gitignore ├── .goreleaser.yml ├── .idea └── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── chroot_start_gofwd.sh ├── cmd.go ├── docker_build_image.sh ├── docker_start_gofwd.sh ├── duo-example.ini ├── duoauth.go ├── examples.go ├── geoip.go ├── go.mod ├── go.sum └── nics.go /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "*" 5 | 6 | jobs: 7 | build: 8 | name: GoReleaser build 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Check out code into the Go module directory 13 | uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Security Scan 18 | uses: securego/gosec@master 19 | with: 20 | # report triggers content trigger a failure using GH Security features 21 | args: '-no-fail -fmt sarif -out results.sarif ./...' 22 | 23 | - name: Upload SARIF file 24 | uses: github/codeql-action/upload-sarif@v1 25 | with: 26 | # Path to SARIF file relative to the root of the repository 27 | sarif_file: results.sarif 28 | 29 | - name: Set Up Go 30 | uses: actions/setup-go@v2 31 | with: 32 | go-version: '1.x' 33 | id: go 34 | 35 | - name: run GoReleaser 36 | uses: goreleaser/goreleaser-action@master 37 | with: 38 | version: latest 39 | args: release --clean -p 2 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | *.bat 17 | gofwd 18 | *.ini 19 | !duo-example.ini 20 | dist/ 21 | .??*~ 22 | results.sarif 23 | ssl/ 24 | .idea/ 25 | 26 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # goreleaser.yaml file for gofwd 2 | 3 | builds: 4 | - 5 | id: "alpha" 6 | goos: 7 | - linux 8 | - freebsd 9 | goarch: 10 | - amd64 11 | - arm 12 | goarm: 13 | - 7 14 | flags: 15 | - -tags=netgo 16 | ldflags: 17 | - -extldflags "-static" -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser 18 | env: 19 | - CGO_ENABLED=0 20 | ignore: 21 | - goos: darwin 22 | goarch: 386 23 | - goos: linux 24 | goarch: 386 25 | - goos: freebsd 26 | goarch: arm 27 | goarm: 7 28 | - goos: windows 29 | goarch: 386 30 | 31 | - 32 | id: "beta" 33 | goos: 34 | - darwin 35 | ldflags: 36 | - -s -w -extldflags "-sectcreate __TEXT __info_plist Info.plist" -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser 37 | env: 38 | - CGO_ENABLED=0 39 | ignore: 40 | - goos: darwin 41 | goarch: 386 42 | - goos: linux 43 | goarch: 386 44 | - goos: freebsd 45 | goarch: 386 46 | - goos: windows 47 | goarch: 386 48 | 49 | - 50 | id: "gamma" 51 | goos: 52 | - windows 53 | flags: 54 | - -tags=netgo 55 | ldflags: 56 | - -extldflags -static -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser -s -w 57 | env: 58 | - CGO_ENABLED=0 59 | ignore: 60 | - goos: darwin 61 | goarch: 386 62 | - goos: linux 63 | goarch: 386 64 | - goos: freebsd 65 | goarch: 386 66 | - goos: windows 67 | goarch: 386 68 | - goos: windows 69 | goarch: arm64 70 | hooks: 71 | post: 72 | - upx -9 "{{ .Path }}" 73 | 74 | 75 | archives: 76 | - 77 | name_template: >- 78 | {{- .ProjectName }}_ 79 | {{- .Version }}_ 80 | {{- if eq .Os "darwin" }}macos 81 | {{- else }}{{ .Os}}{{ end }}_ 82 | {{- .Arch }} 83 | {{- if .Arm }}v{{ .Arm }}{{ end }} 84 | {{- if .Mips }}_{{ .Mips }}{{ end }} 85 | format: tar.xz 86 | format_overrides: 87 | - goos: windows 88 | format: zip 89 | wrap_in_directory: true 90 | files: 91 | - LICENSE 92 | - README.md 93 | - duo-example.ini 94 | - docker_build_image.sh 95 | - docker_start_gofwd.sh 96 | - Dockerfile 97 | 98 | checksum: 99 | name_template: "{{ .ProjectName }}_{{ .Version }}--checksums.txt" 100 | release: 101 | draft: true 102 | changelog: 103 | sort: asc 104 | filters: 105 | exclude: 106 | - '^docs:' 107 | - '^test:' 108 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | ADD /gofwd / 3 | COPY ssl/ /etc/ssl/ 4 | ENTRYPOINT ["/gofwd"] 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 John Taylor 4 | 5 | Some code was adopted from https://github.com/kintoandar/fwd/ 6 | Copyright (c) 2016 Joel Bastos 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | gofwd: cmd.go duoauth.go examples.go geoip.go nics.go 2 | go build -tags netgo -ldflags '-extldflags "-static" -s -w' 3 | 4 | clean: 5 | rm gofwd 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gofwd 2 | 3 | 4 | ## Description 5 | 6 | `gofwd` is a cross-platform TCP port forwarder with Duo 2FA and Geographic IP integration. Its use case is to help protect services when using a VPN is not possible. Before a connection is forwarded, the remote IP address is geographically checked against city, region (state), and/or country. Distance (in miles) can also be used. If this condition is satisfied, a Duo 2FA request can then be sent to a mobile device. The connection is only forwarded after Duo has verified the user. 7 | 8 | Stand-alone, single-file executables for Windows, MacOS, and Linux can be downloaded from [Releases](https://github.com/jftuga/gofwd/releases). 9 | 10 | 11 | ## Use case 12 | 13 | The standard `Duo 2FA` Windows RDP implementation issues the the second factor request after a RDP client has connected and issued a valid username / password combination. The RDP port is always open to the Internet, which is a potential security issue. 14 | 15 | `gofwd` uses `Duo 2FA` *before* forwarding the RDP connection to an internal system. The big caveat with `gofwd` is that it only works well in single-user scenarios. However, being able to access your home lab remotely fits in well in this scenario. 16 | 17 | `gofwd` can also be used to protect SSH such as an AWS EC2 instance or Digital Ocean Droplet. 18 | 19 | Using both Remote Desktop to a home computer and remote SSH access work reliably well. On a home network, `gofwd` can be run on a Raspberry Pi that forwards the connection to a Windows 10 system once Duo authentication is successful. It can also run from within a Docker container for added security. 20 | 21 | The Geo-IP feature is nice because it limits who can initiate a `Duo 2FA` request. If someone tries to connect to your RDP port but is not within the defined geo-ip fence, then a `Duo 2FA` will not be sent to your phone. **Running on a non-standard port for Remote Desktop and SSH is recommended to limit the number of 2FA requests.** You can also explicitly allow and deny networks with the `-A` and `-D` command-line options. These overrides will bypass the geo-ip fence and 2FA. 22 | 23 | For example, you could use a 50 mile radius from your residence and you will probably not receive a `Duo 2FA` request from another person or bot. Be aware that some mobile operators might issue you an IP address that is further away than expected. The geo-ip fence can alternatively be defined based on city, region (state) and/or country or by using latitude, longitude coordinates. `gofwd` uses https://ipinfo.io/ to get this information in real time. **Since 24 | ipinfo provides 1,000 free requests per day (from the same IP address), no API 25 | key is therefore used.** 26 | 27 | **The overall elegance of this solution is that no additional software is needed. As long as you are within your predefined geo-ip location, have your phone, and know your hostname/ip address (and port number), then you will be able to access your system remotely.** 28 | 29 | ## Usage 30 | 31 | ``` 32 | usage: gofwd [] 33 | 34 | 35 | Flags: 36 | --[no-]help Show context-sensitive help (also try --help-long and --help-man). 37 | -i, --[no-]int list local interface IP addresses 38 | -f, --from=FROM from address:port - use '0.0.0.0' for all interfaces; use '_eth0' for the address portion to use this interface; also '_en0', '_Ethernet', etc. 39 | -t, --to=TO to address:port - address portion can also be DNS name 40 | --[no-]examples show command line example and then exit 41 | --[no-]version show version and then exit 42 | --city=CITY only accept incoming connections that originate from given city 43 | --region=REGION only accept incoming connections that originate from given region (eg: state) 44 | --country=COUNTRY only accept incoming connections that originate from given 2 letter country abbreviation 45 | -l, --loc=LOC only accept from within a geographic radius; format: LATITUDE,LONGITUDE (use with --distance) 46 | -d, --distance=DISTANCE only accept from within a given distance (in miles) 47 | -A, --allow=ALLOW allow from a comma delimited list of CIDR networks, bypassing geo-ip, duo 48 | -D, --deny=DENY deny from a comma delimited list of CIDR networks, disregarding geo-ip, duo 49 | --duo=DUO path to duo ini config file and duo username; format: filename:user (see --examples) 50 | --duo-cache-time=120 number of seconds to cache a successful Duo authentication (default is 120) 51 | -p, --[no-]private allow RFC1918 private addresses for the incoming (connecting) IP 52 | ``` 53 | 54 | 55 | ## Examples 56 | 57 | ``` 58 | +-------------------------------------------------------------------+-----------------------------------------------------------------------------+ 59 | | EXAMPLE | COMMAND | 60 | +-------------------------------------------------------------------+-----------------------------------------------------------------------------+ 61 | | get the local IP address *(run this first)*, eg: 1.2.3.4 | gofwd -i | 62 | | forward from a bastion host to an internal server | gofwd -f 1.2.3.4:22 -t 192.168.1.1:22 | 63 | | allow only if the remote IP is within 50 miles of this host | gofwd -f 1.2.3.4:22 -t 192.168.1.1:22 -d 50 | 64 | | allow only if remote IP is located in Denver, CO | gofwd -f 1.2.3.4:22 -t 192.168.1.1:22 -city Denver -region Colorado | 65 | | allow only if remote IP is located in Canada | gofwd -f 1.2.3.4:22 -t 192.168.1.1:22 -country CA | 66 | | allow only if remote IP is located within 75 miles of Atlanta, GA | gofwd -f 1.2.3.4:22 -t 192.168.1.1:22 -l 33.756529,-84.400996 -d 75 | 67 | | to get Latitude, Longitude use https://www.latlong.net/ | | 68 | | allow only for a successful two-factor duo auth for 'testuser' | gofwd -f 1.2.3.4:22 -t 192.168.1.1:22 --duo duo.ini:testuser | 69 | | allow only after both Geo IP and Duo are verified | gofwd -f 1.2.3.4:22 -t 192.168.1.1:22 --region Texas --duo duo.ini:testuser | 70 | | forward from any interface on port 22, allow RFC1918 to connect | gofwd -f 0.0.0.0:22 -t 192.168.1.1:22 -p | 71 | | forward from IP address bounded to eth0, allow RFC1918 to connect | gofwd -f _eth0:22 -t 192.168.1.1:2 -p | 72 | | forward from IP address bounded to eno1, allow RFC1918 to connect | gofwd -f _eno1:80 -t example.com:80 -p | 73 | +-------------------------------------------------------------------+-----------------------------------------------------------------------------+ 74 | ``` 75 | 76 | 77 | ## Two Factor Authentication (2FA) via Duo 78 | 79 | ### Basic Setup 80 | * https://duo.com/ 81 | * `gofwd` will only work with a single Duo user; therefore, only one person will be able to access the resource residing behind `gofwd`. 82 | * * Multiple `gofwd` instantiations can be used for different users. 83 | * * The .ini configuration file supports multiple users *(see below)*. 84 | * You will need to create a Duo account. The free tier supports 10 users. 85 | * Create a user and set their status to `Require two-factor authentication`. This is the default. 86 | * * You should also add an email address and phone number. 87 | * Install the Duo app on to your mobile device. 88 | 89 | ### Application Setup 90 | * On the Duo website, click on Applications. 91 | * Protect an Application 92 | * Select `Partner Auth API` 93 | * Under `Settings`, give your application a name such as `gofwd ssh` or `gofwd rdp`. 94 | * Create a `duo.ini` file with the **user name** as an ini section heading (the one that you just created under *Basic Setup*) 95 | * * Use the **Integration Key**, **Secret Key**, and **API HostName** to configure your .ini file. 96 | * * Example: [duo-example.ini](https://github.com/jftuga/gofwd/blob/master/duo-example.ini) 97 | 98 | ### Running with Duo 99 | * Add the ``--duo`` command line option 100 | * * See the *Examples* section to see how to run `gofwd` with duo authentication enabled 101 | 102 | ## Docker 103 | 104 | ### Example Helper Scripts 105 | * To build an image: [docker_build_image.sh](https://github.com/jftuga/gofwd/blob/master/docker_build_image.sh) 106 | * To run the built image: [docker_start_gofwd.sh](https://github.com/jftuga/gofwd/blob/master/docker_start_gofwd.sh) *(Edit first)* 107 | * * A `FROM` address of `0.0.0.0` should make `gofwd` listen on any interface. 108 | * To use `gofwd.exe` in Docker under Windows, consider using the [Microsoft Windows Nano Server](https://hub.docker.com/_/microsoft-windows-nanoserver) for containers. 109 | * To use `gofwd` in Docker under Linux, consider starting with [Scratch](https://hub.docker.com/_/scratch) for the container. 110 | 111 | ### Static Compilation - Docker Only 112 | * Your version of `gofwd` will need to be statically compiled: 113 | 114 | | Platform | Command 115 | ------------|-------- 116 | | windows | go build -tags netgo -ldflags "-extldflags -static" 117 | | linux/bsd | go build -tags netgo -ldflags '-extldflags "-static" -s -w' 118 | | linux | CGO_ENABLED=0 go build -ldflags='-s -w' 119 | | macos | go build -ldflags '-s -extldflags "-sectcreate __TEXT __info_plist Info.plist"' 120 | | android | go build -ldflags -s 121 | 122 | **NOTE:** *I have not been able to test all of these* 123 | 124 | ## Docker Example 125 | ``` 126 | docker run -d --restart unless-stopped -p 4567:4567 127 | -v /home/ec2-user/duo.ini:/duo.ini \ 128 | jftuga:gofwd:v050.1 -f 0.0.0.0:4567 -t 192.168.1.1:22 \ 129 | --duo /duo.ini:jftuga -l 39.858706,-104.670732 -d 80 130 | ``` 131 | 132 | | Explanation | Parameter | 133 | --------------|------------ 134 | | detach and run Docker in daemon mode | -d 135 | | restart container (on error) unless explicitly stopped | --restart unless-stopped 136 | | redirect external TCP port to internal TCP port | -p 4567:4567 137 | | ini file is located on the host here: `/home/ec2-user/duo.ini` | -v `/home/ec2-user/duo.ini`:/duo.ini 138 | | ini file is mounted inside the container here: `/duo.ini` | -v /home/ec2-user/duo.ini:/`duo.ini` 139 | | container name and tag | jftuga:gofwd:v050.1 140 | | external service is `0.0.0.0` on port `4567` | -f 0.0.0.0:4567 141 | | internal service is `192.168.1.1` on port `22` | -t 192.168.1.1:22 142 | | duo config file is mounted within the container | --duo `/duo.ini`:jftuga 143 | | duo user name | --duo /duo.ini:`jftuga` 144 | | location: use coordinates for Denver, CO | -l 39.858706,-104.670732 145 | | distance: `80 miles` from Denver | -d 80 146 | 147 | 148 | **Note:** if you are running in a NAT environment, such as AWS, then you will need to include the `-p` option to allow RFC1918 private IPv4 addresses. 149 | 150 | 151 | ## chroot environment 152 | * Please review [chroot_start_gofwd.sh](https://github.com/jftuga/gofwd/blob/master/chroot_start_gofwd.sh) 153 | 154 | 155 | ## Acknowledgments 156 | * Some code was adopted from [The little forwarder that could](https://github.com/kintoandar/fwd/) 157 | * `gofwd`uses https://ipinfo.io/ to get Geo IP information in real time 158 | * * They provide 1,000 free, non-authenticated requests per day. 159 | 160 | Other Go code used: 161 | 162 | * Logging: https://go.uber.org/zap 163 | * Command line arguments: https://gopkg.in/alecthomas/kingpin.v2 164 | * Output tables: https://github.com/olekukonko/tablewriter 165 | * Ini file: https://gopkg.in/ini.v1 166 | * Network interfaces: https://github.com/jftuga/nics 167 | * IP info: https://github.com/jftuga/ipinfo 168 | * Duo API: https://github.com/duosecurity/duo_api_golang/authapi 169 | 170 | ## Future Work 171 | * [Run the Docker daemon as a non-root user - Rootless Mode](https://docs.docker.com/engine/security/rootless/) 172 | * [Docker Tips: Running a Container With a Non Root User](https://medium.com/better-programming/running-a-container-with-a-non-root-user-e35830d1f42a) 173 | -------------------------------------------------------------------------------- /chroot_start_gofwd.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | PGM="gofwd" 5 | DUOINI="duo.ini" 6 | DUOUSER="ChangeMe" 7 | CHROOT="${HOME}/gofwd_chroot" 8 | FROM="1.2.3.4:4567" 9 | TO="192.168.1.1:22" 10 | 11 | if [ ! -e "${DUOINI}" ] ; then 12 | echo "File not found: ${DUOINI}" 13 | echo "Exiting." 14 | fi 15 | 16 | # statically compile gofwd, linux command: 17 | go build -tags netgo -ldflags '-extldflags "-static"' 18 | echo "Checking for static build:" 19 | set +e 20 | ldd ${PGM} 21 | set -e 22 | echo 23 | 24 | if [ ! -e "${PGM}" ] ; then 25 | echo "File not found: ${PGM}" 26 | echo "Exiting." 27 | fi 28 | 29 | if [ ! -e ${CHROOT}/etc/pki/ca-trust/extracted ] ; then 30 | mkdir -p ${CHROOT}/etc/pki/ca-trust/extracted 31 | cp ${PGM} ${CHROOT} 32 | cp ${DUOINI} ${CHROOT} 33 | cp -a /etc/resolv.conf ${CHROOT}/etc/ 34 | cp -a /etc/pki/ca-trust/extracted/ ${CHROOT}/etc/pki/ca-trust/ 35 | fi 36 | 37 | # Add the -p switch, if you are running in a NAT environment such as AWS 38 | sudo chroot ${CHROOT} /${PGM} -f ${FROM} -t ${TO} --duo /${DUOINI}:${DUOUSER} 39 | 40 | -------------------------------------------------------------------------------- /cmd.go: -------------------------------------------------------------------------------- 1 | /* 2 | cmd.go 3 | -John Taylor 4 | April 11 2020 5 | 6 | A cross-platform TCP port forwarder with Duo and GeoIP integration 7 | 8 | The following functions were adopted from: https://github.com/kintoandar/fwd/ 9 | errHandler, signalHandler, fwd, tcpStart 10 | 11 | */ 12 | 13 | package main 14 | 15 | import ( 16 | "errors" 17 | "fmt" 18 | "io" 19 | "net" 20 | "os" 21 | "os/signal" 22 | "strings" 23 | "syscall" 24 | "time" 25 | 26 | "github.com/alecthomas/kingpin/v2" 27 | "github.com/olekukonko/tablewriter" 28 | "go.uber.org/zap" 29 | "go.uber.org/zap/zapcore" 30 | ) 31 | 32 | const version = "0.7.3" 33 | 34 | var ( 35 | list = kingpin.Flag("int", "list local interface IP addresses").Short('i').Bool() 36 | from = kingpin.Flag("from", "from address:port - use '0.0.0.0' for all interfaces; use '_eth0' for the address portion to use this interface; also '_en0', '_Ethernet', etc.").Short('f').String() 37 | to = kingpin.Flag("to", "to address:port - address portion can also be DNS name").Short('t').String() 38 | examples = kingpin.Flag("examples", "show command line example and then exit").Bool() 39 | versionOnly = kingpin.Flag("version", "show version and then exit").Bool() 40 | 41 | city = kingpin.Flag("city", "only accept incoming connections that originate from given city").String() 42 | region = kingpin.Flag("region", "only accept incoming connections that originate from given region (eg: state)").String() 43 | country = kingpin.Flag("country", "only accept incoming connections that originate from given 2 letter country abbreviation").String() 44 | loc = kingpin.Flag("loc", "only accept from within a geographic radius; format: LATITUDE,LONGITUDE (use with --distance)").Short('l').String() 45 | distance = kingpin.Flag("distance", "only accept from within a given distance (in miles)").Short('d').Float64() 46 | allowCIDR = kingpin.Flag("allow", "allow from a comma delimited list of CIDR networks, bypassing geo-ip, duo").Short('A').String() 47 | denyCIDR = kingpin.Flag("deny", "deny from a comma delimited list of CIDR networks, disregarding geo-ip, duo").Short('D').String() 48 | 49 | duo = kingpin.Flag("duo", "path to duo ini config file and duo username; format: filename:user (see --examples)").String() 50 | duoAuthCacheTime = kingpin.Flag("duo-cache-time", "number of seconds to cache a successful Duo authentication (default is 120)").Default("120").Int64() 51 | private = kingpin.Flag("private", "allow RFC1918 private addresses for the incoming (connecting) IP").Short('p').Bool() 52 | ) 53 | 54 | var logger *zap.SugaredLogger 55 | 56 | func errHandler(err error, fatal bool) { 57 | if err != nil { 58 | logger.Warnf(err.Error()) 59 | if fatal { 60 | os.Exit(1) 61 | } 62 | } 63 | } 64 | 65 | func signalHandler() { 66 | sigs := make(chan os.Signal, 1) 67 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 68 | go func() { 69 | sig := <-sigs 70 | logger.Errorf("Execution stopped by %s", sig) 71 | os.Exit(0) 72 | }() 73 | } 74 | 75 | func loggingHandler() { 76 | cfg := zap.Config{ 77 | Encoding: "console", 78 | OutputPaths: []string{"stderr"}, 79 | EncoderConfig: zapcore.EncoderConfig{ 80 | MessageKey: "message", 81 | TimeKey: "time", 82 | EncodeTime: zapcore.ISO8601TimeEncoder, 83 | LevelKey: "level", 84 | EncodeLevel: zapcore.CapitalLevelEncoder, 85 | }, 86 | } 87 | cfg.Level = zap.NewAtomicLevelAt(zapcore.DebugLevel) 88 | loggerPlain, _ := cfg.Build() 89 | logger = loggerPlain.Sugar() 90 | } 91 | 92 | // IsPrivateIPv4 https://gist.github.com/r4um/5986319 93 | func isPrivateIPv4(s string) bool { 94 | var rfc1918 []string = []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"} 95 | var ip net.IP = net.ParseIP(s) 96 | 97 | if ip == nil { 98 | return false 99 | } 100 | 101 | for _, cidr := range rfc1918 { 102 | _, net, _ := net.ParseCIDR(cidr) 103 | if net.Contains(ip) { 104 | return true 105 | } 106 | } 107 | 108 | return false 109 | } 110 | 111 | func fwd(src net.Conn, remote string, proto string) { 112 | dst, err := net.Dial(proto, remote) 113 | errHandler(err, false) 114 | if err != nil { 115 | return 116 | } 117 | go func() { 118 | _, err = io.Copy(src, dst) 119 | errHandler(err, false) 120 | }() 121 | go func() { 122 | _, err = io.Copy(dst, src) 123 | errHandler(err, false) 124 | }() 125 | } 126 | 127 | func validateCIDRList(all *string) (string, bool) { 128 | networks := strings.Split(*all, ",") 129 | for _, cidr := range networks { 130 | _, _, err := net.ParseCIDR(cidr) 131 | if err != nil { 132 | return cidr, false 133 | } 134 | } 135 | return "", true 136 | } 137 | 138 | func ipIsInCIDR(s string, cidr *string) bool { 139 | ip := net.ParseIP(s) 140 | networks := strings.Split(*cidr, ",") 141 | for _, cidr := range networks { 142 | _, ipv4Net, err := net.ParseCIDR(cidr) 143 | if err != nil { 144 | logger.Warnf("Invalid CIDR network: %s", err) 145 | return false 146 | } 147 | if ipv4Net.Contains(ip) { 148 | return true 149 | } 150 | } 151 | return false 152 | } 153 | 154 | func tcpStart(from string, to string, localGeoIP ipInfoResult, restrictionsGeoIP ipInfoResult, duoCred duoCredentials, duoAuthCacheTime int64, allowPrivateIP bool) { 155 | proto := "tcp" 156 | 157 | fromAddress, err := net.ResolveTCPAddr(proto, from) 158 | errHandler(err, true) 159 | 160 | toAddress, err := net.ResolveTCPAddr(proto, to) 161 | errHandler(err, true) 162 | 163 | listener, err := net.ListenTCP(proto, fromAddress) 164 | errHandler(err, true) 165 | defer listener.Close() 166 | 167 | logger.Infof("[%v] Forwarding to [%s] [%s]", fromAddress, proto, toAddress) 168 | for { 169 | src, err := listener.Accept() 170 | errHandler(err, true) 171 | 172 | slots := strings.Split(src.RemoteAddr().String(), ":") 173 | remoteIP := slots[0] 174 | logger.Infof("[%v] Incoming connection initiated", remoteIP) 175 | remoteGeoIP, err := getIPInfo(remoteIP) 176 | if "127.0.0.1" != remoteIP { 177 | if allowPrivateIP && isPrivateIPv4(remoteIP) { 178 | logger.Infof("[%v] allowing private IPv4 address, skip lat,lon checks", remoteIP) 179 | err = nil 180 | } 181 | if err != nil { 182 | logger.Warnf("%s", err) 183 | continue 184 | } 185 | } 186 | 187 | if len(*denyCIDR) > 0 && ipIsInCIDR(remoteIP, denyCIDR) { 188 | logger.Infof("[%v] DENIED; Explicitly Denied by -D option", src.RemoteAddr()) 189 | continue 190 | } 191 | 192 | if len(*allowCIDR) > 0 && ipIsInCIDR(remoteIP, allowCIDR) { 193 | logger.Infof("[%v] ESTABLISHED; Explicitly Allowed by -A option", src.RemoteAddr()) 194 | go fwd(src, to, proto) 195 | continue 196 | } 197 | invalidLocation, distanceCalc := validateLocation(localGeoIP, remoteGeoIP, restrictionsGeoIP) 198 | if "127.0.0.1" != remoteIP { 199 | if allowPrivateIP && isPrivateIPv4(remoteIP) { 200 | logger.Infof("[%v] allowing private IPv4 address, skip loc,dist checks", remoteIP) 201 | invalidLocation = "" 202 | } 203 | if len(invalidLocation) > 0 { 204 | logger.Warnf("%s %s", invalidLocation, distanceCalc) 205 | // do not attempt: listener.Close() 206 | continue 207 | } 208 | } 209 | 210 | if len(duoCred.name) > 0 { 211 | var allowed bool 212 | lastAuthTime := "(never)" 213 | cachedDuoAuth := "" 214 | if duoCred.lastAuthTime > 0 { 215 | lastAuthTime = fmt.Sprintf("%v", time.Unix(duoCred.lastAuthTime, 0)) 216 | } 217 | logger.Infof("[%s] last auth time: %v", duoCred.name, lastAuthTime) 218 | 219 | current := time.Now().Unix() 220 | diff := current - duoCred.lastAuthTime 221 | if diff <= duoAuthCacheTime && duoCred.lastIP == remoteIP { 222 | logger.Infof("[%s] last auth time was only %v seconds ago, will not ask again", duoCred.name, diff) 223 | cachedDuoAuth = " CACHED" 224 | } else { 225 | allowed, err = duoCheck(duoCred) 226 | if err != nil { 227 | errHandler(err, false) 228 | logger.Warnf("[%v] DENIED; Duo Auth for user: %s", src.RemoteAddr(), duoCred.name) 229 | continue 230 | } 231 | if !allowed { 232 | errHandler(errors.New("Duo Auth returned false"), false) 233 | logger.Warnf("[%v] DENIED; Duo Auth for user: %s", src.RemoteAddr(), duoCred.name) 234 | continue 235 | } 236 | duoCred.lastAuthTime = time.Now().Unix() 237 | duoCred.lastIP = remoteIP 238 | } 239 | logger.Infof("[%v] ACCEPTED%s; Duo Auth for user: %s", src.RemoteAddr(), cachedDuoAuth, duoCred.name) 240 | } 241 | 242 | logger.Infof("[%v] ESTABLISHED; %s", src.RemoteAddr(), distanceCalc) 243 | go fwd(src, to, proto) 244 | } 245 | } 246 | 247 | func showExamples() { 248 | examples := getExamples() 249 | table := tablewriter.NewWriter(os.Stdout) 250 | table.SetAutoWrapText(false) 251 | table.SetHeader([]string{"Example", "Command"}) 252 | 253 | for _, entry := range examples { 254 | table.Append(entry) 255 | } 256 | table.Render() 257 | } 258 | 259 | func getDuoConfig(duoFile string, duoUser string, duoAuthCacheTime int64) duoCredentials { 260 | duoCred, err := duoReadConfig(duoFile, duoUser) 261 | if err != nil { 262 | errHandler(err, true) 263 | os.Exit(1) 264 | } 265 | logger.Infof("Duo auth activated for user: %s; cache time: %v seconds", duoCred.name, duoAuthCacheTime) 266 | return duoCred 267 | } 268 | 269 | func main() { 270 | loggingHandler() 271 | signalHandler() 272 | 273 | kingpin.Parse() 274 | 275 | if *versionOnly { 276 | fmt.Fprintf(os.Stderr, "gofwd, version %s\n", version) 277 | fmt.Fprintf(os.Stderr, "https://github.com/jftuga/gofwd\n\n") 278 | os.Exit(0) 279 | } 280 | 281 | if *list { 282 | nics() 283 | os.Exit(0) 284 | } 285 | 286 | if *examples { 287 | showExamples() 288 | os.Exit(0) 289 | } 290 | 291 | if !(len(*from) >= 9 && len(*to) >= 9) { 292 | kingpin.FatalUsage("Both --from and --to are mandatory") 293 | os.Exit(1) 294 | } 295 | 296 | if *from == *to { 297 | kingpin.FatalUsage("--from and --to can not be identical") 298 | os.Exit(1) 299 | } 300 | 301 | if !strings.Contains(*from, ":") { 302 | kingpin.FatalUsage("--from does not contain a ':' character") 303 | os.Exit(1) 304 | } 305 | 306 | if !strings.Contains(*to, ":") { 307 | kingpin.FatalUsage("--to does not contain a ':' character") 308 | os.Exit(1) 309 | } 310 | 311 | if strings.HasPrefix(*from, "_") { 312 | pos := strings.Index(*from, ":") 313 | adapter := (*from)[1:pos] 314 | port := (*from)[pos+1:] 315 | address, err := getSpecificNic(adapter) 316 | if err != nil { 317 | kingpin.FatalUsage(err.Error()) 318 | } 319 | *from = address + ":" + port 320 | } 321 | 322 | if len(*loc) > 0 && 0 == *distance { 323 | kingpin.FatalUsage("--distance must be used with --loc") 324 | os.Exit(1) 325 | } 326 | 327 | if *distance > 0 && (len(*city) > 0 || len(*region) > 0 || len(*country) > 0) { 328 | kingpin.FatalUsage("--distance can not be used with any of these: city, region, country; Instead, use --loc with --distance") 329 | os.Exit(1) 330 | } 331 | 332 | var badCIDR string 333 | var ok bool 334 | if len(*denyCIDR) > 0 { 335 | badCIDR, ok = validateCIDRList(denyCIDR) 336 | if !ok { 337 | kingpin.FatalUsage("Invalid CIDR given for -D option: %s\n", badCIDR) 338 | os.Exit(1) 339 | } 340 | } 341 | 342 | if len(*allowCIDR) > 0 { 343 | badCIDR, ok = validateCIDRList(allowCIDR) 344 | if !ok { 345 | kingpin.FatalUsage("Invalid CIDR given for -A option: %s\n", badCIDR) 346 | os.Exit(1) 347 | } 348 | } 349 | 350 | logger.Infof("gofwd, version %v started", version) 351 | logger.Info("from: [%s]", *from) 352 | logger.Info(" to: [%s]", *to) 353 | 354 | var duoCred duoCredentials 355 | if len(*duo) > 0 { 356 | slots := strings.Split(*duo, ":") 357 | if len(slots) != 2 { 358 | kingpin.FatalUsage("Invalid duo filename / user combination") 359 | os.Exit(1) 360 | } 361 | duoCred = getDuoConfig(slots[0], slots[1], *duoAuthCacheTime) 362 | } 363 | 364 | var restrictionsGeoIP ipInfoResult 365 | restrictionsGeoIP.City = *city 366 | restrictionsGeoIP.Region = *region 367 | restrictionsGeoIP.Country = *country 368 | restrictionsGeoIP.Distance = *distance 369 | restrictionsGeoIP.Loc = *loc 370 | logger.Infof("Geo IP Restrictions: %v", restrictionsGeoIP) 371 | 372 | var localGeoIP ipInfoResult 373 | var err error 374 | localGeoIP, err = getIPInfo("") 375 | if err != nil { 376 | errHandler(err, true) 377 | os.Exit(1) 378 | } 379 | 380 | tcpStart(*from, *to, localGeoIP, restrictionsGeoIP, duoCred, *duoAuthCacheTime, *private) 381 | } 382 | -------------------------------------------------------------------------------- /docker_build_image.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | function build() { 6 | echo 7 | echo "Building binary: ${GF}" 8 | echo 9 | if [[ -e ${GF} ]] ; then 10 | echo 11 | echo "Using existing binary: ${GF}" 12 | echo 13 | ls -l ${GF} 14 | return 15 | fi 16 | CGO_ENABLED=0 go build -ldflags="-s -w" 17 | if [[ ! -e ${GF} ]] ; then 18 | echo 19 | echo "Unable to build file: ${GF}" 20 | echo "Build aborted" 21 | echo 22 | exit 1 23 | else 24 | echo 25 | ls -l ${GF} 26 | fi 27 | } 28 | 29 | function bundle() { 30 | echo 31 | echo "Generating file: ${SC}/${BUND}" 32 | echo 33 | 34 | cd ${SC} 35 | curl -LOs https://raw.githubusercontent.com/curl/curl/master/scripts/mk-ca-bundle.pl 36 | perl mk-ca-bundle.pl 37 | if [[ ! -e ${BUND} ]] ; then 38 | echo 39 | echo "Unable to create file: ${BUND}" 40 | echo "Build aborted" 41 | echo 42 | exit 1 43 | else 44 | rm -f certdata.txt mk-ca-bundle.pl 45 | chmod 644 ${BUND} 46 | echo 47 | ls -l ${BUND} 48 | cd - 49 | fi 50 | } 51 | 52 | function image() { 53 | echo 54 | echo Creating Docker Image: ${IMG} 55 | echo 56 | sleep 1 57 | docker build -t ${IMG} -f Dockerfile . 58 | docker image ls ${IMG} 59 | echo 60 | echo 61 | echo Now, use ${IMG} within the docker_start_gofwd.sh script, 62 | echo which will need editing for your deployment. 63 | echo 64 | } 65 | 66 | if [ $# -eq 0 ] ; then 67 | echo give Version Tag on cmd line 68 | echo example: v052.1 69 | exit 1 70 | fi 71 | 72 | IMG=$1 73 | BUND="ca-bundle.crt" 74 | SC="ssl/certs/" 75 | GF="gofwd" 76 | #GOOS="linux" 77 | #GOARCH="amd64" 78 | if [[ ! -e ${SC} ]] ; then 79 | mkdir -p -m 755 ${SC} 80 | fi 81 | 82 | build 83 | bundle 84 | image 85 | 86 | -------------------------------------------------------------------------------- /docker_start_gofwd.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Start gofwd in a docker container 4 | 5 | # You will first need to create a Docker Image with the "docker_build_image.sh" script 6 | # Then, set the IMG variable 7 | 8 | # This script can be run at reboot by adding the following into your crontab: 9 | # (to edit, run: crontab -e) 10 | # @reboot $HOME/bin/docker_start_gofwd.sh 11 | 12 | # After launch, you can view logs: 13 | # 1) Get the container ID: docker container ps 14 | # 2) View the logs: docker logs -f 15 | 16 | IMG=gofwd:ChangeMe 17 | LOG=${HOME}/.gofwd.log 18 | DUOINI=${HOME}/duo.ini 19 | DUOUSR=ChangeMe 20 | EXTERNPORT=4567 21 | FROM=0.0.0.0:${EXTERNPORT} 22 | TO=192.168.1.1:22 23 | LOCATION=39.858706,-104.670732 24 | DIST=80 25 | RESTART=on-failure:10 26 | 27 | #echo "Starting ${IMG} at `date`" 28 | echo "" >> ${LOG} 2>&1 29 | echo "========================================================" >> ${LOG} 2>&1 30 | echo "Starting ${IMG} as `date`" >> ${LOG} 2>&1 31 | echo "========================================================" >> ${LOG} 2>&1 32 | 33 | docker run -d --restart=${RESTART} \ 34 | -p ${EXTERNPORT}:${EXTERNPORT} -v ${DUOINI}:/duo.ini \ 35 | ${IMG} -f ${FROM} -t ${TO} -l ${LOCATION} -d ${DIST} -p --duo /duo.ini:${DUOUSR} 36 | 37 | docker container ps >> ${LOG} 2>&1 38 | 39 | -------------------------------------------------------------------------------- /duo-example.ini: -------------------------------------------------------------------------------- 1 | [testuser] 2 | type=duo 3 | integration=iiiiiiiiiiiiiiiiiiii 4 | secret=ssssssssssssssssssssssssssssssssssssssss 5 | hostname=CHANGE-ME.duosecurity.com 6 | 7 | [testuser2] 8 | type=duo 9 | integration=Different-iiiiiiiiiii 10 | secret=Different-ssssssssssssssssssssssssssssss 11 | hostname=CHANGE-ME.duosecurity.com 12 | -------------------------------------------------------------------------------- /duoauth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | 7 | duoapi "github.com/duosecurity/duo_api_golang" 8 | "github.com/duosecurity/duo_api_golang/authapi" 9 | "gopkg.in/ini.v1" 10 | ) 11 | 12 | type duoCredentials struct { 13 | name string 14 | integration string 15 | secret string 16 | hostname string 17 | lastAuthTime int64 18 | lastIP string 19 | } 20 | 21 | func duoReadConfig(cfgFile string, name string) (duoCredentials, error) { 22 | var duoCred duoCredentials 23 | var err error 24 | cfg, err := ini.Load(cfgFile) 25 | if err != nil { 26 | err = fmt.Errorf("Fail to read file: %v", err) 27 | return duoCred, err 28 | } 29 | 30 | sectionType := cfg.Section(name).Key("type").String() 31 | if "duo" == sectionType { 32 | duoCred.name = name 33 | duoCred.integration = cfg.Section(name).Key("integration").String() 34 | duoCred.secret = cfg.Section(name).Key("secret").String() 35 | duoCred.hostname = cfg.Section(name).Key("hostname").String() 36 | } 37 | 38 | if 0 == len(duoCred.name) { 39 | err = fmt.Errorf("[%s] Duo Config: Invalid user name", name) 40 | } else if len(duoCred.integration) < 15 { 41 | err = fmt.Errorf("[%s] Duo Config: Invalid integration", name) 42 | } else if len(duoCred.secret) < 15 { 43 | err = fmt.Errorf("[%s] Duo Config: Invalid secret", name) 44 | } else if len(duoCred.hostname) < 15 { 45 | err = fmt.Errorf("[%s] Duo Config: Invalid hostname", name) 46 | } 47 | 48 | return duoCred, err 49 | } 50 | 51 | func duoCheck(duoCred duoCredentials) (bool, error) { 52 | var err error 53 | 54 | duoClient := duoapi.NewDuoApi(duoCred.integration, duoCred.secret, duoCred.hostname, "go-client") 55 | if duoClient == nil { 56 | err = fmt.Errorf("Error #100: Failed to create new Duo Api") 57 | return false, err 58 | } 59 | duoAuthClient := authapi.NewAuthApi(*duoClient) 60 | check, err := duoAuthClient.Check() 61 | if err != nil { 62 | err = fmt.Errorf("Error #150: %s", err) 63 | return false, err 64 | } 65 | if check == nil { 66 | err = fmt.Errorf("Error #155: 'check' is nil") 67 | return false, err 68 | } 69 | 70 | var msg, detail string 71 | if check.StatResult.Message != nil { 72 | msg = *check.StatResult.Message 73 | } 74 | if check.StatResult.Message_Detail != nil { 75 | detail = *check.StatResult.Message_Detail 76 | } 77 | if check.StatResult.Stat != "OK" { 78 | err = fmt.Errorf("Error #180: Could not connect to Duo: %q (%q)", msg, detail) 79 | return false, err 80 | } 81 | 82 | duoUser := duoCred.name 83 | options := []func(*url.Values){authapi.AuthUsername(duoUser)} 84 | options = append(options, authapi.AuthDevice("auto")) 85 | result, err := duoAuthClient.Auth("push", options...) 86 | if err != nil { 87 | err = fmt.Errorf("Error #200: %s", err) 88 | return false, err 89 | } 90 | if result == nil { 91 | err = fmt.Errorf("Error #220: 'result' is nil") 92 | return false, err 93 | } 94 | 95 | if false { 96 | fmt.Println("result:", result) 97 | fmt.Println("-----------------------") 98 | fmt.Println("result.StatResult:", result.StatResult) 99 | fmt.Println("-----------------------") 100 | fmt.Println("result.Response:", result.Response) 101 | } 102 | 103 | success := false 104 | if result.StatResult.Stat == "OK" && result.Response.Result == "allow" { 105 | success = true 106 | } 107 | 108 | if !success { 109 | err = fmt.Errorf("Error #230: 'success' is false") 110 | return false, err 111 | } 112 | 113 | return true, nil 114 | } 115 | -------------------------------------------------------------------------------- /examples.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func getExamples() [][]string { 4 | examples := [][]string{} 5 | 6 | examples = append(examples, []string{`get the local IP address *(run this first)*, eg: 1.2.3.4`, `gofwd -i `}) 7 | examples = append(examples, []string{`forward from a bastion host to an internal server`, `gofwd -f 1.2.3.4:22 -t 192.168.1.1:22`}) 8 | examples = append(examples, []string{`allow only if the remote IP is within 50 miles of this host`, `gofwd -f 1.2.3.4:22 -t 192.168.1.1:22 -d 50`}) 9 | examples = append(examples, []string{`allow only if remote IP is located in Denver, CO`, `gofwd -f 1.2.3.4:22 -t 192.168.1.1:22 -city Denver -region Colorado`}) 10 | examples = append(examples, []string{`allow only if remote IP is located in Canada`, `gofwd -f 1.2.3.4:22 -t 192.168.1.1:22 -country CA`}) 11 | examples = append(examples, []string{`allow only if remote IP is located within 75 miles of Atlanta, GA`, `gofwd -f 1.2.3.4:22 -t 192.168.1.1:22 -l 33.756529,-84.400996 -d 75`}) 12 | examples = append(examples, []string{` to get Latitude, Longitude use https://www.latlong.net/`, ` `}) 13 | examples = append(examples, []string{`allow only for a successful two-factor duo auth for 'testuser'`, `gofwd -f 1.2.3.4:22 -t 192.168.1.1:22 --duo duo.ini:testuser`}) 14 | examples = append(examples, []string{`allow only after both Geo IP and Duo are verified`, `gofwd -f 1.2.3.4:22 -t 192.168.1.1:22 --region Texas --duo duo.ini:testuser`}) 15 | examples = append(examples, []string{`forward from any interface on port 22, allow RFC1918 to connect`, `gofwd -f 0.0.0.0:22 -t 192.168.1.1:22 -p`}) 16 | examples = append(examples, []string{`forward from IP address bounded to eth0, allow RFC1918 to connect`, `gofwd -f _eth0:22 -t 192.168.1.1:22 -p`}) 17 | examples = append(examples, []string{`forward from IP address bounded to eno1, allow RFC1918 to connect`, `gofwd -f _eno1:80 -t example.com:80 -p`}) 18 | 19 | return examples 20 | } 21 | -------------------------------------------------------------------------------- /geoip.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "math" 8 | "net/http" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | // This is the format returned by: https://ipinfo.io/w.x.y.z/json 14 | type ipInfoResult struct { 15 | IP string 16 | Hostname string 17 | City string 18 | Region string 19 | Country string 20 | Loc string 21 | Postal string 22 | Org string 23 | Distance float64 24 | ErrMsg error 25 | } 26 | 27 | /* 28 | getIpInfo issues a web query to ipinfo.io 29 | The JSON result is converted to an ipInfoResult struct 30 | Args: 31 | 32 | ip: an IPv4 address 33 | 34 | Returns: 35 | 36 | an ipInfoResult struct containing the information returned by the service 37 | */ 38 | func getIPInfo(ip string) (ipInfoResult, error) { 39 | var obj ipInfoResult 40 | 41 | api := "/json" 42 | if 0 == len(ip) { 43 | api = "json" 44 | } 45 | url := "https://ipinfo.io/" + ip + api 46 | // #nosec G107 -- ip has been validated 47 | resp, err := http.Get(url) 48 | if err != nil { 49 | return obj, err 50 | } 51 | defer resp.Body.Close() 52 | 53 | body, err := ioutil.ReadAll(resp.Body) 54 | if err != nil { 55 | return obj, err 56 | } 57 | 58 | if strings.Contains(string(body), "Rate limit exceeded") { 59 | err := fmt.Errorf("Error for '%s', %s", url, string(body)) 60 | empty := ipInfoResult{} 61 | return empty, err 62 | } 63 | 64 | err = json.Unmarshal(body, &obj) 65 | if err != nil { 66 | empty := ipInfoResult{} 67 | return empty, err 68 | } 69 | 70 | return obj, nil 71 | } 72 | 73 | func validateLocation(localGeoIP ipInfoResult, remoteGeoIP ipInfoResult, restrictionsGeoIP ipInfoResult) (string, string) { 74 | var distanceCalc string 75 | 76 | if 0 == len(localGeoIP.Loc) { 77 | return fmt.Sprintf("localGeoIP '%s' does not have lat,lon", localGeoIP.IP), "" 78 | } 79 | if 0 == len(remoteGeoIP.Loc) { 80 | return fmt.Sprintf("remoteGeoIP '%s' does not have lat,lon", remoteGeoIP.IP), "" 81 | } 82 | 83 | var miles float64 84 | var lat1, lon1, lat2, lon2 float64 85 | var err error 86 | 87 | if restrictionsGeoIP.Distance > 0 { 88 | lat1, lon1, err = latlon2coord(remoteGeoIP.Loc) 89 | if err != nil { 90 | return "", "remote-LatLon coordinates" 91 | } 92 | 93 | if len(restrictionsGeoIP.Loc) > 0 { // location and distance 94 | lat2, lon2, err = latlon2coord(restrictionsGeoIP.Loc) 95 | if err != nil { 96 | return "restrictions.GeoIP-LatLon coordinates", "" 97 | } 98 | } else { // distance only 99 | lat2, lon2, err = latlon2coord(localGeoIP.Loc) 100 | if err != nil { 101 | return "localGeoIP-LatLon coordinates", "" 102 | } 103 | } 104 | miles = HaversineDistance(lat1, lon1, lat2, lon2) 105 | milesDiff := math.Abs(miles - restrictionsGeoIP.Distance) 106 | distanceCalc = fmt.Sprintf("Current Dist: %.2f; Maximum Dist: %.2f; Diff: %.2f", miles, restrictionsGeoIP.Distance, milesDiff) 107 | } 108 | 109 | mismatch := "" 110 | if len(restrictionsGeoIP.City) > 0 && strings.ToUpper(restrictionsGeoIP.City) != strings.ToUpper(remoteGeoIP.City) { 111 | mismatch = fmt.Sprintf("[%s] Forbidden City: %s", remoteGeoIP.IP, remoteGeoIP.City) 112 | } else if len(restrictionsGeoIP.Region) > 0 && strings.ToUpper(restrictionsGeoIP.Region) != strings.ToUpper(remoteGeoIP.Region) { 113 | mismatch = fmt.Sprintf("[%s] Forbidden Region: %s", remoteGeoIP.IP, remoteGeoIP.Region) 114 | } else if len(restrictionsGeoIP.Country) > 0 && strings.ToUpper(restrictionsGeoIP.Country) != strings.ToUpper(remoteGeoIP.Country) { 115 | mismatch = fmt.Sprintf("[%s] Forbidden Country: %s", remoteGeoIP.IP, remoteGeoIP.Country) 116 | } else if restrictionsGeoIP.Distance > 0 && miles > restrictionsGeoIP.Distance { 117 | // mismatch = fmt.Sprintf("[%s] Forbidden Distance: %.2f", remoteGeoIP.IP, miles) 118 | mismatch = fmt.Sprintf("[%s] DENY;", remoteGeoIP.IP) 119 | } 120 | return mismatch, distanceCalc 121 | } 122 | 123 | /* 124 | latlon2coord converts a string such as "36.0525,-79.107" to a tuple of floats 125 | 126 | Args: 127 | 128 | latlon: a string in "lat, lon" format 129 | 130 | Returns: 131 | 132 | a tuple in (float64, float64) format 133 | */ 134 | func latlon2coord(latlon string) (float64, float64, error) { 135 | slots := strings.Split(latlon, ",") 136 | lat, err := strconv.ParseFloat(slots[0], 64) 137 | if err != nil { 138 | err = fmt.Errorf("Error converting latitude to float for: %s", latlon) 139 | } 140 | lon, err := strconv.ParseFloat(slots[1], 64) 141 | if err != nil { 142 | err = fmt.Errorf("Error converting longitude to float for: %s", latlon) 143 | } 144 | return lat, lon, err 145 | } 146 | 147 | // adapted from: https://gist.github.com/cdipaolo/d3f8db3848278b49db68 148 | // haversin(θ) function 149 | func hsin(theta float64) float64 { 150 | return math.Pow(math.Sin(theta/2), 2) 151 | } 152 | 153 | // HaversineDistance returns the distance (in miles) between two points of 154 | // 155 | // a given longitude and latitude relatively accurately (using a spherical 156 | // approximation of the Earth) through the Haversin Distance Formula for 157 | // great arc distance on a sphere with accuracy for small distances 158 | // 159 | // point coordinates are supplied in degrees and converted into rad. in the func 160 | // 161 | // http://en.wikipedia.org/wiki/Haversine_formula 162 | func HaversineDistance(lat1, lon1, lat2, lon2 float64) float64 { 163 | // convert to radians 164 | // must cast radius as float to multiply later 165 | var la1, lo1, la2, lo2, r float64 166 | 167 | piRad := math.Pi / 180 168 | la1 = lat1 * piRad 169 | lo1 = lon1 * piRad 170 | la2 = lat2 * piRad 171 | lo2 = lon2 * piRad 172 | 173 | r = 6378100 // Earth radius in METERS 174 | 175 | // calculate 176 | h := hsin(la2-la1) + math.Cos(la1)*math.Cos(la2)*hsin(lo2-lo1) 177 | 178 | meters := 2 * r * math.Asin(math.Sqrt(h)) 179 | miles := meters / 1609.344 180 | return miles 181 | } 182 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jftuga/gofwd 2 | 3 | go 1.21.1 4 | 5 | require ( 6 | github.com/alecthomas/kingpin/v2 v2.3.2 7 | github.com/duosecurity/duo_api_golang v0.0.0-20230418202038-096d3306c029 8 | github.com/olekukonko/tablewriter v0.0.5 9 | go.uber.org/zap v1.26.0 10 | gopkg.in/ini.v1 v1.67.0 11 | ) 12 | 13 | require ( 14 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect 15 | github.com/mattn/go-runewidth v0.0.9 // indirect 16 | github.com/xhit/go-str2duration/v2 v2.1.0 // indirect 17 | go.uber.org/multierr v1.10.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/kingpin/v2 v2.3.2 h1:H0aULhgmSzN8xQ3nX1uxtdlTHYoPLu5AhHxWrKI6ocU= 2 | github.com/alecthomas/kingpin/v2 v2.3.2/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= 3 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= 4 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/duosecurity/duo_api_golang v0.0.0-20230418202038-096d3306c029 h1:MDyoHXcEq2ZjPFeWrdof3GPBJohXIoL62eVxK/hjhy4= 9 | github.com/duosecurity/duo_api_golang v0.0.0-20230418202038-096d3306c029/go.mod h1:jI+QUTOK3wqIOrUl0Cwnwlgc/P6vs6pZOuQY3aKggwg= 10 | github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= 11 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 12 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 13 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 14 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 15 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 16 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 17 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 18 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 19 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 20 | github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= 21 | github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= 22 | go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= 23 | go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= 24 | go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= 25 | go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 26 | go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 27 | go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 28 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 29 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 30 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 31 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 32 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 33 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 34 | -------------------------------------------------------------------------------- /nics.go: -------------------------------------------------------------------------------- 1 | /* 2 | Adopted from: 3 | https://github.com/jftuga/nics 4 | */ 5 | 6 | package main 7 | 8 | import ( 9 | "fmt" 10 | "net" 11 | "os" 12 | "strconv" 13 | "strings" 14 | 15 | "github.com/olekukonko/tablewriter" 16 | ) 17 | 18 | func isBriefEntry(ifaceName, macAddr, mtu, flags string, ipv4List, ipv6List []string, debug bool) bool { 19 | if debug { 20 | fmt.Println("isBriefEntry:", ifaceName) 21 | } 22 | if strings.Contains(flags, "loopback") { 23 | if debug { 24 | fmt.Println(" not_brief: loopback flag") 25 | } 26 | return false 27 | } 28 | if strings.HasPrefix(macAddr, "00:00:00:00:00:00") { 29 | if debug { 30 | fmt.Println(" not_brief: NULL macAddr") 31 | } 32 | return false 33 | } 34 | if 0 == len(ipv4List) { 35 | if debug { 36 | fmt.Println(" not_brief: no IP addresses") 37 | } 38 | return false 39 | } 40 | for _, ipv4 := range ipv4List { 41 | if strings.HasPrefix(ipv4, "169.254.") { 42 | if debug { 43 | fmt.Println(" not_brief: self assigned:", ipv4) 44 | } 45 | return false 46 | } 47 | } 48 | if debug { 49 | fmt.Println(" is_brief: true") 50 | } 51 | return true 52 | } 53 | 54 | func extractIPAddrs(ifaceName string, allAddresses []net.Addr, brief bool) ([]string, []string) { 55 | var allIPv4 []string 56 | var allIPv6 []string 57 | 58 | for _, netAddr := range allAddresses { 59 | address := netAddr.String() 60 | if strings.Contains(address, ":") { 61 | allIPv6 = append(allIPv6, address) 62 | } else { 63 | allIPv4 = append(allIPv4, address) 64 | } 65 | } 66 | return allIPv4, allIPv6 67 | } 68 | 69 | func getSpecificNic(adapter string) (string, error) { 70 | adapters, err := net.Interfaces() 71 | if err != nil { 72 | return "", err 73 | } 74 | 75 | adapter = strings.ToLower(adapter) 76 | for _, iface := range adapters { 77 | allAddresses, err := iface.Addrs() 78 | if err != nil { 79 | return "", err 80 | } 81 | 82 | allIPv4, _ := extractIPAddrs(iface.Name, allAddresses, true) 83 | if len(allIPv4) == 1 && strings.ToLower(iface.Name) == adapter { 84 | for _, ipWithMask := range allIPv4 { 85 | ip := strings.Split(ipWithMask, "/") 86 | return ip[0], nil 87 | } 88 | } 89 | } 90 | 91 | return "", fmt.Errorf("Unknown adapter: '%s'. Use '-i' to list adapter names.", adapter) 92 | } 93 | 94 | func networkInterfaces(brief bool, debug bool) ([]string, []string, error) { 95 | adapters, err := net.Interfaces() 96 | if err != nil { 97 | return nil, nil, err 98 | } 99 | 100 | table := tablewriter.NewWriter(os.Stdout) 101 | table.SetAutoWrapText(false) 102 | if brief { 103 | table.SetHeader([]string{"Name", "IPv4", "Mac Address", "MTU", "Flags"}) 104 | } else { 105 | table.SetHeader([]string{"Name", "IPv4", "IPv6", "Mac Address", "MTU", "Flags"}) 106 | } 107 | 108 | var v4Addresses []string 109 | var v6Addresses []string 110 | for _, iface := range adapters { 111 | allAddresses, err := iface.Addrs() 112 | if err != nil { 113 | return nil, nil, nil 114 | } 115 | 116 | allIPv4, allIPv6 := extractIPAddrs(iface.Name, allAddresses, brief) 117 | if debug { 118 | fmt.Println() 119 | fmt.Println("---------------------") 120 | fmt.Println(iface.Name, allAddresses) 121 | fmt.Println("ipv4:", allIPv4) 122 | fmt.Println("ipv6:", allIPv6) 123 | } 124 | 125 | ifaceName := strings.ToLower(iface.Name) 126 | macAddr := iface.HardwareAddr.String() 127 | mtu := strconv.Itoa(iface.MTU) 128 | flags := iface.Flags.String() 129 | 130 | if brief && isBriefEntry(ifaceName, macAddr, mtu, flags, allIPv4, allIPv6, debug) { 131 | table.Append([]string{iface.Name, strings.Join(allIPv4, "\n"), macAddr, mtu, flags}) 132 | for _, ipWithMask := range allIPv4 { 133 | ip := strings.Split(ipWithMask, "/") 134 | v4Addresses = append(v4Addresses, ip[0]) 135 | } 136 | continue 137 | } 138 | 139 | if !brief { 140 | table.SetAutoWrapText(true) 141 | table.SetRowLine(true) 142 | table.Append([]string{ifaceName, strings.Join(allIPv4, "\n"), strings.Join(allIPv6, "\n"), macAddr, mtu, strings.Replace(flags, "|", "\n", -1)}) 143 | for _, ipWithMask := range allIPv4 { 144 | ip := strings.Split(ipWithMask, "/") 145 | v4Addresses = append(v4Addresses, ip[0]) 146 | } 147 | for _, ipWithMask := range allIPv6 { 148 | ip := strings.Split(ipWithMask, "/") 149 | v6Addresses = append(v6Addresses, ip[0]) 150 | } 151 | } 152 | } 153 | table.Render() 154 | 155 | return v4Addresses, v6Addresses, err 156 | } 157 | 158 | func nics() { 159 | argsAllDetails := false 160 | argsDebug := false 161 | _, _, err := networkInterfaces(!(argsAllDetails), argsDebug) 162 | if err != nil { 163 | fmt.Fprintf(os.Stderr, "%s\n", err) 164 | } 165 | } 166 | --------------------------------------------------------------------------------