├── .github ├── dependabot.yml └── workflows │ ├── codeql.yml │ ├── dependency-review.yml │ ├── lint.yml │ ├── release.yml │ ├── scorecards.yml │ └── tests.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode └── launch.json ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── apply.go ├── create.go ├── daemon.go ├── daemon_start.go ├── daemon_stop.go ├── delete.go ├── generate.go ├── generate_rules.go ├── generate_systemd.go ├── list.go ├── root.go └── version.go ├── docs └── getting-started.md ├── fwdctl-example.png ├── fwdctl.png ├── go.mod ├── go.sum ├── hack └── fwdctl-seccomp.json ├── install ├── internal ├── constants │ └── constants.go ├── daemon │ ├── management.go │ └── pid_file.go ├── printer │ ├── json.go │ ├── printer_interface.go │ ├── table.go │ └── yaml.go ├── rules │ ├── ruleset.go │ ├── ruleset_test.go │ └── types.go └── template │ ├── rules_template │ ├── rules.go │ └── rules.yml.tpl │ ├── systemd_template │ ├── fwdctl.service.tpl │ ├── systemd_service.go │ └── systemd_service_test.go │ ├── template.go │ └── template_test.go ├── main.go ├── main_test.go ├── pkg └── iptables │ ├── defaults.go │ ├── forward.go │ ├── interface.go │ ├── interface_test.go │ ├── rule.go │ ├── rule_test.go │ ├── utils.go │ ├── validation.go │ └── validation_test.go ├── tests ├── README.md ├── apply.txtar ├── apply_trace.txtar ├── create.txtar ├── create_trace.txtar ├── daemon.txtar ├── daemon_trace.txtar ├── delete.txtar ├── delete_trace.txtar ├── generate.txtar ├── generate_trace.txtar ├── list.txtar ├── list_trace.txtar ├── version.txtar └── version_trace.txtar └── utils.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | 8 | - package-ecosystem: gomod 9 | directory: / 10 | schedule: 11 | interval: daily 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql.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 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: ["main"] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ["main"] 20 | schedule: 21 | - cron: "0 0 * * 1" 22 | 23 | permissions: 24 | contents: read 25 | 26 | jobs: 27 | analyze: 28 | name: Analyze 29 | runs-on: ubuntu-latest 30 | permissions: 31 | actions: read 32 | contents: read 33 | security-events: write 34 | 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | language: ["go"] 39 | # CodeQL supports [ $supported-codeql-languages ] 40 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 41 | 42 | steps: 43 | - name: Harden Runner 44 | uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 45 | with: 46 | egress-policy: audit 47 | 48 | - name: Checkout repository 49 | uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 50 | 51 | # Initializes the CodeQL tools for scanning. 52 | - name: Initialize CodeQL 53 | uses: github/codeql-action/init@04daf014b50eaf774287bf3f0f1869d4b4c4b913 # v2.21.7 54 | with: 55 | languages: ${{ matrix.language }} 56 | # If you wish to specify custom queries, you can do so here or in a config file. 57 | # By default, queries listed here will override any specified in a config file. 58 | # Prefix the list here with "+" to use these queries and those in the config file. 59 | 60 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 61 | # If this step fails, then you should remove it and run the build manually (see below) 62 | - name: Autobuild 63 | uses: github/codeql-action/autobuild@04daf014b50eaf774287bf3f0f1869d4b4c4b913 # v2.21.7 64 | 65 | # ℹ️ Command-line programs to run using the OS shell. 66 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 67 | 68 | # If the Autobuild fails above, remove it and uncomment the following three lines. 69 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 70 | 71 | # - run: | 72 | # echo "Run, Build Application using script" 73 | # ./location_of_script_within_repo/buildscript.sh 74 | 75 | - name: Perform CodeQL Analysis 76 | uses: github/codeql-action/analyze@04daf014b50eaf774287bf3f0f1869d4b4c4b913 # v2.21.7 77 | with: 78 | category: "/language:${{matrix.language}}" 79 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, 4 | # surfacing known-vulnerable versions of the packages declared or updated in the PR. 5 | # Once installed, if the workflow run is marked as required, 6 | # PRs introducing known-vulnerable packages will be blocked from merging. 7 | # 8 | # Source repository: https://github.com/actions/dependency-review-action 9 | name: 'Dependency Review' 10 | on: [pull_request] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | dependency-review: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Harden Runner 20 | uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 21 | with: 22 | egress-policy: audit 23 | 24 | - name: 'Checkout Repository' 25 | uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 26 | - name: 'Dependency Review' 27 | uses: actions/dependency-review-action@0efb1d1d84fc9633afcdaad14c485cbbc90ef46c # v2.5.1 28 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: push 3 | permissions: 4 | contents: read 5 | jobs: 6 | golangci: 7 | name: lint 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Harden Runner 11 | uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 12 | with: 13 | egress-policy: audit 14 | 15 | - uses: actions/setup-go@6edd4406fa81c3da01a34fa6f6343087c207a568 # ratchet:actions/setup-go@v3 16 | with: 17 | go-version: 1.22 18 | 19 | - uses: actions/checkout@755da8c3cf115ac066823e79a1e1788f8940201b # ratchet:actions/checkout@v3 20 | - name: golangci-lint 21 | uses: golangci/golangci-lint-action@07db5389c99593f11ad7b44463c2d4233066a9b1 # ratchet:golangci/golangci-lint-action@v3 22 | with: 23 | version: v1.60.3 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | goreleaser: 11 | permissions: 12 | actions: write # for anchore/sbom-action to upload workflow artifacts 13 | contents: write # for goreleaser/goreleaser-action to create a GitHub release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Harden Runner 17 | uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 18 | with: 19 | egress-policy: audit 20 | 21 | - name: Checkout 22 | uses: actions/checkout@755da8c3cf115ac066823e79a1e1788f8940201b # ratchet:actions/checkout@v3 23 | with: 24 | fetch-depth: 0 25 | - name: Set up Go 26 | uses: actions/setup-go@6edd4406fa81c3da01a34fa6f6343087c207a568 # ratchet:actions/setup-go@v3 27 | with: 28 | go-version: '1.22' 29 | check-latest: true 30 | - run: go version 31 | - name: build 32 | run: make build-gh 33 | - uses: anchore/sbom-action@78fc58e266e87a38d4194b2137a3d4e9bcaf7ca1 # v0.14.3 34 | with: 35 | artifact-name: fwdctl-sbom.spdx.json 36 | - name: Release 37 | uses: softprops/action-gh-release@9d7c94cfd0a1f3ed45544c887983e9fa900f0564 38 | if: startsWith(github.ref, 'refs/tags/') 39 | with: 40 | files: bin/fwdctl 41 | -------------------------------------------------------------------------------- /.github/workflows/scorecards.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. They are provided 2 | # by a third-party and are governed by separate terms of service, privacy 3 | # policy, and support documentation. 4 | 5 | name: Scorecard supply-chain security 6 | on: 7 | # For Branch-Protection check. Only the default branch is supported. See 8 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 9 | branch_protection_rule: 10 | # To guarantee Maintained check is occasionally updated. See 11 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 12 | schedule: 13 | - cron: '20 7 * * 2' 14 | push: 15 | branches: ["main"] 16 | 17 | # Declare default permissions as read only. 18 | permissions: read-all 19 | 20 | jobs: 21 | analysis: 22 | name: Scorecard analysis 23 | runs-on: ubuntu-latest 24 | permissions: 25 | # Needed to upload the results to code-scanning dashboard. 26 | security-events: write 27 | # Needed to publish results and get a badge (see publish_results below). 28 | id-token: write 29 | contents: read 30 | actions: read 31 | 32 | steps: 33 | - name: Harden Runner 34 | uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 35 | with: 36 | egress-policy: audit 37 | 38 | - name: "Checkout code" 39 | uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 40 | with: 41 | persist-credentials: false 42 | 43 | - name: "Run analysis" 44 | uses: ossf/scorecard-action@99c53751e09b9529366343771cc321ec74e9bd3d # v2.0.6 45 | with: 46 | results_file: results.sarif 47 | results_format: sarif 48 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: 49 | # - you want to enable the Branch-Protection check on a *public* repository, or 50 | # - you are installing Scorecards on a *private* repository 51 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. 52 | # repo_token: ${{ secrets.SCORECARD_TOKEN }} 53 | 54 | # Public repositories: 55 | # - Publish results to OpenSSF REST API for easy access by consumers 56 | # - Allows the repository to include the Scorecard badge. 57 | # - See https://github.com/ossf/scorecard-action#publishing-results. 58 | # For private repositories: 59 | # - `publish_results` will always be set to `false`, regardless 60 | # of the value entered here. 61 | publish_results: true 62 | 63 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 64 | # format to the repository Actions tab. 65 | - name: "Upload artifact" 66 | uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 67 | with: 68 | name: SARIF file 69 | path: results.sarif 70 | retention-days: 5 71 | 72 | # Upload the results to GitHub's code scanning dashboard. 73 | - name: "Upload to code-scanning" 74 | uses: github/codeql-action/upload-sarif@04daf014b50eaf774287bf3f0f1869d4b4c4b913 # v2.21.7 75 | with: 76 | sarif_file: results.sarif 77 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | unit-test: 7 | 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4 11 | 12 | - name: Build coverage-instrumented binary 13 | run: | 14 | make build-cover && sudo make -B install 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # ratchet:actions/setup-go@v5 18 | with: 19 | go-version: '1.22' 20 | 21 | - name: Run Unit-Test 22 | run: | 23 | mkdir /tmp/unit/ 24 | go test \ 25 | -v \ 26 | -cover ./... \ 27 | -skip TestFwdctl \ 28 | -args -test.gocoverdir=/tmp/unit/ 29 | 30 | - name: Upload cover profiles 31 | uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # ratchet:actions/upload-artifact@v4 32 | with: 33 | name: unit-test 34 | path: /tmp/unit/ 35 | 36 | integration-test: 37 | 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4 41 | 42 | - name: Set up Go 43 | uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # ratchet:actions/setup-go@v5 44 | with: 45 | go-version: '1.22' 46 | 47 | - name: Install iptables 48 | run: | 49 | sudo apt update 50 | sudo apt install -y iptables 51 | 52 | - name: Build coverage-instrumented binary 53 | run: | 54 | make build-cover && sudo make -B install 55 | 56 | - name: Run integration test 57 | run: | 58 | mkdir /tmp/integration 59 | # we have to run integration tests one-by-one 60 | # otherwhise they will run in parallel. 61 | # since fwdctl apply network forwards, these could 62 | # interact with each other and make the test fail. 63 | go test \ 64 | -exec sudo \ 65 | -cover \ 66 | -v ./... \ 67 | -run TestFwdctl/apply$ \ 68 | -args -test.gocoverdir=/tmp/integration/ 69 | go test \ 70 | -exec sudo \ 71 | -cover \ 72 | -v ./... \ 73 | -run TestFwdctl/create$ \ 74 | -args -test.gocoverdir=/tmp/integration/ 75 | go test \ 76 | -exec sudo \ 77 | -cover \ 78 | -v ./... \ 79 | -run TestFwdctl/delete$ \ 80 | -args -test.gocoverdir=/tmp/integration/ 81 | go test \ 82 | -exec sudo \ 83 | -cover \ 84 | -v ./... \ 85 | -run TestFwdctl/list$ \ 86 | -args -test.gocoverdir=/tmp/integration/ 87 | go test \ 88 | -exec sudo \ 89 | -cover \ 90 | -v ./... \ 91 | -run TestFwdctl/daemon$ \ 92 | -args -test.gocoverdir=/tmp/integration/ 93 | go test \ 94 | -exec sudo \ 95 | -cover \ 96 | -v ./... \ 97 | -run TestFwdctl/version$ \ 98 | -args -test.gocoverdir=/tmp/integration/ 99 | 100 | - name: Upload cover profiles 101 | uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # ratchet:actions/upload-artifact@v4 102 | with: 103 | name: integration-test 104 | path: /tmp/integration/ 105 | 106 | code-coverage: 107 | 108 | runs-on: ubuntu-latest 109 | needs: [unit-test,integration-test] 110 | steps: 111 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4 112 | 113 | - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # ratchet:actions/download-artifact@v4 114 | with: 115 | name: unit-test 116 | path: /tmp/unit-test 117 | 118 | - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # ratchet:actions/download-artifact@v4 119 | with: 120 | name: integration-test 121 | path: /tmp/integration-test 122 | 123 | - name: list files 124 | run: | 125 | ls -lah /tmp/unit-test 126 | ls -lah /tmp/integration-test 127 | 128 | - name: Set up Go 129 | uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # ratchet:actions/setup-go@v5 130 | with: 131 | go-version: '1.22' 132 | 133 | - name: Calculate total coverage 134 | run: | 135 | go tool \ 136 | covdata \ 137 | textfmt \ 138 | -i=/tmp/unit-test,/tmp/integration-test \ 139 | -o code-coverage 140 | go tool \ 141 | cover \ 142 | -func code-coverage 143 | 144 | - name: Update coverage report 145 | uses: ncruces/go-coverage-report@494b2847891f4dd3b10f6704ca533367dbb7493d # ratchet:ncruces/go-coverage-report@v0 146 | with: 147 | report: true 148 | chart: true 149 | amend: true 150 | coverage-file: ./code-coverage 151 | 152 | trace-unit-test: 153 | 154 | runs-on: ubuntu-latest 155 | needs: [unit-test] 156 | steps: 157 | - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # ratchet:actions/checkout@v3 158 | 159 | - name: Set up Go 160 | uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # ratchet:actions/setup-go@v5 161 | with: 162 | go-version: '1.22' 163 | 164 | - name: Build coverage-instrumented binary 165 | run: | 166 | make build-cover && sudo make -B install 167 | 168 | - name: Install harpoon 169 | run: | 170 | curl -s https://raw.githubusercontent.com/alegrey91/harpoon/main/install | sudo bash -s -- --install-version v0.9.3 171 | 172 | - name: Analyze binaries 173 | run: | 174 | sudo harpoon analyze \ 175 | --exclude .git/ \ 176 | --save 177 | 178 | - name: Trace system calls 179 | run: | 180 | sudo harpoon hunt \ 181 | --file harpoon-report.yml \ 182 | --directory unit-test-syscalls \ 183 | --save 184 | 185 | - name: Upload metadata from unit-tests 186 | uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # ratchet:actions/upload-artifact@v4 187 | with: 188 | name: unit-test-syscalls 189 | path: unit-test-syscalls 190 | 191 | trace-integration-test: 192 | 193 | runs-on: ubuntu-latest 194 | needs: [integration-test] 195 | steps: 196 | - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # ratchet:actions/checkout@v3 197 | 198 | - name: Set up Go 199 | uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # ratchet:actions/setup-go@v5 200 | with: 201 | go-version: '1.22' 202 | 203 | - name: Install iptables 204 | run: | 205 | sudo apt update 206 | sudo apt install -y iptables 207 | 208 | - name: Build coverage-instrumented binary 209 | run: | 210 | make build && sudo make -B install 211 | 212 | - name: Install harpoon 213 | run: | 214 | curl -s https://raw.githubusercontent.com/alegrey91/harpoon/main/install | sudo bash -s -- --install-version v0.9.3 215 | 216 | - name: Run integration test 217 | run: | 218 | mkdir -p integration-test-syscalls 219 | go test \ 220 | -exec sudo \ 221 | -v ./... \ 222 | -run TestFwdctl/apply_trace 223 | go test \ 224 | -exec sudo \ 225 | -v ./... \ 226 | -run TestFwdctl/create_trace 227 | go test \ 228 | -exec sudo \ 229 | -v ./... \ 230 | -run TestFwdctl/delete_trace 231 | go test \ 232 | -exec sudo \ 233 | -v ./... \ 234 | -run TestFwdctl/generate_trace 235 | go test \ 236 | -exec sudo \ 237 | -v ./... \ 238 | -run TestFwdctl/list_trace 239 | go test \ 240 | -exec sudo \ 241 | -v ./... \ 242 | -run TestFwdctl/version_trace 243 | shell: bash 244 | 245 | - name: Upload cover profiles 246 | uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # ratchet:actions/upload-artifact@v4 247 | with: 248 | name: integration-test-syscalls 249 | path: integration-test-syscalls 250 | 251 | build-seccomp-profile: 252 | 253 | runs-on: ubuntu-latest 254 | needs: [trace-unit-test, trace-integration-test] 255 | steps: 256 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4 257 | 258 | - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # ratchet:actions/download-artifact@v4 259 | with: 260 | name: unit-test-syscalls 261 | path: unit-test-syscalls 262 | 263 | - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # ratchet:actions/download-artifact@v4 264 | with: 265 | name: integration-test-syscalls 266 | path: ./integration-test-syscalls 267 | 268 | - name: list files 269 | run: | 270 | ls -lah ./unit-test-syscalls 271 | ls -lah ./integration-test-syscalls 272 | 273 | - name: Set up Go 274 | uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # ratchet:actions/setup-go@v5 275 | with: 276 | go-version: '1.22' 277 | 278 | - name: Install harpoon 279 | run: | 280 | curl -s https://raw.githubusercontent.com/alegrey91/harpoon/main/install | sudo bash -s -- --install-version v0.9.3 281 | 282 | - name: Create unique directory 283 | run: | 284 | mkdir -p harpoon 285 | mv unit-test-syscalls/* harpoon/ 286 | mv integration-test-syscalls/* harpoon/ 287 | 288 | - name: Build Seccomp Profile 289 | run: | 290 | sudo harpoon build \ 291 | --add-syscall-sets=dynamic,docker \ 292 | --directory harpoon/ \ 293 | --save \ 294 | --name fwdctl-seccomp.json 295 | 296 | - name: Upload seccomp profile 297 | uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # ratchet:actions/upload-artifact@v4 298 | with: 299 | name: fwdctl-seccomp.json 300 | path: fwdctl-seccomp.json 301 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | dist/ 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/gitleaks/gitleaks 3 | rev: v8.16.3 4 | hooks: 5 | - id: gitleaks 6 | - repo: https://github.com/golangci/golangci-lint 7 | rev: v1.52.2 8 | hooks: 9 | - id: golangci-lint 10 | - repo: https://github.com/jumanjihouse/pre-commit-hooks 11 | rev: 3.0.0 12 | hooks: 13 | - id: shellcheck 14 | - repo: https://github.com/pre-commit/pre-commit-hooks 15 | rev: v4.4.0 16 | hooks: 17 | - id: end-of-file-fixer 18 | - id: trailing-whitespace 19 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Exec with argument (apply)", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "exec", 12 | "asRoot": true, 13 | "console": "integratedTerminal", 14 | "args": [ 15 | "apply" 16 | ], 17 | "program": "${workspaceFolder}/bin/fwdctl", 18 | "showLog": true, 19 | } 20 | 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINARY_NAME=fwdctl 2 | BINARY_DIR=./bin 3 | 4 | build: create-bin-dir 5 | go mod download 6 | go build \ 7 | -v \ 8 | -o ${BINARY_DIR}/${BINARY_NAME} \ 9 | . 10 | 11 | build-cover: create-bin-dir 12 | go mod download 13 | go build \ 14 | -v \ 15 | -cover \ 16 | -o ${BINARY_DIR}/${BINARY_NAME} \ 17 | . 18 | 19 | build-gh: create-bin-dir 20 | ifndef GITHUB_REF_NAME 21 | $(error GITHUB_REF_NAME is undefined) 22 | endif 23 | go mod download 24 | go build \ 25 | -v \ 26 | -ldflags='-s -w -X "github.com/alegrey91/fwdctl/internal/constants.Version=${GITHUB_REF_NAME}"' \ 27 | -o ${BINARY_DIR}/${BINARY_NAME} \ 28 | . 29 | 30 | 31 | install: 32 | cp ${BINARY_DIR}/${BINARY_NAME} /usr/local/bin/ 33 | 34 | create-bin-dir: 35 | mkdir -p ${BINARY_DIR} 36 | 37 | clean: 38 | rm -rf ${BINARY_DIR} 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fwdctl 2 | 3 |

4 | fwdctl 5 |

6 | 7 | 8 | [![Go Reference](https://pkg.go.dev/badge/github.com/alegrey91/fwdctl.svg)](https://pkg.go.dev/github.com/alegrey91/fwdctl) 9 | [![Go Report Card](https://goreportcard.com/badge/github.com/alegrey91/fwdctl)](https://goreportcard.com/report/github.com/alegrey91/fwdctl) 10 | [![Go Coverage](https://github.com/alegrey91/fwdctl/wiki/coverage.svg)](https://raw.githack.com/wiki/alegrey91/fwdctl/coverage.html) 11 | ![https://img.shields.io/ossf-scorecard/github.com/alegrey91/fwdctl?label=openssf%20scorecard&style=flat](https://img.shields.io/ossf-scorecard/github.com/alegrey91/fwdctl?label=openssf%20scorecard&style=flat) 12 | [![Awesome](https://awesome.re/badge.svg)](https://github.com/avelino/awesome-go/) 13 | 14 | **fwdctl** is a simple and intuitive CLI to manage forwards in your **Linux** server. 15 | 16 | ## How it works 17 | 18 | It essentially provides commands to manage forwards, using **iptables** under the hood. 19 | 20 | Let's do an example: 21 | 22 | Suppose you have an **hypervisor** server that hosts some virtual machines inside itself. If you need to expose an internal service, managed by one of these VMs, you can use **fwdctl** from the hypervisor to add the forward to expose this service. 23 | 24 | ![example](./fwdctl-example.png) 25 | 26 | To do so, you have to type this easy command: 27 | 28 | ``` shell 29 | sudo fwdctl create --destination-port 3000 --source-address 192.168.199.105 --source-port 80 30 | ``` 31 | 32 | That's it. 33 | 34 | Full **documentation** [here](docs/getting-started.md). 35 | 36 | ## Installation 37 | 38 | #### Linux x86_64 39 | 40 | ```shell 41 | curl -s https://raw.githubusercontent.com/alegrey91/fwdctl/main/install | sudo sh 42 | ``` 43 | 44 | ## Seccomp (experimental) 45 | 46 | I've recently added a new functionality to trace the system calls used by `fwdctl` during the test pipeline. 47 | 48 | This is done by using another project of mine: [`harpoon`](https://github.com/alegrey91/harpoon). 49 | 50 | Thanks to this, at the end of the pipeline, we have a **seccomp** profile as artifact. You can use this to run `fwdctl` in a more secure way. 51 | 52 | Find the **seccomp** profile here: [`fwdctl-seccomp.json`](hack/fwdctl-seccomp.json). 53 | -------------------------------------------------------------------------------- /cmd/apply.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Alessio Greggi 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | "os" 21 | 22 | "github.com/spf13/cobra" 23 | "golang.org/x/sync/errgroup" 24 | 25 | c "github.com/alegrey91/fwdctl/internal/constants" 26 | "github.com/alegrey91/fwdctl/internal/rules" 27 | iptables "github.com/alegrey91/fwdctl/pkg/iptables" 28 | ) 29 | 30 | // applyCmd represents the apply command 31 | var applyCmd = &cobra.Command{ 32 | Use: "apply", 33 | Short: "Apply rules from file", 34 | Long: `Apply rules described in a configuration file`, 35 | Example: c.ProgramName + " apply --file rule.yml", 36 | RunE: func(cmd *cobra.Command, args []string) error { 37 | rulesContent, err := os.Open(c.RulesFile) 38 | if err != nil { 39 | return fmt.Errorf("opening file: %v", err) 40 | } 41 | ruleSet, err := rules.NewRuleSetFromFile(rulesContent) 42 | if err != nil { 43 | return fmt.Errorf("unable to open rules file: %v", err) 44 | } 45 | ipt, err := iptables.NewIPTablesInstance() 46 | if err != nil { 47 | return fmt.Errorf("unable to get iptables instance: %v", err) 48 | } 49 | 50 | g := new(errgroup.Group) 51 | rulesFileIsValid := true 52 | 53 | g.SetLimit(10) 54 | for _, rule := range ruleSet.Rules { 55 | r := &rule 56 | g.Go(func() error { 57 | err := ipt.ValidateForward(r) 58 | if err != nil { 59 | rulesFileIsValid = false 60 | } 61 | return err 62 | }) 63 | } 64 | 65 | if err := g.Wait(); err != nil { 66 | return fmt.Errorf("validating rule: %v", err) 67 | } 68 | 69 | if rulesFileIsValid { 70 | for ruleId, rule := range ruleSet.Rules { 71 | if err := ipt.CreateForward(&rule); err != nil { 72 | return fmt.Errorf("applying rule (%s): %v", ruleId, err) 73 | } 74 | } 75 | } 76 | return nil 77 | }, 78 | } 79 | 80 | func init() { 81 | rootCmd.AddCommand(applyCmd) 82 | 83 | applyCmd.Flags().StringVarP(&c.RulesFile, "file", "f", "rules.yml", "rules file") 84 | } 85 | -------------------------------------------------------------------------------- /cmd/create.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Alessio Greggi 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | 21 | c "github.com/alegrey91/fwdctl/internal/constants" 22 | iptables "github.com/alegrey91/fwdctl/pkg/iptables" 23 | "github.com/spf13/cobra" 24 | ) 25 | 26 | var ( 27 | iface string 28 | proto string 29 | dport int 30 | saddr string 31 | sport int 32 | ) 33 | 34 | // createCmd represents the create command 35 | var createCmd = &cobra.Command{ 36 | Use: "create", 37 | Aliases: []string{"add"}, 38 | SuggestFor: []string{}, 39 | Short: "Create forward using IPTables util", 40 | Long: `Create forward rule using IPTables util under the hood. 41 | This is really useful in case you need to forward 42 | the traffic from an internal virtual machine inside 43 | your hypervisor, to external. 44 | 45 | +----------------------------+ 46 | | +-----------+ | 47 | | | | | 48 | | +-----+:80 VM | | 49 | | | | | | 50 | =:3000<--+ +-----------+ | 51 | | Hypervisor | 52 | +----------------------------+ 53 | `, 54 | Example: c.ProgramName + " create -d 3000 -s 192.168.199.105 -p 80", 55 | RunE: func(cmd *cobra.Command, args []string) error { 56 | ipt, err := iptables.NewIPTablesInstance() 57 | if err != nil { 58 | return fmt.Errorf("unable to get iptables instance: %v", err) 59 | } 60 | rule := iptables.NewRule(iface, proto, dport, saddr, sport) 61 | if err := ipt.CreateForward(rule); err != nil{ 62 | return fmt.Errorf("creating new rule: %v", err) 63 | } 64 | return nil 65 | }, 66 | } 67 | 68 | func init() { 69 | rootCmd.AddCommand(createCmd) 70 | 71 | createCmd.Flags().StringVarP(&iface, "interface", "i", "lo", "interface name") 72 | createCmd.Flags().StringVarP(&proto, "proto", "P", "tcp", "protocol") 73 | 74 | createCmd.Flags().IntVarP(&dport, "destination-port", "d", 0, "destination port") 75 | _ = createCmd.MarkFlagRequired("destination-port") 76 | 77 | createCmd.Flags().StringVarP(&saddr, "source-address", "s", "", "source address") 78 | _ = createCmd.MarkFlagRequired("source-address") 79 | 80 | createCmd.Flags().IntVarP(&sport, "source-port", "p", 0, "source port") 81 | _ = createCmd.MarkFlagRequired("source-port") 82 | } 83 | -------------------------------------------------------------------------------- /cmd/daemon.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2023 Alessio Greggi 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "github.com/spf13/cobra" 20 | ) 21 | 22 | // daemonCmd represents the daemon command 23 | var daemonCmd = &cobra.Command{ 24 | Use: "daemon", 25 | Short: "Run fwdctl as deamon", 26 | Run: func(cmd *cobra.Command, args []string) { 27 | _ = cmd.Help() 28 | }, 29 | } 30 | 31 | func init() { 32 | rootCmd.AddCommand(daemonCmd) 33 | } 34 | -------------------------------------------------------------------------------- /cmd/daemon_start.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Alessio Greggi 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | "os" 21 | 22 | c "github.com/alegrey91/fwdctl/internal/constants" 23 | "github.com/alegrey91/fwdctl/internal/daemon" 24 | iptables "github.com/alegrey91/fwdctl/pkg/iptables" 25 | "github.com/spf13/cobra" 26 | ) 27 | 28 | // daemonStartCmd represents the daemon command 29 | var daemonStartCmd = &cobra.Command{ 30 | Use: "start", 31 | Short: "Start fwdctl daemon", 32 | Long: ``, 33 | RunE: func(cmd *cobra.Command, args []string) error{ 34 | ipt, err := iptables.NewIPTablesInstance() 35 | if err != nil { 36 | return fmt.Errorf("unable to get iptables instance: %v", err) 37 | } 38 | rulesFile, err := cmd.Flags().GetString("file") 39 | if err != nil { 40 | return fmt.Errorf("unable to read from flag: %v", err) 41 | } 42 | if res := daemon.Start(ipt, rulesFile); res != 0 { 43 | os.Exit(1) 44 | } 45 | return nil 46 | }, 47 | } 48 | 49 | func init() { 50 | daemonCmd.AddCommand(daemonStartCmd) 51 | daemonStartCmd.Flags().StringVarP(&c.RulesFile, "file", "f", "rules.yml", "rules file") 52 | } 53 | -------------------------------------------------------------------------------- /cmd/daemon_stop.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Alessio Greggi 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | 21 | "github.com/alegrey91/fwdctl/internal/daemon" 22 | "github.com/spf13/cobra" 23 | ) 24 | 25 | // daemonStopCmd represents the daemon command 26 | var daemonStopCmd = &cobra.Command{ 27 | Use: "stop", 28 | Short: "Stop fwdctl daemon", 29 | Long: ``, 30 | Run: func(cmd *cobra.Command, args []string) { 31 | fmt.Println("stopping daemon") 32 | daemon.Stop() 33 | }, 34 | } 35 | 36 | func init() { 37 | daemonCmd.AddCommand(daemonStopCmd) 38 | 39 | } 40 | -------------------------------------------------------------------------------- /cmd/delete.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Alessio Greggi 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | "os" 21 | 22 | //"os" 23 | 24 | c "github.com/alegrey91/fwdctl/internal/constants" 25 | "github.com/alegrey91/fwdctl/internal/rules" 26 | iptables "github.com/alegrey91/fwdctl/pkg/iptables" 27 | "github.com/spf13/cobra" 28 | ) 29 | 30 | var ( 31 | ruleId int 32 | file string 33 | ) 34 | 35 | // deleteCmd represents the delete command 36 | var deleteCmd = &cobra.Command{ 37 | Use: "delete", 38 | Aliases: []string{"rm"}, 39 | Short: "Delete forward", 40 | Long: `Delete forward by passing a rule file or rule id. 41 | `, 42 | Example: c.ProgramName + " delete -n 2", 43 | RunE: func(cmd *cobra.Command, args []string) error { 44 | ipt, err := iptables.NewIPTablesInstance() 45 | if err != nil { 46 | return fmt.Errorf("unable to get iptables instance: %v", err) 47 | } 48 | // Delete rule number 49 | if cmd.Flags().Lookup("id").Changed { 50 | if err := ipt.DeleteForwardById(ruleId); err != nil { 51 | return fmt.Errorf("delete forward by ID: %v", err) 52 | } 53 | return nil 54 | } 55 | 56 | // Loop over file content and delete rule one-by-one. 57 | if cmd.Flags().Lookup("file").Changed { 58 | if err := deleteFromFile(ipt, file); err != nil { 59 | return fmt.Errorf("delete from file: %v", err) 60 | } 61 | return nil 62 | } 63 | 64 | if cmd.Flags().Lookup("all").Changed { 65 | if err := ipt.DeleteAllForwards();err != nil { 66 | return fmt.Errorf("delete all forwards: %v", err) 67 | } 68 | return nil 69 | } 70 | 71 | if err = deleteFromFile(ipt, file);err != nil { 72 | return fmt.Errorf("delete from file: %v", err) 73 | } 74 | return nil 75 | }, 76 | } 77 | 78 | func init() { 79 | rootCmd.AddCommand(deleteCmd) 80 | 81 | deleteCmd.Flags().IntVarP(&ruleId, "id", "n", 0, "delete rules through ID") 82 | deleteCmd.Flags().StringVarP(&file, "file", "f", "rules.yml", "delete rules through file") 83 | deleteCmd.Flags().BoolP("all", "a", false, "delete all rules") 84 | deleteCmd.MarkFlagsMutuallyExclusive("id", "file", "all") 85 | } 86 | 87 | func deleteFromFile(ipt *iptables.IPTablesInstance, file string) error { 88 | rulesContent, err := os.Open(file) 89 | if err != nil { 90 | return fmt.Errorf("error opening file: %v", err) 91 | } 92 | rulesFile, err := rules.NewRuleSetFromFile(rulesContent) 93 | if err != nil { 94 | return fmt.Errorf("error instantiating ruleset from file: %v", err) 95 | } 96 | for _, rule := range rulesFile.Rules { 97 | err := ipt.DeleteForwardByRule(&rule) 98 | if err != nil { 99 | return fmt.Errorf("error deleting rule [%s %s %d %s %d]: %v", rule.Iface, rule.Proto, rule.Dport, rule.Saddr, rule.Sport, err) 100 | } 101 | } 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /cmd/generate.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Alessio Greggi 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | 21 | "github.com/spf13/cobra" 22 | ) 23 | 24 | var outputFile string 25 | 26 | // generateCmd represents the generate command 27 | var generateCmd = &cobra.Command{ 28 | Use: "generate", 29 | Aliases: []string{"gen"}, 30 | Short: "Generates templated files", 31 | Long: `Generates templated file for fwdtcl 32 | `, 33 | RunE: func(cmd *cobra.Command, args []string) error { 34 | fmt.Println(cmd.Help()) 35 | return nil 36 | }, 37 | } 38 | 39 | func init() { 40 | rootCmd.AddCommand(generateCmd) 41 | } 42 | -------------------------------------------------------------------------------- /cmd/generate_rules.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Alessio Greggi 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | 21 | "github.com/spf13/cobra" 22 | 23 | "github.com/alegrey91/fwdctl/internal/template" 24 | rt "github.com/alegrey91/fwdctl/internal/template/rules_template" 25 | ) 26 | 27 | // generateRulesCmd represents the generateRules command 28 | var generateRulesCmd = &cobra.Command{ 29 | Use: "rules", 30 | Short: "Generates empty rules file", 31 | Long: `Generates empty rules file 32 | `, 33 | RunE: func(cmd *cobra.Command, args []string) error { 34 | rules := rt.NewRules() 35 | if err := template.GenerateTemplate(rules, outputFile); err != nil { 36 | return fmt.Errorf("generating template: %w", err) 37 | } 38 | return nil 39 | }, 40 | } 41 | 42 | func init() { 43 | generateCmd.AddCommand(generateRulesCmd) 44 | 45 | generateRulesCmd.PersistentFlags().StringVarP(&outputFile, "output-path", "O", "", "output path") 46 | _ = generateRulesCmd.MarkPersistentFlagRequired("output-path") 47 | } 48 | -------------------------------------------------------------------------------- /cmd/generate_systemd.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Alessio Greggi 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | 21 | c "github.com/alegrey91/fwdctl/internal/constants" 22 | "github.com/alegrey91/fwdctl/internal/template" 23 | st "github.com/alegrey91/fwdctl/internal/template/systemd_template" 24 | "github.com/spf13/cobra" 25 | ) 26 | 27 | var installationPath string 28 | var serviceType string 29 | 30 | // generateSystemdCmd represents the generateSystemd command 31 | var generateSystemdCmd = &cobra.Command{ 32 | Use: "systemd", 33 | Short: "Generates systemd service file", 34 | Long: `Generates systemd service file to run fwdctl at boot 35 | `, 36 | RunE: func(cmd *cobra.Command, args []string) error { 37 | systemd, err := st.NewSystemdService(serviceType, installationPath, c.RulesFile) 38 | if err != nil { 39 | return fmt.Errorf("cannot create systemd service: %v", err) 40 | } 41 | 42 | if err = template.GenerateTemplate(systemd, outputFile); err != nil { 43 | return fmt.Errorf("generating templated file: %v", err) 44 | } 45 | return nil 46 | }, 47 | } 48 | 49 | func init() { 50 | generateCmd.AddCommand(generateSystemdCmd) 51 | 52 | generateSystemdCmd.Flags().StringVarP(&installationPath, "installation-path", "p", "/usr/local/bin", "fwdctl installation path") 53 | generateSystemdCmd.Flags().StringVarP(&c.RulesFile, "file", "f", "rules.yml", "rules file path") 54 | generateSystemdCmd.Flags().StringVarP(&serviceType, "type", "t", "oneshot", "systemd service type [oneshot, fork]") 55 | 56 | generateSystemdCmd.PersistentFlags().StringVarP(&outputFile, "output-path", "O", "", "output path") 57 | _ = generateSystemdCmd.MarkPersistentFlagRequired("output-path") 58 | } 59 | -------------------------------------------------------------------------------- /cmd/list.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Alessio Greggi 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | 21 | c "github.com/alegrey91/fwdctl/internal/constants" 22 | "github.com/alegrey91/fwdctl/internal/printer" 23 | iptables "github.com/alegrey91/fwdctl/pkg/iptables" 24 | "github.com/spf13/cobra" 25 | ) 26 | 27 | var ( 28 | format string 29 | ) 30 | 31 | // listCmd represents the list command 32 | var listCmd = &cobra.Command{ 33 | Use: "list", 34 | Aliases: []string{"ls"}, 35 | Short: "List forwards", 36 | Long: `List forwards made with iptables`, 37 | Example: c.ProgramName + "list -o table", 38 | RunE: func(cmd *cobra.Command, args []string) error{ 39 | ipt, err := iptables.NewIPTablesInstance() 40 | if err != nil { 41 | return fmt.Errorf("getting iptables instance: %v", err) 42 | } 43 | ruleList, err := ipt.ListForward(format) 44 | if err != nil { 45 | return fmt.Errorf("listing rules: %v", err) 46 | } 47 | 48 | p := printer.NewPrinter(format) 49 | if err = p.PrintResult(ruleList);err != nil { 50 | return fmt.Errorf("printing result: %v", err) 51 | } 52 | return nil 53 | }, 54 | } 55 | 56 | func init() { 57 | rootCmd.AddCommand(listCmd) 58 | 59 | listCmd.Flags().StringVarP(&format, "output", "o", "table", "output format [table]") 60 | } 61 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Alessio Greggi 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | "os" 21 | 22 | "github.com/spf13/cobra" 23 | ) 24 | 25 | // rootCmd represents the base command when called without any subcommands 26 | var rootCmd = &cobra.Command{ 27 | Use: "fwdctl", 28 | Short: "fwdctl is a simple and intuitive CLI to manage IPTables forwards", 29 | Long: ``, 30 | // Uncomment the following line if your bare application 31 | // has an action associated with it: 32 | // Run: func(cmd *cobra.Command, args []string) { }, 33 | } 34 | 35 | // Execute adds all child commands to the root command and sets flags appropriately. 36 | // This is called by main.main(). It only needs to happen once to the rootCmd. 37 | func Execute() { 38 | if err := rootCmd.Execute(); err != nil { 39 | fmt.Println(err) 40 | os.Exit(1) 41 | } 42 | } 43 | 44 | func init() { 45 | cobra.OnInitialize(initConfig) 46 | } 47 | 48 | // initConfig reads in config file and ENV variables if set. 49 | func initConfig() { 50 | } 51 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Alessio Greggi 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | 21 | c "github.com/alegrey91/fwdctl/internal/constants" 22 | "github.com/spf13/cobra" 23 | ) 24 | 25 | // versionCmd represents the version command 26 | var versionCmd = &cobra.Command{ 27 | Use: "version", 28 | Short: "A brief description of your command", 29 | Long: `A longer description that spans multiple lines and likely contains examples 30 | and usage of using your command. For example: 31 | 32 | Cobra is a CLI library for Go that empowers applications. 33 | This application is a tool to generate the needed files 34 | to quickly create a Cobra application.`, 35 | Run: func(cmd *cobra.Command, args []string) { 36 | fmt.Printf("%s\n", c.Version) 37 | }, 38 | } 39 | 40 | func init() { 41 | rootCmd.AddCommand(versionCmd) 42 | } 43 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | **fwdctl** has many commands to manage your forwards. Let's dive into them! 4 | 5 | ### Apply 6 | 7 | The `apply` command is used to apply rules from a rules file. Here's a rules file example. 8 | 9 | ```shell 10 | cat << EOF > rules.yml 11 | rules: 12 | - dport: 2022 13 | saddr: 192.168.122.43 14 | sport: 22 15 | iface: eth1 16 | proto: tcp 17 | - dport: 3022 18 | saddr: 192.168.122.44 19 | sport: 22 20 | iface: eth1 21 | proto: tcp 22 | EOF 23 | ``` 24 | 25 | Once created, you can easily run: 26 | 27 | ```shell 28 | sudo fwdctl apply --file rules.yml 29 | ``` 30 | 31 | and apply all the rules listed in the file. 32 | 33 | ### Create 34 | 35 | The `create` command is used to create single rules manually. Let's see an example. 36 | 37 | ![](../fwdctl-example.png) 38 | 39 | To implement the rule for this scenario, just type the following command: 40 | 41 | ```shell 42 | sudo fwdctl create --destination-port 3000 --source-address 192.168.199.105 --source-port 80 43 | ``` 44 | 45 | ### Daemon 46 | 47 | The `daemon` command is used to run `fwdctl` as service. 48 | 49 | When modifications are applied to the rules file, `fwdctl` is triggered and start to apply changes that have been requested. 50 | 51 | To start the daemon, run the following command: 52 | 53 | ```shell 54 | sudo fwdctl daemon start -f rules.yaml 55 | ``` 56 | 57 | When you want to stop the daemon execution, then type: 58 | 59 | ```shell 60 | sudo fwdctl daemon stop 61 | ``` 62 | 63 | This will remove all the forwards that have been applied during its execution. 64 | 65 | Here's a demo on how to use this command: 66 | 67 | [![asciicast](https://asciinema.org/a/553296.svg)](https://asciinema.org/a/553296) 68 | 69 | ### Delete 70 | 71 | The `delete` command is used to delete rules using their ID or a file where the rules are listed. 72 | 73 | To delete a specific rule (identified with a number), type the following command: 74 | 75 | ```shell 76 | sudo fwdctl delete --id 4 77 | ``` 78 | 79 | This will remove the rule n. **4**, listed from **iptables**. 80 | 81 | To delete a set of rules listed in a *rule file*, type the following command: 82 | 83 | ```shell 84 | sudo fwdctl delete --file rules.yaml 85 | ``` 86 | 87 | This will remove the listed rules within the file. 88 | 89 | ### List 90 | 91 | The `list` command is used to list the applied rules. 92 | 93 | To list the rules, type the following command: 94 | 95 | ```shell 96 | sudo fwdctl list 97 | ``` 98 | 99 | The output will look like this: 100 | 101 | ```shell 102 | +--------+-----------+----------+---------------+----------------+---------------+ 103 | | NUMBER | INTERFACE | PROTOCOL | EXTERNAL PORT | INTERNAL IP | INTERNAL PORT | 104 | +--------+-----------+----------+---------------+----------------+---------------+ 105 | | 1 | lo | tcp | 2022 | 192.168.122.43 | 22 | 106 | | 2 | lo | tcp | 3022 | 192.168.122.44 | 443 | 107 | +--------+-----------+----------+---------------+----------------+---------------+ 108 | ``` 109 | 110 | If you want to use a different view of applied rules, you can choose between different format: 111 | 112 | ```shell 113 | sudo fwdctl list --output json 114 | ``` 115 | 116 | ### Generate 117 | 118 | The `generate` command is used to generate the following files: 119 | 120 | * **systemd** service for `fwdctl` 121 | * rules empty file 122 | 123 | To generate a systemd service, type the following command: 124 | 125 | ```shell 126 | fwdctl generate systemd -o fwdctl.service 127 | ``` 128 | 129 | To generate a `fwdctl` rules file, instead, type the following command: 130 | 131 | ```shell 132 | fwdctl generate rules -o rules.yml 133 | ``` 134 | 135 | ### Version 136 | 137 | The `version` command is used show the version of the program. 138 | -------------------------------------------------------------------------------- /fwdctl-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alegrey91/fwdctl/b69fc55d544dcc052280994704240b7e00574c8e/fwdctl-example.png -------------------------------------------------------------------------------- /fwdctl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alegrey91/fwdctl/b69fc55d544dcc052280994704240b7e00574c8e/fwdctl.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/alegrey91/fwdctl 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/coreos/go-iptables v0.6.1-0.20220901214115-d2b8608923d1 7 | github.com/fsnotify/fsnotify v1.7.0 8 | github.com/spf13/cobra v1.5.0 9 | golang.org/x/sync v0.8.0 10 | gopkg.in/yaml.v3 v3.0.1 11 | ) 12 | 13 | require ( 14 | github.com/hashicorp/hcl v1.0.0 // indirect 15 | github.com/magiconair/properties v1.8.7 // indirect 16 | github.com/mattn/go-runewidth v0.0.14 // indirect 17 | github.com/mitchellh/mapstructure v1.5.0 // indirect 18 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 19 | github.com/rivo/uniseg v0.4.4 // indirect 20 | github.com/sagikazarmark/locafero v0.4.0 // indirect 21 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 22 | github.com/sourcegraph/conc v0.3.0 // indirect 23 | github.com/spf13/afero v1.11.0 // indirect 24 | github.com/spf13/cast v1.6.0 // indirect 25 | github.com/subosito/gotenv v1.6.0 // indirect 26 | go.uber.org/atomic v1.9.0 // indirect 27 | go.uber.org/multierr v1.9.0 // indirect 28 | golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect 29 | golang.org/x/sys v0.18.0 // indirect 30 | golang.org/x/text v0.14.0 // indirect 31 | golang.org/x/tools v0.18.0 // indirect 32 | gopkg.in/ini.v1 v1.67.0 // indirect 33 | ) 34 | 35 | require ( 36 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 37 | github.com/olekukonko/tablewriter v0.0.5 38 | github.com/rogpeppe/go-internal v1.12.0 39 | github.com/spf13/pflag v1.0.5 // indirect 40 | github.com/spf13/viper v1.19.0 41 | gopkg.in/yaml.v2 v2.4.0 42 | ) 43 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/coreos/go-iptables v0.6.1-0.20220901214115-d2b8608923d1 h1:zSiUKnogKeEwIIeUQP/WPH7m0BJ/IvW0VyL4muaauUY= 2 | github.com/coreos/go-iptables v0.6.1-0.20220901214115-d2b8608923d1/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 7 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 9 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 10 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 11 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 12 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 13 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 14 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 15 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 16 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 17 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 18 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 19 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 20 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 21 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 22 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 23 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 24 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 25 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 26 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 27 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 28 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 29 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 30 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 31 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= 32 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 33 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 34 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 35 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 36 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 37 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 38 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 39 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 40 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 41 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 42 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= 43 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 44 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 45 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 46 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 47 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 48 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 49 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 50 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= 51 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 52 | github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= 53 | github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= 54 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 55 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 56 | github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= 57 | github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= 58 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 59 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 60 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 61 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 62 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 63 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 64 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 65 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 66 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 67 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 68 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 69 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 70 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 71 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 72 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 73 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 74 | golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= 75 | golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= 76 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 77 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 78 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 79 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 80 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 81 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 82 | golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ= 83 | golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= 84 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 85 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 86 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 87 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 88 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 89 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 90 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 91 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 92 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 93 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 94 | -------------------------------------------------------------------------------- /hack/fwdctl-seccomp.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultAction": "SCMP_ACT_ERRNO", 3 | "architectures": [ 4 | "SCMP_ARCH_X86_64", 5 | "SCMP_ARCH_X86", 6 | "SCMP_ARCH_X32" 7 | ], 8 | "syscalls": [ 9 | { 10 | "names": [ 11 | "access", 12 | "arch_prctl", 13 | "bind", 14 | "brk", 15 | "capget", 16 | "capset", 17 | "chdir", 18 | "clone", 19 | "clone3", 20 | "close", 21 | "dup3", 22 | "epoll_create1", 23 | "epoll_ctl", 24 | "epoll_pwait", 25 | "eventfd", 26 | "eventfd2", 27 | "execve", 28 | "exit_group", 29 | "faccessat2", 30 | "fchown", 31 | "fcntl", 32 | "fstat", 33 | "fstatfs", 34 | "futex", 35 | "getcwd", 36 | "getdents64", 37 | "geteuid", 38 | "getpgrp", 39 | "getpid", 40 | "getppid", 41 | "getrandom", 42 | "getrlimit", 43 | "getsockname", 44 | "gettid", 45 | "ioctl", 46 | "lseek", 47 | "lstat", 48 | "madvise", 49 | "mmap", 50 | "mprotect", 51 | "munmap", 52 | "nanosleep", 53 | "newfstatat", 54 | "openat", 55 | "pipe2", 56 | "prctl", 57 | "pread64", 58 | "prlimit64", 59 | "read", 60 | "recvfrom", 61 | "rseq", 62 | "rt_sigaction", 63 | "rt_sigprocmask", 64 | "rt_sigreturn", 65 | "sched_getaffinity", 66 | "sendto", 67 | "set_robust_list", 68 | "set_tid_address", 69 | "setgid", 70 | "setgroups", 71 | "setrlimit", 72 | "setsid", 73 | "setuid", 74 | "sigaltstack", 75 | "socket", 76 | "stat", 77 | "tgkill", 78 | "wait4", 79 | "waitid", 80 | "write" 81 | ], 82 | "action": "SCMP_ACT_ALLOW" 83 | } 84 | ] 85 | } 86 | -------------------------------------------------------------------------------- /install: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | BINARY_NAME="fwdctl" 6 | KERNEL_NAME=$(uname -s) 7 | MACHINE_HW_NAME=$(uname -m) 8 | BINARY_RELEASE_NAME="${BINARY_NAME}" 9 | CHECKSUMS="checksums.txt" 10 | 11 | cd /tmp 12 | 13 | # download binary + checksum file 14 | curl -s https://api.github.com/repos/alegrey91/fwdctl/releases/latest | \ 15 | grep "browser_download_url" | \ 16 | cut -d : -f 2,3 | \ 17 | tr -d \" | \ 18 | wget -q -i - 19 | 20 | # check if binary has been downloaded, otherwise exit 21 | if [ ! -f "$BINARY_RELEASE_NAME" ]; then 22 | echo "$BINARY_RELEASE_NAME doesn't exists." 23 | exit 1 24 | fi 25 | printf "[download succeded]\n" 26 | 27 | # install binary 28 | chmod +x "$BINARY_RELEASE_NAME" 29 | mv "$BINARY_RELEASE_NAME" "/usr/local/bin/$BINARY_NAME" 30 | printf "[installation succeded]\n" 31 | 32 | # clean up 33 | rm -rf "$BINARY_NAME"* "$CHECKSUMS"* 34 | printf "[clean up succeded]\n" 35 | 36 | -------------------------------------------------------------------------------- /internal/constants/constants.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | var ( 4 | Version = "dev" 5 | ProgramName = "fwdctl" 6 | RulesFile string 7 | ) 8 | -------------------------------------------------------------------------------- /internal/daemon/management.go: -------------------------------------------------------------------------------- 1 | package daemon 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | 9 | "github.com/alegrey91/fwdctl/internal/rules" 10 | "github.com/alegrey91/fwdctl/pkg/iptables" 11 | "github.com/fsnotify/fsnotify" 12 | "github.com/spf13/viper" 13 | ) 14 | 15 | var ( 16 | infoLogger *log.Logger 17 | errorLogger *log.Logger 18 | ) 19 | 20 | func init() { 21 | // Initialize different loggers 22 | infoLogger = log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile) 23 | errorLogger = log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile) 24 | } 25 | 26 | // Print daemon banner 27 | func banner() string { 28 | return ` 29 | ┌─┐┬ ┬┌┬┐┌─┐┌┬┐┬ 30 | ├┤ │││ │││ │ │ 31 | └ └┴┘─┴┘└─┘ ┴ ┴─┘ 32 | ┌┬┐┌─┐┌─┐┌┬┐┌─┐┌┐┌ 33 | ││├─┤├┤ ││││ ││││ 34 | ─┴┘┴ ┴└─┘┴ ┴└─┘┘└┘` 35 | } 36 | 37 | // Start run fwdctl in daemon mode 38 | // The flow has the following steps: 39 | // - Open the rules file 40 | // - Create all the defined forwards 41 | // - Start listening on rules file changes 42 | // - When file changes: 43 | // - Calculate the Diff between old and new ruleset 44 | // - Delete unwanted forwards 45 | // - Create wanted forawrds 46 | // 47 | // - Listen for SIGTERM signals to gracefuly shutdown 48 | // - When SIGTERM signal occurs: 49 | // - Delete all the applied forwards 50 | // - Shutdown the daemon 51 | func Start(ipt *iptables.IPTablesInstance, rulesFile string) int { 52 | infoLogger.Println(banner()) 53 | 54 | err := createPidFile() 55 | if err != nil { 56 | errorLogger.Println(err) 57 | return 1 58 | } 59 | defer func() { 60 | err = removePidFile() 61 | }() 62 | infoLogger.Println("PID file created") 63 | 64 | // preparing rule set from rules file 65 | rulesContent, err := os.Open(rulesFile) 66 | if err != nil { 67 | errorLogger.Printf("error opening file: %v", err) 68 | return 1 69 | } 70 | ruleSet, err := rules.NewRuleSetFromFile(rulesContent) 71 | if err != nil { 72 | errorLogger.Println(err) 73 | return 1 74 | } 75 | // apply all the rules present in rulesFile 76 | for ruleId, rule := range ruleSet.Rules { 77 | err = ipt.CreateForward(&rule) 78 | if err != nil { 79 | infoLogger.Printf("rule %s - %v\n", ruleId, err) 80 | } 81 | } 82 | infoLogger.Println("rules from file have been applied") 83 | 84 | // preparing viper module to manage rules file 85 | v := viper.New() 86 | v.SetConfigFile(rulesFile) 87 | v.OnConfigChange(func(e fsnotify.Event) { 88 | infoLogger.Println("configuration has changed") 89 | rulesContent, err := os.Open(rulesFile) 90 | if err != nil { 91 | errorLogger.Printf("error opening file: %v", err) 92 | return 93 | } 94 | newRuleSet, err := rules.NewRuleSetFromFile(rulesContent) 95 | if err != nil { 96 | errorLogger.Println(err) 97 | return 98 | } 99 | rsd := rules.Diff(ruleSet, newRuleSet) 100 | // delete all the rules to be removed 101 | for _, rule := range rsd.ToRemove { 102 | err = ipt.DeleteForwardByRule(rule) 103 | if err != nil { 104 | errorLogger.Println(err) 105 | } 106 | } 107 | // create all the rules to be added 108 | for _, rule := range rsd.ToAdd { 109 | err = ipt.CreateForward(rule) 110 | if err != nil { 111 | errorLogger.Println(err) 112 | } 113 | } 114 | // set the new rule set as the current one 115 | ruleSet = newRuleSet 116 | }) 117 | v.WatchConfig() 118 | 119 | sigChnl := make(chan os.Signal, 1) 120 | signal.Notify(sigChnl, syscall.SIGTERM) 121 | exitcChnl := make(chan bool, 1) 122 | 123 | go func() { 124 | for { 125 | select { 126 | case <-sigChnl: 127 | // flush rules before exit 128 | err := ipt.DeleteAll() 129 | if err != nil { 130 | errorLogger.Println(err) 131 | } 132 | infoLogger.Println("daemon stopped") 133 | exitcChnl <- true 134 | default: 135 | continue 136 | } 137 | } 138 | }() 139 | <-exitcChnl 140 | return 0 141 | } 142 | 143 | // Stop send a SIGTERM signal to the daemon process 144 | func Stop() { 145 | infoLogger.Println("stopping daemon") 146 | pid, err := readPidFile() 147 | if err != nil { 148 | errorLogger.Println(err) 149 | } 150 | err = syscall.Kill(pid, syscall.SIGTERM) 151 | if err != nil { 152 | errorLogger.Println(err) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /internal/daemon/pid_file.go: -------------------------------------------------------------------------------- 1 | package daemon 2 | 3 | import ( 4 | "os" 5 | "strconv" 6 | ) 7 | 8 | var ( 9 | pidFilePath = "/tmp/fwdctl.pid" 10 | ) 11 | 12 | // Create PID file 13 | func createPidFile() error { 14 | pid := []byte(strconv.Itoa(os.Getpid())) 15 | err := os.WriteFile(pidFilePath, pid, 0644) 16 | if err != nil { 17 | return err 18 | } 19 | return nil 20 | } 21 | 22 | // Retrieve PID by reading file content 23 | func readPidFile() (int, error) { 24 | pidB, err := os.ReadFile(pidFilePath) 25 | if err != nil { 26 | return 0, err 27 | } 28 | pid, err := strconv.Atoi(string(pidB)) 29 | if err != nil { 30 | return 0, err 31 | } 32 | return pid, nil 33 | } 34 | 35 | // Remove PID file 36 | func removePidFile() error { 37 | err := os.Remove(pidFilePath) 38 | if err != nil { 39 | return err 40 | } 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /internal/printer/json.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/alegrey91/fwdctl/internal/rules" 8 | "github.com/alegrey91/fwdctl/pkg/iptables" 9 | ) 10 | 11 | type Json struct { 12 | } 13 | 14 | func NewJson() *Json { 15 | return &Json{} 16 | } 17 | 18 | func (j *Json) PrintResult(ruleList map[int]string) error { 19 | rules := rules.NewRuleSet() 20 | for _, rule := range ruleList { 21 | jsonRule, err := iptables.ExtractRuleInfo(rule) 22 | if err != nil { 23 | continue 24 | } 25 | 26 | rules.Add(*jsonRule) 27 | } 28 | val, err := json.MarshalIndent(rules.Rules, "", " ") 29 | if err != nil { 30 | return err 31 | } 32 | 33 | fmt.Println(string(val)) 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /internal/printer/printer_interface.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | type Printer interface { 4 | PrintResult(ruleList map[int]string) error 5 | } 6 | 7 | func NewPrinter(printFormat string) Printer { 8 | switch printFormat { 9 | case "table": 10 | return NewTable() 11 | case "json": 12 | return NewJson() 13 | case "yaml": 14 | return NewYaml() 15 | default: 16 | return NewTable() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /internal/printer/table.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/alegrey91/fwdctl/pkg/iptables" 8 | "github.com/olekukonko/tablewriter" 9 | ) 10 | 11 | type Table struct { 12 | } 13 | 14 | func NewTable() *Table { 15 | return &Table{} 16 | } 17 | 18 | func (t *Table) PrintResult(ruleList map[int]string) error { 19 | table := tablewriter.NewWriter(os.Stdout) 20 | table.SetHeader([]string{"number", "interface", "protocol", "external port", "internal ip", "internal port"}) 21 | for ruleId, rule := range ruleList { 22 | tabRule, err := iptables.ExtractRuleInfo(rule) 23 | if err != nil { 24 | continue 25 | } 26 | tabRow := []string{ 27 | fmt.Sprintf("%d", ruleId), 28 | tabRule.Iface, 29 | tabRule.Proto, 30 | fmt.Sprintf("%d", tabRule.Dport), 31 | tabRule.Saddr, 32 | fmt.Sprintf("%d", tabRule.Sport), 33 | } 34 | table.Append(tabRow) 35 | } 36 | table.Render() 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /internal/printer/yaml.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "fmt" 5 | 6 | yaml "gopkg.in/yaml.v3" 7 | 8 | "github.com/alegrey91/fwdctl/internal/rules" 9 | "github.com/alegrey91/fwdctl/pkg/iptables" 10 | ) 11 | 12 | type Yaml struct { 13 | } 14 | 15 | func NewYaml() *Yaml { 16 | return &Yaml{} 17 | } 18 | 19 | func (y *Yaml) PrintResult(ruleList map[int]string) error { 20 | rules := rules.NewRuleSet() 21 | for _, rule := range ruleList { 22 | jsonRule, err := iptables.ExtractRuleInfo(rule) 23 | if err != nil { 24 | continue 25 | } 26 | 27 | rules.Add(*jsonRule) 28 | } 29 | val, err := yaml.Marshal(rules.Rules) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | fmt.Println(string(val)) 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /internal/rules/ruleset.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "fmt" 7 | "io" 8 | 9 | "github.com/alegrey91/fwdctl/pkg/iptables" 10 | "gopkg.in/yaml.v2" 11 | ) 12 | 13 | func NewRuleSet() *RuleSet { 14 | return &RuleSet{ 15 | Rules: make(map[string]iptables.Rule), 16 | } 17 | } 18 | 19 | // NewRuleSet return the struct that contains informations about rules 20 | func NewRuleSetFromFile(file io.Reader) (*RuleSet, error) { 21 | // Read rules from file 22 | rulesFile, err := io.ReadAll(file) 23 | if err != nil { 24 | return nil, fmt.Errorf("error reading file: %v", err) 25 | } 26 | 27 | // Retrieve rules to fill RuleSet 28 | rules := supportRuleSet{} 29 | err = yaml.Unmarshal(rulesFile, &rules) 30 | if err != nil { 31 | return nil, fmt.Errorf("error unmarshaling content: %v", err) 32 | } 33 | 34 | // Fill RuleSet with rules taken from file 35 | rs := NewRuleSet() 36 | for _, rule := range rules.Rules { 37 | ruleHash := hash(rule) 38 | rs.Rules[ruleHash] = rule 39 | } 40 | return rs, nil 41 | } 42 | 43 | func hash(rule iptables.Rule) string { 44 | md5.New() 45 | ruleString := fmt.Sprintf("%s%s%d%s%d", 46 | rule.Iface, 47 | rule.Proto, 48 | rule.Dport, 49 | rule.Saddr, 50 | rule.Sport, 51 | ) 52 | hash := md5.Sum([]byte(ruleString)) 53 | return hex.EncodeToString(hash[:]) 54 | } 55 | 56 | func (rs *RuleSet) GetHash(rule iptables.Rule) string { 57 | return hash(rule) 58 | } 59 | 60 | func (rs *RuleSet) Add(rule iptables.Rule) { 61 | ruleHash := hash(rule) 62 | rs.Rules[ruleHash] = rule 63 | } 64 | 65 | func (rs *RuleSet) Remove(ruleHash string) { 66 | delete(rs.Rules, ruleHash) 67 | } 68 | 69 | type RuleSetDiff struct { 70 | ToRemove []*iptables.Rule 71 | ToAdd []*iptables.Rule 72 | } 73 | 74 | // Diff method returns a *RuleSetDiff struct. 75 | // It contains a list of Rule(s) to be added / remove 76 | // in order to achieve the new RuleSet state. 77 | func Diff(oldRS, newRS *RuleSet) *RuleSetDiff { 78 | ruleSetDiff := &RuleSetDiff{} 79 | // loop over old rules set, to find rules to be removed 80 | for hash := range oldRS.Rules { 81 | // if key in oldRules is not present in rs, 82 | // then the old rule must be removed 83 | if _, ok := newRS.Rules[hash]; !ok { 84 | rule := oldRS.Rules[hash] 85 | ruleSetDiff.ToRemove = append(ruleSetDiff.ToRemove, &rule) 86 | } 87 | } 88 | // loop over new rules set, to find rules to be added 89 | for hash := range newRS.Rules { 90 | // if key in rs in not present in oldRs, 91 | // then the new rule must be added 92 | if _, ok := oldRS.Rules[hash]; !ok { 93 | rule := newRS.Rules[hash] 94 | ruleSetDiff.ToAdd = append(ruleSetDiff.ToAdd, &rule) 95 | } 96 | } 97 | return ruleSetDiff 98 | } 99 | -------------------------------------------------------------------------------- /internal/rules/ruleset_test.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/alegrey91/fwdctl/pkg/iptables" 9 | ) 10 | 11 | func TestDiff(t *testing.T) { 12 | type args struct { 13 | newRS string 14 | oldRS string 15 | } 16 | tests := []struct { 17 | name string 18 | args args 19 | want *RuleSetDiff 20 | }{ 21 | // TODO: Add test cases. 22 | { 23 | name: "should_return_empty_RuleSetDiff", 24 | args: args{ 25 | newRS: "", 26 | oldRS: "", 27 | }, 28 | want: &RuleSetDiff{}, 29 | }, 30 | { 31 | name: "should_return_two_Rules_to_be_added", 32 | args: args{ 33 | newRS: ` 34 | rules: 35 | - dport: 3000 36 | saddr: 127.0.0.1 37 | sport: 80 38 | iface: lo 39 | proto: tcp 40 | - dport: 2000 41 | saddr: 127.0.0.1 42 | sport: 22 43 | iface: lo 44 | proto: tcp 45 | `, 46 | oldRS: "", 47 | }, 48 | want: &RuleSetDiff{ 49 | ToAdd: []*iptables.Rule{ 50 | { 51 | Iface: "lo", 52 | Proto: "tcp", 53 | Dport: 3000, 54 | Saddr: "127.0.0.1", 55 | Sport: 80, 56 | }, 57 | { 58 | Iface: "lo", 59 | Proto: "tcp", 60 | Dport: 2000, 61 | Saddr: "127.0.0.1", 62 | Sport: 22, 63 | }, 64 | }, 65 | }, 66 | }, 67 | { 68 | name: "should_return_two_Rules_to_be_removed", 69 | args: args{ 70 | newRS: "", 71 | oldRS: ` 72 | rules: 73 | - dport: 3000 74 | saddr: 127.0.0.1 75 | sport: 80 76 | iface: lo 77 | proto: tcp 78 | - dport: 2000 79 | saddr: 127.0.0.1 80 | sport: 22 81 | iface: lo 82 | proto: tcp 83 | `, 84 | }, 85 | want: &RuleSetDiff{ 86 | ToRemove: []*iptables.Rule{ 87 | { 88 | Iface: "lo", 89 | Proto: "tcp", 90 | Dport: 3000, 91 | Saddr: "127.0.0.1", 92 | Sport: 80, 93 | }, 94 | { 95 | Iface: "lo", 96 | Proto: "tcp", 97 | Dport: 2000, 98 | Saddr: "127.0.0.1", 99 | Sport: 22, 100 | }, 101 | }, 102 | }, 103 | }, 104 | } 105 | for _, tt := range tests { 106 | t.Run(tt.name, func(t *testing.T) { 107 | newRuleSet, err := NewRuleSetFromFile(strings.NewReader(tt.args.newRS)) 108 | if err != nil { 109 | t.Error(err) 110 | } 111 | oldRuleSet, err := NewRuleSetFromFile(strings.NewReader(tt.args.oldRS)) 112 | if err != nil { 113 | t.Error(err) 114 | } 115 | if got := Diff(oldRuleSet, newRuleSet); !reflect.DeepEqual(got, tt.want) { 116 | t.Errorf("Diff() = %v, want %v", got, tt.want) 117 | } 118 | }) 119 | } 120 | } 121 | 122 | func TestNewRuleSetFromFile(t *testing.T) { 123 | type args struct { 124 | file string 125 | } 126 | tests := []struct { 127 | name string 128 | args args 129 | want *RuleSet 130 | wantErr bool 131 | }{ 132 | // TODO: Add test cases. 133 | { 134 | name: "should_return_error", 135 | args: args{ 136 | file: ` 137 | { 138 | "rules": { 139 | "dport": 3000, 140 | "saddr": "127.0.0.1", 141 | "sport": 80, 142 | "iface": "lo", 143 | "proto": "tcp", 144 | }, 145 | } 146 | `, 147 | }, 148 | want: nil, 149 | wantErr: true, 150 | }, 151 | { 152 | name: "should_return_valid_resultset", 153 | args: args{ 154 | file: ` 155 | rules: 156 | - dport: 3000 157 | saddr: 127.0.0.1 158 | sport: 80 159 | iface: lo 160 | proto: tcp 161 | `, 162 | }, 163 | want: &RuleSet{ 164 | Rules: map[string]iptables.Rule{ 165 | "0be1c5f4141015ca6a8e873344da06e6": { 166 | Iface: "lo", 167 | Proto: "tcp", 168 | Dport: 3000, 169 | Saddr: "127.0.0.1", 170 | Sport: 80, 171 | }, 172 | }, 173 | }, 174 | wantErr: false, 175 | }, 176 | } 177 | for _, tt := range tests { 178 | t.Run(tt.name, func(t *testing.T) { 179 | got, err := NewRuleSetFromFile(strings.NewReader(tt.args.file)) 180 | if (err != nil) != tt.wantErr { 181 | t.Errorf("NewRuleSetFromFile() error = %v, wantErr %v", err, tt.wantErr) 182 | return 183 | } 184 | if !reflect.DeepEqual(got, tt.want) { 185 | t.Errorf("NewRuleSetFromFile() = %v, want %v", got, tt.want) 186 | } 187 | }) 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /internal/rules/types.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "github.com/alegrey91/fwdctl/pkg/iptables" 5 | ) 6 | 7 | // RulesFile keep track of listed rules 8 | // the result looks like so: 9 | // rules: 10 | // - iface: eth0 11 | // proto: tcp 12 | // dport: 3000 13 | // saddr: 192.168.122.43 14 | // sport: 22 15 | // - iface: eth0 16 | // ... 17 | type RuleSet struct { 18 | //Rules []iptables.Rule `yaml:"rules"` 19 | Rules map[string]iptables.Rule `yaml:"rules"` 20 | } 21 | 22 | // Struct to support creation of RuleSet 23 | type supportRuleSet struct { 24 | Rules []iptables.Rule `yaml:"rules"` 25 | } 26 | -------------------------------------------------------------------------------- /internal/template/rules_template/rules.go: -------------------------------------------------------------------------------- 1 | package rules_template 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | //go:embed rules.yml.tpl 8 | var rulesTemplate string 9 | var rulesTemplateName = "rules" 10 | var rulesFileName = "rules.yml" 11 | 12 | type Rule struct { 13 | } 14 | 15 | func NewRules() *Rule { 16 | return &Rule{} 17 | } 18 | 19 | func (r *Rule) GetTemplateStruct() interface{} { 20 | return r 21 | } 22 | 23 | func (r *Rule) GetFileContent() string { 24 | return rulesTemplate 25 | } 26 | 27 | func (r *Rule) GetTemplateName() string { 28 | return rulesTemplateName 29 | } 30 | 31 | func (r *Rule) GetFileName() string { 32 | return rulesFileName 33 | } 34 | -------------------------------------------------------------------------------- /internal/template/rules_template/rules.yml.tpl: -------------------------------------------------------------------------------- 1 | rules: 2 | - dport: 3 | saddr: 4 | sport: 5 | iface: 6 | proto: 7 | -------------------------------------------------------------------------------- /internal/template/systemd_template/fwdctl.service.tpl: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=fwdctl systemd service 3 | After=network.target 4 | 5 | [Service] 6 | Type={{.ServiceType}} 7 | {{if eq .ServiceType "oneshot"}}ExecStart={{.InstallationPath}}/fwdctl apply --file={{.RulesFile}}{{else if eq .ServiceType "fork"}}ExecStart={{.InstallationPath}}/fwdctl daemon start --file={{.RulesFile}} 8 | ExecStop={{.InstallationPath}}/fwdctl daemon stop 9 | Restart=always 10 | RestartSec=5s{{end}} 11 | StandardOutput=journal 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /internal/template/systemd_template/systemd_service.go: -------------------------------------------------------------------------------- 1 | package systemd_template 2 | 3 | import ( 4 | _ "embed" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | //go:embed fwdctl.service.tpl 12 | var systemdTemplate string 13 | var systemdTemplateName = "systemd" 14 | var systemdFileName = "fwdctl.service" 15 | var allowedServiceTypes = [2]string{"oneshot", "fork"} 16 | 17 | type SystemdService struct { 18 | ServiceType string 19 | InstallationPath string 20 | RulesFile string 21 | } 22 | 23 | func serviceTypeAllowed(st string) bool { 24 | for _, ast := range allowedServiceTypes { 25 | if ast == st { 26 | return true 27 | } 28 | } 29 | return false 30 | } 31 | 32 | func NewSystemdService(serviceType, installationPath, rulesFile string) (*SystemdService, error) { 33 | // checks for systemd service type 34 | if !serviceTypeAllowed(serviceType) { 35 | return nil, fmt.Errorf("service type is not allowed: %s", serviceType) 36 | } 37 | // checks for installation path 38 | if !filepath.IsAbs(installationPath) { 39 | return nil, fmt.Errorf("installation path is not absolute: %s", installationPath) 40 | } 41 | if _, err := os.Stat(installationPath); errors.Is(err, os.ErrNotExist) { 42 | return nil, fmt.Errorf("installation path does not exist: %s", installationPath) 43 | } 44 | // checks for rules file 45 | if !filepath.IsAbs(rulesFile) { 46 | return nil, fmt.Errorf("rules file path is not absolute: %s", rulesFile) 47 | } 48 | if _, err := os.Stat(rulesFile); errors.Is(err, os.ErrNotExist) { 49 | return nil, fmt.Errorf("rules file path does not exist: %s", rulesFile) 50 | } 51 | 52 | return &SystemdService{ 53 | ServiceType: serviceType, 54 | InstallationPath: installationPath, 55 | RulesFile: rulesFile, 56 | }, nil 57 | } 58 | 59 | func (s *SystemdService) GetTemplateStruct() interface{} { 60 | return s 61 | } 62 | 63 | func (s *SystemdService) GetFileContent() string { 64 | return systemdTemplate 65 | } 66 | 67 | func (s *SystemdService) GetTemplateName() string { 68 | return systemdTemplateName 69 | } 70 | 71 | func (s *SystemdService) GetFileName() string { 72 | return systemdFileName 73 | } 74 | -------------------------------------------------------------------------------- /internal/template/systemd_template/systemd_service_test.go: -------------------------------------------------------------------------------- 1 | package systemd_template 2 | 3 | import ( 4 | _ "embed" 5 | "os" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | func Test_serviceTypeAllowed(t *testing.T) { 11 | type args struct { 12 | st string 13 | } 14 | tests := []struct { 15 | name string 16 | args args 17 | want bool 18 | }{ 19 | // TODO: Add test cases. 20 | { 21 | name: "service_type_not_allowed", 22 | args: args{ 23 | st: "none", 24 | }, 25 | want: false, 26 | }, 27 | { 28 | name: "service_type_allowed", 29 | args: args{ 30 | st: "fork", 31 | }, 32 | want: true, 33 | }, 34 | } 35 | for _, tt := range tests { 36 | t.Run(tt.name, func(t *testing.T) { 37 | if got := serviceTypeAllowed(tt.args.st); got != tt.want { 38 | t.Errorf("serviceTypeAllowed() = %v, want %v", got, tt.want) 39 | } 40 | }) 41 | } 42 | } 43 | 44 | func TestNewSystemdService(t *testing.T) { 45 | type args struct { 46 | serviceType string 47 | installationPath string 48 | rulesFile string 49 | } 50 | tests := []struct { 51 | name string 52 | args args 53 | want *SystemdService 54 | wantErr bool 55 | }{ 56 | // TODO: Add test cases. 57 | { 58 | name: "should_return_valid_struct", 59 | args: args{ 60 | serviceType: "fork", 61 | installationPath: "/opt/", 62 | rulesFile: "/tmp/rules.yml", 63 | }, 64 | want: &SystemdService{ 65 | ServiceType: "fork", 66 | InstallationPath: "/opt/", 67 | RulesFile: "/tmp/rules.yml", 68 | }, 69 | wantErr: false, 70 | }, 71 | { 72 | name: "should_return_nil_due_to_wrong_service_type", 73 | args: args{ 74 | serviceType: "aaa", 75 | installationPath: "/opt/", 76 | rulesFile: "/home/user/rules.yml", 77 | }, 78 | want: nil, 79 | wantErr: true, 80 | }, 81 | { 82 | name: "should_return_nil_due_to_wrong_installation_path", 83 | args: args{ 84 | serviceType: "oneshot", 85 | installationPath: "../../tmp/", 86 | rulesFile: "/home/user/rules.yml", 87 | }, 88 | want: nil, 89 | wantErr: true, 90 | }, 91 | { 92 | name: "should_return_nil_due_to_wrong_installation_path_2", 93 | args: args{ 94 | serviceType: "oneshot", 95 | installationPath: "/this/path/doesnt/exist", 96 | rulesFile: "/home/user/rules.yml", 97 | }, 98 | want: nil, 99 | wantErr: true, 100 | }, 101 | { 102 | name: "should_return_nil_due_to_wrong_rules_file", 103 | args: args{ 104 | serviceType: "oneshot", 105 | installationPath: "/opt/", 106 | rulesFile: "rules.yml", 107 | }, 108 | want: nil, 109 | wantErr: true, 110 | }, 111 | { 112 | name: "should_return_nil_due_to_wrong_rules_file_2", 113 | args: args{ 114 | serviceType: "oneshot", 115 | installationPath: "/opt/", 116 | rulesFile: "/this/path/doesnt/exist/rules.yml", 117 | }, 118 | want: nil, 119 | wantErr: true, 120 | }, 121 | } 122 | fileName := "/tmp/rules.yml" 123 | _, err := os.Stat(fileName) 124 | if os.IsNotExist(err) { 125 | file, err := os.Create(fileName) 126 | if err != nil { 127 | t.Fatal(err) 128 | } 129 | defer file.Close() 130 | } 131 | for _, tt := range tests { 132 | t.Run(tt.name, func(t *testing.T) { 133 | got, err := NewSystemdService(tt.args.serviceType, tt.args.installationPath, tt.args.rulesFile) 134 | if (err != nil) != tt.wantErr { 135 | t.Errorf("NewSystemdService() error = %v, wantErr %v", err, tt.wantErr) 136 | return 137 | } 138 | if !reflect.DeepEqual(got, tt.want) { 139 | t.Errorf("NewSystemdService() = %v, want %v", got, tt.want) 140 | } 141 | }) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /internal/template/template.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "text/template" 9 | ) 10 | 11 | type Generator interface { 12 | GetTemplateStruct() interface{} 13 | GetFileContent() string 14 | GetTemplateName() string 15 | GetFileName() string 16 | } 17 | 18 | func GenerateTemplate(g Generator, outputPath string) error { 19 | tpl, err := template.New(g.GetTemplateName()).Parse(g.GetFileContent()) 20 | if err != nil { 21 | return fmt.Errorf("error getting template instance: %v", err) 22 | } 23 | 24 | if !filepath.IsAbs(outputPath) { 25 | return fmt.Errorf("output path is not absolute: %s", outputPath) 26 | } 27 | // if last char of outputPath is "/" we want to remove, 28 | // so the final output will be cleaned. 29 | // this way: /root/template.file instead of /root//template.file 30 | if outputPath != "/" && outputPath[len(outputPath)-1:] == "/" { 31 | outputPath = strings.TrimSuffix(outputPath, "/") 32 | } 33 | 34 | outFile, err := os.Create(filepath.Join(outputPath, g.GetFileName())) 35 | if err != nil { 36 | return fmt.Errorf("error creating output file: %v", err) 37 | } 38 | 39 | err = tpl.Execute(outFile, g.GetTemplateStruct()) 40 | if err != nil { 41 | return fmt.Errorf("error writing content into file: %v", err) 42 | } 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /internal/template/template_test.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import "testing" 4 | 5 | type Test struct { 6 | } 7 | 8 | func NewTest() *Test { 9 | return &Test{} 10 | } 11 | 12 | func (t *Test) GetTemplateStruct() interface{} { 13 | return t 14 | } 15 | 16 | func (t *Test) GetFileContent() string { 17 | return "this_is_the_content" 18 | } 19 | 20 | func (t *Test) GetTemplateName() string { 21 | return "this_is_the_name" 22 | } 23 | 24 | func (t *Test) GetFileName() string { 25 | return "this_is_the_file_name" 26 | } 27 | 28 | func TestGenerateTemplate(t *testing.T) { 29 | type args struct { 30 | g Generator 31 | outputPath string 32 | } 33 | tests := []struct { 34 | name string 35 | args args 36 | wantErr bool 37 | }{ 38 | // TODO: Add test cases. 39 | { 40 | name: "this_should_pass", 41 | args: args{ 42 | g: NewTest(), 43 | outputPath: "/tmp/", 44 | }, 45 | wantErr: false, 46 | }, 47 | { 48 | name: "this_should_fail_due_to_wrong_output_path", 49 | args: args{ 50 | g: NewTest(), 51 | outputPath: "tmp/", 52 | }, 53 | wantErr: true, 54 | }, 55 | } 56 | for _, tt := range tests { 57 | t.Run(tt.name, func(t *testing.T) { 58 | if err := GenerateTemplate(tt.args.g, tt.args.outputPath); (err != nil) != tt.wantErr { 59 | t.Errorf("GenerateTemplate() error = %v, wantErr %v", err, tt.wantErr) 60 | } 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 Alessio Greggi 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package main 17 | 18 | import "github.com/alegrey91/fwdctl/cmd" 19 | 20 | func main() { 21 | cmd.Execute() 22 | } 23 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/rogpeppe/go-internal/testscript" 7 | ) 8 | 9 | func TestFwdctl(t *testing.T) { 10 | testscript.Run(t, testscript.Params{ 11 | TestWork: true, 12 | Dir: "tests", 13 | Cmds: customCommands(), 14 | RequireExplicitExec: true, 15 | Setup: func(env *testscript.Env) error { 16 | env.Setenv("GOCOVERDIR", "/tmp/integration") 17 | return nil 18 | }, 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/iptables/defaults.go: -------------------------------------------------------------------------------- 1 | package iptables 2 | 3 | var ( 4 | FwdTable = "nat" 5 | FwdChain = "PREROUTING" 6 | FwdTarget = "DNAT" 7 | ) 8 | -------------------------------------------------------------------------------- /pkg/iptables/forward.go: -------------------------------------------------------------------------------- 1 | package iptables 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/coreos/go-iptables/iptables" 9 | ) 10 | 11 | var ( 12 | label string = "fwdctl" 13 | ) 14 | 15 | type IPTablesInstance struct { 16 | *iptables.IPTables 17 | } 18 | 19 | func NewIPTablesInstance() (*IPTablesInstance, error) { 20 | ipt := IPTablesInstance{} 21 | iptables, err := getIPTablesInstance() 22 | if err != nil { 23 | return nil, fmt.Errorf("failed: %v", err) 24 | } 25 | ipt.IPTables = iptables 26 | return &ipt, nil 27 | } 28 | 29 | func (ipt *IPTablesInstance) ValidateForward(rule *Rule) error { 30 | return validate(rule.Iface, rule.Proto, rule.Dport, rule.Saddr, rule.Sport) 31 | } 32 | 33 | func (ipt *IPTablesInstance) CreateForward(rule *Rule) error { 34 | // example rule: 35 | // iptables -t nat -A PREROUTING -i eth0 -p tcp -m tcp --dport 3000 -j DNAT --to-destination 192.168.199.105:80 36 | 37 | // check if input interface exists on the system 38 | ifaceExits, err := interfaceExists(rule.Iface) 39 | if err != nil { 40 | return fmt.Errorf("error reading interfaces: %v", err) 41 | } 42 | if !ifaceExits { 43 | return fmt.Errorf("interface %s does not exists", rule.Iface) 44 | } 45 | 46 | // check if provided rule already exists 47 | ruleExists, err := ipt.Exists(FwdTable, FwdChain, rule.String()...) 48 | if err != nil { 49 | return fmt.Errorf("%v", err) 50 | } 51 | if ruleExists { 52 | return fmt.Errorf("rule already exists") 53 | } 54 | 55 | // apply provided rule 56 | err = ipt.AppendUnique(FwdTable, FwdChain, rule.String()...) 57 | if err != nil { 58 | return fmt.Errorf("rule failed: %v", err) 59 | } 60 | return nil 61 | } 62 | 63 | func (ipt *IPTablesInstance) ListForward(outputFormat string) (map[int]string, error) { 64 | ruleList, err := ipt.List(FwdTable, FwdChain) 65 | if err != nil { 66 | return nil, fmt.Errorf("failed listing rules: %v", err) 67 | } 68 | 69 | // check listed rules are tagged with custom tag 70 | fwdRules := make(map[int]string) 71 | for ruleId, rule := range ruleList { 72 | if strings.Contains(rule, label) { 73 | fwdRules[ruleId] = rule 74 | } 75 | } 76 | 77 | return fwdRules, nil 78 | } 79 | 80 | func (ipt *IPTablesInstance) DeleteForwardById(ruleId int) error { 81 | // delete rule 82 | err := ipt.Delete(FwdTable, FwdChain, strconv.Itoa(ruleId)) 83 | if err != nil { 84 | return fmt.Errorf("failed deleting rule n. %d\nerr: %v", ruleId, err) 85 | } 86 | return nil 87 | } 88 | 89 | func (ipt *IPTablesInstance) DeleteForwardByRule(rule *Rule) error { 90 | // TODO: create function to return []string with packed rule, passing iface, proto, etc as arguments. 91 | err := ipt.Delete(FwdTable, FwdChain, rule.String()...) 92 | if err != nil { 93 | return fmt.Errorf("failed deleting rule: '%s'\n err: %v", rule.String(), err) 94 | } 95 | return nil 96 | } 97 | 98 | func (ipt *IPTablesInstance) DeleteAllForwards() error { 99 | ruleList, err := ipt.List(FwdTable, FwdChain) 100 | if err != nil { 101 | return fmt.Errorf("failed listing rules: %v", err) 102 | } 103 | 104 | // check listed rules are tagged with custom tag 105 | fwdRules := make(map[int]string) 106 | for ruleId, rule := range ruleList { 107 | if strings.Contains(rule, label) { 108 | fwdRules[ruleId] = rule 109 | } 110 | } 111 | 112 | for _, rule := range fwdRules { 113 | r, err := ExtractRuleInfo(rule) 114 | if err != nil { 115 | return fmt.Errorf("error extracting rule info: %v", err) 116 | } 117 | 118 | err = ipt.Delete(FwdTable, FwdChain, r.String()...) 119 | if err != nil { 120 | return fmt.Errorf("error deleting rule: %v", err) 121 | } 122 | } 123 | return nil 124 | } 125 | -------------------------------------------------------------------------------- /pkg/iptables/interface.go: -------------------------------------------------------------------------------- 1 | package iptables 2 | 3 | import ( 4 | "net" 5 | ) 6 | 7 | func interfaceExists(iface string) (bool, error) { 8 | ifi, err := net.InterfaceByName(iface) 9 | if err != nil { 10 | return false, err 11 | } 12 | 13 | if ifi != nil { 14 | return true, nil 15 | } 16 | return false, nil 17 | } 18 | -------------------------------------------------------------------------------- /pkg/iptables/interface_test.go: -------------------------------------------------------------------------------- 1 | package iptables 2 | 3 | import "testing" 4 | 5 | func Test_interfaceExists(t *testing.T) { 6 | type args struct { 7 | iface string 8 | } 9 | tests := []struct { 10 | name string 11 | args args 12 | want bool 13 | wantErr bool 14 | }{ 15 | // TODO: Add test cases. 16 | { 17 | name: "should_exist", 18 | args: args{ 19 | iface: "lo", 20 | }, 21 | want: true, 22 | wantErr: false, 23 | }, 24 | { 25 | name: "should_not_exist", 26 | args: args{ 27 | iface: "xyz0", 28 | }, 29 | want: false, 30 | wantErr: true, 31 | }, 32 | { 33 | name: "should_exist", 34 | args: args{ 35 | iface: "lo", 36 | }, 37 | want: true, 38 | wantErr: false, 39 | }, 40 | } 41 | for _, tt := range tests { 42 | t.Run(tt.name, func(t *testing.T) { 43 | got, err := interfaceExists(tt.args.iface) 44 | if (err != nil) != tt.wantErr { 45 | t.Errorf("interfaceExists() error = %v, wantErr %v", err, tt.wantErr) 46 | return 47 | } 48 | if got != tt.want { 49 | t.Errorf("interfaceExists() = %v, want %v", got, tt.want) 50 | } 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pkg/iptables/rule.go: -------------------------------------------------------------------------------- 1 | package iptables 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | type Rule struct { 10 | Iface string `json:"iface" yaml:"iface" default:"lo"` 11 | Proto string `json:"proto" yaml:"proto" default:"tcp"` 12 | Dport int `json:"dport" yaml:"dport"` 13 | Saddr string `json:"saddr" yaml:"saddr"` 14 | Sport int `json:"sport" yaml:"sport"` 15 | Comment string `json:"comment,omitempty" yaml:"comment,omitempty"` 16 | } 17 | 18 | func NewRule(iface string, proto string, dport int, saddr string, sport int) *Rule { 19 | return &Rule{ 20 | Iface: iface, 21 | Proto: proto, 22 | Dport: dport, 23 | Saddr: saddr, 24 | Sport: sport, 25 | Comment: label, 26 | } 27 | } 28 | 29 | // ExtractRuleInfo extract forward information from rule 30 | // if it matches the requirements. 31 | // Returns the Rule struct and error 32 | func ExtractRuleInfo(rawRule string) (*Rule, error) { 33 | // extract rules info: 34 | // -t nat -A PREROUTING -i eth0 -p tcp -m tcp --dport 3000 -j DNAT --to-destination 192.168.199.105:80 35 | // result: 36 | // Rule{Iface: eth0, Proto: tcp, Dport: 3000, Saddr: 192.168.199.105, Sport: 80} 37 | ruleSplit := strings.Split(rawRule, " ") 38 | rule := &Rule{} 39 | for id, arg := range ruleSplit { 40 | switch arg { 41 | case "-i": 42 | rule.Iface = ruleSplit[id+1] 43 | case "-p": 44 | rule.Proto = ruleSplit[id+1] 45 | case "--dport": 46 | dport, err := strconv.Atoi(ruleSplit[id+1]) 47 | if err != nil { 48 | return nil, fmt.Errorf("error converting string '%s' to int: %v", ruleSplit[id+1], err) 49 | } 50 | rule.Dport = dport 51 | case "--to-destination": 52 | rule.Saddr = strings.Split(ruleSplit[id+1], ":")[0] 53 | sport, err := strconv.Atoi(strings.Split(ruleSplit[id+1], ":")[1]) 54 | if err != nil { 55 | return nil, fmt.Errorf("error converting string '%s' to int: %v", ruleSplit[id+1], err) 56 | } 57 | rule.Sport = sport 58 | } 59 | } 60 | if rule.Iface == "" { 61 | return nil, fmt.Errorf("missing iface value") 62 | } 63 | if rule.Proto == "" { 64 | return nil, fmt.Errorf("missing proto value") 65 | } 66 | if rule.Dport == 0 { 67 | return nil, fmt.Errorf("missing dport value") 68 | } 69 | if rule.Saddr == "" { 70 | return nil, fmt.Errorf("missing saddr value") 71 | } 72 | if rule.Sport == 0 { 73 | return nil, fmt.Errorf("missing sport value") 74 | } 75 | 76 | return rule, nil 77 | } 78 | 79 | // String returns a list of string that compose the iptables rule. 80 | // Eg: -t nat -A PREROUTING -i eth0 -p tcp -m tcp --dport 3000 -m comment --comment fwdctl -j DNAT --to-destination 192.168.199.105:80 81 | func (rule *Rule) String() []string { 82 | return []string{ 83 | "-i", rule.Iface, 84 | "-p", rule.Proto, 85 | "-m", rule.Proto, 86 | "--dport", fmt.Sprintf("%d", rule.Dport), 87 | "-m", "comment", "--comment", label, 88 | "-j", FwdTarget, 89 | "--to-destination", rule.Saddr + ":" + fmt.Sprintf("%d", rule.Sport), 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /pkg/iptables/rule_test.go: -------------------------------------------------------------------------------- 1 | package iptables 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestExtractRuleInfo(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | rule string 12 | want *Rule 13 | wantErr bool 14 | }{ 15 | // TODO: Add test cases. 16 | { 17 | name: "example_1", 18 | rule: "-A PREROUTING -i lo -p tcp -m tcp --dport 3001 -m comment --comment fwdctl -j DNAT --to-destination 127.0.0.1:80", 19 | want: &Rule{ 20 | Iface: "lo", 21 | Proto: "tcp", 22 | Dport: 3001, 23 | Saddr: "127.0.0.1", 24 | Sport: 80, 25 | }, 26 | }, 27 | { 28 | name: "example_2", 29 | rule: "-A PREROUTING -i eth0 -p tcp -m tcp --dport 3001 -m comment --comment fwdctl -j DNAT --to-destination 127.0.0.1:80", 30 | want: &Rule{ 31 | Iface: "eth0", 32 | Proto: "tcp", 33 | Dport: 3001, 34 | Saddr: "127.0.0.1", 35 | Sport: 80, 36 | }, 37 | }, 38 | { 39 | name: "example_3", 40 | rule: "-A PREROUTING -i eth0 -p tcp -m tcp --dport 3001 -m comment --comment fwdctl -j DNAT", 41 | want: nil, 42 | wantErr: true, 43 | }, 44 | } 45 | for _, tt := range tests { 46 | t.Run(tt.name, func(t *testing.T) { 47 | got, err := ExtractRuleInfo(tt.rule) 48 | if (err != nil) != tt.wantErr { 49 | t.Errorf("ExtractRuleInfo() error = %v", err) 50 | return 51 | } 52 | if !reflect.DeepEqual(got, tt.want) { 53 | t.Errorf("ExtractRuleInfo() = %v, want %v", got, tt.want) 54 | } 55 | }) 56 | } 57 | } 58 | 59 | func TestRule_String(t *testing.T) { 60 | type fields struct { 61 | Iface string 62 | Proto string 63 | Dport int 64 | Saddr string 65 | Sport int 66 | Comment string 67 | } 68 | tests := []struct { 69 | name string 70 | fields fields 71 | want []string 72 | }{ 73 | // TODO: Add test cases. 74 | { 75 | name: "should_return_consistent_value", 76 | fields: fields{ 77 | Iface: "eth0", 78 | Proto: "tcp", 79 | Dport: 8080, 80 | Saddr: "127.0.0.1", 81 | Sport: 80, 82 | }, 83 | want: []string{"-i", "eth0", "-p", "tcp", "-m", "tcp", "--dport", "8080", "-m", "comment", "--comment", "fwdctl", "-j", "DNAT", "--to-destination", "127.0.0.1:80"}, 84 | }, 85 | { 86 | name: "should_return_consistent_value", 87 | fields: fields{ 88 | Iface: "lo", 89 | Proto: "tcp", 90 | Dport: 8080, 91 | Saddr: "127.0.0.1", 92 | Sport: 90, 93 | }, 94 | want: []string{"-i", "lo", "-p", "tcp", "-m", "tcp", "--dport", "8080", "-m", "comment", "--comment", "fwdctl", "-j", "DNAT", "--to-destination", "127.0.0.1:90"}, 95 | }, 96 | } 97 | for _, tt := range tests { 98 | t.Run(tt.name, func(t *testing.T) { 99 | rule := &Rule{ 100 | Iface: tt.fields.Iface, 101 | Proto: tt.fields.Proto, 102 | Dport: tt.fields.Dport, 103 | Saddr: tt.fields.Saddr, 104 | Sport: tt.fields.Sport, 105 | Comment: tt.fields.Comment, 106 | } 107 | if got := rule.String(); !reflect.DeepEqual(got, tt.want) { 108 | t.Errorf("Rule.String() = %v, want %v", got, tt.want) 109 | } 110 | }) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /pkg/iptables/utils.go: -------------------------------------------------------------------------------- 1 | package iptables 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/coreos/go-iptables/iptables" 7 | ) 8 | 9 | var once sync.Once 10 | 11 | type single *iptables.IPTables 12 | 13 | var singleInstance single 14 | 15 | // getIPTablesInstance create a singletone instance for iptables.New() 16 | func getIPTablesInstance() (*iptables.IPTables, error) { 17 | var err error 18 | if singleInstance == nil { 19 | once.Do(func() { 20 | singleInstance, err = iptables.New() 21 | }) 22 | } 23 | 24 | return singleInstance, err 25 | } 26 | -------------------------------------------------------------------------------- /pkg/iptables/validation.go: -------------------------------------------------------------------------------- 1 | package iptables 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | ) 7 | 8 | func validateIface(iface string) error { 9 | if iface == "" { 10 | return fmt.Errorf("name is empty") 11 | } 12 | ifaces, err := net.Interfaces() 13 | if err != nil { 14 | return fmt.Errorf("error: %v", err) 15 | } 16 | found := false 17 | for _, i := range ifaces { 18 | if i.Name == iface { 19 | found = true 20 | } 21 | } 22 | if !found { 23 | return fmt.Errorf("not found") 24 | } 25 | return nil 26 | } 27 | 28 | func validateProto(proto string) error { 29 | if proto == "" { 30 | return fmt.Errorf("protocol name is empty") 31 | } 32 | if (proto != "tcp") && (proto != "udp") && (proto != "icmp") { 33 | return fmt.Errorf("protocol name not allowed") 34 | } 35 | return nil 36 | } 37 | 38 | func validatePort(port int) error { 39 | if port < 1 || port > 65535 { 40 | return fmt.Errorf("port number not allowed") 41 | } 42 | return nil 43 | } 44 | 45 | func validateAddress(address string) error { 46 | // not a valid check for now. 47 | if address == "" { 48 | return fmt.Errorf("address is empty") 49 | } 50 | return nil 51 | } 52 | 53 | // validate returns both bool and error. 54 | // The boolean return true in case the rule passes all checks. 55 | // In case it does not, then the error will describe the problem. 56 | func validate(iface string, proto string, dport int, saddr string, sport int) error { 57 | err := validateIface(iface) 58 | if err != nil { 59 | return fmt.Errorf("interface: '%s' %v", iface, err) 60 | } 61 | 62 | err = validateProto(proto) 63 | if err != nil { 64 | return fmt.Errorf("protocol: '%s' %v", proto, err) 65 | } 66 | 67 | err = validatePort(dport) 68 | if err != nil { 69 | return fmt.Errorf("destination port: '%d' %v", dport, err) 70 | } 71 | 72 | err = validateAddress(saddr) 73 | if err != nil { 74 | return fmt.Errorf("source address: '%s' %v", saddr, err) 75 | } 76 | 77 | err = validatePort(sport) 78 | if err != nil { 79 | return fmt.Errorf("source port: '%d' %v", sport, err) 80 | } 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /pkg/iptables/validation_test.go: -------------------------------------------------------------------------------- 1 | package iptables 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func Test_validateForward(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | iface string 12 | proto string 13 | dport int 14 | saddr string 15 | sport int 16 | expectedError error 17 | wantErr bool 18 | }{ 19 | { 20 | "should_not_fail", 21 | "lo", 22 | "tcp", 23 | 9090, 24 | "127.0.0.1", 25 | 80, 26 | nil, 27 | false, 28 | }, 29 | { 30 | "should_fail_protocol", 31 | "lo", 32 | "tcps", 33 | 9090, 34 | "127.0.0.1", 35 | 80, 36 | errors.New("protocol: 'tcps' protocol name not allowed"), 37 | true, 38 | }, 39 | { 40 | "should_fail_destination_port", 41 | "lo", 42 | "tcp", 43 | 10202020, 44 | "127.0.0.1", 45 | 80, 46 | errors.New("destination port: '10202020' port number not allowed"), 47 | true, 48 | }, 49 | { 50 | "shoudl_fail_source_port", 51 | "lo", 52 | "tcp", 53 | 9090, 54 | "127.0.0.1", 55 | 800000000, 56 | errors.New("source port: '800000000' port number not allowed"), 57 | true, 58 | }, 59 | { 60 | "shoudl_fail_destination_port", 61 | "lo", 62 | "tcp", 63 | 0, 64 | "127.0.0.1", 65 | 8000, 66 | errors.New("source port: '0' port number not allowed"), 67 | true, 68 | }, 69 | } 70 | for _, tt := range tests { 71 | t.Run(tt.name, func(t *testing.T) { 72 | if err := validate(tt.iface, tt.proto, tt.dport, tt.saddr, tt.sport); (err != nil) != tt.wantErr { 73 | t.Errorf("validateForward() error = %v, wantErr %v", err, tt.wantErr) 74 | } 75 | }) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Integration Tests 2 | 3 | This directory is dedicated to host integration tests written with [`testscript`](https://pkg.go.dev/github.com/rogpeppe/go-internal/testscript). 4 | 5 | ## Converting from test to trace file 6 | 7 | As you can see, for each command we have a couple of files (eg. `apply.txtar`, `apply_trace.txtar`). 8 | 9 | The `.txtar` file is used for testing purposes and is run at the beginning of the pipeline to ensure the binary is behave like we expect. 10 | 11 | The `_trace.txtar`, on the other side, is used for tracing purposes. This means that we re-run the same commands of the previous file, tracing them with [`harpoon`](https://github.com/alegrey91/harpoon) under the hood. 12 | 13 | The file content are quite similar, there are just few differences to follow: 14 | 15 | * Each `exec` of the command under test (eg. `fwdctl apply`) have to be replaced with `exec_cmd`. 16 | 17 | `exec_cmd` is a custom testscript function to trace the command using `harpoon`. 18 | 19 | * If the `exec` of the command under test had a negation (`!`), this should not be added in the command used with `exec_cmd`. 20 | 21 | This because in this case we don't care about the result of `harpoon` that will execute the real command. 22 | 23 | Here's an example: 24 | 25 | ```txt 26 | # command.txtar 27 | 28 | # normal execution of command 29 | exec command list -a 30 | # -x flag doesn't exists, so this should handle the error 31 | ! exec command list -x 32 | ``` 33 | 34 | Should be converted into this: 35 | 36 | ```txt 37 | # command_trace.txtar 38 | 39 | exec_cmd command list -a 40 | exec_cmd command list -x 41 | ``` -------------------------------------------------------------------------------- /tests/apply.txtar: -------------------------------------------------------------------------------- 1 | # this testscript test the 'apply' command 2 | 3 | # if go is not installed, then skip 4 | [!exec:go] skip 5 | 6 | exec fwdctl apply --help 7 | stdout 'Usage:' 8 | 9 | # remove all previously applied forwards 10 | exec fwdctl delete --all 11 | 12 | # test primarly subcommand 'apply' 13 | exec fwdctl apply --file rules-2.yaml 14 | fwd_exists lo tcp 3000 127.0.0.1 80 15 | fwd_exists lo tcp 3001 127.0.0.1 80 16 | fwd_exists lo udp 3002 127.0.0.1 80 17 | 18 | # clean up first test 19 | exec fwdctl delete -n 1 20 | exec fwdctl delete -n 1 21 | exec fwdctl delete -n 1 22 | exec fwdctl delete -n 1 23 | 24 | exec fwdctl apply 25 | 26 | # clean up second test 27 | exec fwdctl delete -n 1 28 | exec fwdctl delete -n 1 29 | exec fwdctl delete -n 1 30 | 31 | # removing the default rules.yml file 32 | # it should fail 33 | exec rm rules.yml 34 | ! exec fwdctl apply 35 | 36 | # should not apply 37 | # (wrong protocol) 38 | ! exec fwdctl apply --file rules-wrong.yaml 39 | ! fwd_exists lo tcp 3000 127.0.0.1 80 40 | ! fwd_exists lo tcp 3001 127.0.0.1 80 41 | ! fwd_exists lo tcp 3002 127.0.0.1 80 42 | 43 | # should not apply 44 | # (empty interface) 45 | ! exec fwdctl apply --file rules-wrong2.yaml 46 | ! fwd_exists lo tcp 3000 127.0.0.1 80 47 | ! fwd_exists lo tcp 3001 127.0.0.1 80 48 | ! fwd_exists lo tcp 3002 127.0.0.1 80 49 | 50 | # should not apply 51 | # (interface doesn't exist) 52 | ! exec fwdctl apply --file rules-wrong3.yaml 53 | ! fwd_exists lo tcp 3000 127.0.0.1 80 54 | ! fwd_exists lo tcp 3001 127.0.0.1 80 55 | ! fwd_exists lo tcp 3002 127.0.0.1 80 56 | 57 | # should not apply 58 | # (empty destination port) 59 | ! exec fwdctl apply --file rules-wrong4.yaml 60 | ! fwd_exists lo tcp 3000 127.0.0.1 80 61 | ! fwd_exists lo tcp 3001 127.0.0.1 80 62 | ! fwd_exists lo tcp 3002 127.0.0.1 80 63 | 64 | # should not apply 65 | # (empty source address) 66 | ! exec fwdctl apply --file rules-wrong5.yaml 67 | ! fwd_exists lo tcp 3000 127.0.0.1 80 68 | ! fwd_exists lo tcp 3001 127.0.0.1 80 69 | ! fwd_exists lo tcp 3002 127.0.0.1 80 70 | 71 | # should not apply 72 | # (empty source port) 73 | ! exec fwdctl apply --file rules-wrong6.yaml 74 | ! fwd_exists lo tcp 3000 127.0.0.1 80 75 | ! fwd_exists lo tcp 3001 127.0.0.1 80 76 | ! fwd_exists lo tcp 3002 127.0.0.1 80 77 | 78 | -- rules.yml -- 79 | rules: 80 | - dport: 3000 81 | saddr: 127.0.0.1 82 | sport: 80 83 | iface: lo 84 | proto: tcp 85 | - dport: 3001 86 | saddr: 127.0.0.1 87 | sport: 80 88 | iface: lo 89 | proto: tcp 90 | - dport: 3002 91 | saddr: 127.0.0.1 92 | sport: 80 93 | iface: lo 94 | proto: udp 95 | 96 | -- rules-2.yaml -- 97 | rules: 98 | - dport: 3000 99 | saddr: 127.0.0.1 100 | sport: 80 101 | iface: lo 102 | proto: tcp 103 | - dport: 3001 104 | saddr: 127.0.0.1 105 | sport: 80 106 | iface: lo 107 | proto: tcp 108 | - dport: 3002 109 | saddr: 127.0.0.1 110 | sport: 80 111 | iface: lo 112 | proto: udp 113 | 114 | -- rules-wrong.yaml -- 115 | rules: 116 | - dport: 3000 117 | saddr: 127.0.0.1 118 | sport: 80 119 | iface: lo 120 | proto: tcp 121 | - dport: 3001 122 | saddr: 127.0.0.1 123 | sport: 80 124 | iface: lo 125 | proto: tcp 126 | - dport: 3002 127 | saddr: 127.0.0.1 128 | sport: 80 129 | iface: lo 130 | proto: xxx 131 | 132 | -- rules-wrong2.yaml -- 133 | rules: 134 | - dport: 3000 135 | saddr: 127.0.0.1 136 | sport: 80 137 | iface: lo 138 | proto: tcp 139 | - dport: 3002 140 | saddr: 127.0.0.1 141 | sport: 80 142 | iface: 143 | proto: tcp 144 | - dport: 3001 145 | saddr: 127.0.0.1 146 | sport: 80 147 | iface: lo 148 | proto: tcp 149 | 150 | -- rules-wrong3.yaml -- 151 | rules: 152 | - dport: 3000 153 | saddr: 127.0.0.1 154 | sport: 80 155 | iface: lo 156 | proto: tcp 157 | - dport: 3002 158 | saddr: 127.0.0.1 159 | sport: 80 160 | iface: aaa 161 | proto: tcp 162 | - dport: 3001 163 | saddr: 127.0.0.1 164 | sport: 80 165 | iface: lo 166 | proto: tcp 167 | 168 | -- rules-wrong4.yaml -- 169 | rules: 170 | - dport: 3000 171 | saddr: 127.0.0.1 172 | sport: 80 173 | iface: lo 174 | proto: tcp 175 | - dport: 176 | saddr: 127.0.0.1 177 | sport: 80 178 | iface: lo 179 | proto: tcp 180 | - dport: 3001 181 | saddr: 127.0.0.1 182 | sport: 80 183 | iface: lo 184 | proto: tcp 185 | 186 | -- rules-wrong5.yaml -- 187 | rules: 188 | - dport: 3000 189 | saddr: 127.0.0.1 190 | sport: 80 191 | iface: lo 192 | proto: tcp 193 | - dport: 3001 194 | saddr: 195 | sport: 80 196 | iface: lo 197 | proto: tcp 198 | - dport: 3001 199 | saddr: 127.0.0.1 200 | sport: 80 201 | iface: lo 202 | proto: tcp 203 | 204 | -- rules-wrong6.yaml -- 205 | rules: 206 | - dport: 3000 207 | saddr: 127.0.0.1 208 | sport: 80 209 | iface: lo 210 | proto: tcp 211 | - dport: 3001 212 | saddr: 127.0.0.1 213 | sport: 214 | iface: lo 215 | proto: tcp 216 | - dport: 3001 217 | saddr: 127.0.0.1 218 | sport: 80 219 | iface: lo 220 | proto: tcp 221 | -------------------------------------------------------------------------------- /tests/apply_trace.txtar: -------------------------------------------------------------------------------- 1 | # this testscript test the 'apply' command 2 | 3 | # if go is not installed, then skip 4 | [!exec:go] skip 5 | 6 | exec_cmd fwdctl apply --help 7 | stdout 'Usage:' 8 | 9 | # remove all previously applied forwards 10 | exec fwdctl delete --all 11 | 12 | # test primarly subcommand 'apply' 13 | exec_cmd fwdctl apply --file rules-2.yaml 14 | fwd_exists lo tcp 3000 127.0.0.1 80 15 | fwd_exists lo tcp 3001 127.0.0.1 80 16 | fwd_exists lo udp 3002 127.0.0.1 80 17 | 18 | # clean up first test 19 | exec fwdctl delete -n 1 20 | exec fwdctl delete -n 1 21 | exec fwdctl delete -n 1 22 | exec fwdctl delete -n 1 23 | 24 | exec_cmd fwdctl apply 25 | 26 | # clean up second test 27 | exec fwdctl delete -n 1 28 | exec fwdctl delete -n 1 29 | exec fwdctl delete -n 1 30 | 31 | # removing the default rules.yml file 32 | # it should fail 33 | exec rm rules.yml 34 | exec_cmd fwdctl apply 35 | 36 | # should not apply 37 | # (wrong protocol) 38 | exec_cmd fwdctl apply --file rules-wrong.yaml 39 | ! fwd_exists lo tcp 3000 127.0.0.1 80 40 | ! fwd_exists lo tcp 3001 127.0.0.1 80 41 | ! fwd_exists lo tcp 3002 127.0.0.1 80 42 | 43 | # should not apply 44 | # (empty interface) 45 | exec_cmd fwdctl apply --file rules-wrong2.yaml 46 | ! fwd_exists lo tcp 3000 127.0.0.1 80 47 | ! fwd_exists lo tcp 3001 127.0.0.1 80 48 | ! fwd_exists lo tcp 3002 127.0.0.1 80 49 | 50 | # should not apply 51 | # (interface doesn't exist) 52 | exec_cmd fwdctl apply --file rules-wrong3.yaml 53 | ! fwd_exists lo tcp 3000 127.0.0.1 80 54 | ! fwd_exists lo tcp 3001 127.0.0.1 80 55 | ! fwd_exists lo tcp 3002 127.0.0.1 80 56 | 57 | # should not apply 58 | # (empty destination port) 59 | exec_cmd fwdctl apply --file rules-wrong4.yaml 60 | ! fwd_exists lo tcp 3000 127.0.0.1 80 61 | ! fwd_exists lo tcp 3001 127.0.0.1 80 62 | ! fwd_exists lo tcp 3002 127.0.0.1 80 63 | 64 | # should not apply 65 | # (empty source address) 66 | exec_cmd fwdctl apply --file rules-wrong5.yaml 67 | ! fwd_exists lo tcp 3000 127.0.0.1 80 68 | ! fwd_exists lo tcp 3001 127.0.0.1 80 69 | ! fwd_exists lo tcp 3002 127.0.0.1 80 70 | 71 | # should not apply 72 | # (empty source port) 73 | exec_cmd fwdctl apply --file rules-wrong6.yaml 74 | ! fwd_exists lo tcp 3000 127.0.0.1 80 75 | ! fwd_exists lo tcp 3001 127.0.0.1 80 76 | ! fwd_exists lo tcp 3002 127.0.0.1 80 77 | 78 | -- rules.yml -- 79 | rules: 80 | - dport: 3000 81 | saddr: 127.0.0.1 82 | sport: 80 83 | iface: lo 84 | proto: tcp 85 | - dport: 3001 86 | saddr: 127.0.0.1 87 | sport: 80 88 | iface: lo 89 | proto: tcp 90 | - dport: 3002 91 | saddr: 127.0.0.1 92 | sport: 80 93 | iface: lo 94 | proto: udp 95 | 96 | -- rules-2.yaml -- 97 | rules: 98 | - dport: 3000 99 | saddr: 127.0.0.1 100 | sport: 80 101 | iface: lo 102 | proto: tcp 103 | - dport: 3001 104 | saddr: 127.0.0.1 105 | sport: 80 106 | iface: lo 107 | proto: tcp 108 | - dport: 3002 109 | saddr: 127.0.0.1 110 | sport: 80 111 | iface: lo 112 | proto: udp 113 | 114 | -- rules-wrong.yaml -- 115 | rules: 116 | - dport: 3000 117 | saddr: 127.0.0.1 118 | sport: 80 119 | iface: lo 120 | proto: tcp 121 | - dport: 3001 122 | saddr: 127.0.0.1 123 | sport: 80 124 | iface: lo 125 | proto: tcp 126 | - dport: 3002 127 | saddr: 127.0.0.1 128 | sport: 80 129 | iface: lo 130 | proto: xxx 131 | 132 | -- rules-wrong2.yaml -- 133 | rules: 134 | - dport: 3000 135 | saddr: 127.0.0.1 136 | sport: 80 137 | iface: lo 138 | proto: tcp 139 | - dport: 3002 140 | saddr: 127.0.0.1 141 | sport: 80 142 | iface: 143 | proto: tcp 144 | - dport: 3001 145 | saddr: 127.0.0.1 146 | sport: 80 147 | iface: lo 148 | proto: tcp 149 | 150 | -- rules-wrong3.yaml -- 151 | rules: 152 | - dport: 3000 153 | saddr: 127.0.0.1 154 | sport: 80 155 | iface: lo 156 | proto: tcp 157 | - dport: 3002 158 | saddr: 127.0.0.1 159 | sport: 80 160 | iface: aaa 161 | proto: tcp 162 | - dport: 3001 163 | saddr: 127.0.0.1 164 | sport: 80 165 | iface: lo 166 | proto: tcp 167 | 168 | -- rules-wrong4.yaml -- 169 | rules: 170 | - dport: 3000 171 | saddr: 127.0.0.1 172 | sport: 80 173 | iface: lo 174 | proto: tcp 175 | - dport: 176 | saddr: 127.0.0.1 177 | sport: 80 178 | iface: lo 179 | proto: tcp 180 | - dport: 3001 181 | saddr: 127.0.0.1 182 | sport: 80 183 | iface: lo 184 | proto: tcp 185 | 186 | -- rules-wrong5.yaml -- 187 | rules: 188 | - dport: 3000 189 | saddr: 127.0.0.1 190 | sport: 80 191 | iface: lo 192 | proto: tcp 193 | - dport: 3001 194 | saddr: 195 | sport: 80 196 | iface: lo 197 | proto: tcp 198 | - dport: 3001 199 | saddr: 127.0.0.1 200 | sport: 80 201 | iface: lo 202 | proto: tcp 203 | 204 | -- rules-wrong6.yaml -- 205 | rules: 206 | - dport: 3000 207 | saddr: 127.0.0.1 208 | sport: 80 209 | iface: lo 210 | proto: tcp 211 | - dport: 3001 212 | saddr: 127.0.0.1 213 | sport: 214 | iface: lo 215 | proto: tcp 216 | - dport: 3001 217 | saddr: 127.0.0.1 218 | sport: 80 219 | iface: lo 220 | proto: tcp 221 | -------------------------------------------------------------------------------- /tests/create.txtar: -------------------------------------------------------------------------------- 1 | # this testscript test the 'create' command 2 | 3 | # if go is not installed, then skip 4 | [!exec:go] skip 5 | 6 | exec fwdctl create --help 7 | stdout 'Usage:' 8 | 9 | # remove all previously applied forwards 10 | exec fwdctl delete --all 11 | 12 | # test primarly subcommand 'create' 13 | exec fwdctl create -d 3000 -s 127.0.0.1 -p 80 -i lo 14 | fwd_exists lo tcp 3000 127.0.0.1 80 15 | 16 | # test alternative name 'add' 17 | exec fwdctl add -d 3001 -s 127.0.0.1 -p 80 -i lo 18 | fwd_exists lo tcp 3001 127.0.0.1 80 19 | 20 | # test creation of udp rule 21 | exec fwdctl create -d 3002 -s 127.0.0.1 -p 80 -i lo -P udp 22 | fwd_exists lo udp 3002 127.0.0.1 80 23 | 24 | exec fwdctl list -o json 25 | cmp stdout fwdctl_list.json 26 | 27 | # clean up environment 28 | exec fwdctl delete -n 1 29 | exec fwdctl delete -n 1 30 | exec fwdctl delete -n 1 31 | 32 | # create rule without specifying interface 33 | exec fwdctl create -d 3003 -s 127.0.0.1 -p 80 34 | fwd_exists lo tcp 3003 127.0.0.1 80 35 | exec fwdctl delete -n 1 36 | 37 | # should not create rules 38 | ! exec fwdctl create -d 3003 -s 127.0.0.1 39 | ! exec fwdctl create -d 3003 -p 80 40 | ! exec fwdctl create -s 127.0.0.1 -p 80 41 | ! exec fwdctl create -i lo -P tcp 42 | 43 | -- fwdctl_list.json -- 44 | { 45 | "02277b77be4aec43a6e91433e2fc1fb0": { 46 | "iface": "lo", 47 | "proto": "udp", 48 | "dport": 3002, 49 | "saddr": "127.0.0.1", 50 | "sport": 80 51 | }, 52 | "0be1c5f4141015ca6a8e873344da06e6": { 53 | "iface": "lo", 54 | "proto": "tcp", 55 | "dport": 3000, 56 | "saddr": "127.0.0.1", 57 | "sport": 80 58 | }, 59 | "73d008929b591e12220cef0bb9a2710e": { 60 | "iface": "lo", 61 | "proto": "tcp", 62 | "dport": 3001, 63 | "saddr": "127.0.0.1", 64 | "sport": 80 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/create_trace.txtar: -------------------------------------------------------------------------------- 1 | # this testscript test the 'create' command 2 | 3 | # if go is not installed, then skip 4 | [!exec:go] skip 5 | 6 | exec_cmd fwdctl create --help 7 | stdout 'Usage:' 8 | 9 | # remove all previously applied forwards 10 | exec fwdctl delete --all 11 | 12 | # test primarly subcommand 'create' 13 | exec_cmd fwdctl create -d 3000 -s 127.0.0.1 -p 80 -i lo 14 | fwd_exists lo tcp 3000 127.0.0.1 80 15 | 16 | # test alternative name 'add' 17 | exec fwdctl add -d 3001 -s 127.0.0.1 -p 80 -i lo 18 | fwd_exists lo tcp 3001 127.0.0.1 80 19 | 20 | # test creation of udp rule 21 | exec_cmd fwdctl create -d 3002 -s 127.0.0.1 -p 80 -i lo -P udp 22 | fwd_exists lo udp 3002 127.0.0.1 80 23 | 24 | exec fwdctl list -o json 25 | cmp stdout fwdctl_list.json 26 | 27 | # clean up environment 28 | exec fwdctl delete -n 1 29 | exec fwdctl delete -n 1 30 | exec fwdctl delete -n 1 31 | 32 | # create rule without specifying interface 33 | exec_cmd fwdctl create -d 3003 -s 127.0.0.1 -p 80 34 | fwd_exists lo tcp 3003 127.0.0.1 80 35 | exec fwdctl delete -n 1 36 | 37 | # should not create rules 38 | exec_cmd fwdctl create -d 3003 -s 127.0.0.1 39 | exec_cmd fwdctl create -d 3003 -p 80 40 | exec_cmd fwdctl create -s 127.0.0.1 -p 80 41 | exec_cmd fwdctl create -i lo -P tcp 42 | 43 | -- fwdctl_list.json -- 44 | { 45 | "02277b77be4aec43a6e91433e2fc1fb0": { 46 | "iface": "lo", 47 | "proto": "udp", 48 | "dport": 3002, 49 | "saddr": "127.0.0.1", 50 | "sport": 80 51 | }, 52 | "0be1c5f4141015ca6a8e873344da06e6": { 53 | "iface": "lo", 54 | "proto": "tcp", 55 | "dport": 3000, 56 | "saddr": "127.0.0.1", 57 | "sport": 80 58 | }, 59 | "73d008929b591e12220cef0bb9a2710e": { 60 | "iface": "lo", 61 | "proto": "tcp", 62 | "dport": 3001, 63 | "saddr": "127.0.0.1", 64 | "sport": 80 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/daemon.txtar: -------------------------------------------------------------------------------- 1 | # this testscript test the 'daemon ' command 2 | 3 | # if go is not installed, then skip 4 | [!exec:go] skip 5 | 6 | exec fwdctl daemon 7 | stdout 'Usage:' 8 | 9 | # remove all previously applied forwards 10 | exec fwdctl delete --all 11 | 12 | exec fwdctl daemon --help 13 | stdout 'Usage:' 14 | 15 | exec fwdctl daemon start --help 16 | stdout 'Usage:' 17 | 18 | exec fwdctl daemon stop --help 19 | stdout 'Usage:' 20 | 21 | ! exec fwdctl daemon start --file file_doesnt_exists.yml 22 | 23 | # should apply the rules from default file 24 | ! exec fwdctl daemon start & 25 | exec sleep 5 26 | fwd_exists lo tcp 3000 127.0.0.1 80 27 | fwd_exists lo tcp 3001 127.0.0.1 80 28 | fwd_exists lo tcp 3002 127.0.0.1 80 29 | 30 | # should change one rule out of three 31 | exec cp rules-2.yml rules.yml 32 | exec sleep 5 33 | fwd_exists lo tcp 3000 127.0.0.1 80 34 | fwd_exists lo tcp 3001 127.0.0.1 80 35 | fwd_exists lo udp 3002 127.0.0.1 81 36 | 37 | # should leave just one single rule 38 | exec cp rules-3.yml rules.yml 39 | exec sleep 5 40 | fwd_exists lo udp 1111 127.0.0.1 22 41 | 42 | # should remove all the rules since the rules file is empty 43 | exec cp rules-4.yml rules.yml 44 | exec sleep 5 45 | exec fwdctl list -o json 46 | cmp stdout empty-output.txt 47 | 48 | # should not apply rules if file is not valid 49 | exec cp rules-5.yml rules.yml 50 | exec sleep 5 51 | exec fwdctl list -o json 52 | exec fwdctl daemon stop 53 | 54 | -- rules.yml -- 55 | rules: 56 | - dport: 3000 57 | saddr: 127.0.0.1 58 | sport: 80 59 | iface: lo 60 | proto: tcp 61 | - dport: 3001 62 | saddr: 127.0.0.1 63 | sport: 80 64 | iface: lo 65 | proto: tcp 66 | - dport: 3002 67 | saddr: 127.0.0.1 68 | sport: 80 69 | iface: lo 70 | proto: tcp 71 | 72 | -- rules-2.yml -- 73 | rules: 74 | - dport: 3000 75 | saddr: 127.0.0.1 76 | sport: 80 77 | iface: lo 78 | proto: tcp 79 | - dport: 3001 80 | saddr: 127.0.0.1 81 | sport: 80 82 | iface: lo 83 | proto: tcp 84 | - dport: 3002 85 | saddr: 127.0.0.1 86 | sport: 81 87 | iface: lo 88 | proto: udp 89 | 90 | -- rules-3.yml -- 91 | rules: 92 | - dport: 1111 93 | saddr: 127.0.0.1 94 | sport: 22 95 | iface: lo 96 | proto: udp 97 | 98 | -- rules-4.yml -- 99 | rules: 100 | 101 | -- rules-5.yml -- 102 | rules: 103 | - dport: 104 | saddr: 127.0.0.1 105 | sport: 80 106 | iface: lo 107 | proto: tcp 108 | - dport: 3001 109 | saddr: 127.0.0.1 110 | sport: 80 111 | iface: lo 112 | proto: tcp 113 | 114 | -- empty-output.txt -- 115 | {} 116 | -------------------------------------------------------------------------------- /tests/daemon_trace.txtar: -------------------------------------------------------------------------------- 1 | # this testscript test the 'daemon' command 2 | 3 | # if go is not installed, then skip 4 | [!exec:go] skip 5 | 6 | exec_cmd fwdctl daemon 7 | stdout 'Usage:' 8 | 9 | # remove all previously applied forwards 10 | exec_cmd fwdctl delete --all 11 | 12 | exec_cmd fwdctl daemon --help 13 | stdout 'Usage:' 14 | 15 | exec_cmd fwdctl daemon start --help 16 | stdout 'Usage:' 17 | 18 | exec_cmd fwdctl daemon stop --help 19 | stdout 'Usage:' 20 | 21 | exec_cmd fwdctl daemon start --file file_doesnt_exists.yml 22 | 23 | # should apply the rules from default file 24 | exec_cmd fwdctl daemon start & 25 | exec sleep 15 26 | fwd_exists lo tcp 3000 127.0.0.1 80 27 | fwd_exists lo tcp 3001 127.0.0.1 80 28 | fwd_exists lo tcp 3002 127.0.0.1 80 29 | 30 | # should change one rule out of three 31 | exec cp rules-2.yml rules.yml 32 | exec sleep 5 33 | fwd_exists lo tcp 3000 127.0.0.1 80 34 | fwd_exists lo tcp 3001 127.0.0.1 80 35 | fwd_exists lo udp 3002 127.0.0.1 81 36 | 37 | # should leave just one single rule 38 | exec cp rules-3.yml rules.yml 39 | exec sleep 5 40 | fwd_exists lo udp 1111 127.0.0.1 22 41 | 42 | # should remove all the rules since the rules file is empty 43 | exec cp rules-4.yml rules.yml 44 | exec sleep 5 45 | exec fwdctl list -o json 46 | cmp stdout empty-output.txt 47 | 48 | # should not apply rules if file is not valid 49 | exec cp rules-5.yml rules.yml 50 | exec sleep 5 51 | exec fwdctl list -o json 52 | exec fwdctl daemon stop 53 | 54 | -- rules.yml -- 55 | rules: 56 | - dport: 3000 57 | saddr: 127.0.0.1 58 | sport: 80 59 | iface: lo 60 | proto: tcp 61 | - dport: 3001 62 | saddr: 127.0.0.1 63 | sport: 80 64 | iface: lo 65 | proto: tcp 66 | - dport: 3002 67 | saddr: 127.0.0.1 68 | sport: 80 69 | iface: lo 70 | proto: tcp 71 | 72 | -- rules-2.yml -- 73 | rules: 74 | - dport: 3000 75 | saddr: 127.0.0.1 76 | sport: 80 77 | iface: lo 78 | proto: tcp 79 | - dport: 3001 80 | saddr: 127.0.0.1 81 | sport: 80 82 | iface: lo 83 | proto: tcp 84 | - dport: 3002 85 | saddr: 127.0.0.1 86 | sport: 81 87 | iface: lo 88 | proto: udp 89 | 90 | -- rules-3.yml -- 91 | rules: 92 | - dport: 1111 93 | saddr: 127.0.0.1 94 | sport: 22 95 | iface: lo 96 | proto: udp 97 | 98 | -- rules-4.yml -- 99 | rules: 100 | 101 | -- rules-5.yml -- 102 | rules: 103 | - dport: 104 | saddr: 127.0.0.1 105 | sport: 80 106 | iface: lo 107 | proto: tcp 108 | - dport: 3001 109 | saddr: 127.0.0.1 110 | sport: 80 111 | iface: lo 112 | proto: tcp 113 | 114 | -- empty-output.txt -- 115 | {} 116 | -------------------------------------------------------------------------------- /tests/delete.txtar: -------------------------------------------------------------------------------- 1 | # this testscript test the 'delete' command 2 | 3 | # if go is not installed, then skip 4 | [!exec:go] skip 5 | 6 | exec fwdctl delete --help 7 | stdout 'Usage:' 8 | 9 | # remove all previously applied forwards 10 | exec fwdctl delete --all 11 | 12 | # delete e simple rule by passing id 13 | exec fwdctl create -d 3000 -s 127.0.0.1 -p 80 -i lo 14 | fwd_exists lo tcp 3000 127.0.0.1 80 15 | exec fwdctl delete -n 1 16 | 17 | # delete list of rules by using a rules.yml file 18 | exec fwdctl create -d 3000 -s 127.0.0.1 -p 80 -i lo 19 | fwd_exists lo tcp 3000 127.0.0.1 80 20 | exec fwdctl create -d 3001 -s 127.0.0.1 -p 80 -i lo 21 | fwd_exists lo tcp 3001 127.0.0.1 80 22 | exec fwdctl create -d 3002 -s 127.0.0.1 -p 80 -i lo 23 | fwd_exists lo tcp 3002 127.0.0.1 80 24 | exec fwdctl delete --file rules.yml 25 | 26 | # delete multiple ids 27 | exec fwdctl apply -f rules.yml 28 | fwd_exists lo tcp 3000 127.0.0.1 80 29 | fwd_exists lo tcp 3001 127.0.0.1 80 30 | fwd_exists lo tcp 3002 127.0.0.1 80 31 | exec fwdctl delete -n 3 32 | exec fwdctl delete -n 2 33 | exec fwdctl delete -n 1 34 | 35 | # create forwards and then delete them with '--all# 36 | exec fwdctl apply -f rules.yml 37 | fwd_exists lo tcp 3000 127.0.0.1 80 38 | fwd_exists lo tcp 3001 127.0.0.1 80 39 | fwd_exists lo tcp 3002 127.0.0.1 80 40 | exec fwdctl delete --all 41 | ! fwd_exists lo tcp 3000 127.0.0.1 80 42 | ! fwd_exists lo tcp 3001 127.0.0.1 80 43 | ! fwd_exists lo tcp 3002 127.0.0.1 80 44 | 45 | # unable to use all the flags at the same time 46 | ! exec fwdctl delete -n 1 -a -f rules.yml 47 | 48 | exec fwdctl apply 49 | fwd_exists lo tcp 3000 127.0.0.1 80 50 | fwd_exists lo tcp 3001 127.0.0.1 80 51 | fwd_exists lo tcp 3002 127.0.0.1 80 52 | exec fwdctl delete 53 | ! fwd_exists lo tcp 3000 127.0.0.1 80 54 | ! fwd_exists lo tcp 3001 127.0.0.1 80 55 | ! fwd_exists lo tcp 3002 127.0.0.1 80 56 | 57 | ! exec fwdctl delete -f file_doesnt_exists.yml 58 | 59 | -- rules.yml -- 60 | rules: 61 | - dport: 3000 62 | saddr: 127.0.0.1 63 | sport: 80 64 | iface: lo 65 | proto: tcp 66 | - dport: 3001 67 | saddr: 127.0.0.1 68 | sport: 80 69 | iface: lo 70 | proto: tcp 71 | - dport: 3002 72 | saddr: 127.0.0.1 73 | sport: 80 74 | iface: lo 75 | proto: tcp 76 | -------------------------------------------------------------------------------- /tests/delete_trace.txtar: -------------------------------------------------------------------------------- 1 | # this testscript test the 'delete' command 2 | 3 | # if go is not installed, then skip 4 | [!exec:go] skip 5 | 6 | exec_cmd fwdctl delete --help 7 | stdout 'Usage:' 8 | 9 | # remove all previously applied forwards 10 | exec_cmd fwdctl delete --all 11 | 12 | # delete e simple rule by passing id 13 | exec fwdctl create -d 3000 -s 127.0.0.1 -p 80 -i lo 14 | fwd_exists lo tcp 3000 127.0.0.1 80 15 | exec_cmd fwdctl delete -n 1 16 | 17 | # delete list of rules by using a rules.yml file 18 | exec fwdctl create -d 3000 -s 127.0.0.1 -p 80 -i lo 19 | fwd_exists lo tcp 3000 127.0.0.1 80 20 | exec fwdctl create -d 3001 -s 127.0.0.1 -p 80 -i lo 21 | fwd_exists lo tcp 3001 127.0.0.1 80 22 | exec fwdctl create -d 3002 -s 127.0.0.1 -p 80 -i lo 23 | fwd_exists lo tcp 3002 127.0.0.1 80 24 | exec_cmd fwdctl delete --file rules.yml 25 | 26 | # delete multiple ids 27 | exec fwdctl apply -f rules.yml 28 | fwd_exists lo tcp 3000 127.0.0.1 80 29 | fwd_exists lo tcp 3001 127.0.0.1 80 30 | fwd_exists lo tcp 3002 127.0.0.1 80 31 | exec_cmd fwdctl delete -n 3 32 | exec_cmd fwdctl delete -n 2 33 | exec_cmd fwdctl delete -n 1 34 | 35 | # create forwards and then delete them with '--all# 36 | exec fwdctl apply -f rules.yml 37 | fwd_exists lo tcp 3000 127.0.0.1 80 38 | fwd_exists lo tcp 3001 127.0.0.1 80 39 | fwd_exists lo tcp 3002 127.0.0.1 80 40 | exec_cmd fwdctl delete --all 41 | ! fwd_exists lo tcp 3000 127.0.0.1 80 42 | ! fwd_exists lo tcp 3001 127.0.0.1 80 43 | ! fwd_exists lo tcp 3002 127.0.0.1 80 44 | 45 | # unable to use all the flags at the same time 46 | exec_cmd fwdctl delete -n 1 -a -f rules.yml 47 | 48 | exec fwdctl apply 49 | fwd_exists lo tcp 3000 127.0.0.1 80 50 | fwd_exists lo tcp 3001 127.0.0.1 80 51 | fwd_exists lo tcp 3002 127.0.0.1 80 52 | exec_cmd fwdctl delete 53 | ! fwd_exists lo tcp 3000 127.0.0.1 80 54 | ! fwd_exists lo tcp 3001 127.0.0.1 80 55 | ! fwd_exists lo tcp 3002 127.0.0.1 80 56 | 57 | exec_cmd fwdctl delete -f file_doesnt_exists.yml 58 | 59 | -- rules.yml -- 60 | rules: 61 | - dport: 3000 62 | saddr: 127.0.0.1 63 | sport: 80 64 | iface: lo 65 | proto: tcp 66 | - dport: 3001 67 | saddr: 127.0.0.1 68 | sport: 80 69 | iface: lo 70 | proto: tcp 71 | - dport: 3002 72 | saddr: 127.0.0.1 73 | sport: 80 74 | iface: lo 75 | proto: tcp 76 | -------------------------------------------------------------------------------- /tests/generate.txtar: -------------------------------------------------------------------------------- 1 | # this testscript test the 'generate' command 2 | 3 | # if go is not installed, then skip 4 | [!exec:go] skip 5 | 6 | exec fwdctl generate 7 | stdout 'Usage:' 8 | 9 | exec fwdctl generate --help 10 | stdout 'Usage:' 11 | 12 | # test 'generate rules' command 13 | exec fwdctl generate rules --help 14 | stdout 'Usage:' 15 | 16 | ! exec fwdctl generate rules -O rules.yaml 17 | stdout 'output path is not absolute:' 18 | 19 | ! exec fwdctl generate rules -O /tmp/rules.yaml 20 | 21 | exec fwdctl generate rules -O /tmp/ 22 | exists /tmp/rules.yml 23 | cmp /tmp/rules.yml rules.yml 24 | 25 | # test 'generate systemd' command 26 | exec fwdctl generate systemd --help 27 | stdout 'Usage:' 28 | 29 | ! exec fwdctl generate systemd --file /tmp/rules.yml 30 | ! exec fwdctl generate systemd --installation-path /usr/local/bin 31 | ! exec fwdctl generate systemd --type "fork" 32 | ! exec fwdctl generate systemd --file /tmp/rules.yml --installation-path /usr/local/bin --type fork 33 | 34 | # test with options '--type oneshot' (default) 35 | exec fwdctl generate systemd --file /tmp/rules.yml --installation-path /usr/local/bin -O /tmp/ 36 | cmp /tmp/fwdctl.service fwdctl-oneshot.service 37 | 38 | # test with options '--type fork' 39 | exec fwdctl generate systemd --file /tmp/rules.yml --installation-path /usr/local/bin --type fork -O /tmp/ 40 | cmp /tmp/fwdctl.service fwdctl-fork.service 41 | 42 | # test with options '--type null' (does not exist) 43 | ! exec fwdctl generate systemd --file /tmp/rules.yml --installation-path /usr/local/bin --type null -O /tmp/ 44 | 45 | -- rules.yml -- 46 | rules: 47 | - dport: 48 | saddr: 49 | sport: 50 | iface: 51 | proto: 52 | -- fwdctl-oneshot.service -- 53 | [Unit] 54 | Description=fwdctl systemd service 55 | After=network.target 56 | 57 | [Service] 58 | Type=oneshot 59 | ExecStart=/usr/local/bin/fwdctl apply --file=/tmp/rules.yml 60 | StandardOutput=journal 61 | 62 | [Install] 63 | WantedBy=multi-user.target 64 | -- fwdctl-fork.service -- 65 | [Unit] 66 | Description=fwdctl systemd service 67 | After=network.target 68 | 69 | [Service] 70 | Type=fork 71 | ExecStart=/usr/local/bin/fwdctl daemon start --file=/tmp/rules.yml 72 | ExecStop=/usr/local/bin/fwdctl daemon stop 73 | Restart=always 74 | RestartSec=5s 75 | StandardOutput=journal 76 | 77 | [Install] 78 | WantedBy=multi-user.target 79 | -------------------------------------------------------------------------------- /tests/generate_trace.txtar: -------------------------------------------------------------------------------- 1 | # this testscript test the 'generate' command 2 | 3 | # if go is not installed, then skip 4 | [!exec:go] skip 5 | 6 | exec_cmd fwdctl generate 7 | stdout 'Usage:' 8 | 9 | exec_cmd fwdctl generate --help 10 | stdout 'Usage:' 11 | 12 | # test 'generate rules' command 13 | exec_cmd fwdctl generate rules --help 14 | stdout 'Usage:' 15 | 16 | exec_cmd fwdctl generate rules -O rules.yaml 17 | stdout 'output path is not absolute:' 18 | 19 | exec_cmd fwdctl generate rules -O /tmp/rules.yaml 20 | 21 | exec_cmd fwdctl generate rules -O /tmp/ 22 | exists /tmp/rules.yml 23 | cmp /tmp/rules.yml rules.yml 24 | 25 | # test 'generate systemd' command 26 | exec_cmd fwdctl generate systemd --help 27 | stdout 'Usage:' 28 | 29 | exec_cmd fwdctl generate systemd --file /tmp/rules.yml 30 | exec_cmd fwdctl generate systemd --installation-path /usr/local/bin 31 | exec_cmd fwdctl generate systemd --type "fork" 32 | exec_cmd fwdctl generate systemd --file /tmp/rules.yml --installation-path /usr/local/bin --type fork 33 | 34 | # test with options '--type oneshot' (default) 35 | exec_cmd fwdctl generate systemd --file /tmp/rules.yml --installation-path /usr/local/bin -O /tmp/ 36 | cmp /tmp/fwdctl.service fwdctl-oneshot.service 37 | 38 | # test with options '--type fork' 39 | exec_cmd fwdctl generate systemd --file /tmp/rules.yml --installation-path /usr/local/bin --type fork -O /tmp/ 40 | cmp /tmp/fwdctl.service fwdctl-fork.service 41 | 42 | # test with options '--type null' (does not exist) 43 | exec_cmd fwdctl generate systemd --file /tmp/rules.yml --installation-path /usr/local/bin --type null -O /tmp/ 44 | 45 | -- rules.yml -- 46 | rules: 47 | - dport: 48 | saddr: 49 | sport: 50 | iface: 51 | proto: 52 | -- fwdctl-oneshot.service -- 53 | [Unit] 54 | Description=fwdctl systemd service 55 | After=network.target 56 | 57 | [Service] 58 | Type=oneshot 59 | ExecStart=/usr/local/bin/fwdctl apply --file=/tmp/rules.yml 60 | StandardOutput=journal 61 | 62 | [Install] 63 | WantedBy=multi-user.target 64 | -- fwdctl-fork.service -- 65 | [Unit] 66 | Description=fwdctl systemd service 67 | After=network.target 68 | 69 | [Service] 70 | Type=fork 71 | ExecStart=/usr/local/bin/fwdctl daemon start --file=/tmp/rules.yml 72 | ExecStop=/usr/local/bin/fwdctl daemon stop 73 | Restart=always 74 | RestartSec=5s 75 | StandardOutput=journal 76 | 77 | [Install] 78 | WantedBy=multi-user.target 79 | -------------------------------------------------------------------------------- /tests/list.txtar: -------------------------------------------------------------------------------- 1 | # this testscript test the 'create' command 2 | 3 | # if go is not installed, then skip 4 | [!exec:go] skip 5 | 6 | exec fwdctl list --help 7 | stdout 'Usage:' 8 | 9 | # remove all previously applied forwards 10 | exec fwdctl delete --all 11 | 12 | # test primarly subcommand 'create' 13 | exec fwdctl create -d 3000 -s 127.0.0.1 -p 80 -i lo 14 | fwd_exists lo tcp 3000 127.0.0.1 80 15 | 16 | # compare table view 17 | exec fwdctl list 18 | cmp stdout list-table.txt 19 | 20 | # compare json view 21 | exec fwdctl list -o json 22 | cmp stdout list-json.txt 23 | 24 | # compare yaml view 25 | exec fwdctl list -o yaml 26 | cmp stdout list-yaml.txt 27 | 28 | # check alias works 29 | exec fwdctl ls 30 | 31 | -- list-table.txt -- 32 | +--------+-----------+----------+---------------+-------------+---------------+ 33 | | NUMBER | INTERFACE | PROTOCOL | EXTERNAL PORT | INTERNAL IP | INTERNAL PORT | 34 | +--------+-----------+----------+---------------+-------------+---------------+ 35 | | 1 | lo | tcp | 3000 | 127.0.0.1 | 80 | 36 | +--------+-----------+----------+---------------+-------------+---------------+ 37 | -- list-json.txt -- 38 | { 39 | "0be1c5f4141015ca6a8e873344da06e6": { 40 | "iface": "lo", 41 | "proto": "tcp", 42 | "dport": 3000, 43 | "saddr": "127.0.0.1", 44 | "sport": 80 45 | } 46 | } 47 | -- list-yaml.txt -- 48 | 0be1c5f4141015ca6a8e873344da06e6: 49 | iface: lo 50 | proto: tcp 51 | dport: 3000 52 | saddr: 127.0.0.1 53 | sport: 80 54 | 55 | -------------------------------------------------------------------------------- /tests/list_trace.txtar: -------------------------------------------------------------------------------- 1 | # this testscript test the 'create' command 2 | 3 | # if go is not installed, then skip 4 | [!exec:go] skip 5 | 6 | exec_cmd fwdctl list --help 7 | stdout 'Usage:' 8 | 9 | # remove all previously applied forwards 10 | exec fwdctl delete --all 11 | 12 | # test primarly subcommand 'create' 13 | exec fwdctl create -d 3000 -s 127.0.0.1 -p 80 -i lo 14 | fwd_exists lo tcp 3000 127.0.0.1 80 15 | 16 | # compare table view 17 | exec_cmd fwdctl list 18 | cmp stdout list-table.txt 19 | 20 | # compare json view 21 | exec_cmd fwdctl list -o json 22 | cmp stdout list-json.txt 23 | 24 | # compare yaml view 25 | exec_cmd fwdctl list -o yaml 26 | cmp stdout list-yaml.txt 27 | 28 | # check alias works 29 | exec_cmd fwdctl ls 30 | 31 | -- list-table.txt -- 32 | stdout: +--------+-----------+----------+---------------+-------------+---------------+ 33 | stdout: | NUMBER | INTERFACE | PROTOCOL | EXTERNAL PORT | INTERNAL IP | INTERNAL PORT | 34 | stdout: +--------+-----------+----------+---------------+-------------+---------------+ 35 | stdout: | 1 | lo | tcp | 3000 | 127.0.0.1 | 80 | 36 | stdout: +--------+-----------+----------+---------------+-------------+---------------+ 37 | -- list-json.txt -- 38 | stdout: { 39 | stdout: "0be1c5f4141015ca6a8e873344da06e6": { 40 | stdout: "iface": "lo", 41 | stdout: "proto": "tcp", 42 | stdout: "dport": 3000, 43 | stdout: "saddr": "127.0.0.1", 44 | stdout: "sport": 80 45 | stdout: } 46 | stdout: } 47 | -- list-yaml.txt -- 48 | stdout: 0be1c5f4141015ca6a8e873344da06e6: 49 | stdout: iface: lo 50 | stdout: proto: tcp 51 | stdout: dport: 3000 52 | stdout: saddr: 127.0.0.1 53 | stdout: sport: 80 54 | stdout: 55 | -------------------------------------------------------------------------------- /tests/version.txtar: -------------------------------------------------------------------------------- 1 | # this testscript test the 'apply' command 2 | 3 | # if go is not installed, then skip 4 | [!exec:go] skip 5 | 6 | exec fwdctl version --help 7 | stdout 'Usage:' 8 | 9 | exec fwdctl version 10 | stdout 'dev' 11 | 12 | # use an invalid argument 13 | ! exec fwdctl version -x 14 | stdout 'unknown shorthand flag:' -------------------------------------------------------------------------------- /tests/version_trace.txtar: -------------------------------------------------------------------------------- 1 | # this testscript test the 'apply' command 2 | 3 | # if go is not installed, then skip 4 | [!exec:go] skip 5 | 6 | exec_cmd fwdctl version --help 7 | stdout 'Usage:' 8 | 9 | exec_cmd fwdctl version 10 | stdout 'dev' 11 | 12 | # use an invalid argument 13 | exec_cmd fwdctl version -x 14 | stdout 'unknown shorthand flag:' -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "regexp" 11 | "strings" 12 | 13 | "github.com/alegrey91/fwdctl/pkg/iptables" 14 | goiptables "github.com/coreos/go-iptables/iptables" 15 | "github.com/rogpeppe/go-internal/testscript" 16 | ) 17 | 18 | func fwdExists(ts *testscript.TestScript, neg bool, args []string) { 19 | if len(args) < 5 { 20 | ts.Fatalf("syntax: fwd_exists iface proto dest_port src_addr src_port") 21 | } 22 | 23 | ipt, err := goiptables.New() 24 | if err != nil { 25 | ts.Fatalf("error creating iptables instance: %q", err) 26 | } 27 | 28 | ruleSpec := []string{ 29 | "-i", args[0], // interface 30 | "-p", args[1], // protocol 31 | "-m", args[1], // protocol 32 | "--dport", args[2], // destination-port 33 | "-m", "comment", "--comment", "fwdctl", 34 | "-j", iptables.FwdTarget, // target (DNAT) 35 | "--to-destination", args[3] + ":" + args[4], // source-address / source-port 36 | } 37 | 38 | exists, err := ipt.Exists(iptables.FwdTable, iptables.FwdChain, ruleSpec...) 39 | if err != nil { 40 | ts.Fatalf("error checking rule: %v", err) 41 | } 42 | if neg && !exists { 43 | ts.Logf("forward doesn't exist") 44 | return 45 | } 46 | if !exists { 47 | ts.Fatalf("forward doesn't exist") 48 | } 49 | } 50 | 51 | //nolint:all 52 | func execCmd(ts *testscript.TestScript, neg bool, args []string) { 53 | var backgroundSpecifier = regexp.MustCompile(`^&([a-zA-Z_0-9]+&)?$`) 54 | uuid := getRandomString() 55 | workDir, err := os.Getwd() 56 | if err != nil { 57 | ts.Fatalf("unable to find work dir: %v", err) 58 | } 59 | customCommand := []string{ 60 | "/usr/sbin/harpoon", 61 | "capture", 62 | "-f", 63 | "main.main", 64 | "--save", 65 | "--directory", 66 | fmt.Sprintf("%s/integration-test-syscalls", workDir), 67 | "--include-cmd-stdout", 68 | "--include-cmd-stderr", 69 | "--name", 70 | fmt.Sprintf("main_main_%s", uuid), 71 | "--", 72 | } 73 | 74 | // find binary path for primary command 75 | cmdPath, err := exec.LookPath(args[0]) 76 | if err != nil { 77 | ts.Fatalf("unable to find binary path for %s: %v", args[0], err) 78 | } 79 | args[0] = cmdPath 80 | customCommand = append(customCommand, args...) 81 | 82 | ts.Logf("executing tracing command: %s", strings.Join(customCommand, " ")) 83 | // check if command has '&' as last char to be ran in background 84 | if len(args) > 0 && backgroundSpecifier.MatchString(args[len(args)-1]) { 85 | _, err = execBackground(ts, customCommand[0], customCommand[1:len(args)-1]...) 86 | } else { 87 | err = ts.Exec(customCommand[0], customCommand[1:]...) 88 | } 89 | if err != nil { 90 | ts.Logf("[%v]\n", err) 91 | if !neg { 92 | ts.Fatalf("unexpected go command failure") 93 | } 94 | } else { 95 | if neg { 96 | ts.Fatalf("unexpected go command success") 97 | } 98 | } 99 | } 100 | 101 | func execBackground(ts *testscript.TestScript, command string, args ...string) (*exec.Cmd, error) { 102 | cmd := exec.Command(command, args...) 103 | path := ts.MkAbs(".") 104 | dir, _ := filepath.Split(path) 105 | 106 | var stdoutBuf, stderrBuf strings.Builder 107 | cmd.Dir = dir 108 | cmd.Env = append(cmd.Env, "PWD="+dir) 109 | cmd.Stdout = &stdoutBuf 110 | cmd.Stderr = &stderrBuf 111 | return cmd, cmd.Start() 112 | } 113 | 114 | //nolint:all 115 | func getRandomString() string { 116 | b := make([]byte, 4) // 4 bytes will give us 6 base64 characters 117 | _, err := rand.Read(b) 118 | if err != nil { 119 | return "" 120 | } 121 | randomString := base64.URLEncoding.EncodeToString(b)[:6] 122 | return randomString 123 | } 124 | 125 | func customCommands() map[string]func(ts *testscript.TestScript, neg bool, args []string) { 126 | return map[string]func(ts *testscript.TestScript, neg bool, args []string){ 127 | 128 | // fwd_exists check that the given forward exists 129 | // invoke as "fwd_exists iface proto dest_port src_addr src_port" 130 | "fwd_exists": fwdExists, 131 | "exec_cmd": execCmd, 132 | } 133 | } 134 | --------------------------------------------------------------------------------