Skip to content

Conversation

@nDmitry
Copy link
Owner

@nDmitry nDmitry commented Dec 6, 2025

Addresses #5

Summary by CodeRabbit

  • New Features

    • IP-based access filtering to restrict requests by IP/CIDR.
    • Optional reverse-proxy support to trust client IP headers.
    • Denied requests return HTTP 403 with a JSON error response.
  • Documentation

    • Added configuration guide and environment variables for ALLOWED_IPS and REVERSE_PROXY.
  • Tests

    • Comprehensive test coverage for IP parsing, CIDR matching, proxy headers, edge cases, and denial behavior.

✏️ Tip: You can customize this high-level summary in your review settings.

@nDmitry
Copy link
Owner Author

nDmitry commented Dec 6, 2025

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Dec 6, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link

coderabbitai bot commented Dec 6, 2025

Walkthrough

Adds an IP-based firewall and middleware to enforce allowed IPs/CIDR ranges (optionally trusting proxy headers), wires it into server startup, and documents two environment variables for configuration.

Changes

Cohort / File(s) Summary
Configuration & Documentation
README.md, compose.yaml
Documents two new environment variables: ALLOWED_IPS (comma-separated IPs/CIDRs) and REVERSE_PROXY (boolean to trust proxy headers).
Firewall Implementation
internal/api/rest/ipfilter.go
New Firewall type and NewFirewall(allowedIPsStr string, trustProxy bool) constructor; parses IP/CIDR list and exposes IsAllowed(r *http.Request) bool using RemoteAddr or proxy headers when trusted.
Firewall Tests
internal/api/rest/ipfilter_test.go
Adds comprehensive tests for constructor parsing, IPv4/IPv6 exact and CIDR matching, proxy header handling, malformed inputs, and edge cases.
REST Integration
internal/api/rest/middleware.go, internal/api/rest/server.go, internal/api/rest/telegram.go
Adds IPFilter interface and IPFilterMiddleware(filter IPFilter) middleware; Server gains ipFilter field, NewServer signature updated to accept ipFilter, and Run wires IP filter into the middleware chain producing 403 JSON on denial.
Application Startup
cmd/tgfeed/main.go
Reads ALLOWED_IPS and REVERSE_PROXY env vars, constructs the firewall via rest.NewFirewall(...), logs status, and passes the ipFilter into NewServer.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant ReverseProxy
    participant Server
    participant Firewall
    Client->>ReverseProxy: HTTP request
    ReverseProxy->>Server: Forward request (adds X-Real-IP / X-Forwarded-For)
    Server->>Firewall: IPFilter.IsAllowed(request)
    Firewall-->>Server: allowed / denied
    alt allowed
        Server->>Server: pass to router/handler chain
        Server-->>Client: 200/handler response
    else denied
        Server-->>Client: 403 {"error":"access denied"}
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

  • Review points:
    • internal/api/rest/ipfilter.go: CIDR parsing, IPv4/IPv6 handling, header parsing edge cases.
    • internal/api/rest/middleware.go & server.go: middleware ordering, nil IPFilter behavior, JSON error encoding.
    • cmd/tgfeed/main.go: env var parsing and error handling during firewall initialization.

Poem

🐰
Soft nets of CIDR, stitched with care,
I guard the burrow, watch the air.
Headers whisper, proxies sigh,
Only kin may pass on by.
A hop, a sniff — access allowed, carrot nigh.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 56.25% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding IP filtering capability with configuration options, which is reflected across all modified files (configuration, main setup, middleware, firewall logic).
✨ Finishing touches
  • 📝 Generate docstrings

Comment @coderabbitai help to get the list of available commands and usage tips.

@nDmitry
Copy link
Owner Author

nDmitry commented Dec 6, 2025

@coderabbitai generate docstrings

@coderabbitai
Copy link

coderabbitai bot commented Dec 6, 2025

Warning

Docstrings generation is disabled for your repository or organization.

@coderabbitai
Copy link

coderabbitai bot commented Dec 6, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch the Go source files with new exported entities
cat internal/api/rest/ipfilter.go
echo "---"
cat internal/api/rest/middleware.go
echo "---"
cat internal/api/rest/server.go
echo "---"
cat internal/api/rest/telegram.go

Length of output: 13287


@nDmitry, I'll generate comprehensive docstrings for the exported entities in the IP filtering feature. Here are the improved docstrings:

internal/api/rest/telegram.go

// Scraper defines the interface for scraping Telegram channel data.
// Implementations must handle fetching channel information and posts from Telegram.
type Scraper interface {
	Scrape(ctx context.Context, username string) (*entity.Channel, error)
}

// Generator defines the interface for generating RSS/Atom feeds from channel data.
// Implementations must support multiple feed formats and filtering options.
type Generator interface {
	Generate(channel *entity.Channel, params *entity.FeedParams) ([]byte, error)
}

// IPFilter defines the interface for IP-based access control.
// Implementations determine whether an HTTP request should be allowed based on
// the client's IP address and optional reverse proxy headers.
type IPFilter interface {
	IsAllowed(r *http.Request) bool
}

internal/api/rest/ipfilter.go

// Firewall implements IP-based access control for HTTP requests.
// It maintains a list of allowed IP addresses and CIDR ranges, and can
// optionally trust reverse proxy headers (X-Real-IP, X-Forwarded-For) to
// determine the client's real IP address.
type Firewall struct {
	allowedNets []*net.IPNet
	trustProxy  bool
}

// NewFirewall creates a new Firewall instance with the specified configuration.
// The allowedIPsStr parameter accepts a comma-separated list of IP addresses
// and/or CIDR ranges (e.g., "10.0.0.0/24,192.168.1.1,2001:db8::/32").
// If allowedIPsStr is empty, all IP addresses are allowed by default.
// When trustProxy is true, the firewall will check X-Real-IP and X-Forwarded-For
// headers to determine the client's IP address, which is necessary when the
// application runs behind a reverse proxy.
// Returns an error if any IP address or CIDR notation is invalid.
func NewFirewall(allowedIPsStr string, trustProxy bool) (*Firewall, error) {
	// ... existing implementation
}

// IsAllowed checks if the request originates from an allowed IP address.
// When trustProxy is enabled, it first checks X-Real-IP and X-Forwarded-For headers
// before falling back to RemoteAddr. If no IP restrictions are configured (empty allowlist),
// all requests are allowed. Returns false if the IP cannot be extracted or is not in the allowlist.
func (f *Firewall) IsAllowed(r *http.Request) bool {
	// ... existing implementation
}

internal/api/rest/middleware.go

// IPFilterMiddleware wraps an http.Handler with IP-based access control.
// If the filter is nil, the middleware passes all requests through unchanged.
// When a filter is provided, each request is validated using filter.IsAllowed.
// Denied requests receive a 403 Forbidden response with a JSON error message.
// The middleware logs warnings for denied requests including the remote address and path.
func IPFilterMiddleware(filter IPFilter) func(http.Handler) http.Handler {
	// ... existing implementation
}

internal/api/rest/server.go

// NewServer creates a new REST API server with the specified dependencies.
// The ipFilter parameter controls IP-based access restrictions; pass nil to disable filtering.
// The port parameter specifies the TCP port to listen on (e.g., "8080").
// The server is pre-configured with secure timeout values to mitigate common attacks.
func NewServer(c cache.Cache, s Scraper, g Generator, ipFilter IPFilter, port string) *Server {
	// ... existing implementation
}

These docstrings follow Go conventions and provide clear explanations of:

  • What each type/interface represents
  • Parameter meanings and formats
  • Return values and error conditions
  • Behavior details, especially for security-sensitive features like IP filtering
  • Integration points with reverse proxies

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (4)
internal/api/rest/server.go (1)

65-73: Middleware ordering: confirm this is the desired logging behavior

The current chain:

  • handler := mux
  • handler = IPFilterMiddleware(s.ipFilter)(handler)
  • handler = Logger(handler)

means Logger wraps IPFilterMiddleware, so even denied requests are fully logged by the HTTP logger (plus a warn from the filter). If you’d prefer not to emit the full request/response logging for blocked IPs (keeping only the filter’s warning), you could invert the order:

-	handler := http.Handler(s.mux)
-	handler = IPFilterMiddleware(s.ipFilter)(handler)
-	handler = Logger(handler)
+	handler := http.Handler(s.mux)
+	handler = Logger(handler)
+	handler = IPFilterMiddleware(s.ipFilter)(handler)

As implemented, behavior is coherent; just ensure it matches your observability/privacy expectations.

internal/api/rest/ipfilter.go (3)

47-97: IP extraction and proxy‑header handling look correct and defensive

  • IsAllowed short‑circuits to true when allowedNets is empty, then uses extractClientIP + isIPAllowed otherwise, which matches the doc comment.
  • With trustProxy == true, you first honor a valid X-Real-IP, then the first entry in X-Forwarded-For, falling back to RemoteAddr only if headers are missing/invalid. With trustProxy == false, only RemoteAddr is used. This is a sensible pattern and avoids trusting spoofed headers when not configured to.
  • If no valid IP can be extracted, you fail closed (IsAllowed returns false), which is the safer default.

If you later want stricter diagnostics, you could differentiate “no IP extracted” vs “IP not in allowlist” in logs, but that’s not required for correctness.


99-114: extractFromRemoteAddr is correct; consider handling bare IPs as a nicety

Using net.SplitHostPort on remoteAddr and then net.ParseIP on the host is the right approach for typical ip:port and [ip]:port formats used by net/http. When parsing fails or IP is invalid, you return a wrapped error and IsAllowed denies the request, which is a safe failure mode.

If you expect environments/tests that sometimes set RemoteAddr to a bare IP without a port, you might optionally handle that case, for example:

 func extractFromRemoteAddr(remoteAddr string) (string, error) {
-	host, _, err := net.SplitHostPort(remoteAddr)
-
-	if err != nil {
-		return "", fmt.Errorf("invalid remote address: %w", err)
-	}
+	host, _, err := net.SplitHostPort(remoteAddr)
+	if err != nil {
+		// Fallback: treat remoteAddr as a bare IP without port.
+		host = remoteAddr
+	}

 	ip := net.ParseIP(host)

 	if ip == nil {
 		return "", fmt.Errorf("invalid IP address: %s", host)
 	}

Not mandatory, but can reduce surprises if upstream occasionally omits the port.


116-188: Allowlist parsing and membership checks are straightforward and robust

  • parseAllowedIPs correctly trims each comma‑separated part, skips empties (tolerating stray commas/whitespace), and aborts on the first invalid entry, returning the underlying error.
  • parseIPOrCIDR appropriately distinguishes CIDR vs single IP, normalizing plain IPs into /32 or /128 CIDRs, which keeps everything in a single *net.IPNet representation.
  • isIPAllowed simply parses the candidate IP and linearly scans allowedNets using Contains, which is perfectly adequate for the expected small config lists.

If you ever grow the allowlist significantly, you might consider structuring the data (e.g., grouping v4/v6 or using a trie), but for typical deployment sizes this is more than sufficient.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6c73250 and ea4ed43.

📒 Files selected for processing (4)
  • internal/api/rest/ipfilter.go (1 hunks)
  • internal/api/rest/middleware.go (2 hunks)
  • internal/api/rest/server.go (3 hunks)
  • internal/api/rest/telegram.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/api/rest/telegram.go
  • internal/api/rest/middleware.go
🧰 Additional context used
🧬 Code graph analysis (1)
internal/api/rest/server.go (2)
internal/api/rest/telegram.go (1)
  • IPFilter (28-30)
internal/api/rest/middleware.go (2)
  • IPFilterMiddleware (76-105)
  • Logger (12-44)
🔇 Additional comments (4)
internal/api/rest/server.go (2)

16-25: Server struct wiring for ipFilter is consistent and minimal

Adding ipFilter IPFilter here cleanly models the new dependency and keeps Server’s state cohesive with the constructor changes; no issues spotted.


27-45: Constructor docs and dependency wiring look accurate

The expanded NewServer signature, doc comments, and field initialization (including ipFilter) line up correctly with the described behavior (nil to disable filtering, preconfigured timeouts). The handler being left nil here and set later in Run is also consistent with the comment.

internal/api/rest/ipfilter.go (2)

10-18: Firewall state design is simple and concurrency‑safe

Storing allowedNets []*net.IPNet plus a trustProxy flag is minimal and read‑only after construction, so it’s safe for concurrent use from HTTP handlers. The docstring correctly explains the responsibilities and proxy‑trust behavior.


19-45: The "empty means allow all" behavior is explicitly documented as intended

When allowedIPsStr is empty, allowedNets is set to nil, and IsAllowed treats len(f.allowedNets) == 0 as "no restrictions → allow all requests." This behavior is already documented in the NewFirewall docstring ("If allowedIPsStr is empty, all IP addresses are allowed by default") and the IsAllowed docstring ("If no IP restrictions are configured (empty allowlist), all requests are allowed").

The same behavior applies to whitespace-only strings: parseAllowedIPs trims whitespace and skips empty parts, resulting in an empty slice that IsAllowed treats as unrestricted access.

This is not a hidden assumption—it is the explicitly stated design. If the intended security posture differs from this, the documentation and default behavior should be aligned accordingly.

@nDmitry nDmitry merged commit d5fe3d6 into main Dec 6, 2025
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants