256 Commits

Author SHA1 Message Date
Zsolt Ero
b7bc98d950 work 2026-05-02 21:46:09 +02:00
Zsolt Ero
b0fc592f7c work 2025-10-23 12:36:03 +02:00
Zsolt Ero
aa9c32ea23 work 2025-10-23 11:48:14 +02:00
Zsolt Ero
4a322a8ddd work 2025-10-23 11:43:56 +02:00
Zsolt Ero
82458b9db4 add lang debug 2025-10-17 11:37:23 +02:00
Zsolt Ero
a8d94319fd styling 2025-10-16 23:22:14 +02:00
Zsolt Ero
f8c337abe6 work 2025-10-16 18:00:32 +02:00
Zsolt Ero
7757c82b59 work 2025-10-16 13:09:47 +02:00
Zsolt Ero
96432037e4 work 2025-10-16 13:06:31 +02:00
Zsolt Ero
ba8c766698 work 2025-10-16 13:02:27 +02:00
Zsolt Ero
a28df3156f work 2025-10-16 12:44:56 +02:00
Zsolt Ero
24dfa2ce37 work 2025-10-16 12:41:41 +02:00
Zsolt Ero
2becae11e1 work 2025-10-15 16:22:53 +02:00
Zsolt Ero
753166316c work 2025-10-15 16:05:35 +02:00
Zsolt Ero
bf60c28bb5 work 2025-10-13 22:42:45 +02:00
Zsolt Ero
397f56be9d log fixes 2025-10-11 01:10:11 +02:00
Zsolt Ero
dfe0a766ed refactor 2025-10-10 10:53:20 +02:00
Zsolt Ero
2e260d30e5 work 2025-10-10 00:55:04 +02:00
Zsolt Ero
407d534801 work 2025-10-10 00:54:09 +02:00
Zsolt Ero
52e34fc1c9 work 2025-10-10 00:27:58 +02:00
Zsolt Ero
e746b00962 work 2025-10-10 00:03:32 +02:00
Zsolt Ero
8352a70111 work 2025-10-09 23:51:37 +02:00
Zsolt Ero
8167f6baf9 work 2025-10-09 23:48:17 +02:00
Zsolt Ero
3ace404697 work 2025-10-09 23:46:25 +02:00
Zsolt Ero
c579698906 work 2025-10-09 23:38:15 +02:00
Zsolt Ero
b3e8bff774 work 2025-10-09 01:22:53 +02:00
Zsolt Ero
f7299f6836 work 2025-10-09 00:53:56 +02:00
Zsolt Ero
c787f602d9 assets 2025-10-09 00:22:19 +02:00
Zsolt Ero
3a66d303c4 imports 2025-10-09 00:16:58 +02:00
Zsolt Ero
d9487abd97 work 2025-10-08 01:49:00 +02:00
Zsolt Ero
8594d730c7 work 2025-10-08 01:47:35 +02:00
Zsolt Ero
45df827cb0 work 2025-10-08 01:32:54 +02:00
Zsolt Ero
154d592ace work 2025-10-08 01:26:20 +02:00
Zsolt Ero
a36e830416 work 2025-10-07 18:24:21 +02:00
Zsolt Ero
17d580023b work 2025-10-07 17:53:42 +02:00
Zsolt Ero
377dd7f334 work 2025-10-07 17:44:57 +02:00
Zsolt Ero
55dae6776f refactor 2025-10-07 16:22:23 +02:00
Zsolt Ero
fe30af3fb2 work 2025-10-07 16:08:53 +02:00
Zsolt Ero
7fa19d33d1 work 2025-10-07 14:50:42 +02:00
Zsolt Ero
6eb32db16a work 2025-10-03 23:27:02 +02:00
Zsolt Ero
d735f4975f added schema 2025-10-03 20:42:04 +02:00
Zsolt Ero
9b34510c8b work 2025-09-18 20:46:27 +02:00
Zsolt Ero
b24f096ad4 work 2025-09-18 19:35:56 +02:00
Zsolt Ero
604f27e7db removed loadbalancer and unused parts 2025-09-18 18:45:23 +02:00
Zsolt Ero
b068aacca1 work 2025-09-18 03:14:02 +02:00
Zsolt Ero
0330f775b8 work 2025-09-18 03:11:27 +02:00
Zsolt Ero
36739c85b8 disable debug cron 2025-09-18 03:09:38 +02:00
Zsolt Ero
60a5c15fbd work 2025-09-18 02:56:24 +02:00
Zsolt Ero
b592fee9ba planetiler 2025-09-18 02:38:07 +02:00
Zsolt Ero
13cdc09eef java 24 from temurin 2025-09-18 02:36:45 +02:00
Zsolt Ero
454b38a111 work 2025-09-18 02:06:58 +02:00
Zsolt Ero
04a274ba3d work 2025-09-18 01:47:22 +02:00
Zsolt Ero
11c72cb6d6 work 2025-09-18 01:33:02 +02:00
Zsolt Ero
638337398f work 2025-09-18 01:13:13 +02:00
Zsolt Ero
b6f3c2fede lint 2025-09-18 01:11:30 +02:00
Zsolt Ero
8e58957651 work 2025-09-18 01:11:07 +02:00
Zsolt Ero
a8bcc9c480 work 2025-09-18 01:03:09 +02:00
Zayd Krunz
a3eeed00b5 Update self_hosting.md (#83) 2025-08-10 11:31:53 +02:00
Zsolt Ero
8abdc7d8a4 multi_accept 2025-08-10 01:48:14 +02:00
Zsolt Ero
1a700fd5df work 2025-08-09 17:21:36 +02:00
Zsolt Ero
48da869c42 lint 2025-04-19 15:48:44 +02:00
Zsolt Ero
584156170e nginx conf 2025-04-19 15:48:38 +02:00
Zsolt Ero
dd97e1fdcb Merge branch 'main' of github.com:hyperknot/openfreemap 2025-04-01 16:41:59 +02:00
Zsolt Ero
74f00d6899 add cloudflare conf 2025-04-01 16:41:53 +02:00
Andreas Hocevar
fd78d27dfe Add OpenLayers instructions to the Quick Start (#70) 2025-03-28 00:28:55 +01:00
Zsolt Ero
e9c8577f26 use cloudflare 2025-03-20 04:06:05 +01:00
Zsolt Ero
306de6aa29 lint 2025-03-02 02:29:14 +01:00
Zsolt Ero
b2802a4e3a self signed 10 years 2025-02-28 14:16:51 +01:00
Zsolt Ero
f0f7841bb1 dummy.cert 2025-02-28 14:10:09 +01:00
Zsolt Ero
6687311f9a X-Robots-Tag "noindex, nofollow" 2025-02-27 16:52:31 +01:00
Zsolt Ero
d76a877f1f canonical urls 2025-02-27 16:33:45 +01:00
Zsolt Ero
dd047e3767 trailing slash 2025-02-27 16:29:56 +01:00
Zsolt Ero
777f194ae6 trailing slash 2025-02-27 16:25:27 +01:00
Zsolt Ero
a63dfb348c sitemap 2025-02-27 16:07:43 +01:00
Zsolt Ero
98d0015fef added 404 2025-02-27 15:14:16 +01:00
Zsolt Ero
dc116233fb removed github pages 2025-02-27 15:06:51 +01:00
Zsolt Ero
8d099cfc89 update 2025-02-27 15:04:09 +01:00
Zsolt Ero
0371dfaaea disable logging 2025-02-27 12:51:05 +01:00
Zsolt Ero
a50347dcb6 autoupdate in background 2025-02-27 05:20:51 +01:00
Zsolt Ero
7e8dcbaa4a prepare-virtualenv, cleaning 2025-02-27 04:40:57 +01:00
Zsolt Ero
e6a241d70c lint 2025-02-26 19:55:34 +01:00
Zsolt Ero
b1f81b0818 npm 2025-02-26 19:51:01 +01:00
Zsolt Ero
18b0a0e30b yarn 2025-02-26 19:48:57 +01:00
Zsolt Ero
63107e9f25 test store path 2025-02-26 19:44:23 +01:00
Zsolt Ero
d91e335cea astro update 2025-02-26 19:31:20 +01:00
Zsolt Ero
85f925d0ef terms update 2025-02-26 18:28:14 +01:00
Zsolt Ero
d112d45205 Merge branch 'main' of github.com:hyperknot/openfreemap 2025-02-26 16:58:58 +01:00
Zsolt Ero
3993f18277 website 2025-02-26 16:58:50 +01:00
Zsolt Ero
cb0ee2ba23 renames 2025-02-26 16:58:41 +01:00
Souleymane Maman Nouri Souley
3419f479e6 Fixe typos (#61) 2025-02-07 23:39:11 +01:00
tbodt
bdc5ab3a60 Add Toki Pona language (#46) 2025-01-09 19:40:14 +01:00
zstadler
7ffe52882d Fix broken link in README.md (#49)
Thanks a lot, good catch!
2025-01-09 15:30:34 +01:00
Zsolt Ero
1dcb66ccc9 lint 2024-11-12 09:47:20 +01:00
Zsolt Ero
5ae8ae0b5d renames 2024-11-08 20:32:49 +01:00
Zsolt Ero
e0e0aa375c renames 2024-11-08 20:23:54 +01:00
Zsolt Ero
44186967d6 domain_le -> domain_direct 2024-11-08 20:19:33 +01:00
Zsolt Ero
474d52b4c5 ledns -> roundrobin 2024-11-08 20:18:22 +01:00
Zsolt Ero
d8c41202dc comments 2024-11-08 18:47:07 +01:00
Zsolt Ero
6d13c536ab comments in .env.sample 2024-11-08 18:45:14 +01:00
Zsolt Ero
ede4babe97 bsky svg 2024-11-04 00:19:32 +01:00
Zsolt Ero
1ae8cb0f4d css 2024-11-01 01:28:32 +01:00
Zsolt Ero
d8c8056f85 bsky update 2024-11-01 01:19:30 +01:00
Zsolt Ero
eed66dbaf1 added bsky 2024-11-01 01:13:18 +01:00
Zsolt Ero
bd7f5fa740 feat(deploy): add deploy-sync script for production environment setup
feat(init-server.py): implement http_host_sync command for server initialization
refactor(http_host_lib): enhance asset downloading functions to return status of changes made
2024-10-28 12:33:53 +01:00
bek
6fbe9f04e9 chore: fix typos in self_hosting.md (#34)
Great catches, thank you!
2024-10-26 00:35:29 +02:00
Zsolt Ero
f04ebd395e nginx logging 2024-10-24 02:35:49 +02:00
Zsolt Ero
d18f58e2cd lint 2024-10-24 02:15:38 +02:00
Zsolt Ero
d5365ef15b docs update 2024-10-24 02:15:27 +02:00
Zsolt Ero
bbbc7230c0 readme 2024-10-24 01:59:11 +02:00
Zsolt Ero
8a47e5ad4d lint 2024-10-24 01:29:40 +02:00
Zsolt Ero
73be28b622 formatting 2024-10-24 01:29:20 +02:00
Zsolt Ero
717a197ba8 Generating the domain inside the style JSON files dynamically (using nginx sub_filter). 2024-10-24 01:29:12 +02:00
Zsolt Ero
2bee8df5fe added note about self-hosting VPS providers 2024-10-24 01:08:09 +02:00
Zsolt Ero
cd375825b6 lint 2024-10-23 23:48:33 +02:00
Zsolt Ero
b9e3dc394e move styles location block to dynamic 2024-10-23 23:48:30 +02:00
Zsolt Ero
2084f24469 skip_letsencrypt implemented 2024-10-23 23:22:00 +02:00
Zsolt Ero
e800123093 variable env file handling 2024-10-23 23:21:38 +02:00
Zsolt Ero
fb44bb0241 add SKIP_LETSENCRYPT 2024-10-23 23:16:18 +02:00
CJ
ff8041000f feat: add leaflet snippet (#33)
Great, thanks!
2024-10-22 14:28:07 +02:00
Zsolt Ero
6ecb63cd55 fix space 2024-10-21 22:40:53 +02:00
Zsolt Ero
efd8580b19 updated docs 2024-10-21 11:20:02 +02:00
Zsolt Ero
ac786ea084 chore(docs): clean up debugging_names.md and self_hosting.md for clarity and conciseness
feat(docs): enhance self_hosting.md with additional instructions for quick setup and deployment
fix(nginx.py): add missing directory creation for nginx certificates in ssh_lib
refactor(nginx.py): update curl command filtering logic based on skip_planet configuration in http_host_lib
2024-10-12 00:08:27 +03:00
Zsolt Ero
4d93db437a docs(self_hosting.md): clarify instructions for setting up environment variables and provide feedback loop recommendation for SKIP_PLANET
refactor(tasks.py): remove unnecessary assertion for cloudflare.ini existence to streamline configuration process
2024-10-11 23:27:29 +03:00
Zsolt Ero
72141bac52 feat(cluster.html): add example HTML file to demonstrate clustering with MapLibre GL JS for visualizing earthquake data 2024-10-11 01:13:47 +03:00
Zsolt Ero
f8f46a37ef docs 2024-09-29 13:51:34 +02:00
Zsolt Ero
24db0df5f9 markdown 2024-09-29 13:46:18 +02:00
Zsolt Ero
90be2d7546 markdown retina 2024-09-29 13:38:33 +02:00
Zsolt Ero
3ea44ac019 docs 2024-09-29 13:17:06 +02:00
Zsolt Ero
a98bd63f30 retina images 2024-09-29 13:10:04 +02:00
Zsolt Ero
8d25ff6f1d lint 2024-09-29 12:59:34 +02:00
Zsolt Ero
c3c64539ef debugging names docs 2024-09-29 12:59:19 +02:00
Zsolt Ero
2efce0a4ab homepage 2024-09-26 15:24:14 +02:00
Zsolt Ero
ab5c001c6b readme update 2024-09-26 13:46:20 +02:00
Zsolt Ero
0201676e32 website 2024-09-26 02:20:23 +02:00
Zsolt Ero
2c0614eccb meta description 2024-09-26 01:49:15 +02:00
Zsolt Ero
8bd54c6714 fix broken link 2024-09-26 01:47:23 +02:00
Zsolt Ero
6bcd8a3481 map controls, disable drag rotate 2024-09-25 14:54:29 +02:00
Zsolt Ero
b2841785ea add RTL support 2024-09-25 14:43:00 +02:00
Zsolt Ero
a7347bf595 prettier on Markdown only 2024-09-25 14:33:59 +02:00
Zsolt Ero
d9faa2cfc2 readme 2024-09-25 14:29:46 +02:00
Zsolt Ero
cf0243521a biome 2024-09-25 14:21:32 +02:00
Zsolt Ero
c31027198d biome 2024-09-25 14:15:54 +02:00
Zsolt Ero
f9955f7067 add biome 2024-09-25 14:14:52 +02:00
Zsolt Ero
8f0d811abd biome
# Conflicts:
#	website/public/scripts/donate.js
#	website/src/styles/_style.css
#	website/src/styles/collapsible.css
2024-09-25 14:10:27 +02:00
Zsolt Ero
728524304c README update 2024-09-25 14:06:36 +02:00
Zsolt Ero
d359c8b197 README update 2024-09-25 13:50:44 +02:00
Zsolt Ero
e5ff96c434 updated readme 2024-09-25 10:47:37 +02:00
Zsolt Ero
f4ca2b20ac simplify extract_mbtiles.py 2024-09-24 22:37:35 +02:00
Zsolt Ero
eb7c2fb752 lint 2024-09-24 17:42:26 +02:00
Zsolt Ero
7116245c9d removed donate 2024-09-24 17:42:08 +02:00
Zsolt Ero
a5099bdd59 Revert "add RTL plugin"
This reverts commit 11c6a395bc.
2024-09-24 15:44:38 +02:00
Zsolt Ero
11c6a395bc add RTL plugin 2024-09-24 15:30:41 +02:00
Zsolt Ero
0c20012d44 add SHA256SUMS 2024-09-24 13:55:12 +02:00
Zsolt Ero
876788a490 homepage 2024-09-20 19:40:05 +02:00
Zsolt Ero
1b98a23117 readme 2024-09-20 17:05:32 +02:00
Zsolt Ero
0d6162301b readme 2024-09-20 16:13:00 +02:00
Zsolt Ero
916995f9c7 readme 2024-09-20 16:10:33 +02:00
Zsolt Ero
e4398b434a self-hosting updates 2024-09-20 16:00:27 +02:00
Zsolt Ero
8469d8563e self-hosting readme 2024-09-20 15:53:31 +02:00
Zsolt Ero
b6a81a0897 added requests 2024-09-20 15:53:24 +02:00
Zsolt Ero
d4e1805bad docs 2024-09-20 15:19:19 +02:00
Zsolt Ero
bce124190a readme update 2024-09-20 14:09:33 +02:00
Zsolt Ero
a491db7d2f homepage rewording 2024-09-20 14:05:55 +02:00
Zsolt Ero
90bf954b7a lint 2024-09-19 17:49:07 +02:00
Zsolt Ero
d3a6c58427 Merge branch 'main' of github.com:hyperknot/openfreemap 2024-09-19 17:35:31 +02:00
Zsolt Ero
236cb6da6b link to GitHub sponsors 2024-09-19 17:35:17 +02:00
Zsolt Ero
16e35cb8df Create FUNDING.yml 2024-09-19 17:22:26 +02:00
Zsolt Ero
d815c0aa56 readme update 2024-09-19 17:20:15 +02:00
Zsolt Ero
2c9799222e readme 2024-09-19 16:46:45 +02:00
Zsolt Ero
f1e17bc295 logo 2024-09-19 16:45:25 +02:00
Zsolt Ero
2e21306863 docs 2024-09-19 16:42:53 +02:00
Zsolt Ero
695ca14416 website 2024-09-19 16:14:26 +02:00
Zsolt Ero
65faeb4e3f readme update 2024-09-19 16:09:33 +02:00
Zsolt Ero
ab8bc87f7f planetiler updates 2024-09-16 19:18:18 +02:00
Zsolt Ero
e3a85349ce readme 2024-09-16 13:44:04 +02:00
Zsolt Ero
ed667c5427 update btrfs bucket url in README 2024-09-16 00:01:05 +02:00
Zsolt Ero
16274eafb1 cron 2024-09-13 17:21:15 +02:00
Zsolt Ero
5174811136 relaxed mode set to 3 min 2024-09-13 02:25:06 +02:00
Zsolt Ero
b083c41d21 relaxed mode tweaks 2024-09-12 17:07:06 +02:00
Zsolt Ero
4945fbb7b3 readme update 2024-09-12 16:42:43 +02:00
Zsolt Ero
bd4c85338a implemented relaxed mode for loadbalancer 2024-09-12 16:42:03 +02:00
Zsolt Ero
3cf11fe7af loadbalancer, config upload moved to shared 2024-09-12 15:58:38 +02:00
Zsolt Ero
4723d3c283 fix --noninteractive 2024-09-12 15:38:16 +02:00
Zsolt Ero
c8aa63edc6 loadbalancer works 2024-09-12 15:34:53 +02:00
Zsolt Ero
a346ef347e back to daily monaco runs 2024-09-12 14:06:16 +02:00
Zsolt Ero
3bfa83a10c logging 2024-09-12 13:23:27 +02:00
Zsolt Ero
04fcdfe028 logging 2024-09-12 13:16:55 +02:00
Zsolt Ero
f461b47099 logging 2024-09-12 13:15:33 +02:00
Zsolt Ero
12fe0de684 better logging 2024-09-12 13:00:23 +02:00
Zsolt Ero
506f9ce48d comment 2024-09-12 12:56:43 +02:00
Zsolt Ero
e015515245 logging 2024-09-12 12:17:14 +02:00
Zsolt Ero
93f89e51e7 logging 2024-09-12 11:59:33 +02:00
Zsolt Ero
f5a7b00256 add noninteractive option 2024-09-12 11:44:23 +02:00
Zsolt Ero
33cb06c7a1 monaco daily 2024-09-12 11:33:35 +02:00
Zsolt Ero
1a5fa5b208 logging fixes 2024-09-12 04:12:13 +02:00
Zsolt Ero
fb75f214d1 readme 2024-09-12 01:02:04 +02:00
Zsolt Ero
0b4591ca60 cron fixes 2024-09-12 00:59:26 +02:00
Zsolt Ero
5deab8aafd version checking using "done" files 2024-09-12 00:49:36 +02:00
Zsolt Ero
6dc02a5fe7 done file 2024-09-12 00:25:34 +02:00
Zsolt Ero
15bb347a61 lint, fix Python 3.10 bug 2024-09-11 23:50:15 +02:00
Zsolt Ero
69bc529dd6 comment 2024-09-11 12:38:03 +02:00
Zsolt Ero
1b28adfb77 access log 2024-09-11 10:48:42 +02:00
Zsolt Ero
793d1b81c3 curl text fixes 2024-09-11 02:31:32 +02:00
Zsolt Ero
78f003e6e3 fix location wrong order with ^~ 2024-09-11 02:19:06 +02:00
Zsolt Ero
27f34ccae6 nginx, curl text 2024-09-11 01:29:40 +02:00
Zsolt Ero
3e45d91811 broken 2024-09-10 23:56:58 +02:00
Zsolt Ero
9904a9c039 typo 2024-09-10 23:49:50 +02:00
Zsolt Ero
f48ab6f4a6 work 2024-09-10 23:47:25 +02:00
Zsolt Ero
0ec790d597 better logging 2024-09-10 14:30:35 +02:00
Zsolt Ero
ad5c66d4cd changelog in README 2024-09-10 02:56:12 +02:00
Zsolt Ero
e904cc8a00 print line fix 2024-09-10 02:50:48 +02:00
Zsolt Ero
75cb9fb753 astro update 2024-09-10 02:50:43 +02:00
Zsolt Ero
03edca21ce compress fonts 2024-09-10 02:26:38 +02:00
Zsolt Ero
eb2d82d764 fix auto-update 2024-09-10 01:46:44 +02:00
Zsolt Ero
0bd2a19d1c tile_gen fixes 2024-09-10 01:14:37 +02:00
Zsolt Ero
5738d542f8 set version 2024-09-01 15:45:35 +02:00
Zsolt Ero
add716cb58 config, set_version 2024-09-01 15:25:30 +02:00
Zsolt Ero
77a5855b0c nginx logs 2024-08-31 01:36:26 +02:00
Zsolt Ero
a7daec032e refactor, auto_clean 2024-08-30 02:16:40 +02:00
Zsolt Ero
d753c8738a versions 2024-08-29 18:40:32 +02:00
Zsolt Ero
c30a55a5cd http-host-once -> http-host-static 2024-08-29 16:35:14 +02:00
Zsolt Ero
66d0bdc515 scripts -> modules 2024-08-29 16:33:59 +02:00
Zsolt Ero
7196e15837 downloading asset work 2024-08-29 16:12:34 +02:00
Zsolt Ero
3079a59434 make-indexes on tile_gen 2024-08-29 16:02:12 +02:00
Zsolt Ero
64475f2d18 refactor 2024-08-29 15:53:38 +02:00
Zsolt Ero
fc240a0edf renames 2024-08-29 01:52:56 +02:00
Zsolt Ero
f55d9f1c8b config refactor, renames 2024-08-29 01:50:52 +02:00
Zsolt Ero
939b782830 config refactor 2024-08-29 01:40:21 +02:00
Zsolt Ero
2fc91aa470 move to scripts 2024-08-29 01:27:08 +02:00
Zsolt Ero
4d20bba7d1 cron 2024-08-29 01:09:13 +02:00
Zsolt Ero
3d83e0809e cron jobs 2024-08-29 00:40:51 +02:00
Zsolt Ero
43c9f31f03 uploading and indexes 2024-08-29 00:20:23 +02:00
Zsolt Ero
b746263cea docs 2024-08-27 02:02:37 +02:00
Zsolt Ero
08d17df476 refactor tile_gen in Python 2024-08-27 01:47:34 +02:00
Zsolt Ero
41f49b0743 debug work 2024-07-23 21:58:58 +02:00
Zsolt Ero
91710627d3 check request method 2024-07-23 21:56:48 +02:00
Zsolt Ero
d19f3a45c0 nginx logs 2024-07-23 20:20:33 +02:00
Zsolt Ero
d001c1e3a4 force sync 2024-07-23 18:40:03 +02:00
Zsolt Ero
0aef97139e rename log extensions 2024-07-23 17:36:15 +02:00
Zsolt Ero
ecf2fd38f9 debug proxy 2024-07-15 18:29:07 +02:00
Zsolt Ero
851ed9e99b multiple subdomains 2024-06-27 17:21:14 +02:00
Zsolt Ero
5665cfaab0 loadbalancer 2024-06-26 15:25:29 +02:00
Zsolt Ero
e28691a446 remove scale control 2024-06-25 15:04:00 +02:00
Zsolt Ero
7991bb34f7 astro upgrade 2024-06-25 14:36:04 +02:00
Zsolt Ero
e047cc3650 readme 2024-06-24 20:56:37 +02:00
Zsolt Ero
1a20131723 curl message 2024-06-24 20:44:39 +02:00
Zsolt Ero
37afbbb902 setversion, http_host 2024-06-24 17:56:05 +02:00
Zsolt Ero
11a9879f18 removed Cloudflare done 2024-06-24 16:54:23 +02:00
Zsolt Ero
dd7965726a DOMAIN_CF removed 2024-06-24 16:48:13 +02:00
Zsolt Ero
5f27cade7a loadbalancer fixes 2024-06-24 16:42:40 +02:00
Zsolt Ero
8c938f9bb1 rename command 2024-06-24 16:13:41 +02:00
Zsolt Ero
fad7465cac cron 2024-06-24 16:13:34 +02:00
Zsolt Ero
6f99eb47c7 readme 2024-06-24 16:13:29 +02:00
Zsolt Ero
9d925f2fd5 cloudflare related fixes 2024-06-24 15:49:20 +02:00
Zsolt Ero
c355fb6e8a styling 2024-06-24 02:25:14 +02:00
Zsolt Ero
abf4a86cb4 quick start guide 2024-06-23 23:11:00 +02:00
155 changed files with 6202 additions and 5382 deletions

2
.envrc
View File

@@ -2,6 +2,6 @@
# auto-activate python virtualenv # auto-activate python virtualenv
# https://github.com/direnv/direnv # https://github.com/direnv/direnv
source venv/bin/activate source .venv/bin/activate
unset PS1 unset PS1

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: [hyperknot]

View File

@@ -1,40 +0,0 @@
name: Deploy to GitHub Pages
on:
push:
branches: [main]
paths:
- 'website/**'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: website
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout your repository using git
uses: actions/checkout@v4
- name: Install, build, and upload your site output
uses: withastro/action@v2
with:
path: website
package-manager: pnpm@latest
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

3
.gitignore vendored
View File

@@ -19,3 +19,6 @@ venv
/pnpm-lock.yaml /pnpm-lock.yaml
/deploy-*.sh
tmp.txt

View File

@@ -1,18 +0,0 @@
const config = {
printWidth: 100,
semi: false,
singleQuote: true,
arrowParens: 'avoid',
plugins: ['prettier-plugin-astro'],
overrides: [
{
files: '*.astro',
options: {
parser: 'astro',
},
},
],
}
module.exports = config

View File

@@ -1,48 +1,57 @@
target-version = "py310" target-version = "py313"
unsafe-fixes = true
line-length = 100 line-length = 100
extend-exclude = ["temp"] extend-exclude = ["alembic", "*.ipynb", "temp"]
lint.select = [ lint.select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
'UP', # pyupgrade
'A', # flake8-builtins 'A', # flake8-builtins
"C4", # flake8-comprehensions "C4", # flake8-comprehensions
'DTZ', # flake8-datetimez
"E", # pycodestyle errors
'EXE', # flake8-executable 'EXE', # flake8-executable
"F", # Pyflakes
'FA', # flake8-future-annotations 'FA', # flake8-future-annotations
"I", # isort
'PT', # flake8-pytest-style 'PT', # flake8-pytest-style
'RSE', # flake8-raise 'RSE', # flake8-raise
'SIM', # flake8-simplify 'SIM', # flake8-simplify
'DTZ', # flake8-datetimez, https://beta.ruff.rs/docs/rules/#flake8-datetimez-dtz 'UP', # pyupgrade
"W", # pycodestyle warnings
] ]
lint.ignore = [ lint.ignore = [
'A003', 'A003',
# 'C408', # keep dict() as-is
'DTZ007', # naive datetime.strptime() without %z
'E501', 'E501',
'E711', 'E711',
'E712', 'E712',
# 'E721', # type comparison 'E721', # type() comparison
# 'E722', # bare except
'E741', 'E741',
'F401', # unused imports 'EXE003', # shebang should contain "python"
'F401', # unused imports
'F841', 'F841',
'PT004', # 'PT018', # assertion should be broken into multiple parts
'SIM102', 'SIM102',
'SIM103', # return the condition directly
'SIM105', 'SIM105',
'SIM108', 'SIM108',
# 'SIM110', # use any() instead of a for loop
# 'SIM114',
'SIM115', 'SIM115',
# 'UP007', # use X | Y instead of Union[X, Y]
# 'UP032', # use an f-string instead of format()
# 'UP046', # prefer type parameters over Generic subclasses
] ]
[format] [format]
quote-style = "single" quote-style = "single"
[lint.isort] [lint.isort]
known-first-party = ["ssh_lib"] known-first-party = ["lib", "api", "deploy", "ssh_lib"]
lines-after-imports = 2 lines-after-imports = 2
[lint.flake8-comprehensions] [lint.flake8-comprehensions]
allow-dict-calls-with-keyword-arguments = true allow-dict-calls-with-keyword-arguments = true

170
README.md
View File

@@ -1,16 +1,16 @@
<a href="https://openfreemap.org/"><img src="website/assets/logo.jpg" alt="logo" height="200" class="logo" /></a> <a href="https://openfreemap.org/"><img src="https://openfreemap.org/logo.jpg" alt="logo" height="200" class="logo" /></a>
# OpenFreeMap # OpenFreeMap
[openfreemap.org](https://openfreemap.org) OpenFreeMap lets you display custom maps on your website and apps for free.
## What is OpenFreeMap? You can either [self-host](docs/self_hosting.md) or use our public instance. Everything is **open-source**, including the full production setup — theres no 'open-core' model here. The map data comes from OpenStreetMap.
OpenFreeMap provides free map hosting so you can display custom maps on your website and apps. Using our **public instance** is completely free: there are no limits on the number of map views or requests. Theres no registration, no user database, no API keys, and no cookies. We aim to cover the running costs of our public instance through donations.
It is truly **free**: there are no limits on the number of map views or requests you can make, nor on how you use your map. There is no registration page, user database, API keys, or cookies. We also provide **weekly** full planet downloads both in Btrfs and MBTiles formats.
It is truly **open-source**: everything, including the full production setup, is in this repo. Map data is from OpenStreetMap. #### Quick introduction and how to guide: [https://openfreemap.org/](https://openfreemap.org/)
## Goals of this project ## Goals of this project
@@ -26,12 +26,27 @@ The [styles repo](https://github.com/hyperknot/openfreemap-styles), on the other
Contributions are more than welcome! Contributions are more than welcome!
## Status of this project
- The tile generation works
- The web servers work
- Weekly auto-updates work
- Servers in our public instance are currently:
- 1 server running tile generation
- 2 servers running web hosting
- Web servers are in Round-Robin DNS configuration with Let's Encrypt provided certificates.
- Load-balancer script works. Currently in monitoring-only mode, as Round-Robin DNS handles downtime.
- The public instance has been the production basemap service of [MapHub](https://maphub.net/) since June 2024.
## Sponsoring
Please consider sponsoring our project on [GitHub Sponsors](https://github.com/sponsors/hyperknot).
## Limitations of this project ## Limitations of this project
The only way this project can possibly work is to be super focused about what it is and what it isn't. OFM has the following limitations by design: The only way this project can possibly work is to be super focused about what it is and what it isn't. OpenFreeMap has the following limitations by design:
1. OFM is not providing:
1. OpenFreeMap is not providing:
- search or geocoding - search or geocoding
- route calculation, navigation or directions - route calculation, navigation or directions
- static image generation - static image generation
@@ -40,27 +55,31 @@ The only way this project can possibly work is to be super focused about what it
- elevation lookup - elevation lookup
- custom tile or dataset hosting - custom tile or dataset hosting
2. OFM is not something you can install on your dev machine. OFM is a deploy script specifically made to set up clean Ubuntu servers or virtual machines. It uses [Fabric](https://www.fabfile.org/) and runs commands over SSH. With a single command it can set up a production-ready OFM server, both for tile hosting and generation. 2. OpenFreeMap is not something you can install locally. This repo is a deploy script specifically made to set up clean Ubuntu servers or virtual machines. It uses [Fabric](https://www.fabfile.org/) and runs commands over SSH. With a single command it can set up a production-ready server, both for tile hosting and generation.
This repo is also Docker free. If someone wants to make a Docker-based version of this, I'm more than happy to link it here. This repo is Docker-free on purpose. If someone wants to make a Docker-based version of this, I'm more than happy to link it here.
3. OFM does not promise worry-free automatic updates for self-hosters. Only use the autoupdate version of http-host if you keep a close eye on this repo. 3. OpenFreeMap does not promise worry-free automatic updates for self-hosters. Only use the autoupdate version of http-host if you keep a close eye on this repo.
## Self hosting
See [self hosting docs](docs/self_hosting.md).
## What is the tech stack? ## What is the tech stack?
There is no tile server running; only Btrfs partition images with 300 million hard-linked files. This was my idea; I haven't read about anyone else doing this in production, but it works really well. There is no tile server running; only Btrfs partition images with 300 million hard-linked files. This was my idea; I haven't read about anyone else doing this in production, but it works really well.
There is no cloud, just dedicated servers. The HTTPS server is nginx on Ubuntu. There is no cloud, just dedicated servers. The web server is nginx on Ubuntu.
## Btrfs images ## Btrfs images
Production-quality hosting of 300 million tiny files is hard. The average file size is just 450 byte. Dozens of tile servers have been written to tackle this problem, but they all have their limitations. Production-quality hosting of 300 million tiny files is hard. The average file size is just 450 byte. Dozens of tile servers have been written to tackle this problem, but they all have their limitations.
The original idea of this project is to avoid using tile servers altogether. Instead, the tiles are directly served from Btrfs partition images + hard links using an optimised nginx config. I wrote [extract_mbtiles](scripts/tile_gen/extract_mbtiles) and [shrink_btrfs](scripts/tile_gen/shrink_btrfs) scripts for this very purpose. The original idea of this project is to avoid using tile servers altogether. Instead, the tiles are directly served from Btrfs partition images + hard links using an optimised nginx config. I wrote [extract_mbtiles](modules/tile_gen/scripts/extract_mbtiles.py) and [shrink_btrfs](modules/tile_gen/scripts/shrink_btrfs.py) scripts for this very purpose.
This replaces a running service with a pure, file-system-level implementation. Since the Linux kernel's file caching is among the highest-performing and most thoroughly tested codes ever written, it delivers serious performance. This replaces a running service with a pure, file-system-level implementation. Since the Linux kernel's file caching is among the highest-performing and most thoroughly tested codes ever written, it delivers serious performance.
I run some [benchmarks](docs/quick_notes/http_benchmark.md) on a Hetzner server, the aim was to saturate a gigabit connection. At the end, it was able to serve 30 Gbit on loopback interface, on cold nginx cache. I run some [benchmarks](docs/benchmark/README.md) on a Hetzner server, the aim was to saturate a gigabit connection. At the end, it was able to serve 30 Gbit on loopback interface, on cold nginx cache.
## Code structure ## Code structure
@@ -70,76 +89,77 @@ The project has the following parts
This sets up everything on a clean Ubuntu server. You run it locally and it sets up the server via SSH. This sets up everything on a clean Ubuntu server. You run it locally and it sets up the server via SSH.
#### HTTP host - scripts/http_host #### HTTP host - modules/http_host
Inside `http_host`, all work is done by `host_manager.py`. Inside `http_host`, all work is done by `http_host.py`.
It does the following: It does the following:
- checks the most up-to-date files in the public buckets - Downloading btrfs images
- downloads/extracts them locally, if needed
- mounts the downloaded Btrfs images in `/mnt/ofm`
- creates the correct TileJSON file
- creates the correct nginx config
- reloads nginx
You can run `./host_manager.py --help` to see which options are available. Some commands can be run locally, including on non-linux machines. - Downloading assets
#### tile generation - scripts/tile_gen - Mounting downloaded btrfs images
_note: Tile generation is 100% optional, as we are providing the processed full planet files for public download._ - Fetches version files
- Running the sync cron task (called every minute with http-host-autoupdate)
You can run `./http_host.py --help` to see which options are available.
#### tile generation - modules/tile_gen
_note: Tile generation is 100% optional, as we are providing the processed full planet btrfs files for public download. You can download full planet images updated weekly, both in Btrfs and in MBTiles format._
The `tile_gen` script downloads a full planet OSM extract and runs it through Planetiler. The `tile_gen` script downloads a full planet OSM extract and runs it through Planetiler.
The created .mbtiles file is then extracted into a Btrfs partition image using the custom [extract_mbtiles](scripts/tile_gen/extract_mbtiles) script. The partition is shrunk using the [shrink_btrfs](scripts/tile_gen/shrink_btrfs) script. The created .mbtiles file is then extracted into a Btrfs partition image using the custom [extract_mbtiles](modules/tile_gen/scripts/extract_mbtiles.py) script. The partition is shrunk using the [shrink_btrfs](modules/tile_gen/scripts/shrink_btrfs.py) script.
Finally, it's uploaded to a public Cloudflare R2 bucket using rclone. Finally, it's uploaded to a public Cloudflare R2 bucket using rclone.
#### styles - [styles repo](https://github.com/hyperknot/openfreemap-styles) #### styles - [styles repo](https://github.com/hyperknot/openfreemap-styles)
A very important part, probably needs the most work in the long term future. The default styles. I've already put countless hours into tweaking up some nice looking styles. Still, it'll take probably the most work in the long term future.
#### load balancer script - scripts/loadbalancer Of course, you are welcome to use custom styles.
#### load balancer script - modules/loadbalancer
A Round Robin DNS based load balancer script for health checking and updating records. It pushes status messages to a Telegram bot. A Round Robin DNS based load balancer script for health checking and updating records. It pushes status messages to a Telegram bot.
## Self hosting
See [self hosting docs](docs/self_hosting.md).
## FAQ ## FAQ
### Full planet downloads ### Full planet downloads
You can directly download the processed full planet runs on the following URLs: Full planet runs are uploaded weekly. You can download them both in Btrfs and in MBTiles formats. The files have the following URL patterns:
https://planet.openfreemap.com/20240607_232801_pt/tiles.mbtiles // 89 GB, mbtiles file https://btrfs.openfreemap.com/areas/planet/{version}/tiles.btrfs.gz (and .mbtiles)
https://planet.openfreemap.com/20240607_232801_pt/tiles.btrfs.gz // 86 GB, Btrfs partition image
Replace the `20240607_232801_pt` part with any newer run, from the [index file](https://planet.openfreemap.com/index.txt). Use the [index file](https://btrfs.openfreemap.com/files.txt) to find out about versions.
_Note: MBTiles files are not required for this project. We provide them for your convenience, allowing you to use the processed planet tiles with any other tool of your choice._
### Public buckets ### Public buckets
There are three public buckets: There are two public buckets:
- https://assets.openfreemap.com - contains fonts, sprites, styles, versions. index: [dirs](https://assets.openfreemap.com/dirs.txt), [files](https://assets.openfreemap.com/index.txt) - https://assets.openfreemap.com - contains fonts, sprites, styles, versions. index: [dirs](https://assets.openfreemap.com/dirs.txt), [files](https://assets.openfreemap.com/files.txt)
- https://planet.openfreemap.com - full planet runs. index: [dirs](https://planet.openfreemap.com/dirs.txt), [files](https://planet.openfreemap.com/index.txt) - https://btrfs.openfreemap.com - full planet runs. index: [dirs](https://btrfs.openfreemap.com/dirs.txt), [files](https://btrfs.openfreemap.com/files.txt)
- https://monaco.openfreemap.com - identical runs to the full planet, but only for Monaco area. Very tiny, ideal for development. index: [dirs](https://monaco.openfreemap.com/dirs.txt), [files](https://monaco.openfreemap.com/index.txt)
### Domains and Cloudflare ### Domains
- `tiles.openfreemap.org` - Cloudflare proxied .org - not hosted through CloudFlare \
- `direct.openfreemap.org` - direct connection, Round-Robin DNS .com - hosted through CloudFlare - serving the public buckets
The project has been designed in such a way that we can migrate away from Cloudflare if needed. This is the reason why there are a .com and a .org domain: the .com will always stay on Cloudflare to host the R2 buckets, while the .org domain is independent. ### What about PMTiles and using the Cloud?
### What about PMTiles? I would have loved to use PMTiles; they are a brilliant idea for serverless map hosting!
I would have loved to use PMTiles; they are a brilliant idea! Unfortunately, on Cloudflare, range requests in 90 GB files have terrible latency, and on AWS, the data transfer costs can be prohibitive.
Unfortunately, making range requests in 80 GB files just doesn't work in production. It is fine for files smaller than 500 MB, but it has terrible latency and caching issues for full planet datasets. Of course, with normal usage, you might fall within cloud vendor's free tier, but the internet is full of stories about people receiving surprise bills from AWS, sometimes amounting to thousands of dollars. It only takes one bad crawling bot getting stuck in a loop on your website to trigger such a bill.
If PMTiles implements splitting to <10 MB files, it can be a valid alternative to running servers. In short, using cloud vendors would make it impossible for me to offer this service for free — this project simply wouldn't exist.
## Contributing ## Contributing
@@ -147,17 +167,16 @@ Contributors welcome!
Smaller tasks: Smaller tasks:
- Cloudflare worker for indexing the public buckets, instead of generating index.txt files. - Cloudflare worker for indexing the public buckets, instead of generating index files.
- Some of the POI icons are missing in the styles. - [styles] Some of the POI icons are missing.
Bigger tasks: Bigger tasks:
- Split the styles to building blocks. For example, there should be a POI block, a label block, a road-style related block. - [styles] Split the styles to building blocks. For example, there should be a POI block, a label block, a road-style related block.
- Implement automatic updates for tile gen, uploading, testing and setting versions.
Tasks outside the scope of this project: Future:
- Make a successor for the OpenMapTiles schema. - Migrate to [Shortbread schema](https://shortbread-tiles.org/) and possibly [VersaTiles](https://versatiles.org/)
#### Dev setup #### Dev setup
@@ -165,6 +184,47 @@ See [dev setup docs](docs/dev_setup.md).
## Changelog ## Changelog
##### v0.9
Updated Planetiler version to latest
Updated OpenJDK to 24 via Temurin repo
##### v0.8
Lot of self-hosting related fixes.
Generating the domain inside the style TileJSON files dynamically (using nginx sub_filter).
Added SELF_SIGNED_CERTS variable for cases when the certificates are self-managed or self-signed is OK.
##### v0.7
MBTiles are now uploaded, next to the btrfs image files.
##### v0.6
Load-balancer implemented with new config format. Implemented relaxed mode for checking while deployments are happening.
##### v0.5
Using a "done" file in the R2 buckets to mark the upload as finished. All scripts are checking for this file now.
Monaco is generated daily, to avoid too frequent nginx reloads, which might be bad for the in-memory cache.
##### v0.4
Auto-update works!
Monaco is generated hourly. Set-latest runs every minute.
Planet is generated weekly, every Wednesday. Set-latest runs every Saturday.
##### v0.3
Lot of performance related problems with Cloudflare when using Round-Robin DNS. Works much better without any Cloudflare proxying, the browsers actually do a great job of client-side failover and selecting the best host.
Load-balancing script running in check mode again.
##### v0.2 ##### v0.2
Load-balancing script is running in write mode, updating records when needed. Load-balancing script is running in write mode, updating records when needed.

54
TODO.md Normal file
View File

@@ -0,0 +1,54 @@
making web maps free and fun again
add dark themes in website
logrotate
/var/log/nginx/*.log /var/log/nginx/*/*.log {
daily # rotate at least once per day
dateext # add -YYYYMMDD to rotated files
rotate 7 # keep up to 7 rotations (about 7 days)
maxage 7 # hard limit: delete rotated logs older than 7 days
missingok
compress
delaycompress
notifempty
sharedscripts
create 0640 www-data adm # adjust user:group for your distro (e.g., nginx adm)
postrotate
# Tell nginx to reopen logs without losing lines
[ -s /run/nginx.pid ] && kill -USR1 "$(cat /run/nginx.pid)" || true
# Alternatively: nginx -s reopen
endscript
}
---
sudo systemctl enable --now logrotate.timer
sudo systemctl status logrotate.timer
/etc/cron.daily/logrotate
Dry run:
sudo logrotate -d /etc/logrotate.d/nginx
Force a rotation immediately:
sudo logrotate -vf /etc/logrotate.conf
---
/var/log/nginx/*.log {
daily # Rotate every day
rotate 7 # Keep only 7 rotated files
missingok
notifempty
compress
delaycompress
sharedscripts
postrotate
[ -f /var/run/nginx.pid ] && kill -USR1 `cat /var/run/nginx.pid`
endscript
}
---

186
biome.jsonc Normal file
View File

@@ -0,0 +1,186 @@
{
"root": true,
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": true,
},
"files": {
"maxSize": 100000,
"ignoreUnknown": true,
"includes": [
"**",
//
"!**/.pytest_cache",
"!**/venv",
"!**/.astro",
"!**/.venv",
"!**/_astro",
"!**/_not_used",
"!**/dist",
"!**/dist-electron",
"!**/node_modules",
"!**/.pytest_cache",
//
],
},
"formatter": {
"indentStyle": "space",
"lineWidth": 100,
},
"linter": {
"enabled": true,
"domains": {
"solid": "all",
},
"rules": {
"recommended": true,
"style": {
"noNonNullAssertion": "off",
"useConsistentArrayType": {
"level": "error",
"options": {
"syntax": "generic",
},
},
"useLiteralEnumMembers": "error",
"useNodejsImportProtocol": "error",
"useAsConstAssertion": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"useExponentiationOperator": "error",
"useTemplate": "error",
"noParameterAssign": "error",
"useDefaultParameterLast": "error",
"useImportType": "error",
"useExportType": "error",
"useShorthandFunctionType": "error",
"noUselessElse": "error",
"useGroupedAccessorPairs": "error",
"useObjectSpread": "error",
},
"security": {
// "noDangerouslySetInnerHtml": "off",
// "noBlankTarget": "off",
},
"a11y": {
"useFocusableInteractive": "off", // The HTML element with the interactive role "treeitem" is not focusable.
"useSemanticElements": "off", // SessionTreeDay / role="group"
"noStaticElementInteractions": "off", // SessionTreeDay / To add interactivity such as a mouse or key event listener to a static element, give the element an appropriate role value.
"useAriaPropsSupportedByRole": "off", // SessionTree / The ARIA attribute 'aria-multiselectable' is not supported by this element.
"useKeyWithClickEvents": "off", // PasteBlock / Enforce to have the onClick mouse event with the onKeyUp, the onKeyDown, or the onKeyPress keyboard event.
"noNoninteractiveElementToInteractiveRole": "off", // SessionTree
// "useValidAnchor": "off",
// "useButtonType": "off",
// "noLabelWithoutControl": "off",
// "noSvgWithoutTitle": "off",
// "noNoninteractiveTabindex": "off",
},
"correctness": {
"noUnusedFunctionParameters": "off",
"noUnusedImports": "off",
"noUnusedVariables": "off",
"noUnusedPrivateClassMembers": "off",
"noUnreachable": "off",
"noSolidDestructuredProps": "error",
"noGlobalDirnameFilename": "error",
"useParseIntRadix": "error",
"useSingleJsDocAsterisk": "error",
},
"suspicious": {
"noExplicitAny": "off",
"noImplicitAnyLet": "off",
"noUselessEscapeInString": "error",
"useStaticResponseMethods": "error",
"useIterableCallbackReturn": "off",
// "noAssignInExpressions": "off",
//"noArrayIndexKey": "off",
},
"complexity": {
"noCommaOperator": "error",
"useNumericLiterals": "error",
// "noArguments": "off",
"useIndexOf": "error",
"noImplicitCoercions": "error", // Number(), Boolean()
// "noUselessFragments": "off"
},
"nursery": {
// "noShadow": "error",
// "useReadonlyClassProperties": "error",
// "noImportCycles": "error",
// "noFloatingPromises": "error",
// "noMisusedPromises": "error",
// "noUnassignedVariables": "error", // bug reported ref= not recognised
"noUselessUndefined": "error",
},
"performance": { "useSolidForComponent": "error" },
},
},
"javascript": {
"formatter": {
"semicolons": "asNeeded",
"quoteStyle": "single",
},
},
"json": {
"parser": {
"allowTrailingCommas": true,
},
"formatter": {
"trailingCommas": "none",
},
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on",
// "useSortedProperties": "on" // sort CSS props
},
},
},
"overrides": [
{
"includes": ["**/*.astro"],
"linter": {
"rules": {
"correctness": {
"noUnusedImports": "off",
},
},
},
},
{
"includes": ["**/*.jsonc"],
"json": {
"formatter": {
"trailingCommas": "all",
},
},
},
{
"includes": ["**/*.css"],
"linter": {
"rules": {
"suspicious": {
"noDuplicateProperties": "off",
"noEmptyBlock": "off",
"noUnknownAtRules": "off",
},
"complexity": {
"noImportantStyles": "off",
},
"style": { "noDescendingSpecificity": "off" },
},
},
},
],
}

View File

@@ -1,30 +0,0 @@
# Leave it empty if you use SSH keys
SSH_PASSWD=
# Direct subdomain, using Let's Encrypt certificates
DOMAIN_LE=
# Let's Encrypt account email
LE_EMAIL=
# CloudFlare subdomain, using origin certificates
# Please put ofm_cf.key and ofm_cf.cert files in config/certs
DOMAIN_CF=tiles.openfreemap.org
# Skip the full planet download, useful for testing (true/false)
SKIP_PLANET=false
# --- Let's Encrypt DNS related variables, not needed for self-hosting
DOMAIN_LEDNS=direct.openfreemap.org
# --- host list
HTTP_HOST_LIST=
# --- Load Balancer script
TELEGRAM_TOKEN=
TELEGRAM_CHAT_ID=

View File

@@ -1 +0,0 @@
*

View File

@@ -1,3 +0,0 @@
# --- Let's Encrypt DNS challange, not needed for self-hosting
dns_cloudflare_api_token = xxx

90
config/config.schema.json Normal file
View File

@@ -0,0 +1,90 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string"
},
"domains": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/domain" }
},
"servers": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/server" }
},
"skip_planet": {
"type": "boolean",
"description": "Skip the full planet download, useful for testing"
}
},
"required": ["domains", "servers"],
"definitions": {
"cert-upload": {
"type": "object",
"description": "Upload your own certificate. Ideal for Cloudflare Origin Certificates with 15 year expiry. Steps: 1) Create an Origin Certificate on Cloudflare at SSL/TLS / Origin Server. 2) Generate private key and CSR with Cloudflare: Private key type: ECC, Certificate Validity: 15 years. 3) Key format: PEM. Save origin certificate as something.cert and private key as something.key in the same directory.",
"additionalProperties": false,
"properties": {
"type": { "const": "upload" },
"cert_path": {
"type": "string",
"minLength": 1,
"pattern": "^.*\\.cert$",
"description": "Path to your certificate file (*.cert). Both absolute and relative paths are supported. The corresponding private key (.key) should be in the same directory with the same basename."
}
},
"required": ["type", "cert_path"]
},
"cert-letsencrypt": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": { "const": "letsencrypt" },
"email": { "type": "string", "format": "email" }
},
"required": ["type", "email"]
},
"cert-dummy": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": { "const": "dummy" }
},
"required": ["type"]
},
"cert": {
"oneOf": [
{ "$ref": "#/definitions/cert-upload" },
{ "$ref": "#/definitions/cert-letsencrypt" },
{ "$ref": "#/definitions/cert-dummy" }
]
},
"domain": {
"type": "object",
"additionalProperties": false,
"properties": {
"domain": { "type": "string", "format": "hostname" },
"cert": { "$ref": "#/definitions/cert" }
},
"required": ["domain", "cert"]
},
"server": {
"type": "object",
"additionalProperties": false,
"properties": {
"hostname": {
"type": "string",
"description": "hostname used for ssh to connect. can be an IP address"
},
"server_ssh_passwd": {
"type": "string",
"description": "Leave this empty if you use SSH keys"
}
},
"required": ["hostname"]
}
}
}

16
debug.py Normal file
View File

@@ -0,0 +1,16 @@
import click
from ssh_lib.cli_helpers import common_options, get_connection
from ssh_lib.tasks_http_host import upload_config_and_certs
@click.group()
def cli():
pass
@cli.command()
@common_options
def debug(hostname, user, port, noninteractive):
c = get_connection(hostname, user, port)
upload_config_and_certs(c)

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

43
docs/benchmark/README.md Normal file
View File

@@ -0,0 +1,43 @@
# HTTP Hosts Benchmarking
This repository contains tools and scripts for benchmarking HTTP hosts performance.
## Prerequisites
Before running the benchmarks, you need to create a path list (`path_list_500k.txt`). You have two options:
1. Generate from real-world server logs using `nginx_to_path_list.py`
2. Generate randomly (Note: real-world usage patterns are typically non-random, e.g., ocean tiles are rarely accessed)
## Important Notes
- Run the benchmarks on `localhost`, and not over the internet! Otherwise you'd be just testing your internet speed.
- The benchmark uses [wrk](https://github.com/wg/wrk) HTTP benchmarking tool
## Usage
Basic command:
```bash
wrk -c10 -t4 -d10s -s /data/ofm/benchmark/wrk_custom_list.lua http://localhost
```
### Parameters Explained
- `-c10`: Number of connections to keep open
- `-t4`: Number of threads to use
- `-d10s`: Duration of the test (10 seconds)
- `-s`: Script file to use
### Thread Count Considerations
- `-t1`: More accurate results as the URL list is loaded exactly in sequence
- `-t4`: Better reflects real-world usage patterns
## Results
Benchmark results can be found in [results.md](results.md)
## Contributing
Feel free to submit your results including which hosts were used.

View File

@@ -1,7 +1,11 @@
import json import json
with open('access.log') as fp: # This script parses a nginx server log and creates a text file
# which can be used in the Lua script.
# The path file is not supplied in this repo.
with open('access.jsonl') as fp:
json_lines = fp.readlines() json_lines = fp.readlines()
paths = [] paths = []

View File

@@ -1,6 +1,6 @@
local counter = 1 local counter = 1
local lines = {} local lines = {}
local url_base = "/planet/20231221_134737_pt/" -- trailing slash local url_base = "/planet/fake_version/" -- trailing slash
local path_list_txt = "/data/ofm/benchmark/path_list_500k.txt" local path_list_txt = "/data/ofm/benchmark/path_list_500k.txt"
for line in io.lines(path_list_txt) do for line in io.lines(path_list_txt) do

27
docs/debugging_names.md Normal file
View File

@@ -0,0 +1,27 @@
# Debugging international names
If there is an issue about international names not being displayed correctly, first, we need to find **one specific example** and check at which stage does the problem appear.
OpenFreeMap map data is created by the following stack:
**OpenStreetMap data ➔ OpenMapTiles specification ➔ Planetiler**
1. To debug OpenStreetMap data, go to OpenStreetMap.org and search for the query string. For example "Iwate Prefecture" gives these results: [nominatim](https://nominatim.openstreetmap.org/ui/details.html?osmtype=R&osmid=3792412&class=boundary) and [openstreetmap](https://www.openstreetmap.org/relation/3792412)
<img src="assets/name-osm-search.png" width=333 />
2. Then we need to check what the data is in the generated vector tiles. The best way to do this is to go to [Maputnik editor](https://maputnik.github.io/editor?style=https://tiles.openfreemap.org/styles/bright) and select View / Inspect.
<img src="assets/name-maputnik-view.png" width=388 />
3. Then you can search for the little red dot matching your label and make a screenshot.
<img src="assets/name-maputnik-details.png" width=240 />
Now we can compare where the naming problem is coming from.
In conclusion: for the **one specific example**, please link the OSM pages and add the inspector screenshot, then we can start with the debugging.
## Next steps
It'd be nice to compare with other OpenMapTiles implementations like [tilemaker](https://github.com/systemed/tilemaker) or the [OpenMapTiles reference](https://github.com/openmaptiles/openmaptiles). I don't have full planet datasets from these implementations currently, so if someone is willing to run one it'd be a great contribution.

View File

@@ -25,6 +25,6 @@ Host orb_my
Then I run commands like the following: Then I run commands like the following:
``` ```
./init-server.py http-host-once orb_my ./init-server.py http-host-static orb_my
./init-server.py debug orb_my ./init-server.py debug orb_my
``` ```

View File

@@ -1,67 +1,121 @@
# Self-hosting Howto # Self-hosting Howto
_note: For most users, **you don't need to run anything**! The tiles are hosted free of charge, without registration. Read the "How can I use it?" section on https://openfreemap.org_ You can either self-host or use our public instance. Everything is **open-source**, including the full production setup — theres no 'open-core' model here.
When self-hosting, there are two tasks you can set up on a server (see details in the repo README). When self-hosting, there are two modules you can set up on a server (see details in the repo README).
- **http-host** - **http-host**
- **tile-gen** - **tile-gen**
note: Tile generation is 100% optional, as we are providing the processed full planet files for public download. It also requires a beefy machine, see below. There is a 99.9% chance you only need **http-host**. Tile-gen is slow, needs a huge machine and is totally pointless, since we upload the processed files every week.
### System requirements ### System requirements
##### Disk space **http-host**: 300 GB disk space for hosting a single run. SSD is recommended, but not required.
- **http-host**: 300 GB for hosting a single run **tile-gen**: 500 GB SDD and at least 64 GB ram
- **tile-gen**: 500 GB **Ubuntu 22** or newer
##### RAM ### Provider recommendation
- **http-host**: 4 GB One amazing deal, which is tested and known to work well for http-host is the €4.5 / month [Contabo Storage VPS](https://contabo.com/en/storage-vps/)
- **tile-gen**: 64 GB+
##### OS
- **Ubuntu 22+**
--- ---
### Warning ### Warning
This project is made to run on clean servers or virtual machines dedicated for this project. The scripts need sudo permissions as they mount/unmount disk images. Do not run this on your dev machine without using virtual machines. If you do, please make sure you understand exactly what each script is doing. This project is made to run on **clean servers** or virtual machines dedicated for this project. The scripts need sudo permissions as they mount/unmount disk images. Do not run this on your dev machine without using virtual machines. If you do, please make sure you understand exactly what each script is doing.
If you run it on a non-clean server, please understand that this will modify your nginx config!
--- ---
## Instructions ## Instructions
Create virtualenv using: `source prepare-virtualenv.sh` I recommend running things quickly first, with `SKIP_PLANET=true` and then once it works, running it with `SKIP_PLANET=false`.
It's recommended to use [direnv](https://direnv.net/), to have automatic venv activation. #### 1. DNS setup
#### 1. Prepare `config` folder Set up a server with at least 300 GB SSD space and configure the DNS for the subdomain of your choice.
For example, make an A record for "maps.example.com" -> 185.199.110.153
1. Copy `.env.sample` to `.env` and set the values. #### 2. Clone and prepare `config` folder
DOMAIN_LE - Use this to specify a domain to be used with Let's Encrypt. Recommended. ```
git clone https://github.com/hyperknot/openfreemap
```
DOMAIN_CF - Use this if you want to use long term CloudFlare Origin certificates. You have to upload the certs into `config/certs` In the config folder, copy `.env.sample` to `.env` and set the values.
1. If you want to run tile generation and upload via rclone, you can copy the `rclone.conf.sample` file as well. For simple self-hosting there is no need for this. `DOMAIN_DIRECT` - Your subdomain \
`LETSENCRYPT_EMAIL` - Your email for Let's Encrypt
#### 2. Deploy a http-host Set `SKIP_PLANET=true` first.
You run the deploy script locally. It'll connect to an SSH server, like this #### 3. Set up Python if you don't have it yet
`./init-server.py http-host-once HOSTNAME` On Ubuntu you can get it by `sudo apt install python3-pip`
After this, go for a walk and by the time you come back it should be up and running with the latest planet tiles deployed. Don't worry about the "Download aborted" lines in the meanwhile, it's a bug in CloudFlare. On macOS you can do `brew install python`
#### 3. Deploy tile-gen server (optional) #### 4. Prepare the Python environment
If you have a really beefy machine (see above) and you want to generate tiles yourself, you can run `./init-server.py tile-gen HOSTNAME`. You run the deploy script locally, and it deploys to a remote server over SSH. You can use a virtualenv if you are used to working with them, but it's not necessary.
Trigger a run manually, by running `planetiler_{area}.sh`. Recommended to use tmux or similar, as it can take days to complete. ```
cd openfreemap
pip install -e .
```
#### 5. Deploy quick version with `SKIP_PLANET=true`
Run the actual deploy command and wait a few minutes
```
./init-server.py http-host-static HOSTNAME
```
#### 5. Check
If everything is OK, you'll have some curl lines printed. Run the first one locally and make sure it's showing HTTP/2 200. For example this is an OK response.
```locally to test them.
curl -sI https://test.openfreemap.org/monaco
HTTP/2 200
access-control-allow-origin: *
cache-control: max-age=86400
cache-control: public
content-length: 5776
content-type: application/json
date: Fri, 11 Oct 2024 21:01:23 GMT
etag: "670991d1-1690"
expires: Sat, 12 Oct 2024 21:01:23 GMT
last-modified: Fri, 11 Oct 2024 21:00:01 GMT
server: nginx
x-ofm-debug: latest JSON monaco
```
#### 6. Deploy and check with `SKIP_PLANET=false`
Update your `.env` file and re-run the same `./init-server.py http-host-static HOSTNAME` as before.
Go for a walk and by the time you come back it should be up and running with the latest planet tiles deployed. Don't worry about the "Download aborted" lines in the meanwhile, it's a bug in CloudFlare.
If your server doesn't have an SSD, the download + uncompressing process can take hours.
---
#### Deploy tile-gen server (optional)
If you have a really beefy machine (see above) and you really want to generate tiles yourself, you can run `./init-server.py tile-gen HOSTNAME`.
Trigger a run manually, by running
```
sudo /data/ofm/venv/bin/python -u /data/ofm/tile_gen/bin/tile_gen.py make-tiles planet
```
It's recommended to use tmux or similar, as it can take days to complete.

182
http-host.py Executable file
View File

@@ -0,0 +1,182 @@
#!/usr/bin/env python3
import json
import click
from modules.http_host.http_host_lib.get_version_shared import get_deployed_version
from ssh_lib.cli_helpers import common_options, get_connection
from ssh_lib.config import config
from ssh_lib.pycurl import pycurl_get
from ssh_lib.tasks_http_host import prepare_http_host, read_jsonc, run_http_host_sync
from ssh_lib.tasks_shared import prepare_shared
from ssh_lib.utils import (
get_ip_from_ssh_alias,
put,
)
@click.group()
def cli():
pass
@cli.command()
@common_options
def init_static(hostname, user, port, noninteractive):
if not noninteractive and not click.confirm(f'Run script on {hostname}?'):
return
c = get_connection(hostname, user, port)
prepare_shared(c)
prepare_http_host(c)
run_http_host_sync(c)
# Check server health after deployment
results = check_server_health(hostname)
print_server_health(results)
@cli.command()
@common_options
@click.option('--sync', is_flag=True, help='Run manual sync after init')
def init_autoupdate(hostname, user, port, noninteractive, sync):
if not noninteractive and not click.confirm(f'Run script on {hostname}?'):
return
c = get_connection(hostname, user, port)
c.sudo('rm -f /etc/cron.d/ofm_http_host')
prepare_shared(c)
prepare_http_host(c)
# if --sync, run manual sync
if sync:
run_http_host_sync(c)
put(c, config.local_modules_dir / 'http_host' / 'cron.d' / 'ofm_http_host', '/etc/cron.d/')
# Check server health after deployment
results = check_server_health(hostname)
print_server_health(results)
@cli.command()
@common_options
def sync(hostname, user, port, noninteractive):
if not noninteractive and not click.confirm(f'Run script on {hostname}?'):
return
c = get_connection(hostname, user, port)
run_http_host_sync(c)
# Check server health after sync
results = check_server_health(hostname)
print_server_health(results)
@cli.command()
@click.option('--hostname', help='Check only a specific server')
def debug(hostname):
results = check_server_health(hostname)
print_server_health(results)
def check_server_health(hostname: str = None) -> dict:
"""
Check health of servers by verifying deployed version matches expected version.
Args:
hostname: Optional hostname to check. If None, checks all servers in config.
Returns:
dict: Results for each server with structure:
{
'server_hostname': {
'ip': '1.2.3.4',
'all_ok': True/False,
'domains': {
'domain.com': {'status': 'ok'/'failed', 'error': None/'error message'}
}
}
}
"""
config_data = read_jsonc()
area = 'monaco' if config_data.get('skip_planet') else 'planet'
version = get_deployed_version(area)['version']
domains = [d['domain'] for d in config_data['domains']]
servers = [
{'hostname': s['hostname'], 'ip': get_ip_from_ssh_alias(s['hostname'])}
for s in config_data['servers']
]
# Filter to specific server if requested
if hostname:
servers = [s for s in servers if s['hostname'] == hostname]
if not servers:
raise ValueError(f'Server {hostname} not found in config')
results = {}
for server in servers:
server_hostname = server['hostname']
server_ip = server['ip']
results[server_hostname] = {'ip': server_ip, 'domains': {}, 'all_ok': True}
for domain in domains:
try:
check_host_using_tilejson(
url=f'https://{domain}/{area}/{version}',
ip=server_ip,
version=version,
)
results[server_hostname]['domains'][domain] = {'status': 'ok', 'error': None}
except AssertionError:
results[server_hostname]['domains'][domain] = {
'status': 'failed',
'error': f'Version mismatch (expected {version})',
}
results[server_hostname]['all_ok'] = False
except Exception as e:
results[server_hostname]['domains'][domain] = {'status': 'failed', 'error': str(e)}
results[server_hostname]['all_ok'] = False
return results
def print_server_health(results: dict) -> None:
"""Print server health results in a human-readable format."""
for server_hostname, server_data in results.items():
status = (
click.style('OK', fg='green')
if server_data['all_ok']
else click.style('FAILED', fg='red')
)
server_line = f'SERVER {server_hostname} ({server_data["ip"]})'
print(f'{server_line:<50} {status}')
for domain, domain_data in server_data['domains'].items():
domain_line = f' {domain}'
if domain_data['status'] == 'ok':
print(f'{domain_line:<50} {click.style("OK", fg="green")}')
else:
print(
f'{domain_line:<50} {click.style("FAILED", fg="red")}\n {domain_data["error"]}'
)
print()
def check_host_using_tilejson(*, url: str, ip: str, version: str) -> None:
tilejson_str = pycurl_get(url, ip)
tilejson = json.loads(tilejson_str)
tiles_url = tilejson['tiles'][0]
version_in_tilejson = tiles_url.split('/')[4]
assert version_in_tilejson == version
if __name__ == '__main__':
cli()

View File

@@ -1,144 +0,0 @@
#!/usr/bin/env python3
import click
from fabric import Config, Connection
from ssh_lib import SCRIPTS_DIR, TILE_GEN_BIN, dotenv_val
from ssh_lib.tasks import (
prepare_http_host,
prepare_shared,
prepare_tile_gen,
run_http_host_sync,
setup_ledns_writer,
setup_loadbalancer,
upload_http_host_config,
)
from ssh_lib.utils import (
put,
put_dir,
)
def get_connection(hostname, user, port):
ssh_passwd = dotenv_val('SSH_PASSWD')
if ssh_passwd:
print('Using SSH password')
c = Connection(
host=hostname,
user=user,
port=port,
connect_kwargs={'password': ssh_passwd},
config=Config(overrides={'sudo': {'password': ssh_passwd}}),
)
else:
c = Connection(
host=hostname,
user=user,
port=port,
)
return c
def common_options(func):
"""Decorator to define common options."""
func = click.argument('hostname')(func)
func = click.option('--port', type=int, help='SSH port (if not in .ssh/config)')(func)
func = click.option('--user', help='SSH user (if not in .ssh/config)')(func)
return func
@click.group()
def cli():
pass
@cli.command()
@common_options
def http_host_once(hostname, user, port):
if not click.confirm(f'Run script on {hostname}?'):
return
c = get_connection(hostname, user, port)
prepare_shared(c)
upload_http_host_config(c)
prepare_http_host(c)
run_http_host_sync(c)
@cli.command()
@common_options
def http_host_autoupdate(hostname, user, port):
if not click.confirm(f'Run script on {hostname}?'):
return
c = get_connection(hostname, user, port)
prepare_shared(c)
upload_http_host_config(c)
prepare_http_host(c)
put(c, SCRIPTS_DIR / 'http_host' / 'cron.d' / 'ofm_http_host', '/etc/cron.d/')
@cli.command()
@common_options
def tile_gen(hostname, user, port):
if not click.confirm(f'Run script on {hostname}?'):
return
c = get_connection(hostname, user, port)
prepare_shared(c)
prepare_tile_gen(c)
@cli.command()
@common_options
def ledns_writer(hostname, user, port):
if not click.confirm(f'Run script on {hostname}?'):
return
c = get_connection(hostname, user, port)
setup_ledns_writer(c)
@cli.command()
@common_options
def loadbalancer(hostname, user, port):
if not click.confirm(f'Run script on {hostname}?'):
return
c = get_connection(hostname, user, port)
prepare_shared(c)
setup_loadbalancer(c)
@cli.command()
@common_options
def debug(hostname, user, port):
c = get_connection(hostname, user, port)
# upload_http_host_config(c)
# upload_http_host_files(c)
# sudo_cmd(c, f'{VENV_BIN}/python -u /data/ofm/http_host/bin/host_manager.py nginx-sync')
# put(c, SCRIPTS_DIR / 'tile_gen' / 'upload_manager.py', f'{TILE_GEN_BIN}')
put_dir(c, SCRIPTS_DIR / 'loadbalancer', '/data/ofm/loadbalancer')
put_dir(
c,
SCRIPTS_DIR / 'loadbalancer' / 'loadbalancer_lib',
'/data/ofm/loadbalancer/loadbalancer_lib',
)
if __name__ == '__main__':
cli()

View File

@@ -1,6 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -e
pnpm prettier -w . node_modules/.bin/prettier -w "**/*.md"
# biome
#pnpm biome check --write --unsafe --colors=off --log-level=info --log-kind=pretty . | grep path | sort
pnpm biome check --write --unsafe .
ruff check --fix . ruff check --fix .
ruff format . ruff format .

View File

@@ -0,0 +1,4 @@
# every minute sync, locking so that only one process can run at a time
* * * * * ofm /usr/bin/flock -n /tmp/http_host.lockfile -c 'sudo /data/ofm/venv/bin/python -u /data/ofm/http_host/bin/http_host.py sync >> /data/ofm/http_host/logs/http_host_sync.log 2>&1'

115
modules/http_host/http_host.py Executable file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env python3
from datetime import datetime, timezone
import click
from http_host_lib.assets import (
download_assets,
)
from http_host_lib.btrfs import (
download_area_version,
)
from http_host_lib.get_version_shared import get_versions_for_area
from http_host_lib.mount import auto_mount
from http_host_lib.nginx_config_gen import write_nginx_config
from http_host_lib.sync import auto_clean_btrfs, full_sync
from http_host_lib.versions import fetch_version_files
now = datetime.now(timezone.utc)
@click.group()
def cli():
"""
Manages OpenFreeMap HTTP hosts, including:\n
- Downloading btrfs images\n
- Downloading assets\n
- Mounting downloaded btrfs images\n
- Fetches version files\n
- Running the sync cron task (called every minute with http-host-autoupdate)
"""
@cli.command()
@click.argument('area', required=False)
@click.option(
'--version', default='latest', help='Optional version string, like "20231227_043106_pt"'
)
def download_btrfs(area: str, version: str):
"""
Downloads and uncompresses tiles.btrfs files from the btrfs bucket
Version can be "latest" (default) or specified, like "20231227_043106_pt"
Use --version=1 to list all available versions
"""
download_area_version(area, version)
@cli.command(name='download-assets')
def download_assets_():
"""
Downloads and extracts assets
"""
download_assets()
@cli.command()
def mount():
"""
Mounts/unmounts the btrfs images from /data/ofm/http_host/runs automatically.
When finished, /mnt/ofm dir will have all the present tiles.btrfs files mounted in a read-only way.
"""
auto_mount()
@cli.command(name='fetch-versions')
def fetch_version_files_():
"""
Fetches the version files from remote to local.
Remote versions are specified by https://assets.openfreemap.com/versions/deployed_{area}.txt
"""
fetch_version_files()
@cli.command()
def auto_clean():
"""
Cleans the old btrfs images
"""
auto_clean_btrfs()
@cli.command()
def nginx_config():
"""
Writes the nginx config files and reloads nginx
"""
write_nginx_config()
@cli.command()
@click.option('--force', is_flag=True, help='Force nginx sync run')
def sync(force):
"""
Runs the sync task, normally called by cron every minute
On a new server this also takes care of everything, no need to run anything manually.
"""
print(f'---\n{now}\nStarting sync')
full_sync(force)
@cli.command()
def debug():
versions = get_versions_for_area('monaco')
print(versions)
if __name__ == '__main__':
cli()

View File

@@ -1,26 +1,44 @@
import shutil import shutil
import subprocess import subprocess
from pathlib import Path
import requests import requests
from http_host_lib.config import config
from http_host_lib.utils import download_file_aria2, download_if_size_differs from http_host_lib.utils import download_file_aria2, download_if_size_differs
def download_and_extract_asset_tar_gz(assets_dir, asset_kind): def download_assets() -> bool:
"""
Downloads and extracts assets
"""
changed = False
changed += download_and_extract_asset_tar_gz('fonts')
changed += download_and_extract_asset_tar_gz('styles')
changed += download_and_extract_asset_tar_gz('natural_earth')
changed += download_sprites()
return changed
def download_and_extract_asset_tar_gz(asset_kind):
""" """
Download and extract asset.tgz if the file size differ or not available locally Download and extract asset.tgz if the file size differ or not available locally
Returns True if modified
""" """
print(f'Downloading asset {asset_kind}') print(f'Downloading asset {asset_kind}')
asset_dir = assets_dir / asset_kind asset_dir = config.assets_dir / asset_kind
asset_dir.mkdir(exist_ok=True, parents=True) asset_dir.mkdir(exist_ok=True, parents=True)
url = f'https://assets.openfreemap.com/{asset_kind}/ofm.tar.gz' url = f'https://assets.openfreemap.com/{asset_kind}/ofm.tar.gz'
local_file = asset_dir / 'ofm.tar.gz' local_file = asset_dir / 'ofm.tar.gz'
if not download_if_size_differs(url, local_file): if not download_if_size_differs(url, local_file):
return print(f' skipping asset: {asset_kind}')
return False
ofm_dir = asset_dir / 'ofm' ofm_dir = asset_dir / 'ofm'
ofm_dir_bak = asset_dir / 'ofm.bak' ofm_dir_bak = asset_dir / 'ofm.bak'
@@ -33,24 +51,32 @@ def download_and_extract_asset_tar_gz(assets_dir, asset_kind):
check=True, check=True,
) )
print(f' downloaded asset: {asset_kind}')
return True
def download_sprites(assets_dir: Path):
def download_sprites() -> bool:
""" """
Sprites are special assets, as we have to keep the old versions indefinitely Sprites are special assets, as we have to keep the old versions indefinitely
""" """
sprites_dir = assets_dir / 'sprites' print('Downloading sprites')
sprites_dir = config.assets_dir / 'sprites'
sprites_dir.mkdir(exist_ok=True, parents=True) sprites_dir.mkdir(exist_ok=True, parents=True)
r = requests.get('https://assets.openfreemap.com/index.txt', timeout=30) r = requests.get('https://assets.openfreemap.com/files.txt', timeout=30)
r.raise_for_status() r.raise_for_status()
sprites_remote = [l for l in r.text.splitlines() if l.startswith('sprites/')] sprites_remote = [l for l in r.text.splitlines() if l.startswith('sprites/')]
changed = False
for sprite in sprites_remote: for sprite in sprites_remote:
sprite_name = sprite.split('/')[1].replace('.tar.gz', '') sprite_name = sprite.split('/')[1].replace('.tar.gz', '')
if (sprites_dir / sprite_name).is_dir(): if (sprites_dir / sprite_name).is_dir():
print(f' skipping sprite version: {sprite_name}')
continue continue
url = f'https://assets.openfreemap.com/sprites/{sprite_name}.tar.gz' url = f'https://assets.openfreemap.com/sprites/{sprite_name}.tar.gz'
@@ -62,3 +88,7 @@ def download_sprites(assets_dir: Path):
check=True, check=True,
) )
local_file.unlink() local_file.unlink()
print(f' downloaded sprite version: {sprite_name}')
changed = True
return changed

View File

@@ -0,0 +1,94 @@
import shutil
import subprocess
import sys
from http_host_lib.config import config
from http_host_lib.get_version_shared import get_versions_for_area
from http_host_lib.utils import download_file_aria2, get_remote_file_size
def download_area_version(area: str, version: str) -> bool:
"""
Downloads and uncompresses tiles.btrfs files from the btrfs bucket
"latest" version means the latest in the remote bucket
"deployed" version means to read the currently deployed version string from the config dir
"""
if area not in config.areas:
sys.exit(f' Please specify area: {config.areas}')
versions = get_versions_for_area(area)
if not versions:
print(f' No versions found for {area}')
return False
# latest version
if version == 'latest':
selected_version = versions[-1]
# deployed version
elif version == 'deployed':
try:
selected_version = (config.deployed_versions_dir / f'{area}.txt').read_text().strip()
except Exception:
return False
# specific version
else:
if version not in versions:
available_versions_str = '\n'.join(versions)
print(
f' Requested version is not available.\nAvailable versions for {area}:\n{available_versions_str}'
)
return False
selected_version = version
return download_and_extract_btrfs(area, selected_version)
def download_and_extract_btrfs(area: str, version: str) -> bool:
"""
returns True if download successful, False if skipped
"""
print(f'Downloading btrfs: {area} {version}')
version_dir = config.runs_dir / area / version
btrfs_file = version_dir / 'tiles.btrfs'
if btrfs_file.exists():
print(' file exists, skipping download')
return False
temp_dir = config.runs_dir / '_tmp'
shutil.rmtree(temp_dir, ignore_errors=True)
temp_dir.mkdir(parents=True)
url = f'https://btrfs.openfreemap.com/areas/{area}/{version}/tiles.btrfs.gz'
# check disk space
disk_free = shutil.disk_usage(temp_dir).free
file_size = get_remote_file_size(url)
if not file_size:
print(f' cannot get remote file size for {url}')
return False
needed_space = file_size * 3
if disk_free < needed_space:
print(f' not enough disk space. Needed: {needed_space}, free space: {disk_free}')
return False
target_file = temp_dir / 'tiles.btrfs.gz'
download_file_aria2(url, target_file)
print(' uncompressing...')
subprocess.run(['unpigz', temp_dir / 'tiles.btrfs.gz'], check=True)
btrfs_src = temp_dir / 'tiles.btrfs'
shutil.rmtree(version_dir, ignore_errors=True)
version_dir.mkdir(parents=True)
btrfs_src.rename(btrfs_file)
shutil.rmtree(temp_dir)
return True

View File

@@ -0,0 +1,38 @@
import json
import subprocess
from pathlib import Path
class Configuration:
areas = ['planet', 'monaco']
http_host_dir = Path('/data/ofm/http_host')
http_host_bin = http_host_dir / 'bin'
http_host_scripts_dir = http_host_bin / 'scripts'
runs_dir = http_host_dir / 'runs'
assets_dir = http_host_dir / 'assets'
mnt_dir = Path('/mnt/ofm')
nginx_templates = Path(__file__).parent / 'nginx_templates'
nginx_certs_dir = Path('/data/nginx/certs')
nginx_sites_dir = Path('/data/nginx/sites')
if Path('/data/ofm').exists():
ofm_config_dir = Path('/data/ofm/config')
else:
repo_root = Path(__file__).parent.parent.parent.parent
ofm_config_dir = repo_root / 'config'
json_config = json.loads((ofm_config_dir / 'config.json').read_text())
deployed_versions_dir = ofm_config_dir / 'deployed_versions'
rclone_config = ofm_config_dir / 'rclone.conf'
rclone_bin = subprocess.run(['which', 'rclone'], capture_output=True, text=True).stdout.strip()
config = Configuration()

View File

@@ -0,0 +1 @@
../../tile_gen/tile_gen_lib/get_version_shared.py

View File

@@ -0,0 +1,106 @@
import subprocess
import sys
from pathlib import Path
from http_host_lib.config import config
from http_host_lib.utils import assert_linux, assert_sudo
def auto_mount():
"""
Mounts/unmounts the btrfs images from /data/ofm/http_host/runs automatically.
When finished, /mnt/ofm dir will have all the present tiles.btrfs files mounted in a read-only way.
"""
print('Running auto mount')
assert_linux()
assert_sudo()
if not config.runs_dir.exists():
sys.exit(' download-btrfs needs to be run first')
# clean_up_mounts(config.mnt_dir) # disabling, as it can be in use before the nginx sync works
create_fstab()
print(' running mount -a')
subprocess.run(['mount', '-a'], check=True)
def create_fstab():
print(' creating fstab')
fstab_new = []
for area in ['planet', 'monaco']:
area_dir = (config.runs_dir / area).resolve()
if not area_dir.exists():
continue
versions = sorted(area_dir.iterdir())
for version in versions:
version_str = version.name
btrfs_file = area_dir / version_str / 'tiles.btrfs'
if not btrfs_file.is_file():
print(f" {btrfs_file} doesn't exist, skipping")
continue
mnt_folder = config.mnt_dir / f'{area}-{version_str}'
mnt_folder.mkdir(exist_ok=True, parents=True)
fstab_new.append(f'{btrfs_file} {mnt_folder} btrfs loop,ro 0 0\n')
print(f' created fstab entry for {mnt_folder}')
with open('/etc/fstab') as fp:
fstab_orig = [l for l in fp.readlines() if f'{config.mnt_dir}/' not in l]
with open('/etc/fstab', 'w') as fp:
fp.writelines(fstab_orig + fstab_new)
def clean_up_mounts(mnt_dir):
if not mnt_dir.exists():
return
print('Cleaning up mounts')
# handle deleted files
p = subprocess.run(['mount'], capture_output=True, text=True, check=True)
lines = [l for l in p.stdout.splitlines() if f'{mnt_dir}/' in l and '(deleted)' in l]
# Extract unique mount paths (deduplicate)
mount_paths = set()
for l in lines:
mnt_path = Path(l.split('(deleted) on ')[1].split(' type btrfs')[0])
mount_paths.add(mnt_path)
# Process each unique mount path once
for mnt_path in mount_paths:
if not mnt_path.exists():
print(f' skipping {mnt_path} (already removed)')
continue
print(f' removing deleted mount {mnt_path}')
# Unmount ALL instances (handle stacked mounts)
while subprocess.run(['mountpoint', '-q', str(mnt_path)]).returncode == 0:
print(f' unmounting {mnt_path}')
subprocess.run(['umount', mnt_path], check=True)
mnt_path.rmdir()
# clean all mounts not in current fstab
with open('/etc/fstab') as fp:
fstab_str = fp.read()
for subdir in mnt_dir.iterdir():
if f'{subdir} ' in fstab_str:
continue
print(f' removing old mount {subdir}')
# Unmount ALL instances here too
while subprocess.run(['mountpoint', '-q', str(subdir)]).returncode == 0:
print(f' unmounting {subdir}')
subprocess.run(['umount', subdir], check=True)
subdir.rmdir()

View File

@@ -0,0 +1,292 @@
import subprocess
import sys
from pathlib import Path
from http_host_lib.config import config
from http_host_lib.utils import python_venv_executable
def write_nginx_config():
print('Writing nginx config')
if not config.mnt_dir.exists():
sys.exit(' mount needs to be run first')
# remove old configs
for file in config.nginx_sites_dir.glob('ofm-*.conf'):
file.unlink()
curl_help_text = ''
for domain_data in config.json_config['domains']:
curl_help_text += process_domain(domain_data)
subprocess.run(['nginx', '-t'], check=True)
subprocess.run(['systemctl', 'reload', 'nginx'], check=True)
exclude_path = '/planet' if config.json_config.get('skip_planet') else '/monaco'
curl_help_lines = [l for l in curl_help_text.splitlines() if exclude_path not in l]
curl_help_joined = '\n'.join(curl_help_lines)
print(f'test with:\n{curl_help_joined}')
def process_domain(domain_data) -> str:
if domain_data['cert']['type'] == 'upload':
if (
not Path(domain_data['cert_file']).is_file()
or not Path(domain_data['key_file']).is_file()
):
sys.exit(
f' cert or key file does not exist: {domain_data["cert_file"]} {domain_data["key_file"]}'
)
return create_nginx_conf(domain_data)
return ''
def create_nginx_conf(domain_data: dict) -> str:
dynamic_block_text, curl_help_text = dynamic_blocks(domain_data)
template = (config.nginx_templates / 'common.conf').read_text()
template = template.replace('__DYNAMIC_BLOCKS__', dynamic_block_text)
template = template.replace('__DOMAIN_SLUG__', domain_data['slug'])
template = template.replace('__DOMAIN__', domain_data['domain'])
curl_help_text = curl_help_text.replace('__DOMAIN_SLUG__', domain_data['slug'])
curl_help_text = curl_help_text.replace('__DOMAIN__', domain_data['domain'])
(config.nginx_sites_dir / f'ofm-{domain_data["slug"]}.conf').write_text(template)
print(f' nginx config written: {domain_data["domain"]} {domain_data["slug"]}')
return curl_help_text
def dynamic_blocks(domain_data: dict) -> tuple[str, str]:
nginx_conf_text = ''
curl_help_text = ''
help_area = 'monaco' if config.json_config.get('skip_planet') else 'planet'
for subdir in config.mnt_dir.iterdir():
if not subdir.is_dir():
continue
area, version = subdir.name.split('-')
nginx_conf_text += create_version_location(
area=area, version=version, mnt_dir=subdir, domain_data=domain_data
)
if area == help_area:
for path in [
f'/{area}/{version}',
f'/{area}/{version}/14/8529/5974.pbf',
# f'/{area}/{version}/9999/9999/9999.pbf', # empty_tile test
]:
# curl_help_text += f'curl -H "Host: __DOMAIN_SLUG__" -I http://localhost{path}\n'
curl_help_text += f'curl -sI https://__DOMAIN__{path}\n'
nginx_conf_text += create_latest_locations(domain_data=domain_data)
for path in [
f'/{help_area}',
f'/{help_area}/latest',
f'/{help_area}/latest/14/8529/5974.pbf',
# f'/{help_area}/latest/9999/9999/9999.pbf', # empty_tile test
]:
# curl_help_text += f'curl -H "Host: __DOMAIN_SLUG__" -I http://localhost{path}\n'
curl_help_text += f'curl -sI https://__DOMAIN__{path}\n'
nginx_conf_text += '\n' + (config.nginx_templates / 'static_blocks.conf').read_text()
return nginx_conf_text, curl_help_text
def create_version_location(*, area: str, version: str, mnt_dir: Path, domain_data: dict) -> str:
run_dir = config.runs_dir / area / version
if not run_dir.is_dir():
print(f" {run_dir} doesn't exist, skipping")
return ''
tilejson_path = run_dir / f'tilejson-{domain_data["slug"]}.json'
metadata_path = mnt_dir / 'metadata.json'
if not metadata_path.is_file():
print(f" {metadata_path} doesn't exist, skipping")
return ''
url_prefix = f'https://{domain_data["domain"]}/{area}/{version}'
subprocess.run(
[
python_venv_executable(),
config.http_host_scripts_dir / 'metadata_to_tilejson.py',
'--minify',
metadata_path,
tilejson_path,
url_prefix,
],
check=True,
)
return f"""
# specific JSON {area} {version}
location = /{area}/{version} {{ # no trailing slash
alias {tilejson_path}; # no trailing slash
expires 1w;
default_type application/json;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header x-ofm-debug 'specific JSON {area} {version}';
}}
# specific PBF {area} {version}
location ^~ /{area}/{version}/ {{ # trailing slash
alias {mnt_dir}/tiles/; # trailing slash
try_files $uri @empty_tile;
add_header Content-Encoding gzip;
expires 10y;
types {{
application/vnd.mapbox-vector-tile pbf;
}}
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header x-ofm-debug 'specific PBF {area} {version}';
}}
"""
def create_latest_locations(*, domain_data: dict) -> str:
location_str = ''
local_version_files = config.deployed_versions_dir.glob('*.txt')
for file in local_version_files:
area = file.stem
with open(file) as fp:
version = fp.read().strip()
print(f' linking latest version for {area}: {version}')
# checking runs dir
run_dir = config.runs_dir / area / version
tilejson_path = run_dir / f'tilejson-{domain_data["slug"]}.json'
if not tilejson_path.is_file():
print(
f' skipping latest block for {area} / {version}: {tilejson_path} does not exist'
)
continue
# checking mnt dir
mnt_dir = Path(f'/mnt/ofm/{area}-{version}')
mnt_file = mnt_dir / 'metadata.json'
if not mnt_file.is_file():
print(f' skipping latest block for {area} / {version}: {mnt_file} does not exist')
continue
# latest
location_str += f"""
# latest JSON {area}
location = /{area} {{ # no trailing slash
alias {tilejson_path}; # no trailing slash
expires 1d;
default_type application/json;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header x-ofm-debug 'latest JSON {area}';
}}
"""
# wildcard
# identical to create_version_location
location_str += f"""
# wildcard JSON {area}
location ~ ^/{area}/([^/]+)$ {{
# regex location is unreliable with alias, only root is reliable
root {run_dir}; # no trailing slash
try_files /tilejson-{domain_data['slug']}.json =404;
expires 1w;
default_type application/json;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header x-ofm-debug 'wildcard JSON {area}';
}}
# wildcard PBF {area}
location ~ ^/{area}/([^/]+)/(.+)$ {{
# regex location is unreliable with alias, only root is reliable
root {mnt_dir}/tiles/; # trailing slash
try_files /$2 @empty_tile;
add_header Content-Encoding gzip;
expires 10y;
types {{
application/vnd.mapbox-vector-tile pbf;
}}
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header x-ofm-debug 'wildcard PBF {area}';
}}
"""
return location_str
# if not self_signed_certs:
# subprocess.run(
# [
# 'certbot',
# 'certonly',
# '--webroot',
# '--webroot-path=/data/nginx/acme-challenges',
# '--noninteractive',
# '-m',
# config.ofm_config['letsencrypt_email'],
# '--agree-tos',
# '--cert-name=ofm_direct',
# # '--staging',
# '--deploy-hook',
# 'nginx -t && service nginx reload',
# '-d',
# domain_direct,
# ],
# check=True,
# )
#
# # link certs to nginx dir
# direct_cert.unlink()
# direct_key.unlink()
#
# etc_cert = Path('/etc/letsencrypt/live/ofm_direct/fullchain.pem')
# etc_key = Path('/etc/letsencrypt/live/ofm_direct/privkey.pem')
# assert etc_cert.is_file()
# assert etc_key.is_file()
# direct_cert.symlink_to(etc_cert)
# direct_key.symlink_to(etc_key)

View File

@@ -1,5 +1,5 @@
server { server {
server_name __LOCAL__ __DOMAIN__; server_name __LOOPBACK_HOSTNAME__ __DOMAIN__;
# ssl: https://ssl-config.mozilla.org / intermediate config # ssl: https://ssl-config.mozilla.org / intermediate config
@@ -8,8 +8,8 @@ server {
listen [::]:443 ssl; listen [::]:443 ssl;
http2 on; http2 on;
ssl_certificate /data/nginx/certs/ofm_le.cert; ssl_certificate /data/nginx/certs/ofm_direct.cert;
ssl_certificate_key /data/nginx/certs/ofm_le.key; ssl_certificate_key /data/nginx/certs/ofm_direct.key;
ssl_session_timeout 1d; ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m; # about 40000 sessions ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
@@ -22,23 +22,46 @@ server {
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off; ssl_prefer_server_ciphers off;
# access log disabled by default
#access_log /data/ofm/http_host/logs_nginx/le-access.log access_json buffer=32k; # access log doesn't contain IP address
access_log off; access_log off;
#access_log /data/ofm/http_host/logs_nginx/le-access.jsonl access_json buffer=128k;
error_log /data/ofm/http_host/logs_nginx/le-error.log; error_log /data/ofm/http_host/logs_nginx/le-error.log;
add_header X-Robots-Tag "noindex, nofollow" always;
location ^~ /.well-known/acme-challenge/ { location ^~ /.well-known/acme-challenge/ {
# trailing slash # trailing slash
root /data/nginx/acme-challenges; root /data/nginx/acme-challenges;
try_files $uri =404; try_files $uri =404;
} }
__LOCATION_BLOCKS__ __DYNAMIC_BLOCKS__
location /styles/ {
# trailing slash
alias /data/ofm/http_host/assets/styles/ofm/; # trailing slash
try_files $uri.json =404;
expires 1d;
default_type application/json;
# substitute the domain in the TileJSON
sub_filter '__TILEJSON_DOMAIN__' '__DOMAIN__';
sub_filter_once off;
sub_filter_types '*';
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
add_header X-Robots-Tag "noindex, nofollow" always;
}
# catch-all block to deny all other requests # catch-all block to deny all other requests
location / { location / {
deny all; deny all;
error_log /data/ofm/http_host/logs_nginx/__LOCAL__-error.log error; error_log /data/ofm/http_host/logs_nginx/le-deny.log error;
} }
} }

View File

@@ -0,0 +1,60 @@
server {
server_name __DOMAIN_SLUG__ __DOMAIN__;
# ssl: https://ssl-config.mozilla.org / intermediate config
listen 80;
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
ssl_certificate /data/nginx/certs/ofm-__DOMAIN_SLUG__.cert;
ssl_certificate_key /data/nginx/certs/ofm-__DOMAIN_SLUG__.key;
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
ssl_session_tickets off;
ssl_dhparam /etc/nginx/ffdhe2048.txt;
# intermediate configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
# access log doesn't contain IP address
#access_log off;
access_log /data/ofm/http_host/logs_nginx/__DOMAIN_SLUG__-access.jsonl access_json buffer=128k;
error_log /data/ofm/http_host/logs_nginx/__DOMAIN_SLUG__-error.log;
add_header X-Robots-Tag "noindex, nofollow" always;
__DYNAMIC_BLOCKS__
location /styles/ {
# trailing slash
alias /data/ofm/http_host/assets/styles/ofm/; # trailing slash
try_files $uri.json =404;
expires 1d;
default_type application/json;
# substitute the domain in the TileJSON
sub_filter '__TILEJSON_DOMAIN__' '__DOMAIN__';
sub_filter_once off;
sub_filter_types '*';
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
add_header X-Robots-Tag "noindex, nofollow" always;
}
# catch-all block to deny all other requests
location / {
deny all;
error_log /data/ofm/http_host/logs_nginx/__DOMAIN_SLUG__-deny.log error;
}
}

View File

@@ -1,3 +1,9 @@
# Serve robots.txt that blocks all crawlers
#location = /robots.txt {
# add_header Content-Type text/plain;
#return 200 "User-agent: *\nDisallow: /\n";
#}
location /fonts/ { location /fonts/ {
# trailing slash # trailing slash
@@ -8,19 +14,8 @@ location /fonts/ {
add_header 'Access-Control-Allow-Origin' '*' always; add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public; add_header Cache-Control public;
} add_header X-Robots-Tag "noindex, nofollow" always;
location /styles/ {
# trailing slash
alias /data/ofm/http_host/assets/styles/ofm/; # trailing slash
try_files $uri.json =404;
expires 1d;
default_type application/json;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
} }
location /natural_earth/ { location /natural_earth/ {
@@ -33,6 +28,8 @@ location /natural_earth/ {
add_header 'Access-Control-Allow-Origin' '*' always; add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public; add_header Cache-Control public;
add_header X-Robots-Tag "noindex, nofollow" always;
} }
location /sprites/ { location /sprites/ {
@@ -45,6 +42,8 @@ location /sprites/ {
add_header 'Access-Control-Allow-Origin' '*' always; add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public; add_header Cache-Control public;
add_header X-Robots-Tag "noindex, nofollow" always;
} }
@@ -53,10 +52,16 @@ location @empty_tile {
return 200 ''; return 200 '';
expires 10y; expires 10y;
default_type application/vnd.mapbox-vector-tile;
types {
application/vnd.mapbox-vector-tile pbf;
}
add_header 'Access-Control-Allow-Origin' '*' always; add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public; add_header Cache-Control public;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header x-ofm-debug 'empty tile';
} }
location = / { location = / {

View File

@@ -0,0 +1,98 @@
import shutil
from http_host_lib.assets import download_assets
from http_host_lib.btrfs import download_area_version
from http_host_lib.config import config
from http_host_lib.mount import auto_mount, clean_up_mounts
from http_host_lib.nginx_config_gen import write_nginx_config
from http_host_lib.utils import assert_linux, assert_sudo
from http_host_lib.versions import fetch_version_files
def full_sync(force=False):
"""
Runs the sync task, normally called by cron every minute
On a new server this also takes care of everything, no need to run anything manually.
"""
assert_linux()
assert_sudo()
# if it's a manual/forced run, we clean up old/deleted mounts
if force:
clean_up_mounts(config.mnt_dir)
# start
versions_changed = fetch_version_files()
assets_changed = download_assets()
btrfs_downloaded = False
# download latest and deployed monaco
btrfs_downloaded += download_area_version(area='monaco', version='latest')
btrfs_downloaded += download_area_version(area='monaco', version='deployed')
# download latest and deployed planet
if not config.json_config.get('skip_planet'):
btrfs_downloaded += download_area_version(area='planet', version='latest')
btrfs_downloaded += download_area_version(area='planet', version='deployed')
if btrfs_downloaded or versions_changed or assets_changed or force:
auto_clean_btrfs()
auto_mount()
write_nginx_config()
clean_up_mounts(config.mnt_dir)
def auto_clean_btrfs():
"""
Clean old btrfs runs
For each area we keep max two versions:
1. The newest one available locally
2. The one currently deployed, specified in /data/ofm/config/deployed_versions
3. If there is no deployed version, then we include the second newest one
"""
print('Running auto clean btrfs')
for area in config.areas:
area_dir = config.runs_dir / area
if not area_dir.is_dir():
continue
local_versions = sorted([i.name for i in area_dir.iterdir()])
versions_to_keep = set()
# add newest version
if local_versions:
versions_to_keep.add(local_versions[-1])
# add deployed version
try:
deployed_version_file = config.deployed_versions_dir / f'{area}.txt'
deployed_version = deployed_version_file.read_text().strip()
if (config.runs_dir / area / deployed_version).exists():
versions_to_keep.add(deployed_version)
except Exception:
pass
# if still only one version, we include the second newest one
if len(versions_to_keep) == 1 and len(local_versions) >= 2:
versions_to_keep.add(local_versions[-2])
print(f' keeping runs for {area}: {sorted(versions_to_keep)}')
versions_to_remove = set(local_versions).difference(versions_to_keep)
for version in versions_to_remove:
# Interesting bit: linux allows us to remove the disk image file for a mount
# while the mount is still being used.
# We delete the disk image, update nginx config and only then unmount the /mnt dir.
print(f' removing runs for {area}: {version}')
version_dir = config.runs_dir / area / version
shutil.rmtree(version_dir)

View File

@@ -1,7 +1,6 @@
import os import os
import subprocess import subprocess
import sys import sys
import time
from pathlib import Path from pathlib import Path
import requests import requests
@@ -57,3 +56,14 @@ def download_file_aria2(url: str, local_file: Path):
], ],
check=True, check=True,
) )
def python_venv_executable() -> Path:
venv_path = os.environ.get('VIRTUAL_ENV')
if venv_path:
return Path(venv_path) / 'bin' / 'python'
elif sys.prefix != sys.base_prefix:
return Path(sys.prefix) / 'bin' / 'python'
else:
return Path(sys.executable)

View File

@@ -0,0 +1,38 @@
from http_host_lib.config import config
from http_host_lib.get_version_shared import get_deployed_version
from http_host_lib.utils import assert_linux, assert_sudo
def fetch_version_files() -> bool:
"""
Syncs the version files from remote to local.
Remote versions are specified by https://assets.openfreemap.com/versions/deployed_{area}.txt
"""
print('Syncing local version files')
assert_linux()
assert_sudo()
need_nginx_sync = False
for area in config.areas:
deployed_version = get_deployed_version(area)['version']
if not deployed_version:
print(f' deployed version not found: {area}')
continue
print(f' deployed version {area}: {deployed_version}')
local_version_file = config.deployed_versions_dir / f'{area}.txt'
try:
local_version_old = local_version_file.read_text()
except Exception:
local_version_old = None
if deployed_version != local_version_old:
config.deployed_versions_dir.mkdir(exist_ok=True, parents=True)
local_version_file.write_text(deployed_version)
need_nginx_sync = True
return need_nginx_sync

View File

@@ -3,9 +3,8 @@ from setuptools import find_packages, setup
requirements = [ requirements = [
'click', 'click',
'requests',
'pycurl', 'pycurl',
'python-dotenv', 'requests',
] ]

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = [
#
# ]
# ///
import subprocess
from pathlib import Path
from urllib.parse import urlparse
rclone_config = Path('../../config/rclone.conf')
url = 'https://download.mapterhorn.com/planet.pmtiles'
parsed = urlparse(url)
base_url = f'{parsed.scheme}://{parsed.netloc}'
path = parsed.path.lstrip('/')
bucket_name = 'ofm-mapterhorn'
remote_name = 'remote'
destination = f'{remote_name}:{bucket_name}'
common_opts = [
# '--verbose=10',
# '--dump',
# 'headers',
'--progress',
'--config',
rclone_config,
]
subprocess.run(
[
'rclone',
'copy',
'--http-url',
base_url,
f':http:{path}',
destination,
'--multi-thread-streams=8',
'--s3-chunk-size=100M',
*common_opts,
]
)

View File

@@ -0,0 +1,21 @@
# Define common variables
CMD="sudo /data/ofm/venv/bin/python -u /data/ofm/tile_gen/bin/tile_gen.py"
LOG_DIR=/data/ofm/tile_gen/logs
# every day at 23:10, make a monaco run
10 23 * * * ofm $CMD make-tiles monaco --upload >> $LOG_DIR/monaco-make-tiles.log 2>&1
# debug monaco run, normally disabled, enable to run every minute
#* * * * * ofm $CMD make-tiles monaco --upload >> $LOG_DIR/monaco-make-tiles.log 2>&1
# every minute, set monaco to latest
* * * * * ofm $CMD set-version monaco >> $LOG_DIR/monaco-set-version.log 2>&1
# every Wednesday, make a planet run
10 0 * * 3 ofm $CMD make-tiles planet --upload >> $LOG_DIR/planet-make-tiles.log 2>&1
# every Saturday, set planet to latest
0 11 * * 6 ofm $CMD set-version planet >> $LOG_DIR/planet-set-version.log 2>&1
# once per minute, create indexes
* * * * * ofm $CMD make-indexes >> $LOG_DIR/make-indexes-cron.log 2>&1

View File

@@ -0,0 +1 @@
These are self contained Python scripts, they can be run outside of this project's environment.

View File

@@ -1,6 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json import json
import os
import shutil import shutil
import sqlite3 import sqlite3
import sys import sys
@@ -34,9 +33,6 @@ def cli(mbtiles_path: Path, dir_path: Path):
write_dedupl_files(c, dir_path=dir_path) write_dedupl_files(c, dir_path=dir_path)
write_tile_files(c, dir_path=dir_path) write_tile_files(c, dir_path=dir_path)
# planetiler has missing tiles by design, so disabling this
# assert_all_tiles_present(mbtiles_path, dir_path)
write_metadata(c, dir_path=dir_path) write_metadata(c, dir_path=dir_path)
conn.commit() conn.commit()
@@ -125,24 +121,6 @@ def write_tile_files(c, *, dir_path):
raise raise
def assert_all_tiles_present(mbtiles_path, dir_path):
"""
If it's a full planet run,
make sure there are exactly the right number of files generated.
"""
if 'planet' in mbtiles_path.resolve().parent.name:
assert count_files(dir_path / 'tiles') == calculate_tiles_sum(14)
print(f'Tile number: {calculate_tiles_sum(14)} - OK')
def count_files(folder):
total = 0
for root, dirs, files in os.walk(folder):
total += len(files)
return
def get_fixed_dedupl_name(bug_fix_dict, dedupl_path): def get_fixed_dedupl_name(bug_fix_dict, dedupl_path):
if dedupl_path in bug_fix_dict: if dedupl_path in bug_fix_dict:
return dedupl_path.with_name(f'{dedupl_path.name}-{bug_fix_dict[dedupl_path]}') return dedupl_path.with_name(f'{dedupl_path.name}-{bug_fix_dict[dedupl_path]}')
@@ -166,16 +144,5 @@ def flip_y(zoom, y):
return (2**zoom - 1) - y return (2**zoom - 1) - y
def calculate_tiles(zoom_level):
return (2**zoom_level) ** 2
def calculate_tiles_sum(zoom_level):
"""
Sum of tiles up to zoom level (geometric series)
"""
return (4 ** (zoom_level + 1) - 1) // 3
if __name__ == '__main__': if __name__ == '__main__':
cli() cli()

View File

@@ -69,7 +69,7 @@ def cli(btrfs_img: Path):
mnt_dir.rmdir() mnt_dir.rmdir()
subprocess.run(['truncate', '-s', str(total_size), btrfs_img]) subprocess.run(['truncate', '-s', str(total_size), btrfs_img])
print(f'Truncated {btrfs_img} to {total_size//1_000_000} MB size') print(f'Truncated {btrfs_img} to {total_size // 1_000_000} MB size')
print('shrink_btrfs.py DONE') print('shrink_btrfs.py DONE')
@@ -86,7 +86,7 @@ def get_usage(mnt: Path, key: str):
def do_shrink(mnt: Path, delta_size: float): def do_shrink(mnt: Path, delta_size: float):
delta_size = int(delta_size) delta_size = int(delta_size)
print(f'Trying to shrink by {delta_size//1_000_000} MB') print(f'Trying to shrink by {delta_size // 1_000_000} MB')
p = subprocess.run(['btrfs', 'filesystem', 'resize', str(-delta_size), mnt]) p = subprocess.run(['btrfs', 'filesystem', 'resize', str(-delta_size), mnt])
return p.returncode == 0 return p.returncode == 0

96
modules/tile_gen/tile_gen.py Executable file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
from datetime import datetime, timezone
import click
from tile_gen_lib.btrfs import make_btrfs
from tile_gen_lib.get_version_shared import (
get_deployed_version,
get_versions_for_area,
)
from tile_gen_lib.planetiler import run_planetiler
from tile_gen_lib.rclone import make_indexes_for_bucket, set_version_on_bucket, upload_area
now = datetime.now(timezone.utc)
@click.group()
def cli():
"""
Generates tiles and uploads to CloudFlare
"""
@cli.command()
@click.argument('area', required=True)
@click.option('--upload', is_flag=True, help='Upload after generation is complete')
def make_tiles(area, upload):
"""
Generate tiles for a given area, optionally upload it to the btrfs bucket
"""
print(f'---\n{now}\nStarting make-tiles {area} upload: {upload}')
run_folder = run_planetiler(area)
make_btrfs(run_folder)
if upload:
upload_area(area)
@cli.command(name='upload-area')
@click.argument('area', required=True)
def upload_area_(area):
"""
Upload all runs from a given area to the btrfs bucket
"""
print(f'---\n{now}\nStarting upload-area {area}')
upload_area(area)
@cli.command()
def make_indexes():
"""
Make indexes for all buckets
"""
print(f'---\n{now}\nStarting make-indexes')
for bucket in ['ofm-btrfs', 'ofm-assets']:
make_indexes_for_bucket(bucket)
@cli.command()
@click.argument('area', required=True)
@click.option(
'--version', default='latest', help='Optional version string, like "20231227_043106_pt"'
)
def set_version(area, version):
"""
Set versions for a given area
"""
print(f'---\n{now}\nStarting set-version {area}')
if version == 'latest':
versions = get_versions_for_area(area)
if not versions:
print(f' No versions found for {area}')
return
version = versions[-1]
print(f' Latest version on bucket: {area} {version}')
try:
if get_deployed_version(area)['version'] == version:
return
except Exception:
pass
set_version_on_bucket(area, version)
if __name__ == '__main__':
cli()

View File

@@ -0,0 +1,146 @@
import os
import shutil
import subprocess
from pathlib import Path
from .config import config
from .utils import python_venv_executable
IMAGE_SIZE = '200G'
def make_btrfs(run_folder: Path):
os.chdir(run_folder)
cleanup_folder(run_folder)
# make an empty file that's definitely bigger then the current OSM output
for image in ['image.btrfs', 'image2.btrfs']:
subprocess.run(['fallocate', '-l', IMAGE_SIZE, image], check=True)
subprocess.run(['mkfs.btrfs', '-m', 'single', image], check=True, capture_output=True)
for image, mount in [('image.btrfs', 'mnt_rw'), ('image2.btrfs', 'mnt_rw2')]:
Path(mount).mkdir()
# https://btrfs.readthedocs.io/en/latest/btrfs-man5.html#mount-options
# compression doesn't make sense, data is already gzip compressed
subprocess.run(
[
'sudo',
'mount',
'-t',
'btrfs',
'-o',
'noacl,nobarrier,noatime,max_inline=4096',
image,
mount,
],
check=True,
)
subprocess.run(['sudo', 'chown', 'ofm:ofm', '-R', mount], check=True)
# extract mbtiles
extract_script = config.tile_gen_scripts_dir / 'extract_mbtiles.py'
with open('extract_out.log', 'w') as out, open('extract_err.log', 'w') as err:
subprocess.run(
[
python_venv_executable(),
extract_script,
'tiles.mbtiles',
'mnt_rw/extract',
],
check=True,
stdout=out,
stderr=err,
)
shutil.copy('mnt_rw/extract/osm_date', '.')
# process logs
subprocess.run('grep fixed extract_out.log > dedupl_fixed.log', shell=True)
# unfortunately, by deleting files from the btrfs partition, the partition size grows
# so we need to rsync onto a new partition instead of deleting
with open('rsync_out.log', 'w') as out, open('rsync_err.log', 'w') as err:
subprocess.run(
[
'rsync',
'-avH',
'--max-alloc=4294967296',
'--exclude',
'dedupl',
'mnt_rw/extract/',
'mnt_rw2/',
],
check=True,
stdout=out,
stderr=err,
)
# collect stats
for i, mount in enumerate(['mnt_rw', 'mnt_rw2'], 1):
with open(f'stats{i}.txt', 'w') as f:
for cmd in [
['df', '-h', mount],
['btrfs', 'filesystem', 'df', mount],
['btrfs', 'filesystem', 'show', mount],
['btrfs', 'filesystem', 'usage', mount],
]:
f.write(f'\n\n{" ".join(cmd)}\n')
result = subprocess.run(['sudo'] + cmd, check=True, capture_output=True, text=True)
f.write(result.stdout)
# unmount and cleanup
for mount in ['mnt_rw', 'mnt_rw2']:
subprocess.run(['sudo', 'umount', mount], check=True)
shutil.rmtree('mnt_rw')
shutil.rmtree('mnt_rw2')
# shrink btrfs
shrink_script = config.tile_gen_scripts_dir / 'shrink_btrfs.py'
with open('shrink_out.log', 'w') as out, open('shrink_err.log', 'w') as err:
subprocess.run(
['sudo', python_venv_executable(), shrink_script, 'image2.btrfs'],
check=True,
stdout=out,
stderr=err,
)
os.unlink('image.btrfs')
shutil.move('image2.btrfs', 'tiles.btrfs')
# parallel gzip (pigz)
subprocess.run(['pigz', 'tiles.btrfs', '--fast'], check=True)
# move logs
Path('logs').mkdir()
for pattern in ['*.log', '*.txt']:
for file in Path().glob(pattern):
shutil.move(file, 'logs')
# create a checksum file, Ubuntu style naming convention
with open('SHA256SUMS', 'w') as out:
subprocess.run(
['sha256sum', 'tiles.btrfs.gz', 'tiles.mbtiles'],
check=True,
stdout=out,
)
print('extract_btrfs.py DONE')
def cleanup_folder(run_folder: Path):
print(f'cleaning up {run_folder}')
for mount in ['mnt_rw', 'mnt_rw2']:
subprocess.run(['sudo', 'umount', run_folder / mount], capture_output=True)
for pattern in ['mnt_rw*', 'tmp_*', '*.btrfs', '*.gz', '*.log', '*.txt', 'logs', 'osm_date']:
for item in run_folder.glob(pattern):
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()

View File

@@ -0,0 +1,28 @@
import subprocess
from pathlib import Path
class Configuration:
areas = ['planet', 'monaco']
tile_gen_dir = Path('/data/ofm/tile_gen')
tile_gen_bin = tile_gen_dir / 'bin'
tile_gen_scripts_dir = tile_gen_bin / 'scripts'
planetiler_bin = tile_gen_dir / 'planetiler'
planetiler_path = planetiler_bin / 'planetiler.jar'
runs_dir = tile_gen_dir / 'runs'
if Path('/data/ofm').exists():
ofm_config_dir = Path('/data/ofm/config')
else:
repo_root = Path(__file__).parent.parent.parent.parent
ofm_config_dir = repo_root / 'config'
rclone_config = ofm_config_dir / 'rclone.conf'
rclone_bin = subprocess.run(['which', 'rclone'], capture_output=True, text=True).stdout.strip()
config = Configuration()

View File

@@ -0,0 +1,48 @@
"""
This file is shared / symlinked between tile_gen_lib and http_host_lib
"""
from datetime import datetime, timezone
import requests
def get_versions_for_area(area: str) -> list:
"""
Download the files.txt and check for the runs with the "done" file present
"""
r = requests.get('https://btrfs.openfreemap.com/files.txt', timeout=30)
r.raise_for_status()
versions = []
files = r.text.splitlines()
for f in files:
if not f.startswith(f'areas/{area}/'):
continue
if not f.endswith('/done'):
continue
version_str = f.split('/')[2]
versions.append(version_str)
return sorted(versions)
def get_deployed_version(area: str) -> dict:
r = requests.get(f'https://assets.openfreemap.com/deployed_versions/{area}.txt', timeout=30)
r.raise_for_status()
version = r.text.strip()
last_modified_str = r.headers.get('Last-Modified')
last_modified = parse_http_last_modified(last_modified_str)
return dict(
version=version,
last_modified=last_modified,
)
def parse_http_last_modified(date_string) -> datetime:
parsed_date = datetime.strptime(date_string, '%a, %d %b %Y %H:%M:%S GMT')
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
return parsed_date

View File

@@ -0,0 +1,70 @@
import os
import shutil
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from .btrfs import cleanup_folder
from .config import config
def run_planetiler(area: str) -> Path:
assert area in config.areas
date = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
area_dir = config.runs_dir / area
# delete all previous runs for the given area
if area_dir.is_dir():
for subdir in area_dir.iterdir():
cleanup_folder(subdir)
print('running rmtree')
shutil.rmtree(area_dir, ignore_errors=True)
print('rmtree done')
run_folder = area_dir / f'{date}_pt'
run_folder.mkdir(parents=True, exist_ok=True)
os.chdir(run_folder)
# link to discussion about why exactly 30 GB
# https://github.com/onthegomap/planetiler/discussions/690#discussioncomment-7756397
java_memory_gb = 30 if area == 'planet' else 1
command = [
'java',
f'-Xmx{java_memory_gb}g',
'-jar',
config.planetiler_path,
f'--area={area}',
'--download',
'--download-threads=10',
'--download-chunk-size-mb=1000',
'--fetch-wikidata',
'--output=tiles.mbtiles',
'--storage=mmap',
'--force',
'--languages=default,tok',
]
if area == 'planet':
command.append('--nodemap-type=array')
command.append('--bounds=planet')
if area == 'monaco':
command.append('--nodemap-type=sortedtable')
print(command)
out_path = run_folder / 'planetiler.out'
err_path = run_folder / 'planetiler.err'
with out_path.open('w') as out_file, err_path.open('w') as err_file:
subprocess.run(command, stdout=out_file, stderr=err_file, check=True, cwd=run_folder)
shutil.rmtree(run_folder / 'data', ignore_errors=True)
print('planetiler.jar DONE')
return run_folder

View File

@@ -0,0 +1,148 @@
import subprocess
import sys
from .config import config
def upload_area(area):
"""
Uploads an area, making sure there is exactly one run present
"""
print(f'Uploading area: {area}')
assert area in config.areas
area_dir = config.runs_dir / area
if not area_dir.exists():
return
runs = list(area_dir.iterdir())
if len(runs) != 1:
print('Error: Make sure there is only one run in the given area')
sys.exit(1)
run = runs[0].name
upload_area_run(area, run)
make_indexes_for_bucket('ofm-btrfs')
def upload_area_run(area, run):
print(f'Uploading {area} {run} to btrfs bucket')
run_dir = config.runs_dir / area / run
assert run_dir.is_dir()
subprocess.run(
[
'rclone',
'sync',
'--verbose=1',
'--transfers=8',
'--multi-thread-streams=8',
'--fast-list',
'--stats-file-name-length=0',
'--stats-one-line',
'--log-file',
run_dir / 'logs' / 'rclone.log',
'--exclude',
'logs/**',
run_dir,
f'remote:ofm-btrfs/areas/{area}/{run}',
],
env=dict(RCLONE_CONFIG=config.rclone_config),
check=True,
)
# crate "done" file
subprocess.run(
[
'rclone',
'touch',
f'remote:ofm-btrfs/areas/{area}/{run}/done',
],
env=dict(RCLONE_CONFIG=config.rclone_config),
check=True,
)
def make_indexes_for_bucket(bucket):
print(f'Making indexes for bucket: {bucket}')
# files
p = subprocess.run(
[
'rclone',
'lsf',
'--recursive',
'--files-only',
'--fast-list',
'--exclude',
'dirs.txt',
'--exclude',
'files.txt',
f'remote:{bucket}',
],
env=dict(RCLONE_CONFIG=config.rclone_config),
check=True,
capture_output=True,
text=True,
)
index_str = p.stdout
# upload to files.txt
subprocess.run(
[
'rclone',
'rcat',
f'remote:{bucket}/files.txt',
],
env=dict(RCLONE_CONFIG=config.rclone_config),
check=True,
input=index_str.encode(),
)
# directories
p = subprocess.run(
[
'rclone',
'lsf',
'--recursive',
'--dirs-only',
'--dir-slash=false',
'--fast-list',
f'remote:{bucket}',
],
env=dict(RCLONE_CONFIG=config.rclone_config),
check=True,
capture_output=True,
text=True,
)
index_str = p.stdout
# upload to dirs.txt
subprocess.run(
[
'rclone',
'rcat',
f'remote:{bucket}/dirs.txt',
],
env=dict(RCLONE_CONFIG=config.rclone_config),
check=True,
input=index_str.encode(),
)
def set_version_on_bucket(area, version):
print(f'setting version: {area} {version}')
subprocess.run(
[
config.rclone_bin,
'rcat',
f'remote:ofm-assets/deployed_versions/{area}.txt',
],
env=dict(RCLONE_CONFIG=config.rclone_config),
check=True,
input=version.strip().encode(),
)

View File

@@ -0,0 +1,14 @@
import os
import sys
from pathlib import Path
def python_venv_executable() -> Path:
venv_path = os.environ.get('VIRTUAL_ENV')
if venv_path:
return Path(venv_path) / 'bin' / 'python'
elif sys.prefix != sys.base_prefix:
return Path(sys.prefix) / 'bin' / 'python'
else:
return Path(sys.executable)

View File

@@ -1,6 +1,8 @@
{ {
"dependencies": { "dependencies": {
"prettier": "^3.2.4", "@biomejs/biome": "^2.2.4",
"prettier": "^3.6.2",
"prettier-plugin-astro": "^0.14.0" "prettier-plugin-astro": "^0.14.0"
} },
"packageManager": "pnpm@10.18.2"
} }

View File

@@ -1,18 +1,20 @@
#!/usr/bin/env bash #!/usr/bin/env bash
find . -name "*.egg-info" -exec rm -rf {} + find . -name '*.egg-info' -type d -prune -exec rm -rf {} +
find . -name __pycache__ -exec rm -rf {} + find . -name '*.pyc' -delete
find . -name __pycache__ -type d -prune -exec rm -rf {} +
# deactivate find . -name .DS_Store -delete
rm -rf venv find . -name .ipynb_checkpoints -exec rm -rf {} +
python3 -m venv venv find . -name .pytest_cache -exec rm -rf {} +
source venv/bin/activate find . -name .ruff_cache -exec rm -rf {} +
find . -name .venv -type d -prune -exec rm -rf {} +
pip install -U pip wheel setuptools find . -name venv -type d -prune -exec rm -rf {} +
pip install -e .
pip install -e scripts/http_host
pip install -e scripts/tile_gen
uv venv --python=3.12
source .venv/bin/activate
uv pip install -e .
uv pip install -e modules/http_host
uv pip install -e modules/tile_gen

View File

@@ -1,8 +0,0 @@
wrk -c10 -t4 -d10s -s /data/ofm/benchmark/wrk_custom_list.lua http://localhost
# -t1 => more correct, since the url list is loaded exactly in sequence
# -t4 => reflecting real world usage

View File

@@ -1,4 +0,0 @@
# every minute sync, locking so that only one process can run at a time
* * * * * ofm /usr/bin/flock -n /tmp/hostmanager.lockfile -c 'sudo /data/ofm/venv/bin/python -u /data/ofm/http_host/bin/host_manager.py sync >> /data/ofm/http_host/logs/host_manager_sync.log 2>&1'

View File

@@ -1,2 +0,0 @@
# once per day
2 34 * * * ofm sudo /usr/bin/bash /data/ofm/http_host/bin/ledns_reader.sh >> /data/ofm/http_host/logs/ledns_reader.log 2>&1

View File

@@ -1,202 +0,0 @@
#!/usr/bin/env python3
import datetime
import subprocess
import sys
from pathlib import Path
import click
import requests
from http_host_lib import DEFAULT_ASSETS_DIR, DEFAULT_RUNS_DIR, HOST_CONFIG, MNT_DIR
from http_host_lib.download_assets import (
download_and_extract_asset_tar_gz,
download_sprites,
)
from http_host_lib.download_tileset import download_and_extract_tileset
from http_host_lib.mount import clean_up_mounts, create_fstab
from http_host_lib.nginx import write_nginx_config
from http_host_lib.set_tileset_versions import set_tileset_versions
from http_host_lib.utils import assert_linux, assert_sudo
@click.group()
def cli():
"""
Manages OpenFreeMap HTTP hosts, including:\n
- Downloading assets\n
- Downloading tilesets\n
- Mounting directories\n
- Updating nginx config\n
- Setting the latest versions of tilesets\n
- Running the sync cron task (called every minute)
"""
@cli.command()
@click.argument('area', required=False)
@click.option('--version', default='latest', help='Version string, like "20231227_043106_pt"')
@click.option(
'--runs-dir',
help='Specify runs directory',
type=click.Path(dir_okay=True, file_okay=False, path_type=Path),
)
@click.option('--list-versions', is_flag=True, help='List all versions in an area and terminate')
def download_tileset(area: str, version: str, list_versions: bool, runs_dir: Path):
"""
Downloads and extracts the latest tiles.btrfs file from the public bucket.
Version can also be specified.
"""
print('running download_tileset')
if area not in {'planet', 'monaco'}:
sys.exit(' please specify area: "planet" or "monaco"')
r = requests.get(f'https://{area}.openfreemap.com/dirs.txt', timeout=30)
r.raise_for_status()
versions = sorted(r.text.splitlines())
all_versions_str = '\n'.join(versions)
if list_versions:
print(all_versions_str)
return
if version == 'latest':
selected_version = versions[-1]
else:
if version not in versions:
sys.exit(f'Requested version is not available. Available versions:\n{all_versions_str}')
selected_version = version
if not runs_dir:
runs_dir = DEFAULT_RUNS_DIR
if not runs_dir.parent.exists():
sys.exit("runs dir's parent doesn't exist")
return download_and_extract_tileset(area, selected_version, runs_dir)
@cli.command()
@click.option(
'--assets-dir',
help='Specify assets directory',
type=click.Path(dir_okay=True, file_okay=False, path_type=Path),
)
def download_assets(assets_dir: Path):
"""
Downloads and extracts assets
"""
print('running download_assets')
if not assets_dir:
assets_dir = DEFAULT_ASSETS_DIR
if not assets_dir.parent.exists():
sys.exit("asset dir's parent doesn't exist")
download_and_extract_asset_tar_gz(assets_dir, 'fonts')
download_and_extract_asset_tar_gz(assets_dir, 'styles')
download_and_extract_asset_tar_gz(assets_dir, 'natural_earth')
download_sprites(assets_dir)
@cli.command()
def mount():
"""
Mounts/unmounts the btrfs images from /data/ofm/http_host/runs automatically.
When finished, /mnt/ofm dir will have all the present tiles.btrfs files mounted in a read-only way.
"""
print('running mount')
assert_linux()
assert_sudo()
if not DEFAULT_RUNS_DIR.exists():
sys.exit(' download_tileset needs to be run first')
clean_up_mounts(MNT_DIR)
create_fstab()
print(' running mount -a')
subprocess.run(['mount', '-a'], check=True)
clean_up_mounts(MNT_DIR)
@cli.command()
def set_latest_versions():
"""
Sets the latest version of the tilesets to the version specified by
https://assets.openfreemap.com/versions/deployed_planet.txt
1. Checks if the given version is present on the disk and is mounted
2. Writes to a version file
"""
print('running set_latest_versions')
assert_linux()
assert_sudo()
if not MNT_DIR.exists():
sys.exit(' mount needs to be run first')
return set_tileset_versions()
@cli.command()
def nginx_sync():
"""
Syncs the nginx config to the state of the system
"""
print('running nginx_sync')
assert_linux()
assert_sudo()
if not MNT_DIR.exists():
sys.exit(' mount needs to be run first')
write_nginx_config()
@cli.command()
@click.pass_context
def sync(ctx):
"""
Runs the sync task, normally called by cron every minute
On a new server this also takes care of everything, no need to run anything manually.
"""
print('---')
print('running sync')
print(datetime.datetime.now(tz=datetime.timezone.utc))
assert_linux()
assert_sudo()
download_done = False
download_done += ctx.invoke(download_tileset, area='monaco')
if not HOST_CONFIG.get('skip_planet'):
download_done += ctx.invoke(download_tileset, area='planet')
if download_done:
ctx.invoke(mount)
ctx.invoke(download_assets)
deploy_done = ctx.invoke(set_latest_versions)
if download_done or deploy_done:
ctx.invoke(nginx_sync)
if __name__ == '__main__':
print(HOST_CONFIG)
cli()

View File

@@ -1,20 +0,0 @@
import json
from pathlib import Path
NGINX_DIR = Path(__file__).parent / 'nginx'
DEFAULT_RUNS_DIR = Path('/data/ofm/http_host/runs')
DEFAULT_ASSETS_DIR = Path('/data/ofm/http_host/assets')
MNT_DIR = Path('/mnt/ofm')
OFM_CONFIG_DIR = Path('/data/ofm/config')
HTTP_HOST_BIN_DIR = Path('/data/ofm/http_host/bin')
CERTS_DIR = Path('/data/nginx/certs')
try:
with open('/data/ofm/config/http_host.json') as fp:
HOST_CONFIG = json.load(fp)
except Exception:
HOST_CONFIG = {}

View File

@@ -1,53 +0,0 @@
import shutil
import subprocess
import sys
from pathlib import Path
import click
from http_host_lib.utils import download_file_aria2, get_remote_file_size
def download_and_extract_tileset(area: str, version: str, runs_dir: Path) -> bool:
"""
returns True if downloaded something
"""
click.echo(f'downloading {area} {version}')
version_dir = runs_dir / area / version
btrfs_file = version_dir / 'tiles.btrfs'
if btrfs_file.exists():
print(' file exists, skipping download')
return False
temp_dir = runs_dir / '_tmp'
shutil.rmtree(temp_dir, ignore_errors=True)
temp_dir.mkdir(parents=True)
url = f'https://{area}.openfreemap.com/{version}/tiles.btrfs.gz'
# check disk space
disk_free = shutil.disk_usage(temp_dir).free
file_size = get_remote_file_size(url)
if not file_size:
raise ValueError('Cannot get remote file size')
needed_space = file_size * 3
if disk_free < needed_space:
raise ValueError(f'Not enough disk space. Needed: {needed_space}, free space: {disk_free}')
target_file = temp_dir / 'tiles.btrfs.gz'
download_file_aria2(url, target_file)
print('Uncompressing...')
subprocess.run(['unpigz', temp_dir / 'tiles.btrfs.gz'], check=True)
btrfs_src = temp_dir / 'tiles.btrfs'
shutil.rmtree(version_dir, ignore_errors=True)
version_dir.mkdir(parents=True)
btrfs_src.rename(btrfs_file)
shutil.rmtree(temp_dir)
return True

View File

@@ -1,62 +0,0 @@
import subprocess
from pathlib import Path
from http_host_lib import DEFAULT_RUNS_DIR, MNT_DIR
def create_fstab():
fstab_new = []
for area in ['planet', 'monaco']:
area_dir = (DEFAULT_RUNS_DIR / area).resolve()
if not area_dir.exists():
continue
versions = sorted(area_dir.iterdir())
for version in versions:
version_str = version.name
btrfs_file = area_dir / version_str / 'tiles.btrfs'
if not btrfs_file.is_file():
continue
mnt_folder = MNT_DIR / f'{area}-{version_str}'
mnt_folder.mkdir(exist_ok=True, parents=True)
fstab_new.append(f'{btrfs_file} {mnt_folder} btrfs loop,ro 0 0\n')
print(f' created fstab entry for {btrfs_file} -> {mnt_folder}')
with open('/etc/fstab') as fp:
fstab_orig = [l for l in fp.readlines() if f'{MNT_DIR}/' not in l]
with open('/etc/fstab', 'w') as fp:
fp.writelines(fstab_orig + fstab_new)
def clean_up_mounts(mnt_dir):
if not mnt_dir.exists():
return
print(' cleaning up mounts')
# handle deleted files
p = subprocess.run(['mount'], capture_output=True, text=True, check=True)
lines = [l for l in p.stdout.splitlines() if f'{mnt_dir}/' in l and '(deleted)' in l]
for l in lines:
mnt_path = Path(l.split('(deleted) on ')[1].split(' type btrfs')[0])
print(f' removing deleted mount {mnt_path}')
assert mnt_path.exists()
subprocess.run(['umount', mnt_path], check=True)
mnt_path.rmdir()
# clean all mounts not in current fstab
with open('/etc/fstab') as fp:
fstab_str = fp.read()
for subdir in mnt_dir.iterdir():
if f'{subdir} ' in fstab_str:
continue
print(f' removing old mount {subdir}')
subprocess.run(['umount', subdir], check=True)
subdir.rmdir()

View File

@@ -1,247 +0,0 @@
import shutil
import subprocess
import sys
from pathlib import Path
from http_host_lib import (
CERTS_DIR,
DEFAULT_RUNS_DIR,
HOST_CONFIG,
HTTP_HOST_BIN_DIR,
MNT_DIR,
NGINX_DIR,
OFM_CONFIG_DIR,
)
def write_nginx_config():
curl_text_mix = ''
domain_cf = HOST_CONFIG['domain_cf']
domain_le = HOST_CONFIG['domain_le']
domain_ledns = HOST_CONFIG['domain_ledns']
# processing Cloudflare config
if domain_cf:
if not (CERTS_DIR / 'ofm_cf.cert').is_file() or not (CERTS_DIR / 'ofm_cf.key').is_file():
sys.exit('ofm_cf.cert or ofm_cf.key missing')
curl_text_mix += create_nginx_conf(
template_path=NGINX_DIR / 'cf.conf',
local='ofm_cf',
domain=domain_cf,
)
# processing Cloudflare config
if domain_ledns:
if not (OFM_CONFIG_DIR / 'rclone.conf').is_file():
sys.exit('rclone.conf missing')
# download the ledns certificate from bucket using rclone
write_ledns_reader_script(domain_ledns)
subprocess.run(['bash', HTTP_HOST_BIN_DIR / 'ledns_reader.sh'], check=True)
curl_text_mix += create_nginx_conf(
template_path=NGINX_DIR / 'ledns.conf',
local='ofm_ledns',
domain=domain_ledns,
)
# processing Let's Encrypt config
if domain_le:
le_cert = CERTS_DIR / 'ofm_le.cert'
le_key = CERTS_DIR / 'ofm_le.key'
if not le_cert.is_file() or not le_key.is_file():
shutil.copyfile(Path('/etc/nginx/ssl/dummy.crt'), le_cert)
shutil.copyfile(Path('/etc/nginx/ssl/dummy.key'), le_key)
curl_text_mix += create_nginx_conf(
template_path=NGINX_DIR / 'le.conf',
local='ofm_le',
domain=domain_le,
)
subprocess.run(['nginx', '-t'], check=True)
subprocess.run(['systemctl', 'reload', 'nginx'], check=True)
subprocess.run(
[
'certbot',
'certonly',
'--webroot',
'--webroot-path=/data/nginx/acme-challenges',
'--noninteractive',
'-m',
HOST_CONFIG['le_email'],
'--agree-tos',
'--cert-name=ofm_le',
# '--staging',
'--deploy-hook',
'nginx -t && service nginx reload',
'-d',
domain_le,
],
check=True,
)
# link certs to nginx dir
le_cert.unlink()
le_key.unlink()
etc_cert = Path('/etc/letsencrypt/live/ofm_le/fullchain.pem')
etc_key = Path('/etc/letsencrypt/live/ofm_le/privkey.pem')
assert etc_cert.is_file()
assert etc_key.is_file()
le_cert.symlink_to(etc_cert)
le_key.symlink_to(etc_key)
subprocess.run(['nginx', '-t'], check=True)
subprocess.run(['systemctl', 'reload', 'nginx'], check=True)
print(curl_text_mix)
def create_nginx_conf(*, template_path, local, domain):
location_str, curl_text = create_location_blocks(local=local, domain=domain)
with open(template_path) as fp:
template = fp.read()
template = template.replace('__LOCATION_BLOCKS__', location_str)
template = template.replace('__LOCAL__', local)
template = template.replace('__DOMAIN__', domain)
curl_text = curl_text.replace('__LOCAL__', local)
curl_text = curl_text.replace('__DOMAIN__', domain)
with open(f'/data/nginx/sites/{local}.conf', 'w') as fp:
fp.write(template)
print(f' nginx config written: {domain} {local}')
return curl_text
def create_location_blocks(*, local, domain):
location_str = ''
curl_text = ''
for subdir in MNT_DIR.iterdir():
if not subdir.is_dir():
continue
area, version = subdir.name.split('-')
location_str += create_version_location(
area=area, version=version, subdir=subdir, local=local, domain=domain
)
if not curl_text:
curl_text = (
'\ntest with:\n'
f'curl -H "Host: __LOCAL__" -I http://localhost/{area}/{version}/14/8529/5975.pbf\n'
f'curl -I https://__DOMAIN__/{area}/{version}/14/8529/5975.pbf'
)
location_str += create_latest_locations(local=local, domain=domain)
with open(NGINX_DIR / 'location_static.conf') as fp:
location_str += '\n' + fp.read()
return location_str, curl_text
def create_version_location(
*, area: str, version: str, subdir: Path, local: str, domain: str
) -> str:
run_dir = DEFAULT_RUNS_DIR / area / version
if not run_dir.is_dir():
print(f" {run_dir} doesn't exists, skipping")
return ''
tilejson_path = run_dir / f'tilejson-{local}.json'
metadata_path = subdir / 'metadata.json'
if not metadata_path.is_file():
print(f" {metadata_path} doesn't exists, skipping")
return ''
url_prefix = f'https://{domain}/{area}/{version}'
subprocess.run(
[
sys.executable,
Path(__file__).parent.parent / 'metadata_to_tilejson.py',
'--minify',
metadata_path,
tilejson_path,
url_prefix,
],
check=True,
)
return f"""
location = /{area}/{version} {{ # no trailing slash
alias {tilejson_path}; # no trailing slash
expires 1w;
default_type application/json;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
}}
location /{area}/{version}/ {{ # trailing slash
alias {subdir}/tiles/; # trailing slash
try_files $uri @empty_tile;
add_header Content-Encoding gzip;
expires 10y;
types {{
application/vnd.mapbox-vector-tile pbf;
}}
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
}}
"""
def create_latest_locations(*, local: str, domain: str) -> str:
location_str = ''
local_version_files = OFM_CONFIG_DIR.glob('tileset_version_*.txt')
for file in local_version_files:
area = file.stem.split('_')[-1]
with open(file) as fp:
version = fp.read().strip()
print(f' setting latest version for {area}: {version}')
run_dir = DEFAULT_RUNS_DIR / area / version
tilejson_path = run_dir / f'tilejson-{local}.json'
assert tilejson_path.is_file()
location_str += f"""
location = /{area} {{ # no trailing slash
alias {tilejson_path}; # no trailing slash
expires 1d;
default_type application/json;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header Cache-Control public;
}}
"""
return location_str
def write_ledns_reader_script(domain_ledns):
script = f"""
#!/usr/bin/env bash
export RCLONE_CONFIG=/data/ofm/config/rclone.conf
rclone copyto -v "remote:ofm-private/ledns/{domain_ledns}/ofm_ledns.cert" /data/nginx/certs/ofm_ledns.cert
rclone copyto -v "remote:ofm-private/ledns/{domain_ledns}/ofm_ledns.key" /data/nginx/certs/ofm_ledns.key
""".strip()
with open(HTTP_HOST_BIN_DIR / 'ledns_reader.sh', 'w') as fp:
fp.write(script)

View File

@@ -1,36 +0,0 @@
server {
server_name __LOCAL__ __DOMAIN__;
# ssl: https://ssl-config.mozilla.org / modern config
# to be used with the Cloudflare proxied endpoint
listen 80;
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
ssl_certificate /data/nginx/certs/ofm_cf.cert;
ssl_certificate_key /data/nginx/certs/ofm_cf.key;
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
ssl_session_tickets off;
# modern configuration
ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers off;
# access log disabled by default
#access_log /data/ofm/http_host/logs_nginx/cf-access.log access_json buffer=32k;
access_log off;
error_log /data/ofm/http_host/logs_nginx/cf-error.log;
__LOCATION_BLOCKS__
# catch-all block to deny all other requests
location / {
deny all;
error_log /data/ofm/http_host/logs_nginx/__LOCAL__-error.log error;
}
}

View File

@@ -1,38 +0,0 @@
server {
server_name __LOCAL__ __DOMAIN__;
# ssl: https://ssl-config.mozilla.org / intermediate config
listen 80;
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
ssl_certificate /data/nginx/certs/ofm_ledns.cert;
ssl_certificate_key /data/nginx/certs/ofm_ledns.key;
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
ssl_session_tickets off;
ssl_dhparam /etc/nginx/ffdhe2048.txt;
# intermediate configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
# access log disabled by default
#access_log /data/ofm/http_host/logs_nginx/ledns-access.log access_json buffer=32k;
access_log off;
error_log /data/ofm/http_host/logs_nginx/ledns-error.log;
__LOCATION_BLOCKS__
# catch-all block to deny all other requests
location / {
deny all;
error_log /data/ofm/http_host/logs_nginx/__LOCAL__-error.log error;
}
}

View File

@@ -1,45 +0,0 @@
from pathlib import Path
import requests
from http_host_lib import OFM_CONFIG_DIR
def set_tileset_versions():
need_nginx_sync = False
for area in ['planet', 'monaco']:
r = requests.get(f'https://assets.openfreemap.com/versions/deployed_{area}.txt', timeout=30)
r.raise_for_status()
remote_version = r.text.strip()
print(f' remote version for {area}: {remote_version}')
local_version_file = OFM_CONFIG_DIR / f'tileset_version_{area}.txt'
if not local_version_file.exists():
local_version_start = None
else:
with open(local_version_file) as fp:
local_version_start = fp.read()
if not remote_version:
print(' remote version not specified')
if local_version_start is not None:
local_version_file.unlink()
need_nginx_sync = True
continue
mnt_file = Path(f'/mnt/ofm/{area}-{remote_version}/metadata.json')
if not mnt_file.exists():
print(' local version does not exist')
if local_version_start is not None:
local_version_file.unlink()
need_nginx_sync = True
continue
if remote_version != local_version_start:
with open(local_version_file, 'w') as fp:
fp.write(remote_version)
need_nginx_sync = True
return need_nginx_sync

View File

@@ -1,11 +0,0 @@
#!/usr/bin/env bash
#env > /data/ofm/ledns/env.txt
#RENEWED_DOMAINS=direct.openfreemap.org
#RENEWED_LINEAGE=/etc/letsencrypt/live/ofm_ledns
export RCLONE_CONFIG=/data/ofm/config/rclone.conf
rclone copyto -v --copy-links "$RENEWED_LINEAGE/fullchain.pem" "remote:ofm-private/ledns/$RENEWED_DOMAINS/ofm_ledns.cert"
rclone copyto -v --copy-links "$RENEWED_LINEAGE/privkey.pem" "remote:ofm-private/ledns/$RENEWED_DOMAINS/ofm_ledns.key"

View File

@@ -1,4 +0,0 @@
# every minute
* * * * * ofm sudo /data/ofm/venv/bin/python -u /data/ofm/loadbalancer/loadbalancer.py fix >> /data/ofm/loadbalancer/logs/check.log 2>&1

View File

@@ -1,160 +0,0 @@
#!/usr/bin/env python3
import datetime
import json
import click
import requests
from dotenv import dotenv_values
from loadbalancer_lib.cloudflare import get_zone_id, set_records_round_robin
from loadbalancer_lib.curl import pycurl_get, pycurl_status
from loadbalancer_lib.telegram_ import telegram_send_message
AREAS = ['planet', 'monaco']
@click.group()
def cli():
"""
Manages load-balancing of Round-Robin DNS records
"""
@cli.command()
def check():
"""
Runs load-balancing check (triggered by cron every minute)
"""
print(f'starting loadbalancer check at: {datetime.datetime.now(tz=datetime.timezone.utc)}')
check_or_fix(fix=False)
@cli.command()
def fix():
"""
Fixes records based on check results
"""
print(f'starting loadbalancer fix at: {datetime.datetime.now(tz=datetime.timezone.utc)}')
check_or_fix(fix=True)
def check_or_fix(fix=False):
with open('/data/ofm/config/loadbalancer.json') as fp:
c = json.load(fp)
# print(c)
try:
results_by_ip = {}
working_hosts = set()
for area in AREAS:
for host_ip, host_is_ok in run_area(c, area).items():
results_by_ip.setdefault(host_ip, True)
results_by_ip[host_ip] &= host_is_ok
for host_ip, host_is_ok in results_by_ip.items():
if not host_is_ok:
message = f'OFM ERROR with host: {host_ip}'
print(message)
telegram_send_message(message, c['telegram_token'], c['telegram_chat_id'])
else:
working_hosts.add(host_ip)
except Exception as e:
message = f'OFM ERROR with loadbalancer: {e}'
print(message)
telegram_send_message(message, c['telegram_token'], c['telegram_chat_id'])
return
print(f'working hosts: {sorted(working_hosts)}')
if fix:
# if no hosts are detected working, probably a bug in this script
# fail-safe to include all hosts
if not working_hosts:
working_hosts = set(c['http_host_list'])
message = 'OFM loadbalancer FIX found no working hosts, reverting to full list!'
print(message)
telegram_send_message(message, c['telegram_token'], c['telegram_chat_id'])
updated = update_records(c, working_hosts)
if updated:
message = f'OFM loadbalancer FIX modified records, new records: {working_hosts}'
print(message)
telegram_send_message(message, c['telegram_token'], c['telegram_chat_id'])
def run_area(c, area):
target_version = get_target_version(area)
print(f'target version: {area}: {target_version}')
results = {}
for host_ip in c['http_host_list']:
try:
check_host(c['domain_ledns'], host_ip, area, target_version)
results[host_ip] = True
except Exception:
results[host_ip] = False
return results
def check_host(domain, host_ip, area, version):
# check TileJSON first
url = f'https://{domain}/{area}'
tilejson_str = pycurl_get(url, domain, host_ip)
tilejson = json.loads(tilejson_str)
tiles_url = tilejson['tiles'][0]
version_in_tilejson = tiles_url.split('/')[4]
assert version_in_tilejson == version
# check actual vector tile
url = f'https://{domain}/{area}/{version}/14/8529/5975.pbf'
assert pycurl_status(url, domain, host_ip) == 200
def get_target_version(area):
url = f'https://assets.openfreemap.com/versions/deployed_{area}.txt'
response = requests.get(url)
response.raise_for_status()
return response.text.strip()
def update_records(c, working_hosts) -> bool:
config = dotenv_values('/data/ofm/config/cloudflare.ini')
cloudflare_api_token = config['dns_cloudflare_api_token']
domain = '.'.join(c['domain_ledns'].split('.')[-2:])
zone_id = get_zone_id(domain, cloudflare_api_token=cloudflare_api_token)
updated = False
updated |= set_records_round_robin(
zone_id=zone_id,
name=c['domain_ledns'],
host_ip_set=working_hosts,
proxied=False,
ttl=300,
comment='domain_ledns',
cloudflare_api_token=cloudflare_api_token,
)
updated |= set_records_round_robin(
zone_id=zone_id,
name=c['domain_cf'],
host_ip_set=working_hosts,
proxied=True,
comment='domain_cf',
cloudflare_api_token=cloudflare_api_token,
)
return updated
if __name__ == '__main__':
cli()

View File

@@ -1,109 +0,0 @@
from pprint import pprint
import requests
# docs: https://api.cloudflare.com/
def cloudflare_get(path: str, params: dict, cloudflare_api_token: str):
headers = {'Authorization': f'Bearer {cloudflare_api_token}'}
res = requests.get(
f'https://api.cloudflare.com/client/v4{path}', headers=headers, params=params
)
res.raise_for_status()
data = res.json()
assert data['success'] is True
return data
def get_zone_id(domain, cloudflare_api_token: str):
data = cloudflare_get(
'/zones', params=dict(name=domain), cloudflare_api_token=cloudflare_api_token
)
assert len(data['result']) == 1
zone_info = data['result'][0]
return zone_info['id']
def get_dns_records_round_robin(zone_id, cloudflare_api_token: str) -> dict:
data = cloudflare_get(
f'/zones/{zone_id}/dns_records',
params=dict(per_page=5000),
cloudflare_api_token=cloudflare_api_token,
)
records = data['result']
data = {}
for r in records:
if r['type'] != 'A':
continue
data.setdefault(r['name'], [])
data[r['name']].append(dict(content=r['content'], id=r['id']))
return data
def set_records_round_robin(
zone_id,
*,
name: str,
host_ip_set: set,
ttl: int = 1,
proxied: bool,
comment: str = None,
cloudflare_api_token: str,
) -> bool:
headers = {'Authorization': f'Bearer {cloudflare_api_token}'}
dns_records = get_dns_records_round_robin(zone_id, cloudflare_api_token=cloudflare_api_token)
current_records = dns_records.get(name, [])
current_ips = {r['content'] for r in current_records}
if current_ips == host_ip_set:
print(f'No need to update records: {name} currently set: {sorted(current_ips)}')
return False
# changing records
# delete all current records first
for r in current_records:
delete_record(zone_id, id_=r['id'], cloudflare_api_token=cloudflare_api_token)
# create new records
for ip in host_ip_set:
print(f'Creating record: {name} {ip}')
json_data = dict(
type='A',
name=name,
content=ip,
ttl=ttl,
proxied=proxied,
comment=comment,
)
res = requests.post(
f'https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records',
headers=headers,
json=json_data,
)
res.raise_for_status()
data = res.json()
assert data['success'] is True
return True
def delete_record(zone_id, *, id_: str, cloudflare_api_token: str):
headers = {'Authorization': f'Bearer {cloudflare_api_token}'}
print(f'Deleting record: {id_}')
res = requests.delete(
f'https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{id_}',
headers=headers,
json={},
)
res.raise_for_status()
data = res.json()
assert data['success'] is True

View File

@@ -1,45 +0,0 @@
from io import BytesIO
import pycurl
def pycurl_status(url, domain, host_ip):
"""
Uses pycurl to make a HTTPS HEAD request using custom resolving,
checks if the status code is 200
"""
c = pycurl.Curl()
c.setopt(c.URL, url)
c.setopt(c.CAINFO, '/etc/ssl/certs/ca-certificates.crt')
c.setopt(c.RESOLVE, [f'{domain}:443:{host_ip}'])
c.setopt(c.NOBODY, True)
c.setopt(c.TIMEOUT, 5)
c.perform()
status_code = c.getinfo(c.RESPONSE_CODE)
c.close()
return status_code
def pycurl_get(url, domain, host_ip):
"""
Uses pycurl to make a HTTPS GET request using custom resolving,
checks if the status code is 200, and returns the content.
"""
buffer = BytesIO()
c = pycurl.Curl()
c.setopt(c.URL, url)
c.setopt(c.CAINFO, '/etc/ssl/certs/ca-certificates.crt')
c.setopt(c.RESOLVE, [f'{domain}:443:{host_ip}'])
c.setopt(c.WRITEDATA, buffer)
c.setopt(c.TIMEOUT, 5)
c.perform()
status_code = c.getinfo(c.RESPONSE_CODE)
c.close()
if status_code != 200:
raise ValueError('non-200')
return buffer.getvalue().decode('utf8')

View File

@@ -1,14 +0,0 @@
import requests
def telegram_send_message(message, bot_token, chat_id):
url = f'https://api.telegram.org/bot{bot_token}/sendMessage'
payload = {'chat_id': chat_id, 'text': message}
response = requests.post(url, data=payload)
if response.status_code == 200:
print('Message sent successfully!')
else:
print('Failed to send message:', response.text)

View File

@@ -1,102 +0,0 @@
#!/usr/bin/env bash
set -e
TILE_GEN_BIN=/data/ofm/tile_gen/bin
VENV_PYTHON=/data/ofm/venv/bin/python
sudo umount mnt_rw 2> /dev/null || true
sudo umount mnt_rw2 2> /dev/null || true
rm -rf mnt_rw* tmp_*
rm -f -- *.btrfs *.gz
rm -rf -- *.log *.txt logs
# make an empty file that's definitely bigger then the current OSM output
fallocate -l 200G image.btrfs
fallocate -l 200G image2.btrfs
# metadata: single needed as default is now DUP
mkfs.btrfs \
-m single \
image.btrfs > /dev/null
mkfs.btrfs \
-m single \
image2.btrfs > /dev/null
# https://btrfs.readthedocs.io/en/latest/btrfs-man5.html#mount-options
# compression doesn't make sense, data is already gzip compressed
mkdir -p mnt_rw mnt_rw2
sudo mount \
-t btrfs \
-o noacl,nobarrier,noatime,max_inline=4096 \
image.btrfs mnt_rw
sudo mount \
-t btrfs \
-o noacl,nobarrier,noatime,max_inline=4096 \
image2.btrfs mnt_rw2
sudo chown ofm:ofm -R mnt_rw mnt_rw2
$VENV_PYTHON $TILE_GEN_BIN/extract_mbtiles/extract_mbtiles.py \
tiles.mbtiles mnt_rw/extract \
> extract_out.log 2> extract_err.log
cp mnt_rw/extract/osm_date .
grep fixed extract_out.log > dedupl_fixed.log || true
# Unfortunately, by deleting files from the btrfs partition, the size _grows_.
# So we need to rsync onto a new partition.
rsync -avH \
--max-alloc=4294967296 \
--exclude dedupl \
mnt_rw/extract/ mnt_rw2/ \
> rsync_out.log 2> rsync_err.log
# collect stats
{
echo -e "df -h"
sudo df -h mnt_rw
echo -e "\n\nbtrfs filesystem df"
sudo btrfs filesystem df mnt_rw
echo -e "\n\nbtrfs filesystem show"
sudo btrfs filesystem show mnt_rw
echo -e "\n\nbtrfs filesystem usage"
sudo btrfs filesystem usage mnt_rw
} > stats1.txt
{
echo -e "df -h"
sudo df -h mnt_rw2
echo -e "\n\nbtrfs filesystem df"
sudo btrfs filesystem df mnt_rw2
echo -e "\n\nbtrfs filesystem show"
sudo btrfs filesystem show mnt_rw2
echo -e "\n\nbtrfs filesystem usage"
sudo btrfs filesystem usage mnt_rw2
} > stats2.txt
sudo umount mnt_rw
sudo umount mnt_rw2
rm -r mnt_rw*
sudo $VENV_PYTHON $TILE_GEN_BIN/shrink_btrfs/shrink_btrfs.py image2.btrfs \
> shrink_out.log 2> shrink_err.log
rm image.btrfs
mv image2.btrfs tiles.btrfs
pigz tiles.btrfs --fast
mkdir -p logs
mv -- *.log logs
mv -- *.txt logs
echo extract_btrfs.sh DONE

View File

@@ -1,31 +0,0 @@
#!/usr/bin/env bash
set -e
TILE_GEN_BIN=/data/ofm/tile_gen/bin
AREA=monaco
DATE=$(date +"%Y%m%d_%H%M%S")
RUN_FOLDER="/data/ofm/tile_gen/runs/$AREA/${DATE}_pt"
mkdir -p "$RUN_FOLDER"
cd "$RUN_FOLDER" || exit
java -Xmx1g \
-jar $TILE_GEN_BIN/planetiler.jar \
`# Download the latest osm.pbf from s3://osm-pds bucket` \
--area=$AREA --download \
`# Accelerate the download by fetching the 10 1GB chunks at a time in parallel` \
--download-threads=10 --download-chunk-size-mb=1000 \
`# Also download name translations from wikidata` \
--fetch-wikidata \
--output=tiles.mbtiles \
`# Store temporary node locations at fixed positions in a memory-mapped file` \
--nodemap-type=array --storage=mmap \
--force \
> planetiler.out 2> planetiler.err
rm -r data
echo planetiler.jar DONE
$TILE_GEN_BIN/extract_btrfs.sh

View File

@@ -1,33 +0,0 @@
#!/usr/bin/env bash
set -e
TILE_GEN_BIN=/data/ofm/tile_gen/bin
AREA=planet
DATE=$(date +"%Y%m%d_%H%M%S")
RUN_FOLDER="/data/ofm/tile_gen/runs/$AREA/${DATE}_pt"
mkdir -p "$RUN_FOLDER"
cd "$RUN_FOLDER" || exit
# the Xmx value below the most important parameter here
# 30 GB works well
java -Xmx30g \
-jar $TILE_GEN_BIN/planetiler.jar \
`# Download the latest planet.osm.pbf from s3://osm-pds bucket` \
--area=planet --bounds=planet --download \
`# Accelerate the download by fetching the 10 1GB chunks at a time in parallel` \
--download-threads=10 --download-chunk-size-mb=1000 \
`# Also download name translations from wikidata` \
--fetch-wikidata \
--output=tiles.mbtiles \
`# Store temporary node locations at fixed positions in a memory-mapped file` \
--nodemap-type=array --storage=mmap \
--force \
> planetiler.out 2> planetiler.err
rm -r data
echo planetiler.jar DONE
$TILE_GEN_BIN/extract_btrfs.sh

View File

@@ -1,13 +0,0 @@
from setuptools import find_packages, setup
requirements = [
'click',
]
setup(
python_requires='>=3.10',
install_requires=requirements,
packages=find_packages(),
)

View File

@@ -1,197 +0,0 @@
#!/usr/bin/env python3
import json
import pathlib
import shutil
import subprocess
import click
AREAS = ['planet', 'monaco']
RUNS_DIR = pathlib.Path('/data/ofm/tile_gen/runs')
def upload_rclone(area, run):
subprocess.run(
[
'rclone',
'sync',
'--transfers=8',
'--multi-thread-streams=8',
'--fast-list',
'-v',
'--stats-file-name-length',
'0',
'--stats-one-line',
'--log-file',
RUNS_DIR / area / run / 'logs' / 'rclone.log',
'--exclude',
'logs/**',
RUNS_DIR / area / run,
f'remote:ofm-{area}/{run}',
],
env=dict(RCLONE_CONFIG='/data/ofm/config/rclone.conf'),
check=True,
)
def make_indexes():
for area in AREAS:
print(f'creating index {area}')
# files
p = subprocess.run(
[
'rclone',
'lsf',
'-R',
'--files-only',
'--fast-list',
'--exclude',
'dirs.txt',
'--exclude',
'index.txt',
f'remote:ofm-{area}',
],
env=dict(RCLONE_CONFIG='/data/ofm/config/rclone.conf'),
check=True,
capture_output=True,
text=True,
)
index_str = p.stdout
subprocess.run(
[
'rclone',
'rcat',
f'remote:ofm-{area}/index.txt',
],
env=dict(RCLONE_CONFIG='/data/ofm/config/rclone.conf'),
check=True,
input=index_str.encode(),
)
# directories
p = subprocess.run(
[
'rclone',
'lsf',
'-R',
'--dirs-only',
'--dir-slash=false',
'--fast-list',
f'remote:ofm-{area}',
],
env=dict(RCLONE_CONFIG='/data/ofm/config/rclone.conf'),
check=True,
capture_output=True,
text=True,
)
index_str = p.stdout
subprocess.run(
[
'rclone',
'rcat',
f'remote:ofm-{area}/dirs.txt',
],
env=dict(RCLONE_CONFIG='/data/ofm/config/rclone.conf'),
check=True,
input=index_str.encode(),
)
@click.group()
def cli():
"""
Uploads runs to Cloudflare
"""
@cli.command()
def upload_runs():
"""
Upload all runs present in system
"""
print('running upload_runs')
for area in AREAS:
if not (RUNS_DIR / area).exists():
continue
p = subprocess.run(
[
'rclone',
'lsjson',
'--dirs-only',
'--fast-list',
f'remote:ofm-{area}',
],
text=True,
capture_output=True,
check=True,
env=dict(RCLONE_CONFIG='/data/ofm/config/rclone.conf'),
)
rclone_json = json.loads(p.stdout)
runs_remote = {p['Path'] for p in rclone_json}
runs_local = {p.name for p in (RUNS_DIR / area).iterdir()}
runs_to_upload = runs_local - runs_remote
for run in runs_to_upload:
print(f'uploading {area} {run}')
upload_rclone(area, run)
make_indexes()
@cli.command()
def index():
"""
Run index on Cloudflare buckets
"""
make_indexes()
@cli.command()
def set_latest_versions():
"""
Sets the latest version as the deployed one
"""
for area in AREAS:
print(f'setting latest version for {area}')
p = subprocess.run(
[
'rclone',
'cat',
f'remote:ofm-{area}/dirs.txt',
],
env=dict(RCLONE_CONFIG='/data/ofm/config/rclone.conf'),
check=True,
capture_output=True,
text=True,
)
versions = [l.strip() for l in p.stdout.strip().splitlines()]
versions.sort(reverse=True)
latest_version = versions[0]
print(latest_version)
subprocess.run(
[
'rclone',
'rcat',
f'remote:ofm-assets/versions/deployed_{area}.txt',
],
env=dict(RCLONE_CONFIG='/data/ofm/config/rclone.conf'),
check=True,
input=latest_version.encode(),
)
if __name__ == '__main__':
cli()

View File

@@ -5,9 +5,14 @@ requirements = [
'click', 'click',
'fabric', 'fabric',
'nginxfmt', 'nginxfmt',
'python-dotenv', # 'python-dotenv',
'ruff', 'ruff',
'marko', 'marko',
'requests',
'jsonschema',
'json5',
'pycurl',
'certifi',
] ]

View File

@@ -1,21 +0,0 @@
from pathlib import Path
from dotenv import dotenv_values
ASSETS_DIR = Path(__file__).parent / 'assets'
CONFIG_DIR = Path(__file__).parent.parent / 'config'
SCRIPTS_DIR = Path(__file__).parent.parent / 'scripts'
OFM_DIR = '/data/ofm'
REMOTE_CONFIG = '/data/ofm/config'
VENV_BIN = '/data/ofm/venv/bin'
TILE_GEN_SRC = '/data/ofm/tile_gen/src'
TILE_GEN_BIN = '/data/ofm/tile_gen/bin'
HTTP_HOST_BIN = '/data/ofm/http_host/bin'
DOTENV_VALUES = dotenv_values(f'{CONFIG_DIR}/.env')
def dotenv_val(key):
return DOTENV_VALUES.get(key, '').strip()

View File

@@ -0,0 +1,10 @@
[Unit]
Before=docker.service
[Service]
Type=oneshot
ExecStart=/bin/bash -c 'echo never > /sys/kernel/mm/transparent_hugepage/enabled'
ExecStart=/bin/bash -c 'echo never > /sys/kernel/mm/transparent_hugepage/defrag'
[Install]
RequiredBy=docker.service

View File

@@ -1,29 +1,31 @@
# https://www.cloudflare.com/ips/ # https://www.cloudflare.com/ips/
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22; set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22; set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22; set_real_ip_from 103.31.4.0/22;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 131.0.72.0/22;
set_real_ip_from 141.101.64.0/18; set_real_ip_from 141.101.64.0/18;
set_real_ip_from 162.158.0.0/15; set_real_ip_from 108.162.192.0/18;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 190.93.240.0/20; set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22; set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17; set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;
set_real_ip_from 2400:cb00::/32; set_real_ip_from 2400:cb00::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2606:4700::/32; set_real_ip_from 2606:4700::/32;
set_real_ip_from 2803:f800::/32; set_real_ip_from 2803:f800::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2a06:98c0::/29; set_real_ip_from 2a06:98c0::/29;
set_real_ip_from 2c0f:f248::/32; set_real_ip_from 2c0f:f248::/32;
# use any of the following two # use any of the following two
real_ip_header CF-Connecting-IP; real_ip_header CF-Connecting-IP;
#real_ip_header X-Forwarded-For; #real_ip_header X-Forwarded-For;

Some files were not shown because too many files have changed in this diff Show More