Compare commits
1 commit
01f7d061bc
...
0daf26f6d9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0daf26f6d9 |
6 changed files with 101 additions and 306 deletions
41
bin/disinto
41
bin/disinto
|
|
@ -1488,30 +1488,17 @@ p.write_text(text)
|
||||||
touch "${FACTORY_ROOT}/.env"
|
touch "${FACTORY_ROOT}/.env"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Configure Forgejo and Woodpecker URLs when EDGE_TUNNEL_FQDN is set.
|
# Configure Forgejo and Woodpecker subpath URLs when EDGE_TUNNEL_FQDN is set
|
||||||
# In subdomain mode, uses per-service FQDNs at root path instead of subpath URLs.
|
|
||||||
if [ -n "${EDGE_TUNNEL_FQDN:-}" ]; then
|
if [ -n "${EDGE_TUNNEL_FQDN:-}" ]; then
|
||||||
local routing_mode="${EDGE_ROUTING_MODE:-subpath}"
|
# Forgejo ROOT_URL with /forge/ subpath (note trailing slash - Forgejo needs it)
|
||||||
if [ "$routing_mode" = "subdomain" ]; then
|
|
||||||
# Subdomain mode: Forgejo at forge.<project>.disinto.ai (root path)
|
|
||||||
if ! grep -q '^FORGEJO_ROOT_URL=' "${FACTORY_ROOT}/.env" 2>/dev/null; then
|
|
||||||
echo "FORGEJO_ROOT_URL=https://${EDGE_TUNNEL_FQDN_FORGE:-forge.${EDGE_TUNNEL_FQDN}}/" >> "${FACTORY_ROOT}/.env"
|
|
||||||
fi
|
|
||||||
# Subdomain mode: Woodpecker at ci.<project>.disinto.ai (root path)
|
|
||||||
if ! grep -q '^WOODPECKER_HOST=' "${FACTORY_ROOT}/.env" 2>/dev/null; then
|
|
||||||
echo "WOODPECKER_HOST=https://${EDGE_TUNNEL_FQDN_CI:-ci.${EDGE_TUNNEL_FQDN}}" >> "${FACTORY_ROOT}/.env"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# Subpath mode: Forgejo ROOT_URL with /forge/ subpath (trailing slash required)
|
|
||||||
if ! grep -q '^FORGEJO_ROOT_URL=' "${FACTORY_ROOT}/.env" 2>/dev/null; then
|
if ! grep -q '^FORGEJO_ROOT_URL=' "${FACTORY_ROOT}/.env" 2>/dev/null; then
|
||||||
echo "FORGEJO_ROOT_URL=https://${EDGE_TUNNEL_FQDN}/forge/" >> "${FACTORY_ROOT}/.env"
|
echo "FORGEJO_ROOT_URL=https://${EDGE_TUNNEL_FQDN}/forge/" >> "${FACTORY_ROOT}/.env"
|
||||||
fi
|
fi
|
||||||
# Subpath mode: Woodpecker WOODPECKER_HOST with /ci subpath (no trailing slash for v3)
|
# Woodpecker WOODPECKER_HOST with /ci subpath (no trailing slash for v3)
|
||||||
if ! grep -q '^WOODPECKER_HOST=' "${FACTORY_ROOT}/.env" 2>/dev/null; then
|
if ! grep -q '^WOODPECKER_HOST=' "${FACTORY_ROOT}/.env" 2>/dev/null; then
|
||||||
echo "WOODPECKER_HOST=https://${EDGE_TUNNEL_FQDN}/ci" >> "${FACTORY_ROOT}/.env"
|
echo "WOODPECKER_HOST=https://${EDGE_TUNNEL_FQDN}/ci" >> "${FACTORY_ROOT}/.env"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
|
||||||
|
|
||||||
# Prompt for FORGE_ADMIN_PASS before setup_forge
|
# Prompt for FORGE_ADMIN_PASS before setup_forge
|
||||||
# This ensures the password is set before Forgejo user creation
|
# This ensures the password is set before Forgejo user creation
|
||||||
|
|
@ -1616,15 +1603,9 @@ p.write_text(text)
|
||||||
create_woodpecker_oauth "$forge_url" "$forge_repo"
|
create_woodpecker_oauth "$forge_url" "$forge_repo"
|
||||||
|
|
||||||
# Create OAuth2 app on Forgejo for disinto-chat (#708)
|
# Create OAuth2 app on Forgejo for disinto-chat (#708)
|
||||||
# In subdomain mode, callback is at chat.<project> root instead of /chat/ subpath.
|
|
||||||
local chat_redirect_uri
|
local chat_redirect_uri
|
||||||
if [ -n "${EDGE_TUNNEL_FQDN:-}" ]; then
|
if [ -n "${EDGE_TUNNEL_FQDN:-}" ]; then
|
||||||
local chat_routing_mode="${EDGE_ROUTING_MODE:-subpath}"
|
|
||||||
if [ "$chat_routing_mode" = "subdomain" ]; then
|
|
||||||
chat_redirect_uri="https://${EDGE_TUNNEL_FQDN_CHAT:-chat.${EDGE_TUNNEL_FQDN}}/oauth/callback"
|
|
||||||
else
|
|
||||||
chat_redirect_uri="https://${EDGE_TUNNEL_FQDN}/chat/oauth/callback"
|
chat_redirect_uri="https://${EDGE_TUNNEL_FQDN}/chat/oauth/callback"
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
chat_redirect_uri="http://localhost/chat/oauth/callback"
|
chat_redirect_uri="http://localhost/chat/oauth/callback"
|
||||||
fi
|
fi
|
||||||
|
|
@ -2824,29 +2805,15 @@ disinto_edge() {
|
||||||
# Write to .env (replace existing entries to avoid duplicates)
|
# Write to .env (replace existing entries to avoid duplicates)
|
||||||
local tmp_env
|
local tmp_env
|
||||||
tmp_env=$(mktemp)
|
tmp_env=$(mktemp)
|
||||||
grep -Ev "^EDGE_TUNNEL_(HOST|PORT|FQDN|FQDN_FORGE|FQDN_CI|FQDN_CHAT)=" "$env_file" > "$tmp_env" 2>/dev/null || true
|
grep -Ev "^EDGE_TUNNEL_(HOST|PORT|FQDN)=" "$env_file" > "$tmp_env" 2>/dev/null || true
|
||||||
mv "$tmp_env" "$env_file"
|
mv "$tmp_env" "$env_file"
|
||||||
echo "EDGE_TUNNEL_HOST=${edge_host}" >> "$env_file"
|
echo "EDGE_TUNNEL_HOST=${edge_host}" >> "$env_file"
|
||||||
echo "EDGE_TUNNEL_PORT=${port}" >> "$env_file"
|
echo "EDGE_TUNNEL_PORT=${port}" >> "$env_file"
|
||||||
echo "EDGE_TUNNEL_FQDN=${fqdn}" >> "$env_file"
|
echo "EDGE_TUNNEL_FQDN=${fqdn}" >> "$env_file"
|
||||||
|
|
||||||
# Subdomain mode: write per-service FQDNs (#1028)
|
|
||||||
local reg_routing_mode="${EDGE_ROUTING_MODE:-subpath}"
|
|
||||||
if [ "$reg_routing_mode" = "subdomain" ]; then
|
|
||||||
echo "EDGE_TUNNEL_FQDN_FORGE=forge.${fqdn}" >> "$env_file"
|
|
||||||
echo "EDGE_TUNNEL_FQDN_CI=ci.${fqdn}" >> "$env_file"
|
|
||||||
echo "EDGE_TUNNEL_FQDN_CHAT=chat.${fqdn}" >> "$env_file"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Registered: ${project}"
|
echo "Registered: ${project}"
|
||||||
echo " Port: ${port}"
|
echo " Port: ${port}"
|
||||||
echo " FQDN: ${fqdn}"
|
echo " FQDN: ${fqdn}"
|
||||||
if [ "$reg_routing_mode" = "subdomain" ]; then
|
|
||||||
echo " Mode: subdomain"
|
|
||||||
echo " Forge: forge.${fqdn}"
|
|
||||||
echo " CI: ci.${fqdn}"
|
|
||||||
echo " Chat: chat.${fqdn}"
|
|
||||||
fi
|
|
||||||
echo " Saved to: ${env_file}"
|
echo " Saved to: ${env_file}"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,6 @@ FORGE_URL = os.environ.get("FORGE_URL", "http://localhost:3000")
|
||||||
CHAT_OAUTH_CLIENT_ID = os.environ.get("CHAT_OAUTH_CLIENT_ID", "")
|
CHAT_OAUTH_CLIENT_ID = os.environ.get("CHAT_OAUTH_CLIENT_ID", "")
|
||||||
CHAT_OAUTH_CLIENT_SECRET = os.environ.get("CHAT_OAUTH_CLIENT_SECRET", "")
|
CHAT_OAUTH_CLIENT_SECRET = os.environ.get("CHAT_OAUTH_CLIENT_SECRET", "")
|
||||||
EDGE_TUNNEL_FQDN = os.environ.get("EDGE_TUNNEL_FQDN", "")
|
EDGE_TUNNEL_FQDN = os.environ.get("EDGE_TUNNEL_FQDN", "")
|
||||||
EDGE_TUNNEL_FQDN_CHAT = os.environ.get("EDGE_TUNNEL_FQDN_CHAT", "")
|
|
||||||
EDGE_ROUTING_MODE = os.environ.get("EDGE_ROUTING_MODE", "subpath")
|
|
||||||
|
|
||||||
# Shared secret for Caddy forward_auth verify endpoint (#709).
|
# Shared secret for Caddy forward_auth verify endpoint (#709).
|
||||||
# When set, only requests carrying this value in X-Forward-Auth-Secret are
|
# When set, only requests carrying this value in X-Forward-Auth-Secret are
|
||||||
|
|
@ -126,8 +124,6 @@ OPCODE_PONG = 0xA
|
||||||
|
|
||||||
def _build_callback_uri():
|
def _build_callback_uri():
|
||||||
"""Build the OAuth callback URI based on tunnel configuration."""
|
"""Build the OAuth callback URI based on tunnel configuration."""
|
||||||
if EDGE_ROUTING_MODE == "subdomain" and EDGE_TUNNEL_FQDN_CHAT:
|
|
||||||
return f"https://{EDGE_TUNNEL_FQDN_CHAT}/oauth/callback"
|
|
||||||
if EDGE_TUNNEL_FQDN:
|
if EDGE_TUNNEL_FQDN:
|
||||||
return f"https://{EDGE_TUNNEL_FQDN}/chat/oauth/callback"
|
return f"https://{EDGE_TUNNEL_FQDN}/chat/oauth/callback"
|
||||||
return "http://localhost/chat/oauth/callback"
|
return "http://localhost/chat/oauth/callback"
|
||||||
|
|
@ -335,14 +331,47 @@ class _WebSocketHandler:
|
||||||
self.message_queue = message_queue
|
self.message_queue = message_queue
|
||||||
self.closed = False
|
self.closed = False
|
||||||
|
|
||||||
async def accept_connection(self, sec_websocket_key, sec_websocket_protocol=None):
|
async def accept_connection(self):
|
||||||
"""Accept the WebSocket handshake.
|
"""Accept the WebSocket handshake."""
|
||||||
|
# Read the HTTP request
|
||||||
|
request_line = await self._read_line()
|
||||||
|
if not request_line.startswith("GET "):
|
||||||
|
self._close_connection()
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Parse the request
|
||||||
|
headers = {}
|
||||||
|
while True:
|
||||||
|
line = await self._read_line()
|
||||||
|
if line == "":
|
||||||
|
break
|
||||||
|
if ":" in line:
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
headers[key.strip().lower()] = value.strip()
|
||||||
|
|
||||||
|
# Validate WebSocket upgrade
|
||||||
|
if headers.get("upgrade", "").lower() != "websocket":
|
||||||
|
self._send_http_error(400, "Bad Request", "WebSocket upgrade required")
|
||||||
|
self._close_connection()
|
||||||
|
return False
|
||||||
|
|
||||||
|
if headers.get("connection", "").lower() != "upgrade":
|
||||||
|
self._send_http_error(400, "Bad Request", "Connection upgrade required")
|
||||||
|
self._close_connection()
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get Sec-WebSocket-Key
|
||||||
|
sec_key = headers.get("sec-websocket-key", "")
|
||||||
|
if not sec_key:
|
||||||
|
self._send_http_error(400, "Bad Request", "Missing Sec-WebSocket-Key")
|
||||||
|
self._close_connection()
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get Sec-WebSocket-Protocol if provided
|
||||||
|
sec_protocol = headers.get("sec-websocket-protocol", "")
|
||||||
|
|
||||||
The HTTP request has already been parsed by BaseHTTPRequestHandler,
|
|
||||||
so we use the provided key and protocol instead of re-reading from socket.
|
|
||||||
"""
|
|
||||||
# Validate subprotocol
|
# Validate subprotocol
|
||||||
if sec_websocket_protocol and sec_websocket_protocol != WEBSOCKET_SUBPROTOCOL:
|
if sec_protocol and sec_protocol != WEBSOCKET_SUBPROTOCOL:
|
||||||
self._send_http_error(
|
self._send_http_error(
|
||||||
400,
|
400,
|
||||||
"Bad Request",
|
"Bad Request",
|
||||||
|
|
@ -352,7 +381,7 @@ class _WebSocketHandler:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Generate accept key
|
# Generate accept key
|
||||||
accept_key = self._generate_accept_key(sec_websocket_key)
|
accept_key = self._generate_accept_key(sec_key)
|
||||||
|
|
||||||
# Send handshake response
|
# Send handshake response
|
||||||
response = (
|
response = (
|
||||||
|
|
@ -362,8 +391,8 @@ class _WebSocketHandler:
|
||||||
f"Sec-WebSocket-Accept: {accept_key}\r\n"
|
f"Sec-WebSocket-Accept: {accept_key}\r\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
if sec_websocket_protocol:
|
if sec_protocol:
|
||||||
response += f"Sec-WebSocket-Protocol: {sec_websocket_protocol}\r\n"
|
response += f"Sec-WebSocket-Protocol: {sec_protocol}\r\n"
|
||||||
|
|
||||||
response += "\r\n"
|
response += "\r\n"
|
||||||
self.writer.write(response.encode("utf-8"))
|
self.writer.write(response.encode("utf-8"))
|
||||||
|
|
@ -458,8 +487,10 @@ class _WebSocketHandler:
|
||||||
async def _decode_frame(self):
|
async def _decode_frame(self):
|
||||||
"""Decode a WebSocket frame. Returns (opcode, payload)."""
|
"""Decode a WebSocket frame. Returns (opcode, payload)."""
|
||||||
try:
|
try:
|
||||||
# Read first two bytes (use readexactly for guaranteed length)
|
# Read first two bytes
|
||||||
header = await self.reader.readexactly(2)
|
header = await self.reader.read(2)
|
||||||
|
if len(header) < 2:
|
||||||
|
return None, None
|
||||||
|
|
||||||
fin = (header[0] >> 7) & 1
|
fin = (header[0] >> 7) & 1
|
||||||
opcode = header[0] & 0x0F
|
opcode = header[0] & 0x0F
|
||||||
|
|
@ -468,18 +499,18 @@ class _WebSocketHandler:
|
||||||
|
|
||||||
# Extended payload length
|
# Extended payload length
|
||||||
if length == 126:
|
if length == 126:
|
||||||
ext = await self.reader.readexactly(2)
|
ext = await self.reader.read(2)
|
||||||
length = struct.unpack(">H", ext)[0]
|
length = struct.unpack(">H", ext)[0]
|
||||||
elif length == 127:
|
elif length == 127:
|
||||||
ext = await self.reader.readexactly(8)
|
ext = await self.reader.read(8)
|
||||||
length = struct.unpack(">Q", ext)[0]
|
length = struct.unpack(">Q", ext)[0]
|
||||||
|
|
||||||
# Masking key
|
# Masking key
|
||||||
if masked:
|
if masked:
|
||||||
mask_key = await self.reader.readexactly(4)
|
mask_key = await self.reader.read(4)
|
||||||
|
|
||||||
# Payload
|
# Payload
|
||||||
payload = await self.reader.readexactly(length)
|
payload = await self.reader.read(length)
|
||||||
|
|
||||||
# Unmask if needed
|
# Unmask if needed
|
||||||
if masked:
|
if masked:
|
||||||
|
|
@ -499,21 +530,14 @@ class _WebSocketHandler:
|
||||||
break
|
break
|
||||||
|
|
||||||
if opcode == OPCODE_CLOSE:
|
if opcode == OPCODE_CLOSE:
|
||||||
await self._send_close()
|
self._send_close()
|
||||||
break
|
break
|
||||||
elif opcode == OPCODE_PING:
|
elif opcode == OPCODE_PING:
|
||||||
await self._send_pong(payload)
|
self._send_pong(payload)
|
||||||
elif opcode == OPCODE_PONG:
|
elif opcode == OPCODE_PONG:
|
||||||
pass # Ignore pong
|
pass # Ignore pong
|
||||||
elif opcode in (OPCODE_TEXT, OPCODE_BINARY):
|
elif opcode in (OPCODE_TEXT, OPCODE_BINARY):
|
||||||
# Handle text messages from client (e.g., chat_request)
|
# Handle text messages from client (e.g., heartbeat ack)
|
||||||
try:
|
|
||||||
msg = payload.decode("utf-8")
|
|
||||||
data = json.loads(msg)
|
|
||||||
if data.get("type") == "chat_request":
|
|
||||||
# Invoke Claude with the message
|
|
||||||
await self._handle_chat_request(data.get("message", ""))
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Check if we should stop waiting for messages
|
# Check if we should stop waiting for messages
|
||||||
|
|
@ -524,103 +548,25 @@ class _WebSocketHandler:
|
||||||
print(f"WebSocket connection error: {e}", file=sys.stderr)
|
print(f"WebSocket connection error: {e}", file=sys.stderr)
|
||||||
finally:
|
finally:
|
||||||
self._close_connection()
|
self._close_connection()
|
||||||
# Clean up the message queue on disconnect
|
|
||||||
if self.user in _websocket_queues:
|
|
||||||
del _websocket_queues[self.user]
|
|
||||||
|
|
||||||
async def _send_close(self):
|
def _send_close(self):
|
||||||
"""Send a close frame."""
|
"""Send a close frame."""
|
||||||
try:
|
try:
|
||||||
# Close code 1000 = normal closure
|
frame = self._encode_frame(OPCODE_CLOSE, b"\x03\x00")
|
||||||
frame = self._encode_frame(OPCODE_CLOSE, struct.pack(">H", 1000))
|
|
||||||
self.writer.write(frame)
|
self.writer.write(frame)
|
||||||
await self.writer.drain()
|
self.writer.drain()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def _send_pong(self, payload):
|
def _send_pong(self, payload):
|
||||||
"""Send a pong frame."""
|
"""Send a pong frame."""
|
||||||
try:
|
try:
|
||||||
frame = self._encode_frame(OPCODE_PONG, payload)
|
frame = self._encode_frame(OPCODE_PONG, payload)
|
||||||
self.writer.write(frame)
|
self.writer.write(frame)
|
||||||
await self.writer.drain()
|
self.writer.drain()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def _handle_chat_request(self, message):
|
|
||||||
"""Handle a chat_request WebSocket frame by invoking Claude."""
|
|
||||||
if not message:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Validate Claude binary exists
|
|
||||||
if not os.path.exists(CLAUDE_BIN):
|
|
||||||
await self.send_text(json.dumps({
|
|
||||||
"type": "error",
|
|
||||||
"message": "Claude CLI not found",
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Spawn claude --print with stream-json for streaming output
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
[CLAUDE_BIN, "--print", "--output-format", "stream-json", message],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Stream output line by line
|
|
||||||
for line in iter(proc.stdout.readline, ""):
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
event = json.loads(line)
|
|
||||||
etype = event.get("type", "")
|
|
||||||
|
|
||||||
# Extract text content from content_block_delta events
|
|
||||||
if etype == "content_block_delta":
|
|
||||||
delta = event.get("delta", {})
|
|
||||||
if delta.get("type") == "text_delta":
|
|
||||||
text = delta.get("text", "")
|
|
||||||
if text:
|
|
||||||
# Send tokens to client
|
|
||||||
await self.send_text(text)
|
|
||||||
|
|
||||||
# Check for usage event to know when complete
|
|
||||||
if etype == "result":
|
|
||||||
pass # Will send complete after loop
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Wait for process to complete
|
|
||||||
proc.wait()
|
|
||||||
|
|
||||||
if proc.returncode != 0:
|
|
||||||
await self.send_text(json.dumps({
|
|
||||||
"type": "error",
|
|
||||||
"message": f"Claude CLI failed with exit code {proc.returncode}",
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
|
|
||||||
# Send complete signal
|
|
||||||
await self.send_text(json.dumps({
|
|
||||||
"type": "complete",
|
|
||||||
}))
|
|
||||||
|
|
||||||
except FileNotFoundError:
|
|
||||||
await self.send_text(json.dumps({
|
|
||||||
"type": "error",
|
|
||||||
"message": "Claude CLI not found",
|
|
||||||
}))
|
|
||||||
except Exception as e:
|
|
||||||
await self.send_text(json.dumps({
|
|
||||||
"type": "error",
|
|
||||||
"message": str(e),
|
|
||||||
}))
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Conversation History Functions (#710)
|
# Conversation History Functions (#710)
|
||||||
|
|
@ -1309,30 +1255,28 @@ class ChatHandler(BaseHTTPRequestHandler):
|
||||||
# Create message queue for this user
|
# Create message queue for this user
|
||||||
_websocket_queues[user] = asyncio.Queue()
|
_websocket_queues[user] = asyncio.Queue()
|
||||||
|
|
||||||
# Get WebSocket upgrade headers from the HTTP request
|
|
||||||
sec_websocket_key = self.headers.get("Sec-WebSocket-Key", "")
|
|
||||||
sec_websocket_protocol = self.headers.get("Sec-WebSocket-Protocol", "")
|
|
||||||
|
|
||||||
# Validate Sec-WebSocket-Key
|
|
||||||
if not sec_websocket_key:
|
|
||||||
self.send_error_page(400, "Bad Request", "Missing Sec-WebSocket-Key")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get the socket from the connection
|
# Get the socket from the connection
|
||||||
sock = self.connection
|
sock = self.connection
|
||||||
sock.setblocking(False)
|
sock.setblocking(False)
|
||||||
|
reader = asyncio.StreamReader()
|
||||||
|
protocol = asyncio.StreamReaderProtocol(reader)
|
||||||
|
|
||||||
# Create async server to handle the connection
|
# Create async server to handle the connection
|
||||||
async def handle_ws():
|
async def handle_ws():
|
||||||
try:
|
try:
|
||||||
# Wrap the socket in asyncio streams using open_connection
|
# Wrap the socket in asyncio streams
|
||||||
reader, writer = await asyncio.open_connection(sock=sock)
|
transport, _ = await asyncio.get_event_loop().create_connection(
|
||||||
|
lambda: protocol,
|
||||||
|
sock=sock,
|
||||||
|
)
|
||||||
|
ws_reader = protocol._stream_reader
|
||||||
|
ws_writer = transport
|
||||||
|
|
||||||
# Create WebSocket handler
|
# Create WebSocket handler
|
||||||
ws_handler = _WebSocketHandler(reader, writer, user, _websocket_queues[user])
|
ws_handler = _WebSocketHandler(ws_reader, ws_writer, user, _websocket_queues[user])
|
||||||
|
|
||||||
# Accept the connection (pass headers from HTTP request)
|
# Accept the connection
|
||||||
if not await ws_handler.accept_connection(sec_websocket_key, sec_websocket_protocol):
|
if not await ws_handler.accept_connection():
|
||||||
return
|
return
|
||||||
|
|
||||||
# Start a task to read from the queue and send to client
|
# Start a task to read from the queue and send to client
|
||||||
|
|
@ -1345,8 +1289,8 @@ class ChatHandler(BaseHTTPRequestHandler):
|
||||||
# Send ping to keep connection alive
|
# Send ping to keep connection alive
|
||||||
try:
|
try:
|
||||||
frame = ws_handler._encode_frame(OPCODE_PING, b"")
|
frame = ws_handler._encode_frame(OPCODE_PING, b"")
|
||||||
writer.write(frame)
|
ws_writer.write(frame)
|
||||||
await writer.drain()
|
await ws_writer.drain()
|
||||||
except Exception:
|
except Exception:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1370,8 +1314,8 @@ class ChatHandler(BaseHTTPRequestHandler):
|
||||||
print(f"WebSocket handler error: {e}", file=sys.stderr)
|
print(f"WebSocket handler error: {e}", file=sys.stderr)
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
writer.close()
|
ws_writer.close()
|
||||||
await writer.wait_closed()
|
await ws_writer.wait_closed()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,6 @@ _create_forgejo_oauth_app() {
|
||||||
|
|
||||||
# Set up Woodpecker CI to use Forgejo as its forge backend.
|
# Set up Woodpecker CI to use Forgejo as its forge backend.
|
||||||
# Creates an OAuth2 app on Forgejo for Woodpecker, activates the repo.
|
# Creates an OAuth2 app on Forgejo for Woodpecker, activates the repo.
|
||||||
# Respects EDGE_ROUTING_MODE: in subdomain mode, uses EDGE_TUNNEL_FQDN_CI for redirect URI.
|
|
||||||
# Usage: create_woodpecker_oauth <forge_url> <repo_slug>
|
# Usage: create_woodpecker_oauth <forge_url> <repo_slug>
|
||||||
_create_woodpecker_oauth_impl() {
|
_create_woodpecker_oauth_impl() {
|
||||||
local forge_url="$1"
|
local forge_url="$1"
|
||||||
|
|
@ -151,13 +150,7 @@ _create_woodpecker_oauth_impl() {
|
||||||
echo ""
|
echo ""
|
||||||
echo "── Woodpecker OAuth2 setup ────────────────────────────"
|
echo "── Woodpecker OAuth2 setup ────────────────────────────"
|
||||||
|
|
||||||
local wp_redirect_uri="http://localhost:8000/authorize"
|
_create_forgejo_oauth_app "woodpecker-ci" "http://localhost:8000/authorize" || return 0
|
||||||
local routing_mode="${EDGE_ROUTING_MODE:-subpath}"
|
|
||||||
if [ "$routing_mode" = "subdomain" ] && [ -n "${EDGE_TUNNEL_FQDN_CI:-}" ]; then
|
|
||||||
wp_redirect_uri="https://${EDGE_TUNNEL_FQDN_CI}/authorize"
|
|
||||||
fi
|
|
||||||
|
|
||||||
_create_forgejo_oauth_app "woodpecker-ci" "$wp_redirect_uri" || return 0
|
|
||||||
local client_id="${_OAUTH_CLIENT_ID}"
|
local client_id="${_OAUTH_CLIENT_ID}"
|
||||||
local client_secret="${_OAUTH_CLIENT_SECRET}"
|
local client_secret="${_OAUTH_CLIENT_SECRET}"
|
||||||
|
|
||||||
|
|
@ -165,15 +158,10 @@ _create_woodpecker_oauth_impl() {
|
||||||
# WP_FORGEJO_CLIENT/SECRET match the docker-compose.yml variable references
|
# WP_FORGEJO_CLIENT/SECRET match the docker-compose.yml variable references
|
||||||
# WOODPECKER_HOST must be host-accessible URL to match OAuth2 redirect_uri
|
# WOODPECKER_HOST must be host-accessible URL to match OAuth2 redirect_uri
|
||||||
local env_file="${FACTORY_ROOT}/.env"
|
local env_file="${FACTORY_ROOT}/.env"
|
||||||
local wp_host="http://localhost:8000"
|
|
||||||
if [ "$routing_mode" = "subdomain" ] && [ -n "${EDGE_TUNNEL_FQDN_CI:-}" ]; then
|
|
||||||
wp_host="https://${EDGE_TUNNEL_FQDN_CI}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
local wp_vars=(
|
local wp_vars=(
|
||||||
"WOODPECKER_FORGEJO=true"
|
"WOODPECKER_FORGEJO=true"
|
||||||
"WOODPECKER_FORGEJO_URL=${forge_url}"
|
"WOODPECKER_FORGEJO_URL=${forge_url}"
|
||||||
"WOODPECKER_HOST=${wp_host}"
|
"WOODPECKER_HOST=http://localhost:8000"
|
||||||
)
|
)
|
||||||
if [ -n "${client_id:-}" ]; then
|
if [ -n "${client_id:-}" ]; then
|
||||||
wp_vars+=("WP_FORGEJO_CLIENT=${client_id}")
|
wp_vars+=("WP_FORGEJO_CLIENT=${client_id}")
|
||||||
|
|
|
||||||
|
|
@ -607,12 +607,9 @@ COMPOSEEOF
|
||||||
- EDGE_TUNNEL_USER=${EDGE_TUNNEL_USER:-tunnel}
|
- EDGE_TUNNEL_USER=${EDGE_TUNNEL_USER:-tunnel}
|
||||||
- EDGE_TUNNEL_PORT=${EDGE_TUNNEL_PORT:-}
|
- EDGE_TUNNEL_PORT=${EDGE_TUNNEL_PORT:-}
|
||||||
- EDGE_TUNNEL_FQDN=${EDGE_TUNNEL_FQDN:-}
|
- EDGE_TUNNEL_FQDN=${EDGE_TUNNEL_FQDN:-}
|
||||||
# Subdomain fallback (#1028): per-service FQDNs for subdomain routing mode.
|
# Subdomain fallback (#713): if subpath routing (#704/#708) fails, add:
|
||||||
# Set EDGE_ROUTING_MODE=subdomain to activate. See docs/edge-routing-fallback.md.
|
# EDGE_TUNNEL_FQDN_FORGE, EDGE_TUNNEL_FQDN_CI, EDGE_TUNNEL_FQDN_CHAT
|
||||||
- EDGE_ROUTING_MODE=${EDGE_ROUTING_MODE:-subpath}
|
# See docs/edge-routing-fallback.md for the full pivot plan.
|
||||||
- EDGE_TUNNEL_FQDN_FORGE=${EDGE_TUNNEL_FQDN_FORGE:-}
|
|
||||||
- EDGE_TUNNEL_FQDN_CI=${EDGE_TUNNEL_FQDN_CI:-}
|
|
||||||
- EDGE_TUNNEL_FQDN_CHAT=${EDGE_TUNNEL_FQDN_CHAT:-}
|
|
||||||
# Shared secret for Caddy ↔ chat forward_auth (#709)
|
# Shared secret for Caddy ↔ chat forward_auth (#709)
|
||||||
- FORWARD_AUTH_SECRET=${FORWARD_AUTH_SECRET:-}
|
- FORWARD_AUTH_SECRET=${FORWARD_AUTH_SECRET:-}
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -703,8 +700,6 @@ COMPOSEEOF
|
||||||
CHAT_OAUTH_CLIENT_ID: ${CHAT_OAUTH_CLIENT_ID:-}
|
CHAT_OAUTH_CLIENT_ID: ${CHAT_OAUTH_CLIENT_ID:-}
|
||||||
CHAT_OAUTH_CLIENT_SECRET: ${CHAT_OAUTH_CLIENT_SECRET:-}
|
CHAT_OAUTH_CLIENT_SECRET: ${CHAT_OAUTH_CLIENT_SECRET:-}
|
||||||
EDGE_TUNNEL_FQDN: ${EDGE_TUNNEL_FQDN:-}
|
EDGE_TUNNEL_FQDN: ${EDGE_TUNNEL_FQDN:-}
|
||||||
EDGE_TUNNEL_FQDN_CHAT: ${EDGE_TUNNEL_FQDN_CHAT:-}
|
|
||||||
EDGE_ROUTING_MODE: ${EDGE_ROUTING_MODE:-subpath}
|
|
||||||
DISINTO_CHAT_ALLOWED_USERS: ${DISINTO_CHAT_ALLOWED_USERS:-}
|
DISINTO_CHAT_ALLOWED_USERS: ${DISINTO_CHAT_ALLOWED_USERS:-}
|
||||||
# Shared secret for Caddy forward_auth verify endpoint (#709)
|
# Shared secret for Caddy forward_auth verify endpoint (#709)
|
||||||
FORWARD_AUTH_SECRET: ${FORWARD_AUTH_SECRET:-}
|
FORWARD_AUTH_SECRET: ${FORWARD_AUTH_SECRET:-}
|
||||||
|
|
@ -810,11 +805,6 @@ _generate_agent_docker_impl() {
|
||||||
# Output path: ${FACTORY_ROOT}/docker/Caddyfile (gitignored — generated artifact).
|
# Output path: ${FACTORY_ROOT}/docker/Caddyfile (gitignored — generated artifact).
|
||||||
# The edge compose service mounts this path as /etc/caddy/Caddyfile.
|
# The edge compose service mounts this path as /etc/caddy/Caddyfile.
|
||||||
# On a fresh clone, `disinto init` calls generate_caddyfile before first `disinto up`.
|
# On a fresh clone, `disinto init` calls generate_caddyfile before first `disinto up`.
|
||||||
#
|
|
||||||
# Routing mode (EDGE_ROUTING_MODE env var):
|
|
||||||
# subpath — (default) all services under <project>.disinto.ai/{forge,ci,chat,staging}
|
|
||||||
# subdomain — per-service subdomains: forge.<project>, ci.<project>, chat.<project>
|
|
||||||
# See docs/edge-routing-fallback.md for the full pivot plan.
|
|
||||||
_generate_caddyfile_impl() {
|
_generate_caddyfile_impl() {
|
||||||
local docker_dir="${FACTORY_ROOT}/docker"
|
local docker_dir="${FACTORY_ROOT}/docker"
|
||||||
local caddyfile="${docker_dir}/Caddyfile"
|
local caddyfile="${docker_dir}/Caddyfile"
|
||||||
|
|
@ -824,22 +814,8 @@ _generate_caddyfile_impl() {
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local routing_mode="${EDGE_ROUTING_MODE:-subpath}"
|
|
||||||
|
|
||||||
if [ "$routing_mode" = "subdomain" ]; then
|
|
||||||
_generate_caddyfile_subdomain "$caddyfile"
|
|
||||||
else
|
|
||||||
_generate_caddyfile_subpath "$caddyfile"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Created: ${caddyfile} (routing_mode=${routing_mode})"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Subpath Caddyfile: all services under a single :80 block with path-based routing.
|
|
||||||
_generate_caddyfile_subpath() {
|
|
||||||
local caddyfile="$1"
|
|
||||||
cat > "$caddyfile" <<'CADDYFILEEOF'
|
cat > "$caddyfile" <<'CADDYFILEEOF'
|
||||||
# Caddyfile — edge proxy configuration (subpath mode)
|
# Caddyfile — edge proxy configuration
|
||||||
# IP-only binding at bootstrap; domain + TLS added later via vault resource request
|
# IP-only binding at bootstrap; domain + TLS added later via vault resource request
|
||||||
|
|
||||||
:80 {
|
:80 {
|
||||||
|
|
@ -882,50 +858,8 @@ _generate_caddyfile_subpath() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CADDYFILEEOF
|
CADDYFILEEOF
|
||||||
}
|
|
||||||
|
|
||||||
# Subdomain Caddyfile: four host blocks per docs/edge-routing-fallback.md.
|
echo "Created: ${caddyfile}"
|
||||||
# Uses env vars EDGE_TUNNEL_FQDN_FORGE, EDGE_TUNNEL_FQDN_CI, EDGE_TUNNEL_FQDN_CHAT,
|
|
||||||
# and EDGE_TUNNEL_FQDN (main project domain → staging).
|
|
||||||
_generate_caddyfile_subdomain() {
|
|
||||||
local caddyfile="$1"
|
|
||||||
cat > "$caddyfile" <<'CADDYFILEEOF'
|
|
||||||
# Caddyfile — edge proxy configuration (subdomain mode)
|
|
||||||
# Per-service subdomains; see docs/edge-routing-fallback.md
|
|
||||||
|
|
||||||
# Main project domain — staging / landing
|
|
||||||
{$EDGE_TUNNEL_FQDN} {
|
|
||||||
reverse_proxy staging:80
|
|
||||||
}
|
|
||||||
|
|
||||||
# Forgejo — root path, no subpath rewrite needed
|
|
||||||
{$EDGE_TUNNEL_FQDN_FORGE} {
|
|
||||||
reverse_proxy forgejo:3000
|
|
||||||
}
|
|
||||||
|
|
||||||
# Woodpecker CI — root path
|
|
||||||
{$EDGE_TUNNEL_FQDN_CI} {
|
|
||||||
reverse_proxy woodpecker:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
# Chat — with forward_auth (#709, on its own host)
|
|
||||||
{$EDGE_TUNNEL_FQDN_CHAT} {
|
|
||||||
handle /login {
|
|
||||||
reverse_proxy chat:8080
|
|
||||||
}
|
|
||||||
handle /oauth/callback {
|
|
||||||
reverse_proxy chat:8080
|
|
||||||
}
|
|
||||||
handle /* {
|
|
||||||
forward_auth chat:8080 {
|
|
||||||
uri /auth/verify
|
|
||||||
copy_headers X-Forwarded-User
|
|
||||||
header_up X-Forward-Auth-Secret {$FORWARD_AUTH_SECRET}
|
|
||||||
}
|
|
||||||
reverse_proxy chat:8080
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CADDYFILEEOF
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Generate docker/index.html default page.
|
# Generate docker/index.html default page.
|
||||||
|
|
|
||||||
|
|
@ -59,23 +59,6 @@ check_pipeline_stall = false
|
||||||
# compact_pct = 60
|
# compact_pct = 60
|
||||||
# poll_interval = 60
|
# poll_interval = 60
|
||||||
|
|
||||||
# Edge routing mode (default: subpath)
|
|
||||||
#
|
|
||||||
# Controls how services are exposed through the edge proxy.
|
|
||||||
# subpath — all services under <project>.disinto.ai/{forge,ci,chat,staging}
|
|
||||||
# subdomain — per-service subdomains: forge.<project>, ci.<project>, chat.<project>
|
|
||||||
#
|
|
||||||
# Set to "subdomain" if subpath routing causes unfixable issues (redirect loops,
|
|
||||||
# OAuth callback mismatches, cookie collisions). See docs/edge-routing-fallback.md.
|
|
||||||
#
|
|
||||||
# Set in .env (not TOML) since it's consumed by docker-compose and shell scripts:
|
|
||||||
# EDGE_ROUTING_MODE=subdomain
|
|
||||||
#
|
|
||||||
# In subdomain mode, `disinto edge register` also writes:
|
|
||||||
# EDGE_TUNNEL_FQDN_FORGE=forge.<project>.disinto.ai
|
|
||||||
# EDGE_TUNNEL_FQDN_CI=ci.<project>.disinto.ai
|
|
||||||
# EDGE_TUNNEL_FQDN_CHAT=chat.<project>.disinto.ai
|
|
||||||
|
|
||||||
# [mirrors]
|
# [mirrors]
|
||||||
# github = "git@github.com:johba/disinto.git"
|
# github = "git@github.com:johba/disinto.git"
|
||||||
# codeberg = "git@codeberg.org:johba/disinto.git"
|
# codeberg = "git@codeberg.org:johba/disinto.git"
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,13 @@ EOF
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# TODO(#713): Subdomain fallback — if subpath routing (#704/#708) fails, this
|
||||||
|
# function would need to register additional routes for forge.<project>,
|
||||||
|
# ci.<project>, chat.<project> subdomains (or accept a --subdomain parameter).
|
||||||
|
# See docs/edge-routing-fallback.md for the full pivot plan.
|
||||||
|
|
||||||
# Register a new tunnel
|
# Register a new tunnel
|
||||||
# Usage: do_register <project> <pubkey>
|
# Usage: do_register <project> <pubkey>
|
||||||
# When EDGE_ROUTING_MODE=subdomain, also registers forge.<project>, ci.<project>,
|
|
||||||
# and chat.<project> subdomain routes (see docs/edge-routing-fallback.md).
|
|
||||||
do_register() {
|
do_register() {
|
||||||
local project="$1"
|
local project="$1"
|
||||||
local pubkey="$2"
|
local pubkey="$2"
|
||||||
|
|
@ -76,32 +79,17 @@ do_register() {
|
||||||
local port
|
local port
|
||||||
port=$(allocate_port "$project" "$full_pubkey" "${project}.${DOMAIN_SUFFIX}")
|
port=$(allocate_port "$project" "$full_pubkey" "${project}.${DOMAIN_SUFFIX}")
|
||||||
|
|
||||||
# Add Caddy route for main project domain
|
# Add Caddy route
|
||||||
add_route "$project" "$port"
|
add_route "$project" "$port"
|
||||||
|
|
||||||
# Subdomain mode: register additional routes for per-service subdomains
|
|
||||||
local routing_mode="${EDGE_ROUTING_MODE:-subpath}"
|
|
||||||
if [ "$routing_mode" = "subdomain" ]; then
|
|
||||||
local subdomain
|
|
||||||
for subdomain in forge ci chat; do
|
|
||||||
add_route "${subdomain}.${project}" "$port"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Rebuild authorized_keys for tunnel user
|
# Rebuild authorized_keys for tunnel user
|
||||||
rebuild_authorized_keys
|
rebuild_authorized_keys
|
||||||
|
|
||||||
# Reload Caddy
|
# Reload Caddy
|
||||||
reload_caddy
|
reload_caddy
|
||||||
|
|
||||||
# Build JSON response
|
# Return JSON response
|
||||||
local response="{\"port\":${port},\"fqdn\":\"${project}.${DOMAIN_SUFFIX}\""
|
echo "{\"port\":${port},\"fqdn\":\"${project}.${DOMAIN_SUFFIX}\"}"
|
||||||
if [ "$routing_mode" = "subdomain" ]; then
|
|
||||||
response="${response},\"routing_mode\":\"subdomain\""
|
|
||||||
response="${response},\"subdomains\":{\"forge\":\"forge.${project}.${DOMAIN_SUFFIX}\",\"ci\":\"ci.${project}.${DOMAIN_SUFFIX}\",\"chat\":\"chat.${project}.${DOMAIN_SUFFIX}\"}"
|
|
||||||
fi
|
|
||||||
response="${response}}"
|
|
||||||
echo "$response"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Deregister a tunnel
|
# Deregister a tunnel
|
||||||
|
|
@ -121,18 +109,9 @@ do_deregister() {
|
||||||
# Remove from registry
|
# Remove from registry
|
||||||
free_port "$project" >/dev/null
|
free_port "$project" >/dev/null
|
||||||
|
|
||||||
# Remove Caddy route for main project domain
|
# Remove Caddy route
|
||||||
remove_route "$project"
|
remove_route "$project"
|
||||||
|
|
||||||
# Subdomain mode: also remove per-service subdomain routes
|
|
||||||
local routing_mode="${EDGE_ROUTING_MODE:-subpath}"
|
|
||||||
if [ "$routing_mode" = "subdomain" ]; then
|
|
||||||
local subdomain
|
|
||||||
for subdomain in forge ci chat; do
|
|
||||||
remove_route "${subdomain}.${project}"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Rebuild authorized_keys for tunnel user
|
# Rebuild authorized_keys for tunnel user
|
||||||
rebuild_authorized_keys
|
rebuild_authorized_keys
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue