Compare commits

...

17 Commits

Author SHA1 Message Date
8ec34cfbeb Ignore vim swapfiles
Some checks failed
Bandit / build (3.11) (push) Successful in 10s
Flake8 / build (3.11) (push) Successful in 9s
Mypy / build (3.11) (push) Failing after 4m30s
Pylint / build (3.11) (push) Successful in 28s
pip-audit / build (3.11) (pull_request) Failing after 12s
2026-03-13 22:26:15 +01:00
479f990044 add more linters 2026-03-13 22:25:24 +01:00
078514b69e Add CORS handling, and load settings from .env
Some checks failed
Flake8 / build (3.11) (push) Successful in 10s
Mypy / build (3.11) (push) Failing after 1m46s
Pylint / build (3.11) (push) Successful in 28s
2026-03-11 19:21:44 +01:00
7754b77b82 ::error::Unsupported platform: freebsd Revert "Try this with uv"
Some checks failed
Flake8 / build (3.11) (push) Successful in 9s
Mypy / build (3.11) (push) Failing after 1m59s
Pylint / build (3.11) (push) Successful in 29s
This reverts commit 87a9b1dd5f.
2026-03-05 16:35:20 +01:00
87a9b1dd5f Try this with uv
Some checks failed
Flake8 / build (3.11) (push) Successful in 13s
Mypy / build (3.11) (push) Failing after 2m12s
Pylint / build (3.11) (push) Failing after 2m33s
2026-03-05 16:00:55 +01:00
98643f9a69 Add requirements 2025-03-14 14:00:47 +01:00
e77e4dc174 Added workflows 2025-03-14 13:39:44 +01:00
a95dfaae2b Merge pull request 'Add Cache-Control headers to output' (#1) from cache_control into main
Reviewed-on: #1
2024-05-07 15:16:45 +00:00
4eb906f6d4 Code cleanup 2024-05-07 17:12:23 +02:00
878a2b6bbf Add missing parameter 2024-05-07 16:50:41 +02:00
d090e90ee2 Add Cache-Control headers 2024-05-06 17:24:27 +02:00
38416f103f Needs to be absolute path when ran from supervisord 2023-12-25 15:21:02 +01:00
484e548c46 Reduce logging 2023-12-25 15:15:35 +01:00
1fa64feefc Redirect empty GET with request to client address 2023-12-25 14:16:45 +01:00
f78ed7592d Add startup script and socket directory for gunicorn 2023-12-25 14:15:39 +01:00
0b4322d671 Handle AddressNotFoundError and allow empty placeholder GeoLocation for failed queries 2023-12-18 14:59:27 +01:00
949e8582bc documentation fixes 2023-12-12 14:49:53 +01:00
10 changed files with 211 additions and 33 deletions

View File

@ -0,0 +1,17 @@
---
name: Bandit
on: [push]
# XXX need to do stuff with uv
jobs:
build:
runs-on: freebsd
strategy:
matrix:
python-version: ["3.11"]
steps:
- uses: actions/checkout@v4
- name: Analyse code with Bandit
run: |
bandit -r .

View File

@ -0,0 +1,17 @@
---
name: Flake8
on: [push]
# XXX need to do stuff with uv
jobs:
build:
runs-on: freebsd
strategy:
matrix:
python-version: ["3.11"]
steps:
- uses: actions/checkout@v4
- name: Analyse code with Flake8
run: |
flake8 $(git ls-files '*.py')

17
.gitea/workflows/mypy.yml Normal file
View File

@ -0,0 +1,17 @@
---
name: Mypy
on: [push]
# XXX need to do stuff with uv
jobs:
build:
runs-on: freebsd
strategy:
matrix:
python-version: ["3.11"]
steps:
- uses: actions/checkout@v4
- name: Analyse code with Mypy
run: |
mypy --install-types --non-interactive $(git ls-files '*.py')

View File

@ -0,0 +1,23 @@
---
name: pip-audit
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 0 * * 0' # Weekly on Sunday
# XXX need to do stuff with uv
jobs:
build:
runs-on: freebsd
strategy:
matrix:
python-version: ["3.11"]
steps:
- uses: actions/checkout@v4
- name: Check vulnerable components with pip-audit
run: |
pip-audit .

View File

@ -0,0 +1,17 @@
---
name: Pylint
on: [push]
# XXX need to do stuff with uv
jobs:
build:
runs-on: freebsd
strategy:
matrix:
python-version: ["3.11"]
steps:
- uses: actions/checkout@v4
- name: Analyse code with Pylint
run: |
pylint $(git ls-files '*.py')

2
.gitignore vendored
View File

@ -160,3 +160,5 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
# Vim swap files
*.sw?

View File

@ -1,70 +1,129 @@
''' '''
Simple Geolocation with FastAPI Simple Geolocation with FastAPI
''' '''
import os
from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network
from typing import Annotated, Optional, Union from typing import Annotated, Optional, Union
import geoip2.database import geoip2.database
from fastapi import FastAPI, Path, Body from dotenv import load_dotenv
from fastapi import Body, FastAPI, Path, Request, Response, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from geoip2.errors import AddressNotFoundError
from pydantic import BaseModel from pydantic import BaseModel
# Load environment variables
load_dotenv()
app = FastAPI() app = FastAPI()
GEOLITE2_ASN_DB = '/usr/local/share/GeoIP/GeoLite2-ASN.mmdb' # Configure CORS from environment variables
GEOLITE2_CITY_DB = '/usr/local/share/GeoIP/GeoLite2-City.mmdb' cors_origins = os.getenv('CORS_ALLOW_ORIGINS', 'http://localhost')
allow_origins = [origin.strip() for origin in cors_origins.split(',')
if origin.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=allow_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],
)
GEOLITE2_ASN_DB = os.getenv('GEOLITE2_ASN_DB',
'/usr/local/share/GeoIP/GeoLite2-ASN.mmdb')
GEOLITE2_CITY_DB = os.getenv('GEOLITE2_CITY_DB',
'/usr/local/share/GeoIP/GeoLite2-City.mmdb')
class IPAddressParam(BaseModel): class IPAddressParam(BaseModel):
ip: Union[IPv6Address,IPv4Address] '''
Payload entry as used in POST
'''
ip: Union[IPv6Address, IPv4Address]
class Locality(BaseModel): class Locality(BaseModel):
''' '''
Locality data Locality data
''' '''
city: Optional[str] city: Optional[str] = None
country: Optional[str] country: Optional[str] = None
continent: Optional[str] continent: Optional[str] = None
is_eu: bool is_eu: bool = False
class GeoLocation(BaseModel): class GeoLocation(BaseModel):
''' '''
Geolocation data model Geolocation data model
''' '''
ip: Union[IPv6Address,IPv4Address] ip: Optional[Union[IPv6Address, IPv4Address]] = None
asn: Optional[int] asn: Optional[int] = None
asn_org: Optional[str] asn_org: Optional[str] = None
network: Union[IPv6Network,IPv4Network,None] network: Optional[Union[IPv6Network, IPv4Network]] = None
locality: Locality locality: Locality = Locality()
@app.post("/") @app.post("/")
async def root_post(ipaddresses: Annotated[list[IPAddressParam], async def root_post(ipaddresses: Annotated[
Body(title="The IPAddresses to geolocate")] list[IPAddressParam],
) -> list[GeoLocation]: Body(title="The IPAddresses to geolocate")],
response: Response
) -> list[GeoLocation]:
'''
Return GeoLocation item(s) for a list of IPAddressParam objects
'''
geolocations = [] geolocations = []
with (geoip2.database.Reader(GEOLITE2_ASN_DB) as reader_asn, with (geoip2.database.Reader(GEOLITE2_ASN_DB) as reader_asn,
geoip2.database.Reader(GEOLITE2_CITY_DB) as reader_city): geoip2.database.Reader(GEOLITE2_CITY_DB) as reader_city):
for ipaddress in ipaddresses: for ipaddress in ipaddresses:
asn_data = reader_asn.asn(ipaddress.ip) try:
city_data = reader_city.city(ipaddress.ip) asn_data = reader_asn.asn(ipaddress.ip)
geolocations.append(GeoLocation( city_data = reader_city.city(ipaddress.ip)
ip=ipaddress.ip,
asn=asn_data.autonomous_system_number, geolocations.append(GeoLocation(
asn_org=asn_data.autonomous_system_organization, ip=ipaddress.ip,
network=asn_data.network, asn=asn_data.autonomous_system_number,
locality=Locality( asn_org=asn_data.autonomous_system_organization,
city=city_data.city.name, network=asn_data.network,
country=city_data.country.iso_code, locality=Locality(
continent=city_data.continent.code, city=city_data.city.name,
is_eu=city_data.country.is_in_european_union country=city_data.country.iso_code,
continent=city_data.continent.code,
is_eu=city_data.country.is_in_european_union
)
))
except AddressNotFoundError:
geolocations.append(GeoLocation(
ip=ipaddress.ip
)
) )
)) if geolocations:
response.headers['Cache-Control'] = 'private, max-age=604800'
return geolocations return geolocations
@app.get("/{ipaddress}") @app.get("/{ipaddress}")
async def root_get(ipaddress: Annotated[Union[IPv4Address,IPv6Address], async def root_get(ipaddress: Annotated[
Path(title="The IPAddress to geolocate")] Union[IPv4Address, IPv6Address],
) -> GeoLocation: Path(title="The IPAddress to geolocate")],
response: Response
) -> GeoLocation:
''' '''
Look up geolocation for ip in path parameter Look up geolocation for ip in path parameter
''' '''
return (await root_post([IPAddressParam(ip=ipaddress)])).pop() locations = await root_post([IPAddressParam(ip=ipaddress)], response)
if locations:
response.headers['Cache-Control'] = 'private, max-age=604800'
return locations.pop()
response.status_code = status.HTTP_404_NOT_FOUND
return GeoLocation()
@app.get("/")
def root_redirect(req: Request) -> RedirectResponse:
'''
Redirect empty request using REMOTE_ADDR
'''
return RedirectResponse(url=str(req.url) + str(req.client.host))

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
geoip2==4.7.0
fastapi==0.115.6

0
run/.gitkeep Normal file
View File

24
start.sh Executable file
View File

@ -0,0 +1,24 @@
#!/bin/sh
NAME=ismijnipverweg
DIR=/opt/apps/ismijnipverweg/app
USER=www
GROUP=nobody
WORKERS=3
WORKER_CLASS=uvicorn.workers.UvicornWorker
#VENV=$DIR/.venv/bin/activate
BIND=unix:$DIR/../run/gunicorn.sock
LOG_LEVEL=error
cd $DIR
# source $VENV
exec /usr/local/bin/gunicorn main:app \
--name $NAME \
--workers $WORKERS \
--worker-class $WORKER_CLASS \
--user=$USER \
--group=$GROUP \
--bind=$BIND \
--log-level=$LOG_LEVEL \
--log-file=-