diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index abf60d63a4..99d274f78d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -35,6 +35,7 @@ /securitycenter/**/* @GoogleCloudPlatform/python-samples-reviewers @GoogleCloudPlatform/cloud-samples-reviewers @GoogleCloudPlatform/gcp-security-command-center /service_extensions/**/* @GoogleCloudPlatform/service-extensions-samples-reviewers @GoogleCloudPlatform/python-samples-reviewers @GoogleCloudPlatform/cloud-samples-reviewers /tpu/**/* @GoogleCloudPlatform/python-samples-reviewers @GoogleCloudPlatform/cloud-samples-reviewers +/vpc/**/* @GoogleCloudPlatform/python-samples-reviewers @GoogleCloudPlatform/cloud-samples-reviewers /vmwareengine/**/* @GoogleCloudPlatform/python-samples-reviewers @GoogleCloudPlatform/cloud-samples-reviewers /webrisk/**/* @GoogleCloudPlatform/python-samples-reviewers @GoogleCloudPlatform/cloud-samples-reviewers diff --git a/vpc/get_google_ip_ranges/README.md b/vpc/get_google_ip_ranges/README.md new file mode 100644 index 0000000000..4936aed9fe --- /dev/null +++ b/vpc/get_google_ip_ranges/README.md @@ -0,0 +1,92 @@ +# Google Cloud VPC: Get Google APIs and Services IP Ranges + +This sample shows how to retrieve Google's public IP range feeds and compute the IP address ranges (CIDRs) used exclusively for Google APIs and default domain services (such as `*.googleapis.com`). + +It calculates the IP set difference `(goog.json - cloud.json)` to separate default Google API domains from customer Google Cloud resource IPs, which is useful for configuring VPC firewall rules, custom routing, and [Private Google Access](https://cloud.google.com/vpc/docs/private-google-access). + +## Setup + +1. Create and activate a Python virtual environment: + + ```bash + python3 -m venv env + source env/bin/activate + ``` + +2. Install the dependencies needed to run the sample: + + ```bash + pip install -r requirements.txt + ``` + +## How to Run + +Run the sample script directly with Python: + +```bash +python get_google_ip_ranges.py +``` + +### Example Output + +```text +Fetched https://www.gstatic.com/ipranges/goog.json (published: 2026-08-11T13:04:46.50592) +Fetched https://www.gstatic.com/ipranges/cloud.json (published: 2026-08-11T13:04:46.50592) +IP ranges for Google APIs and services default domains: +8.8.4.0/24 +8.8.8.0/24 +8.35.200.0/21 +... +2600:1900::/34 +2600:1901:1::/48 +``` + +## Testing + +### Run Tests with Pytest + +1. Install test dependencies: + + ```bash + pip install -r requirements-test.txt + ``` + +2. Run the test suite: + + ```bash + pytest + ``` + +### Run Tests and Linting with Nox + +This sample uses [Nox](https://github.com/wntrblm/nox) for automated testing and linting. + +1. Install `nox`: + + ```bash + pip install nox + ``` + +2. Run code style linting: + + ```bash + nox -s lint + ``` + +3. Run code formatting with Black: + + ```bash + nox -s blacken + ``` + +4. Run tests against a specific Python version: + + ```bash + nox -s py-3.13 + ``` + +## References + +* [Google Cloud Sample Authoring Guide](https://github.com/GoogleCloudPlatform/python-docs-samples/blob/main/AUTHORING_GUIDE.md) +* [Private Google Access Configuration](https://cloud.google.com/vpc/docs/configure-private-google-access#ip-addr-defaults) +* [Google IP Address Ranges](https://cloud.google.com/vpc/docs/private-google-access#ip-addr-defaults) \ No newline at end of file diff --git a/vpc/get_google_ip_ranges/get_google_ip_ranges.py b/vpc/get_google_ip_ranges/get_google_ip_ranges.py new file mode 100644 index 0000000000..5994bfb4fc --- /dev/null +++ b/vpc/get_google_ip_ranges/get_google_ip_ranges.py @@ -0,0 +1,106 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# [START vpc_get_google_ip_ranges] +from __future__ import annotations + +import json +from urllib.error import URLError +from urllib.request import urlopen + +import netaddr + +# Google publishes two official IP range feeds: +# - goog: Complete list of all Google-owned IP ranges (Search, YouTube, APIs, etc.) +# - cloud: External IP ranges allocated for customer Google Cloud resources +IPRANGE_URLS: dict[str, str] = { + "goog": "https://www.gstatic.com/ipranges/goog.json", + "cloud": "https://www.gstatic.com/ipranges/cloud.json", +} + + +def get_data(url: str) -> netaddr.IPSet | None: + """Fetches JSON IP range data from a URL and converts prefixes into an IPSet. + + Args: + url: The public URL of the JSON IP ranges feed. + + Returns: + A netaddr.IPSet containing all parsed IPv4 and IPv6 network prefixes, + or None if the network request or JSON parsing fails. + """ + # Step 1: Download and parse the JSON feed + try: + with urlopen(url, timeout=30) as response: + data = json.load(response) + except (URLError, json.JSONDecodeError, TimeoutError) as error: + print(f"ERROR: Failed to fetch or parse {url}: {error}") + return None + + # Verify root payload is a JSON object + if not isinstance(data, dict): + print(f"ERROR: Expected JSON object from {url}, got {type(data).__name__}") + return None + + creation_time = data.get("creationTime", "Unknown") + print(f"Fetched {url} (published: {creation_time})") + + # Step 2: Extract both IPv4 and IPv6 CIDR prefixes into an IPSet + cidr_set = netaddr.IPSet() + prefixes = data.get("prefixes", []) + if isinstance(prefixes, list): + for prefix_entry in prefixes: + if not isinstance(prefix_entry, dict): + continue + try: + if "ipv4Prefix" in prefix_entry: + cidr_set.add(prefix_entry["ipv4Prefix"]) + if "ipv6Prefix" in prefix_entry: + cidr_set.add(prefix_entry["ipv6Prefix"]) + except (netaddr.AddrFormatError, ValueError) as error: + print(f"WARNING: Skipping invalid prefix in {url}: {error}") + + return cidr_set + + +def main() -> None: + """Calculates and displays CIDR ranges for Google APIs and default domain services. + + By subtracting customer Google Cloud IP ranges ('cloud') from all Google IP + ranges ('goog'), we isolate the IP ranges used exclusively for Google APIs + and default services (such as *.googleapis.com). + """ + # Step 1: Fetch and parse IP ranges for both 'goog' and 'cloud' feeds + cidrs: dict[str, netaddr.IPSet] = {} + for feed_name, url in IPRANGE_URLS.items(): + feed_cidrs = get_data(url) + + if feed_cidrs is None: + raise ValueError(f"ERROR: Could not process data from {url}") + + cidrs[feed_name] = feed_cidrs + + # Step 2: Compute set difference (goog - cloud) + # This removes customer cloud IPs, leaving only default Google APIs/services + default_domain_cidrs = cidrs["goog"] - cidrs["cloud"] + + # Step 3: Print the aggregated CIDR list + print("IP ranges for Google APIs and services default domains:") + for cidr in default_domain_cidrs.iter_cidrs(): + print(cidr) + + +if __name__ == "__main__": + main() +# [END vpc_get_google_ip_ranges] \ No newline at end of file diff --git a/vpc/get_google_ip_ranges/get_google_ip_ranges_test.py b/vpc/get_google_ip_ranges/get_google_ip_ranges_test.py new file mode 100644 index 0000000000..babe642aa2 --- /dev/null +++ b/vpc/get_google_ip_ranges/get_google_ip_ranges_test.py @@ -0,0 +1,227 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import io +import json +from unittest.mock import MagicMock, patch +from urllib.error import URLError + +import get_google_ip_ranges + +import netaddr +import pytest + +@pytest.fixture +def sample_goog_data() -> dict: + """Returns sample goog.json dictionary payload for testing.""" + return { + "syncToken": "1631120724291", + "creationTime": "2021-09-08T10:05:24.291305", + "prefixes": [ + {"ipv4Prefix": "8.8.4.0/24"}, + {"ipv4Prefix": "34.128.208.0/12"}, + {"ipv4Prefix": "8.35.192.0/20"}, + {"ipv6Prefix": "2600:1900::/32"}, + ], + } + + +@pytest.fixture +def sample_cloud_data() -> dict: + """Returns sample cloud.json dictionary payload for testing.""" + return { + "syncToken": "1631120724291", + "creationTime": "2021-09-08T00:00:00.000001", + "prefixes": [ + { + "ipv4Prefix": "8.8.4.0/16", + "service": "Google Cloud", + "scope": "asia-east1", + }, + { + "ipv4Prefix": "34.128.208.0/16", + "service": "Google Cloud", + "scope": "asia-east1", + }, + ], + } + + +def test_get_data_success( + sample_goog_data: dict, capsys: pytest.CaptureFixture +) -> None: + """Tests that get_data successfully parses IPv4 and IPv6 prefixes into an IPSet.""" + # Arrange + mock_response = io.BytesIO(json.dumps(sample_goog_data).encode("utf-8")) + mock_context_manager = MagicMock() + mock_context_manager.__enter__.return_value = mock_response + + # Act + with patch("get_google_ip_ranges.urlopen", return_value=mock_context_manager): + result = get_google_ip_ranges.get_data( + "https://www.gstatic.com/ipranges/goog.json" + ) + + # Assert + assert isinstance(result, netaddr.IPSet) + assert netaddr.IPNetwork("8.8.4.0/24") in result + assert netaddr.IPNetwork("8.35.192.0/20") in result + assert netaddr.IPNetwork("2600:1900::/32") in result + + captured = capsys.readouterr() + assert "published: 2021-09-08T10:05:24.291305" in captured.out + + +def test_get_data_url_error(capsys: pytest.CaptureFixture) -> None: + """Tests that get_data handles network errors (URLError) gracefully.""" + # Act + with patch( + "get_google_ip_ranges.urlopen", side_effect=URLError("Connection failed") + ): + result = get_google_ip_ranges.get_data( + "https://www.gstatic.com/ipranges/goog.json" + ) + + # Assert + assert result is None + captured = capsys.readouterr() + assert "ERROR: Failed to fetch or parse" in captured.out + + +def test_get_data_json_decode_error(capsys: pytest.CaptureFixture) -> None: + """Tests that get_data handles malformed JSON gracefully.""" + # Arrange + mock_response = io.BytesIO(b"not-a-valid-json-document") + mock_context_manager = MagicMock() + mock_context_manager.__enter__.return_value = mock_response + + # Act + with patch("get_google_ip_ranges.urlopen", return_value=mock_context_manager): + result = get_google_ip_ranges.get_data( + "https://www.gstatic.com/ipranges/goog.json" + ) + + # Assert + assert result is None + captured = capsys.readouterr() + assert "ERROR: Failed to fetch or parse" in captured.out + + +def test_get_data_non_dict_json(capsys: pytest.CaptureFixture) -> None: + """Tests that get_data handles non-dictionary JSON payloads gracefully.""" + # Arrange + mock_response = io.BytesIO(json.dumps(["unexpected", "list"]).encode("utf-8")) + mock_context_manager = MagicMock() + mock_context_manager.__enter__.return_value = mock_response + + # Act + with patch("get_google_ip_ranges.urlopen", return_value=mock_context_manager): + result = get_google_ip_ranges.get_data( + "https://www.gstatic.com/ipranges/goog.json" + ) + + # Assert + assert result is None + captured = capsys.readouterr() + assert "ERROR: Expected JSON object" in captured.out + + +def test_get_data_invalid_prefix_entry(capsys: pytest.CaptureFixture) -> None: + """Tests that get_data skips malformed prefix entries gracefully.""" + # Arrange + payload = { + "creationTime": "2026-01-01T00:00:00", + "prefixes": [ + {"ipv4Prefix": "8.8.8.0/24"}, + {"ipv4Prefix": "not-a-valid-cidr"}, + "invalid_non_dict_entry", + ], + } + mock_response = io.BytesIO(json.dumps(payload).encode("utf-8")) + mock_context_manager = MagicMock() + mock_context_manager.__enter__.return_value = mock_response + + # Act + with patch("get_google_ip_ranges.urlopen", return_value=mock_context_manager): + result = get_google_ip_ranges.get_data( + "https://www.gstatic.com/ipranges/goog.json" + ) + + # Assert + assert isinstance(result, netaddr.IPSet) + assert netaddr.IPNetwork("8.8.8.0/24") in result + captured = capsys.readouterr() + assert "WARNING: Skipping invalid prefix" in captured.out + + +def test_get_data_timeout_error(capsys: pytest.CaptureFixture) -> None: + """Tests that get_data handles socket timeouts gracefully.""" + # Act + with patch( + "get_google_ip_ranges.urlopen", + side_effect=TimeoutError("Request timed out"), + ): + result = get_google_ip_ranges.get_data( + "https://www.gstatic.com/ipranges/goog.json" + ) + + # Assert + assert result is None + captured = capsys.readouterr() + assert "ERROR: Failed to fetch or parse" in captured.out + + +def test_main_success( + sample_goog_data: dict, + sample_cloud_data: dict, + capsys: pytest.CaptureFixture, +) -> None: + """Tests that main() computes (goog - cloud) and outputs expected CIDRs.""" + + # Arrange + def mock_get_data_side_effect(url: str) -> netaddr.IPSet: + if "goog.json" in url: + cidrs = netaddr.IPSet() + for prefix in sample_goog_data["prefixes"]: + if "ipv4Prefix" in prefix: + cidrs.add(prefix["ipv4Prefix"]) + if "ipv6Prefix" in prefix: + cidrs.add(prefix["ipv6Prefix"]) + return cidrs + elif "cloud.json" in url: + cidrs = netaddr.IPSet() + for prefix in sample_cloud_data["prefixes"]: + if "ipv4Prefix" in prefix: + cidrs.add(prefix["ipv4Prefix"]) + if "ipv6Prefix" in prefix: + cidrs.add(prefix["ipv6Prefix"]) + return cidrs + return netaddr.IPSet() + + # Act + with patch("get_google_ip_ranges.get_data", side_effect=mock_get_data_side_effect): + get_google_ip_ranges.main() + + # Assert + captured = capsys.readouterr() + assert "IP ranges for Google APIs and services default domains:" in captured.out + assert "8.35.192.0/20" in captured.out + assert "2600:1900::/32" in captured.out + + +def test_main_failure_raises_value_error() -> None: + """Tests that main() raises ValueError when a feed download fails.""" + with patch("get_google_ip_ranges.get_data", return_value=None): + with pytest.raises(ValueError, match="ERROR: Could not process data from"): + get_google_ip_ranges.main() \ No newline at end of file diff --git a/vpc/get_google_ip_ranges/noxfile_config.py b/vpc/get_google_ip_ranges/noxfile_config.py new file mode 100644 index 0000000000..c18233122e --- /dev/null +++ b/vpc/get_google_ip_ranges/noxfile_config.py @@ -0,0 +1,28 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Default TEST_CONFIG_OVERRIDE for python repos.""" + +TEST_CONFIG_OVERRIDE = { + # You can opt out from testing specific Python versions. + "ignored_versions": ["2.7", "3.7", "3.8"], + # Enforce Python type hints + "enforce_type_hints": True, + # Project id environment variable + "gcloud_project_env": "GOOGLE_CLOUD_PROJECT", + # Version of pip override if needed + "pip_version_override": None, + # Custom environment variables for tests + "envs": {}, +} \ No newline at end of file diff --git a/vpc/get_google_ip_ranges/requirements-test.txt b/vpc/get_google_ip_ranges/requirements-test.txt new file mode 100644 index 0000000000..0a70ffadb4 --- /dev/null +++ b/vpc/get_google_ip_ranges/requirements-test.txt @@ -0,0 +1,6 @@ +pytest==8.3.4 +pytest-mock==3.15.1 +flake8==7.3.0 +flake8-annotations==3.2.0 +flake8-import-order==0.19.2 +black==26.5.1 \ No newline at end of file diff --git a/vpc/get_google_ip_ranges/requirements.txt b/vpc/get_google_ip_ranges/requirements.txt new file mode 100644 index 0000000000..c4acfb6d4d --- /dev/null +++ b/vpc/get_google_ip_ranges/requirements.txt @@ -0,0 +1 @@ +netaddr==1.3.0 \ No newline at end of file