Skip to content

Commit 12c37b1

Browse files
Add Tailscale integration and fix CF Access policy OR logic bug
Tailscale: OAuth-based manager, API routes, settings UI, reconciler hooks, config loader support, and Docker/env wiring.
1 parent c8a2ebb commit 12c37b1

13 files changed

Lines changed: 1213 additions & 12 deletions

dockflare/Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ RUN echo "DEBUG: Contents of /usr/src/app/app/static AFTER npm run build:css:" &
3333
RUN echo "DEBUG: Specifically checking for /usr/src/app/app/static/css/output.css AFTER npm run build:css:"
3434
RUN ls -l /usr/src/app/app/static/css/output.css || echo "/usr/src/app/app/static/css/output.css NOT FOUND after build"
3535

36+
FROM tailscale/tailscale:latest AS tailscale-cli
37+
3638
FROM python:3.13-slim AS runtime
3739
ARG DOCKFLARE_UID=65532
3840
ARG DOCKFLARE_GID=65532
@@ -42,6 +44,7 @@ WORKDIR /app
4244
RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates && rm -rf /var/lib/apt/lists/*
4345
COPY requirements.txt .
4446
RUN pip install --no-cache-dir -r requirements.txt
47+
COPY --from=tailscale-cli /usr/local/bin/tailscale /usr/local/bin/tailscale
4548
RUN mkdir -p /app/static/css
4649
COPY --from=frontend-builder /usr/src/app/app/static/css/output.css /app/static/css/output.css
4750
COPY ./app /app

dockflare/app/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838

3939
tunnel_state = { "name": config.TUNNEL_NAME, "id": None, "token": None, "status_message": "Initializing...", "error": None }
4040
cloudflared_agent_state = { "container_status": "unknown", "last_action_status": None }
41+
tailscale_state = { "node": None, "version_mismatch": False, "last_error": None, "service_count": 0 }
4142

4243
log_queue = queue.Queue(maxsize=config.MAX_LOG_QUEUE_SIZE)
4344
state_update_queue = queue.Queue(maxsize=50)
@@ -263,6 +264,11 @@ def inject_version():
263264
app_instance.register_blueprint(email_bp)
264265
logging.info("Email blueprint registered.")
265266

267+
from .web.tailscale_api_routes import tailscale_api_bp
268+
csrf.exempt(tailscale_api_bp)
269+
app_instance.register_blueprint(tailscale_api_bp)
270+
logging.info("Tailscale API blueprint registered.")
271+
266272
return app_instance
267273

268274
app = create_app()

dockflare/app/config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,17 @@ def _get_int_env(name, default, minimum=None):
154154
EMAIL_JWT_AUDIENCE = 'dockflare-mail'
155155
EMAIL_JWT_EXPIRY_SECONDS = 3600
156156

157+
TAILSCALE_SOCKET = '/var/run/tailscale/tailscaled.sock'
158+
TAILSCALE_CLI_TIMEOUT = 30
159+
TAILSCALE_RECONCILE_INTERVAL = 60
160+
TAILSCALE_CLEANUP_ON_SHUTDOWN = False
161+
TAILSCALE_LABEL_NS = f"{LABEL_PREFIX}ts."
162+
163+
TAILSCALE_ENABLED = False
164+
TAILSCALE_OAUTH_CLIENT_ID = None
165+
TAILSCALE_OAUTH_CLIENT_SECRET = None
166+
TAILSCALE_TAILNET = '-'
167+
TAILSCALE_DEFAULT_TAGS = ['tag:container']
168+
TAILSCALE_IGNORE_SERVICES = []
169+
TAILSCALE_SERVICE_PREFIX = ''
170+

dockflare/app/core/docker_handler.py

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
from app import config, docker_client, cloudflared_agent_state, tunnel_state, publish_state_event
2828

29-
from app.core.state_manager import managed_rules, state_lock, save_state
29+
from app.core.state_manager import managed_rules, state_lock, save_state, tailscale_rules, upsert_tailscale_rule
3030
from app.core.tunnel_manager import update_cloudflare_config
3131
from app.core.cloudflare_api import create_cloudflare_dns_record, get_zone_id_from_name, list_account_zones
3232
from app.core.access_manager import handle_access_policy_from_labels
@@ -91,6 +91,126 @@ def is_valid_service(service_str):
9191
logging.warning(f"Invalid service string format: '{service_str}' does not match supported patterns (HTTP, HTTPS, TCP, SSH, RDP, HTTP_STATUS, Bastion).")
9292
return False
9393

94+
def _parse_tailscale_configs(labels, container_id, container_name):
95+
ts_ns = config.TAILSCALE_LABEL_NS
96+
if labels.get(f"{ts_ns}enable", "false").lower() not in ("true", "1", "t", "yes"):
97+
return []
98+
99+
name = labels.get(f"{ts_ns}name") or container_name.lstrip("/").lower().replace("_", "-")
100+
101+
port_str = labels.get(f"{ts_ns}port", "")
102+
if not port_str:
103+
logging.warning("TS_HANDLER: Container %s has ts.enable but no ts.port, skipping.", container_name)
104+
return []
105+
106+
try:
107+
port = int(port_str)
108+
except ValueError:
109+
logging.warning("TS_HANDLER: Invalid ts.port '%s' for %s, skipping.", port_str, container_name)
110+
return []
111+
112+
protocol = labels.get(f"{ts_ns}protocol", "https")
113+
if protocol not in ("http", "https", "tcp"):
114+
logging.warning("TS_HANDLER: Invalid ts.protocol '%s' for %s, using https.", protocol, container_name)
115+
protocol = "https"
116+
117+
funnel = labels.get(f"{ts_ns}funnel", "false").lower() in ("true", "1", "t", "yes")
118+
119+
try:
120+
funnel_port = int(labels.get(f"{ts_ns}funnel_port", "443"))
121+
except ValueError:
122+
funnel_port = 443
123+
124+
destination = labels.get(f"{ts_ns}destination", f"localhost:{port}")
125+
126+
return [{
127+
"name": name,
128+
"port": port,
129+
"protocol": protocol,
130+
"funnel": funnel,
131+
"funnel_port": funnel_port,
132+
"destination": destination,
133+
"container_id": container_id,
134+
"container_name": container_name,
135+
}]
136+
137+
138+
def _process_tailscale_start(labels, container_id, container_name):
139+
if not config.TAILSCALE_ENABLED:
140+
return
141+
142+
from app.core import tailscale_manager
143+
144+
ts_configs = _parse_tailscale_configs(labels, container_id, container_name)
145+
if not ts_configs:
146+
return
147+
148+
for ts_cfg in ts_configs:
149+
name = ts_cfg["name"]
150+
rule_key = tailscale_manager._build_svc_name(name)
151+
try:
152+
tailscale_manager.add_service(
153+
name=name,
154+
port=ts_cfg["port"],
155+
protocol=ts_cfg["protocol"],
156+
destination=ts_cfg["destination"],
157+
)
158+
rule_data = {
159+
"name": name,
160+
"port": ts_cfg["port"],
161+
"protocol": ts_cfg["protocol"],
162+
"destination": ts_cfg["destination"],
163+
"funnel": ts_cfg["funnel"],
164+
"funnel_port": ts_cfg["funnel_port"],
165+
"container_id": container_id,
166+
"container_name": container_name,
167+
"status": "active",
168+
"delete_at": None,
169+
"source": "docker",
170+
"funnel_active": False,
171+
}
172+
if ts_cfg["funnel"]:
173+
try:
174+
tailscale_manager.enable_funnel(
175+
funnel_port=ts_cfg["funnel_port"],
176+
protocol=ts_cfg["protocol"],
177+
destination=ts_cfg["destination"],
178+
)
179+
rule_data["funnel_active"] = True
180+
except tailscale_manager.TailscaleFunnelACLError as e:
181+
logging.error("TS_HANDLER: Funnel ACL error for %s: %s", name, e)
182+
upsert_tailscale_rule(rule_key, rule_data)
183+
logging.info("TS_HANDLER: Registered service %s for container %s", rule_key, container_name)
184+
except tailscale_manager.TailscaleSocketError as e:
185+
logging.error("TS_HANDLER: Socket error for %s: %s", name, e)
186+
except tailscale_manager.TailscaleUntaggedNodeError as e:
187+
logging.error("TS_HANDLER: Untagged node error for %s: %s", name, e)
188+
except tailscale_manager.TailscaleCLIError as e:
189+
logging.error("TS_HANDLER: CLI error for %s: %s", name, e)
190+
except Exception as e:
191+
logging.error("TS_HANDLER: Unexpected error for %s: %s", name, e, exc_info=True)
192+
193+
194+
def _process_tailscale_stop(container_id):
195+
if not config.TAILSCALE_ENABLED:
196+
return
197+
198+
from datetime import datetime, timedelta, timezone
199+
200+
state_changed = False
201+
delete_at = datetime.now(timezone.utc) + timedelta(seconds=config.GRACE_PERIOD_SECONDS)
202+
203+
with state_lock:
204+
for rule_key, rule_data in list(tailscale_rules.items()):
205+
if rule_data.get("container_id") == container_id and rule_data.get("status") == "active":
206+
rule_data["status"] = "pending_deletion"
207+
rule_data["delete_at"] = delete_at
208+
state_changed = True
209+
logging.info("TS_HANDLER: Scheduled deletion of %s at %s", rule_key, delete_at.isoformat())
210+
if state_changed:
211+
save_state()
212+
213+
94214
def process_container_start(container_obj):
95215
from app import app
96216
with app.app_context():
@@ -272,6 +392,7 @@ def process_container_start(container_obj):
272392
index += 1
273393
if not hostnames_to_process:
274394
logging.warning(f"DOCKER_HANDLER: No valid hostname configs for {container_name_val} ({container_id_val[:12]}).")
395+
_process_tailscale_start(labels, container_id_val, container_name_val)
275396
return
276397

277398
logging.info(f"DOCKER_HANDLER: Found {len(hostnames_to_process)} hostname configurations for container {container_name_val}")
@@ -490,6 +611,7 @@ def process_container_start(container_obj):
490611
else:
491612
logging.error(f"DOCKER_HANDLER: Failed to update Cloudflare tunnel config for {container_name_val}. DNS records not managed.")
492613

614+
_process_tailscale_start(labels, container_id_val, container_name_val)
493615

494616
except NotFound:
495617
logging.warning(f"DOCKER_HANDLER: Container {container_name_val} ({container_id_val[:12] if container_id_val else 'UnknownID'}) not found.")
@@ -536,6 +658,8 @@ def schedule_container_stop(container_id_val):
536658
save_state()
537659
publish_state_event('snapshot_refresh')
538660

661+
_process_tailscale_stop(container_id_val)
662+
539663
def docker_event_listener(stop_event_param, label_prefix):
540664
if not docker_client:
541665
logging.error(f"Docker client unavailable, event listener for {label_prefix} cannot start.")

dockflare/app/core/reconciler.py

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from app import config, docker_client, tunnel_state, publish_state_event
2424
from flask import current_app
2525

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
2727
from app.core.cloudflare_api import (
2828
get_zone_id_from_name,
2929
create_cloudflare_dns_record,
@@ -687,3 +687,140 @@ def cleanup_expired_rules(stop_event_param):
687687
stop_event_param.wait(wait_duration)
688688

689689
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

Comments
 (0)