A modern Mayan calender! (CLI and Web app w/ API) http://mayan.ing
  • Python 67.2%
  • Emacs Lisp 13.1%
  • JavaScript 11.4%
  • CSS 4.1%
  • HTML 3.1%
  • Other 1.1%
Find a file
Vincent Batts 5b05ec6549 log the real client IP behind a reverse proxy
gunicorn's access log prints REMOTE_ADDR, which behind a proxy is the
proxy (or Docker's bridge gateway), so logs showed the container's local
address. The rate limiter already read X-Forwarded-For, but the logs did
not.

- mayan/api.py: at the start of each request, set REMOTE_ADDR to the real
  client address (from X-Forwarded-For, trusting MAYAN_PROXY_HOPS
  proxies) and keep the socket address in environ["mayan.proxy_addr"].
  gunicorn logs from the same environ dict, so access-log lines and 429s
  now show the visitor. Applies even when rate limiting is disabled.
- mayan/ratelimit.py: split out client_ip() (plain IP, no port) and
  group_key() (IPv6 grouped by /64). An unparseable forwarded entry now
  falls back to the socket address, so junk never reaches logs or keys.
  Policy.check() takes the address; MAYAN_PROXY_HOPS is read even when
  limiting is off.
- README: logs show the real client (a 172.x line means no usable
  X-Forwarded-For, e.g. a direct health check), an HAProxy backend example
  (`option forwardfor` is enough), a note that X-Real-IP / X-Client-IP are
  not used, and to set MAYAN_PROXY_HOPS=2 if another layer sits in front
  of HAProxy.
- tests: 7 new (87 total) for the two-header-line shape HAProxy produces,
  REMOTE_ADDR rewriting, no-header fallback, trusted-proxy 0, and hops
  with limiting disabled.

Checked in the Docker image: a client-forged leading header is ignored,
"ip:port" entries log as the bare IP, and a request without the header
logs the gateway address.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cs15HErdrGNzMQCYWProJg
2026-11-05 16:19:56 -05:00
docs add website screenshots to README 2026-11-05 15:24:22 -05:00
mayan log the real client IP behind a reverse proxy 2026-11-05 16:19:56 -05:00
references use 116 days for the Mercury almanac period 2026-11-05 15:57:35 -05:00
tests log the real client IP behind a reverse proxy 2026-11-05 16:19:56 -05:00
web add per-client rate limiting to the API 2026-11-05 16:15:49 -05:00
.dockerignore add .env.example and project link in the site footer 2026-11-05 15:57:42 -05:00
.env.example add per-client rate limiting to the API 2026-11-05 16:15:49 -05:00
.gitignore add .env.example and project link in the site footer 2026-11-05 15:57:42 -05:00
compose.yaml add per-client rate limiting to the API 2026-11-05 16:15:49 -05:00
Dockerfile add Dockerfile and compose for deployment 2026-11-05 14:27:49 -05:00
LICENSE Initial commit 2026-11-05 17:59:02 +00:00
overview.md use 116 days for the Mercury almanac period 2026-11-05 15:57:35 -05:00
README.md log the real client IP behind a reverse proxy 2026-11-05 16:19:56 -05:00
requirements-prod.txt add Dockerfile and compose for deployment 2026-11-05 14:27:49 -05:00
requirements.txt add Skyfield astronomy layer with --astro and --ascii CLI flags 2026-11-05 14:12:01 -05:00

mayan-cal

Convert dates to the Maya calendar (Long Count, Tzolk'in, Haab') and show where the Sun, Moon and planets actually are that day, next to the cycle lengths the Maya almanacs used for them. It comes as a date-style command line tool, a JSON API and a small website.

$ python -m mayan 2012-12-21
13.0.0.0.0 4 Ahau 3 Kankin

The website showing 13.0.0.0.0 (21 Dec 2012): the Long Count, Tzolk'in and Haab' wheels, an orbit map and cycle bars for the Moon and planets

The same page at phone width

The site at 13.0.0.0.0 (21 Dec 2012), on desktop and at phone width.

The calendar part is exact integer arithmetic. The sky part uses real positions from Skyfield and the JPL DE440s ephemeris. See overview.md for the background: what each cycle is, what the sources do and do not support, and which almanac periods are debated.

Requirements

  • Python 3.11 or newer.
  • The calendar and CLI need only the standard library.
  • Astronomy (--astro, --ascii, astro=1) needs pip install -r requirements.txt (Skyfield). The first use downloads a ~32 MB ephemeris into ~/.cache/mayan-cal (override with MAYAN_DATA_DIR). It covers 1849–2150.
python3 -m venv venv && . venv/bin/activate
pip install -r requirements.txt

Command line

python -m mayan [WHEN ...] [options]

WHEN can be any of:

Form Example
now (default) now, today
ISO date 2012-12-21
ISO date and time 2012-12-21T12:00:00Z
Unix time @1356048000
Long Count 13.0.0.0.0
Option Meaning
-j, --json JSON output, one object per WHEN
-f, --format TEMPLATE e.g. '{long_count} {tzolkin} {haab}'; fields: days, long_count, baktun, katun, tun, uinal, kin, tzolkin, tzolkin_number, tzolkin_name, haab, haab_day, haab_month, calendar_round, gregorian, weekday, text
-c, --correlation gmt (default), thompson, spinden, hochleitner, or an integer offset
-z, --tz timezone for the day boundary (default: local; utc or an IANA name)
-a, --astro add real positions of the Sun, Moon and planets and their cycle progress
--ascii also draw a top-down ASCII map of the orbits (implies --astro)

Exit status is 2 for bad input and 1 for an astronomy failure (missing ephemeris, date out of range, a Long Count with no Gregorian date).

$ python -m mayan -z utc -f '{weekday} {gregorian}: {long_count}' 2012-12-21 @0
Friday 2012-12-21: 13.0.0.0.0
Thursday 1970-01-01: 12.17.16.7.5

$ python -m mayan -a 2012-12-21

Website and API

python -m mayan.server            # http://127.0.0.1:8000
python -m mayan.server --host 0.0.0.0 --port 9000

The site shows the Long Count, the Tzolk'in and Haab' wheels, a top-down orbit map and a bar per body for how far it is through its cycle. You can step by meaningful intervals (20 days, 260, 584 for Venus, 780 for Mars, ...) or press Play. The date and correlation are kept in the URL hash. Clicking the "attested" or "debated" tag next to a body opens a dialog with what supports that period, with citations (the text comes from /api/v1/info, field body_notes).

mayan.server is a development server. For production use the container below, or any WSGI server: gunicorn mayan.api:app.

API

GET /api/v1/date

Parameter Default Meaning
when now same forms as the CLI (max 100 characters)
tz utc timezone for the day boundary
correlation gmt as above
astro 0 1 adds a celestial object
$ curl 'localhost:8000/api/v1/date?when=2012-12-21&astro=1'
{"api_version": 1, "long_count": "13.0.0.0.0",
 "tzolkin": {"number": 4, "name": "Ahau", "text": "4 Ahau"},
 "haab": {"day": 3, "month": "Kankin", "text": "3 Kankin"},
 "gregorian": "2012-12-21", "celestial": {...}, ...}

GET /api/v1/info returns reference data: Tzolk'in and Haab' names, Long Count unit sizes, correlations, and each body's Maya period with a confidence of attested or debated.

Errors are JSON {"error": "..."}: 400 for bad input, 404, 405 (GET and HEAD only), 429 when rate limited (with a Retry-After header), 503 when astronomy is unavailable. Responses allow any origin (Access-Control-Allow-Origin: *) because the data is read-only.

Docker

docker compose up -d --build     # http://127.0.0.1:8000

The image uses gunicorn, has the ephemeris baked in (no download at runtime), runs as an unprivileged user, and the compose service uses a read-only root filesystem with all capabilities dropped and a 512 MB memory limit.

Variable Default Meaning
MAYAN_BIND 127.0.0.1 host address to bind; 0.0.0.0 exposes it directly
MAYAN_PORT 8000 host port
WEB_CONCURRENCY 2 gunicorn worker processes

Put them in a .env file next to compose.yaml (Compose reads it automatically; it is git-ignored). Start from cp .env.example .env. Check the result with docker compose config. A variable set in your shell overrides .env.

If port 8000 is taken: set MAYAN_PORT=8765 in .env, or run MAYAN_PORT=8765 docker compose up -d.

Before exposing it to the public, put a TLS-terminating reverse proxy (Caddy, nginx, a cloud load balancer) in front and check MAYAN_PROXY_HOPS (below).

Rate limiting

The API has a built-in token-bucket limit per client IP (IPv6 clients are grouped by /64). Each /api/ request costs 1 token and an astro=1 request costs 2, since it runs an ephemeris calculation. A client that runs out gets 429 with a Retry-After header. Static files are not limited.

Variable Default Meaning
MAYAN_RATE_LIMIT 1 0 turns limiting off
MAYAN_RATE_BURST 120 bucket size: requests allowed at once
MAYAN_RATE_PER_MINUTE 120 refill rate
MAYAN_ASTRO_COST 2 tokens for an astro=1 request
MAYAN_PROXY_HOPS 1 number of reverse proxies in front of the container (0 if clients connect directly)

Two things to get right:

  • The real client IP comes from X-Forwarded-For. The default MAYAN_PROXY_HOPS=1 uses the last entry, which your proxy appended, and ignores anything a client put earlier in the header. Your proxy must set or append the header (Caddy does by default; nginx needs proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;). If the header is missing, the socket address is used, which behind a proxy is the proxy itself, so everyone would share one bucket. Use 2 for two chained proxies, never more than you really run, or clients can forge the header and dodge the limit. If clients connect directly, set 0 so the header is ignored. Entries written as ip:port or [ipv6]:port are handled.
  • Logs show the real client too. The app sets REMOTE_ADDR to that client address, so gunicorn's access log (and 429s) show the visitor, not the proxy or Docker's bridge gateway. A log line with a 172.x address means the request had no usable X-Forwarded-For (for example a direct health check). The socket address is kept in environ["mayan.proxy_addr"].

For HAProxy, option forwardfor in the backend is all that is needed:

backend mayan
    mode http
    option forwardfor
    server app 127.0.0.1:8000 check

Other headers such as X-Real-IP or X-Client-IP are not used. If something else (a CDN, another load balancer) sits in front of HAProxy, the entry HAProxy appends is that layer's address, so set MAYAN_PROXY_HOPS=2.

  • Limits are per gunicorn worker, kept in memory, so the effective limit is about WEB_CONCURRENCY times the numbers above, and they reset on restart. For a strict or shared limit, also limit at the proxy.

The website's Play button steps once a second, which stays within the default limit. If the limit is hit, the page shows the server's message and stops.

Notes on accuracy

  • Default correlation is Goodman–Martinez–Thompson (JDN 584,283), the same default as Emacs cal-mayan.el. The Long Count is the same number under any correlation; only its link to the Gregorian calendar changes.
  • Gregorian conversion works for years 1–9999 (Python's date). Long Counts outside that still give a Tzolk'in and Haab' but no Gregorian date or sky.
  • Date-only inputs use noon in the chosen timezone for the astronomy.
  • "Day N of the cycle" for each body is its geometric synodic phase times the Maya almanac period. It is approximate: orbits are not circular and the Dresden Codex tables start at heliacal risings, not conjunctions. Mercury, Jupiter and Saturn are marked debated because scholars disagree that the Maya tracked those cycles.
  • The almanac periods were checked on 5 Nov 2026 against a compiled reference table (Van Stone / FAMSI) and one paper abstract, not against the codex or Bricker & Bricker (2011). Venus 584, Mars 780, the 405-lunation 11,960-day eclipse span and the lunar ratios are confirmed. Mercury 116, Jupiter 399 and Saturn 378 are only tentatively supported, and the 177/148-day eclipse steps come from a preprint and a popular site. Details and links are in overview.md and references/almanac-periods.md.

Development

python -m unittest discover       # 87 tests

Astronomy and API astronomy tests are skipped when Skyfield or the cached ephemeris is missing; the tests never download anything.

Layout:

Path Role
mayan/core.py integer Long Count / Tzolk'in / Haab' arithmetic
mayan/convert.py input parsing and the JSON result; shared by CLI and API
mayan/astro.py Skyfield positions and Maya cycle data
mayan/render.py text table and ASCII orbit map
mayan/cli.py, mayan/api.py, mayan/server.py front ends
mayan/ratelimit.py per-client token-bucket limiter used by the API
web/ the website (vanilla JS and SVG, no build step)
references/ source material (see below)

References and licenses

The project itself is under the MIT license (see LICENSE); the files in references/ keep their own terms.