Files
Juan DenisandClaude Fable 5 f34969eaac fix(models): close all 26 dangling-on-delete FKs + orphan sweep migration
Every NOT NULL FK from the KNOWN_DANGLING_ON_DELETE audit now declares
its parent-side relationship, so the delete-cascade policy door covers
it: AI conversations/pending actions, API keys, dashboards, event
subscriptions, login links, sent invitations, resource grants (users);
env vars + history, previews + settings, WAF policy (applications);
surveys, doctor results, proxy stack, tunnels (servers); manifests
(projects); DDNS hosts (zones); exposed services (tunnels); drills
(backup policies); rollouts (agent versions); site plugin installs
(WordPress sites); queue messages (groups). The ratchet set is now
EMPTY and stays that way.

One new deliberate exception: Workspace.projects — projects carry
environments and app groupings, so delete_workspace now refuses (409)
while any remain instead of silently destroying them (previously it
crashed with IntegrityError).

Migration 095 sweeps rows already orphaned on existing installs by
pre-fix hard-deletes — including an application's leftover env-var
secrets, which a reused SQLite row id could have attached to a future
app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 20:25:29 -04:00

59 lines
2.3 KiB
Python

from datetime import datetime
from app import db
from app.models.mixins import uuid_pk, TimestampMixin
class ExposedService(TimestampMixin, db.Model):
"""A service on a private (NAT'd) host published to the public internet
over a WireGuard tunnel (roadmap Phase 2). The edge runs an nginx vhost
for ``hostname`` that proxies to the private peer's WG IP; the private
agent forwards that to the real local service on ``port``.
See docs/REMOTE_ACCESS_ROADMAP.md.
"""
__tablename__ = 'exposed_services'
id = uuid_pk()
tunnel_id = db.Column(db.String(36), db.ForeignKey('tunnels.id'), nullable=False, index=True)
hostname = db.Column(db.String(255), nullable=False) # e.g. jellyfin.example.com
port = db.Column(db.Integer, nullable=False) # service port on the private host (e.g. 8096)
nginx_site_name = db.Column(db.String(120)) # the edge vhost we created
require_auth = db.Column(db.Boolean, default=False)
auth_username = db.Column(db.String(100))
ssl_enabled = db.Column(db.Boolean, default=False)
status = db.Column(db.String(20), default='pending', index=True) # pending / published / error
last_error = db.Column(db.Text)
# Parent-side backref so the delete-cascade policy covers the NOT NULL FK.
tunnel = db.relationship('Tunnel',
backref=db.backref('exposed_services', lazy='dynamic'))
def url(self):
if not self.hostname:
return None
return ('https://' if self.ssl_enabled else 'http://') + self.hostname
def to_dict(self):
return {
'id': self.id,
'tunnel_id': self.tunnel_id,
'hostname': self.hostname,
'port': self.port,
'nginx_site_name': self.nginx_site_name,
'require_auth': self.require_auth,
'auth_username': self.auth_username,
'ssl_enabled': self.ssl_enabled,
'status': self.status,
'last_error': self.last_error,
'url': self.url(),
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return '<ExposedService %s -> tunnel %s :%s>' % (self.hostname, self.tunnel_id, self.port)