|
23 | 23 | from app import config, docker_client, tunnel_state, publish_state_event |
24 | 24 | from flask import current_app |
25 | 25 |
|
26 | | -from app.core.state_manager import managed_rules, state_lock, save_state, get_agent, update_agent |
| 26 | +from app.core.state_manager import managed_rules, state_lock, save_state, get_agent, update_agent, tailscale_rules, upsert_tailscale_rule, remove_tailscale_rule, list_tailscale_rules |
27 | 27 | from app.core.cloudflare_api import ( |
28 | 28 | get_zone_id_from_name, |
29 | 29 | create_cloudflare_dns_record, |
@@ -687,3 +687,140 @@ def cleanup_expired_rules(stop_event_param): |
687 | 687 | stop_event_param.wait(wait_duration) |
688 | 688 |
|
689 | 689 | logging.info("Cleanup task for expired rules stopped.") |
| 690 | + |
| 691 | + |
| 692 | +def _run_tailscale_reconciliation(): |
| 693 | + from app import app as main_app |
| 694 | + from app.core import tailscale_manager |
| 695 | + from app.core.docker_handler import _parse_tailscale_configs |
| 696 | + |
| 697 | + with main_app.app_context(): |
| 698 | + if not config.TAILSCALE_ENABLED: |
| 699 | + return |
| 700 | + |
| 701 | + desired_rules = {} |
| 702 | + try: |
| 703 | + containers = docker_client.containers.list(all=False) |
| 704 | + for c in containers: |
| 705 | + try: |
| 706 | + c.reload() |
| 707 | + ts_configs = _parse_tailscale_configs(c.labels, c.id, c.name) |
| 708 | + for ts_cfg in ts_configs: |
| 709 | + rule_key = tailscale_manager._build_svc_name(ts_cfg["name"]) |
| 710 | + desired_rules[rule_key] = ts_cfg |
| 711 | + except Exception as e_cont: |
| 712 | + logging.error("TS_RECONCILE: Error parsing container %s: %s", c.id[:12] if c.id else "?", e_cont) |
| 713 | + except Exception as e_list: |
| 714 | + logging.error("TS_RECONCILE: Error listing containers: %s", e_list) |
| 715 | + return |
| 716 | + |
| 717 | + current_rules = list_tailscale_rules() |
| 718 | + |
| 719 | + for rule_key, ts_cfg in desired_rules.items(): |
| 720 | + existing = current_rules.get(rule_key) |
| 721 | + if existing and existing.get("status") == "active": |
| 722 | + continue |
| 723 | + |
| 724 | + if existing and existing.get("status") == "pending_deletion": |
| 725 | + existing["status"] = "active" |
| 726 | + existing["delete_at"] = None |
| 727 | + existing["container_id"] = ts_cfg["container_id"] |
| 728 | + upsert_tailscale_rule(rule_key, existing) |
| 729 | + logging.info("TS_RECONCILE: Restored %s (was pending_deletion)", rule_key) |
| 730 | + continue |
| 731 | + |
| 732 | + try: |
| 733 | + tailscale_manager.add_service( |
| 734 | + name=ts_cfg["name"], |
| 735 | + port=ts_cfg["port"], |
| 736 | + protocol=ts_cfg["protocol"], |
| 737 | + destination=ts_cfg["destination"], |
| 738 | + ) |
| 739 | + rule_data = { |
| 740 | + "name": ts_cfg["name"], |
| 741 | + "port": ts_cfg["port"], |
| 742 | + "protocol": ts_cfg["protocol"], |
| 743 | + "destination": ts_cfg["destination"], |
| 744 | + "funnel": ts_cfg["funnel"], |
| 745 | + "funnel_port": ts_cfg["funnel_port"], |
| 746 | + "container_id": ts_cfg["container_id"], |
| 747 | + "container_name": ts_cfg["container_name"], |
| 748 | + "status": "active", |
| 749 | + "delete_at": None, |
| 750 | + "source": "docker", |
| 751 | + "funnel_active": False, |
| 752 | + } |
| 753 | + if ts_cfg["funnel"]: |
| 754 | + try: |
| 755 | + tailscale_manager.enable_funnel( |
| 756 | + funnel_port=ts_cfg["funnel_port"], |
| 757 | + protocol=ts_cfg["protocol"], |
| 758 | + destination=ts_cfg["destination"], |
| 759 | + ) |
| 760 | + rule_data["funnel_active"] = True |
| 761 | + except tailscale_manager.TailscaleFunnelACLError as e: |
| 762 | + logging.error("TS_RECONCILE: Funnel ACL error for %s: %s", rule_key, e) |
| 763 | + upsert_tailscale_rule(rule_key, rule_data) |
| 764 | + logging.info("TS_RECONCILE: Added service %s", rule_key) |
| 765 | + except tailscale_manager.TailscaleSocketError as e: |
| 766 | + logging.error("TS_RECONCILE: Socket error adding %s: %s", rule_key, e) |
| 767 | + except tailscale_manager.TailscaleCLIError as e: |
| 768 | + logging.error("TS_RECONCILE: CLI error adding %s: %s", rule_key, e) |
| 769 | + |
| 770 | + now_utc = datetime.now(timezone.utc) |
| 771 | + for rule_key, rule_data in current_rules.items(): |
| 772 | + if rule_data.get("status") != "active": |
| 773 | + continue |
| 774 | + if rule_data.get("source", "docker") != "docker": |
| 775 | + continue |
| 776 | + if rule_key not in desired_rules: |
| 777 | + delete_at = now_utc + timedelta(seconds=config.GRACE_PERIOD_SECONDS) |
| 778 | + rule_data = dict(rule_data) |
| 779 | + rule_data["status"] = "pending_deletion" |
| 780 | + rule_data["delete_at"] = delete_at |
| 781 | + upsert_tailscale_rule(rule_key, rule_data) |
| 782 | + logging.info("TS_RECONCILE: Marked %s for deletion at %s", rule_key, delete_at.isoformat()) |
| 783 | + |
| 784 | + |
| 785 | +def tailscale_background_loop(stop_event_param): |
| 786 | + from app import app as main_app |
| 787 | + from app.core import tailscale_manager |
| 788 | + |
| 789 | + logging.info("Tailscale background loop starting.") |
| 790 | + |
| 791 | + while not stop_event_param.is_set(): |
| 792 | + next_run = time.time() + config.TAILSCALE_RECONCILE_INTERVAL |
| 793 | + |
| 794 | + with main_app.app_context(): |
| 795 | + try: |
| 796 | + if config.TAILSCALE_ENABLED: |
| 797 | + _run_tailscale_reconciliation() |
| 798 | + |
| 799 | + now_utc = datetime.now(timezone.utc) |
| 800 | + for rule_key, rule_data in list(list_tailscale_rules().items()): |
| 801 | + if rule_data.get("status") != "pending_deletion": |
| 802 | + continue |
| 803 | + delete_at = rule_data.get("delete_at") |
| 804 | + if isinstance(delete_at, datetime): |
| 805 | + delete_at_utc = delete_at.astimezone(timezone.utc) if delete_at.tzinfo else delete_at.replace(tzinfo=timezone.utc) |
| 806 | + if delete_at_utc > now_utc: |
| 807 | + continue |
| 808 | + try: |
| 809 | + tailscale_manager.remove_service(rule_data.get("name", rule_key)) |
| 810 | + if rule_data.get("funnel_active"): |
| 811 | + tailscale_manager.disable_funnel( |
| 812 | + rule_data.get("funnel_port", 443), |
| 813 | + rule_data.get("protocol", "https"), |
| 814 | + ) |
| 815 | + remove_tailscale_rule(rule_key) |
| 816 | + logging.info("TS_CLEANUP: Removed expired service %s", rule_key) |
| 817 | + except Exception as e: |
| 818 | + logging.error("TS_CLEANUP: Error removing %s: %s", rule_key, e) |
| 819 | + except Exception as e_outer: |
| 820 | + logging.error("TS_BACKGROUND: Loop error: %s", e_outer, exc_info=True) |
| 821 | + |
| 822 | + wait_duration = max(0, next_run - time.time()) |
| 823 | + if not stop_event_param.is_set(): |
| 824 | + stop_event_param.wait(wait_duration) |
| 825 | + |
| 826 | + logging.info("Tailscale background loop stopped.") |
0 commit comments