diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3b14d54b..25cb06d96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,91 +1,71 @@ name: CI + on: - release: - types: - - published push: branches: - - master - paths: - - .github/workflows/ci.yml - - cmd/** - - internal/** - - pkg/** - - .dockerignore - - .golangci.yml - - Dockerfile - - go.mod - - go.sum + - main + - develop + - release/* + - beta/* + tags: + - v* pull_request: - paths: - - .github/workflows/ci.yml - - cmd/** - - internal/** - - pkg/** - - .dockerignore - - .golangci.yml - - Dockerfile - - go.mod - - go.sum + branches: + - main + - develop + - release/* + - beta/* + release: + types: [published] + workflow_dispatch: jobs: verify: runs-on: ubuntu-latest - permissions: - actions: read - contents: read - env: - DOCKER_BUILDKIT: "1" steps: - uses: actions/checkout@v4 - - - uses: reviewdog/action-misspell@v1 + - uses: actions/setup-go@v5 with: - locale: "US" - level: error - exclude: | - ./internal/storage/servers.json - *.md - - - name: Linting - run: docker build --target lint . - - - name: Mocks check - run: docker build --target mocks . - - - name: Build test image - run: docker build --target test -t test-container . - - - name: Run tests in test container - run: | - touch coverage.txt - docker run --rm --device /dev/net/tun \ - -v "$(pwd)/coverage.txt:/tmp/gobuild/coverage.txt" \ - test-container - - - name: Build final image - run: docker build -t final-image . + go-version-file: go.mod + cache: true + - name: Verify + run: make verify codeql: + name: CodeQL runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write + + strategy: + fail-fast: false + matrix: + language: ["go"] + steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: "^1.23" - - uses: github/codeql-action/init@v3 + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 with: - languages: go - - uses: github/codeql-action/autobuild@v3 - - uses: github/codeql-action/analyze@v3 + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and queries in the config file. + # For more information on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-qlpacks + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 publish: if: | - github.repository == 'qdm12/gluetun' && ( github.event_name == 'push' || github.event_name == 'release' || @@ -109,9 +89,8 @@ jobs: flavor: | latest=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} images: | - ghcr.io/qdm12/gluetun - qmcgaw/gluetun - qmcgaw/private-internet-access + ghcr.io/${{ github.repository_owner }}/gluetun + ${{ github.repository_owner }}/gluetun tags: | type=ref,event=pr type=semver,pattern=v{{major}}.{{minor}}.{{patch}} @@ -122,20 +101,24 @@ jobs: - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 + # Login to Docker Hub (optional, only if you want to push to Docker Hub) - uses: docker/login-action@v3 + if: github.event_name != 'pull_request' with: - username: qmcgaw - password: ${{ secrets.DOCKERHUB_PASSWORD }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + # Login to GitHub Container Registry - uses: docker/login-action@v3 + if: github.event_name != 'pull_request' with: registry: ghcr.io - username: qdm12 - password: ${{ github.token }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Short commit id: shortcommit - run: echo "::set-output name=value::$(git rev-parse --short HEAD)" + run: echo "value=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT - name: Build and push final image uses: docker/build-push-action@v6 @@ -147,4 +130,4 @@ jobs: COMMIT=${{ steps.shortcommit.outputs.value }} VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} tags: ${{ steps.meta.outputs.tags }} - push: true + push: ${{ github.event_name != 'pull_request' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..261ebb4c3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Release +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate changelog + id: changelog + run: | + # Simple changelog generation + echo "## Changes" > CHANGELOG.md + git log --pretty=format:"- %s" $(git describe --tags --abbrev=0 HEAD^)..HEAD >> CHANGELOG.md + echo "changelog<> $GITHUB_OUTPUT + cat CHANGELOG.md >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create Release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + body: ${{ steps.changelog.outputs.changelog }} + draft: false + prerelease: false \ No newline at end of file diff --git a/internal/firewall/enable.go b/internal/firewall/enable.go index 30a6ebcd2..f81090508 100644 --- a/internal/firewall/enable.go +++ b/internal/firewall/enable.go @@ -41,36 +41,6 @@ func (c *Config) SetEnabled(ctx context.Context, enabled bool) (err error) { return nil } -func (c *Config) disable(ctx context.Context) (err error) { - if err = c.clearAllRules(ctx); err != nil { - return fmt.Errorf("clearing all rules: %w", err) - } - if err = c.setIPv4AllPolicies(ctx, "ACCEPT"); err != nil { - return fmt.Errorf("setting ipv4 policies: %w", err) - } - if err = c.setIPv6AllPolicies(ctx, "ACCEPT"); err != nil { - return fmt.Errorf("setting ipv6 policies: %w", err) - } - - const remove = true - err = c.redirectPorts(ctx, remove) - if err != nil { - return fmt.Errorf("removing port redirections: %w", err) - } - - return nil -} - -// To use in defered call when enabling the firewall. -func (c *Config) fallbackToDisabled(ctx context.Context) { - if ctx.Err() != nil { - return - } - if err := c.disable(ctx); err != nil { - c.logger.Error("failed reversing firewall changes: " + err.Error()) - } -} - func (c *Config) enable(ctx context.Context) (err error) { touched := false if err = c.setIPv4AllPolicies(ctx, "DROP"); err != nil { @@ -90,6 +60,11 @@ func (c *Config) enable(ctx context.Context) (err error) { } }() + // Clear any previously applied post-rules + if err = c.clearAppliedPostRules(ctx); err != nil { + c.logger.Warn("failed to clear previous post-rules: " + err.Error()) + } + // Loopback traffic if err = c.acceptInputThroughInterface(ctx, "lo", remove); err != nil { return err @@ -144,6 +119,7 @@ func (c *Config) enable(ctx context.Context) (err error) { return fmt.Errorf("redirecting ports: %w", err) } + // Apply post-rules only once at the end if err := c.runUserPostRules(ctx, c.customRulesPath, remove); err != nil { return fmt.Errorf("running user defined post firewall rules: %w", err) } @@ -151,6 +127,41 @@ func (c *Config) enable(ctx context.Context) (err error) { return nil } +func (c *Config) disable(ctx context.Context) (err error) { + // Clear applied post-rules when disabling + if err = c.clearAppliedPostRules(ctx); err != nil { + c.logger.Warn("failed to clear post-rules during disable: " + err.Error()) + } + + if err = c.clearAllRules(ctx); err != nil { + return fmt.Errorf("clearing all rules: %w", err) + } + if err = c.setIPv4AllPolicies(ctx, "ACCEPT"); err != nil { + return fmt.Errorf("setting ipv4 policies: %w", err) + } + if err = c.setIPv6AllPolicies(ctx, "ACCEPT"); err != nil { + return fmt.Errorf("setting ipv6 policies: %w", err) + } + + const remove = true + err = c.redirectPorts(ctx, remove) + if err != nil { + return fmt.Errorf("removing port redirections: %w", err) + } + + return nil +} + +// To use in defered call when enabling the firewall. +func (c *Config) fallbackToDisabled(ctx context.Context) { + if ctx.Err() != nil { + return + } + if err := c.disable(ctx); err != nil { + c.logger.Error("failed reversing firewall changes: " + err.Error()) + } +} + func (c *Config) allowVPNIP(ctx context.Context) (err error) { if !c.vpnConnection.IP.IsValid() { return nil diff --git a/internal/firewall/firewall.go b/internal/firewall/firewall.go index 8114d550f..7a5540779 100644 --- a/internal/firewall/firewall.go +++ b/internal/firewall/firewall.go @@ -3,6 +3,7 @@ package firewall import ( "context" "net/netip" + "strings" "sync" "github.com/qdm12/gluetun/internal/models" @@ -29,6 +30,7 @@ type Config struct { //nolint:maligned outboundSubnets []netip.Prefix allowedInputPorts map[uint16]map[string]struct{} // port to interfaces set mapping portRedirections portRedirections + appliedPostRules []string // Track applied post-rules to avoid duplicates stateMutex sync.Mutex } @@ -60,3 +62,30 @@ func NewConfig(ctx context.Context, logger Logger, localNetworks: localNetworks, }, nil } + +// clearAppliedPostRules removes all previously applied post-rules +func (c *Config) clearAppliedPostRules(ctx context.Context) error { + for _, rule := range c.appliedPostRules { + flippedRule := flipRule(rule) + if strings.Contains(rule, "ip6tables") { + if err := c.runIP6tablesInstruction(ctx, flippedRule); err != nil { + c.logger.Debug("failed to remove post-rule (may not exist): " + err.Error()) + } + } else { + if err := c.runIptablesInstruction(ctx, flippedRule); err != nil { + c.logger.Debug("failed to remove post-rule (may not exist): " + err.Error()) + } + } + } + c.appliedPostRules = nil + return nil +} + +// applyPostRulesOnce applies post-rules only if they haven't been applied yet +func (c *Config) applyPostRulesOnce(ctx context.Context) error { + if len(c.appliedPostRules) > 0 { + c.logger.Debug("post-rules already applied, skipping") + return nil + } + return c.runUserPostRules(ctx, c.customRulesPath, false) +} diff --git a/internal/firewall/iptables.go b/internal/firewall/iptables.go index 0b1002f55..2df2ed204 100644 --- a/internal/firewall/iptables.go +++ b/internal/firewall/iptables.go @@ -283,6 +283,7 @@ func (c *Config) runUserPostRules(ctx context.Context, filepath string, remove b _ = c.runIptablesInstruction(ctx, flipRule(rule)) } }() + for _, line := range lines { var ipv4 bool var rule string @@ -313,6 +314,21 @@ func (c *Config) runUserPostRules(ctx context.Context, filepath string, remove b rule = flipRule(rule) } + // Check if this rule was already applied (avoid duplicates) + if !remove { + ruleAlreadyApplied := false + for _, appliedRule := range c.appliedPostRules { + if appliedRule == line { + ruleAlreadyApplied = true + break + } + } + if ruleAlreadyApplied { + c.logger.Debug("skipping duplicate post-rule: " + line) + continue + } + } + switch { case ipv4: err = c.runIptablesInstruction(ctx, rule) @@ -326,6 +342,11 @@ func (c *Config) runUserPostRules(ctx context.Context, filepath string, remove b } successfulRules = append(successfulRules, rule) + + // Track applied rules (only when adding, not removing) + if !remove { + c.appliedPostRules = append(c.appliedPostRules, line) + } } return nil } diff --git a/internal/firewall/ports.go b/internal/firewall/ports.go index 6f5867ee2..eff4b98a1 100644 --- a/internal/firewall/ports.go +++ b/internal/firewall/ports.go @@ -42,6 +42,11 @@ func (c *Config) SetAllowedPort(ctx context.Context, port uint16, intf string) ( netInterfaces[intf] = struct{}{} c.allowedInputPorts[port] = netInterfaces + // Apply post-rules only once after adding the port, and only if not already applied + if err := c.applyPostRulesOnce(ctx); err != nil { + c.logger.Warn("failed to apply post-rules after adding port: " + err.Error()) + } + return nil }