summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/papod.clj167
-rw-r--r--tests/unit.clj72
2 files changed, 221 insertions, 18 deletions
diff --git a/src/papod.clj b/src/papod.clj
index 245d3b7..edbedf5 100644
--- a/src/papod.clj
+++ b/src/papod.clj
@@ -755,6 +755,112 @@
(defconst- +charset+
"UTF-8")
+;; PROXY protocol v2: untls injects a header on every accepted
+;; connection carrying the SNI as PP2_TYPE_AUTHORITY. We use that
+;; authority string to resolve the network the client is joining.
+;; Spec: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
+
+(def- +proxy-v2-signature+
+ (byte-array
+ [(unchecked-byte 0x0D) (unchecked-byte 0x0A)
+ (unchecked-byte 0x0D) (unchecked-byte 0x0A)
+ (unchecked-byte 0x00) (unchecked-byte 0x0D)
+ (unchecked-byte 0x0A) (unchecked-byte 0x51)
+ (unchecked-byte 0x55) (unchecked-byte 0x49)
+ (unchecked-byte 0x54) (unchecked-byte 0x0A)]))
+
+(defconst- +pp2-type-authority+ 0x02)
+
+(def- +network-name-fallback+
+ (System/getenv "PAPOD_NETWORK_NAME"))
+
+(defn- read-fully!
+ [^java.io.InputStream is n]
+ (let [buf (byte-array n)]
+ (loop [off 0]
+ (cond
+ (= off n) buf
+
+ :else
+ (let [r (.read is buf off (- n off))]
+ (if (pos? r) (recur (+ off r)) nil))))))
+
+(defn- parse-proxy-v2-tlvs
+ "Walk a PROXY v2 TLV body looking for AUTHORITY (0x02). Returns
+ the value as a String, or empty string if absent / malformed."
+ [^bytes body]
+ (let [n (alength body)]
+ (loop [i 0]
+ (cond
+ (> (+ i 3) n) ""
+
+ :else
+ (let [t (bit-and (aget body i) 0xFF)
+ vl (bit-or (bit-shift-left
+ (bit-and (aget body (+ i 1)) 0xFF) 8)
+ (bit-and (aget body (+ i 2)) 0xFF))
+ vs (+ i 3)]
+ (cond
+ (> (+ vs vl) n) ""
+
+ (= t +pp2-type-authority+)
+ (String. body vs vl ^String +charset+)
+
+ :else (recur (+ vs vl))))))))
+
+(defn- read-proxy-v2-authority!
+ "If the next bytes on `bis` are a PROXY v2 header, consume the
+ full header and return the AUTHORITY TLV value (or empty string
+ if absent). If the bytes are not a PROXY v2 header, reset the
+ stream so they can be read normally, and return nil."
+ [^java.io.BufferedInputStream bis]
+ (.mark bis 16)
+ (let [first12 (read-fully! bis 12)]
+ (cond
+ (nil? first12)
+ (do (try (.reset bis) (catch Exception _)) nil)
+
+ (not (java.util.Arrays/equals
+ ^bytes first12 ^bytes +proxy-v2-signature+))
+ (do (.reset bis) nil)
+
+ :else
+ (let [hdr (read-fully! bis 4)]
+ (when hdr
+ (let [ver-cmd (bit-and (aget hdr 0) 0xFF)
+ len (bit-or
+ (bit-shift-left
+ (bit-and (aget hdr 2) 0xFF) 8)
+ (bit-and (aget hdr 3) 0xFF))
+ body (if (pos? len)
+ (read-fully! bis len)
+ (byte-array 0))]
+ (when (and body (= ver-cmd 0x21))
+ (parse-proxy-v2-tlvs body))))))))
+
+(defn- find-or-create-network!
+ [conn authority]
+ (let [lookup #(ffirst
+ (d/q '{:find [?id]
+ :in [$ ?n]
+ :where [[?e :papod.network/name ?n]
+ [?e :papod.network/id ?id]]}
+ (d/db conn) %))]
+ (or (lookup authority)
+ (let [new-id (java.util.UUID/randomUUID)]
+ (try
+ @(d/transact conn
+ [{:db/ensure :papod.network/attrs
+ :papod.network/id new-id
+ :papod.network/name authority
+ :papod.network/description ""
+ :papod.network/type "public"
+ :papod.network/created-at (java.util.Date.)}])
+ new-id
+ (catch Exception _
+ ;; Race: another connection just created it.
+ (lookup authority)))))))
+
(defn- client-target
[client]
(or (:nick @client) "*"))
@@ -1129,11 +1235,10 @@
"ERROR :Closing link: (Bad Password)"])
:else
- (do ;; Assign default network if none selected via PASS
- (when (and (not (:network-id @client))
- (:default-network-id components))
- (swap! client assoc :network-id
- (:default-network-id components)))
+ (do ;; network-id is set at connect time from the PROXY v2
+ ;; AUTHORITY (or PAPOD_NETWORK_NAME fallback); PASS may
+ ;; have overridden it explicitly. Either way, we assume
+ ;; it's set by now.
(swap! client assoc :registered? true)
(when-let [n (:n-unreg components)]
(swap! n (fn [v] (max 0 (dec v)))))
@@ -6687,6 +6792,8 @@
(Long/parseLong t)
300000))
+(declare client-loop-body!)
+
(defn- client-loop!
[socket components]
(let [_ (try
@@ -6694,15 +6801,45 @@
(when s
(.setSoTimeout s (int +idle-timeout-ms+))))
(catch Exception _))
- r (java.io.InputStreamReader.
+ bis (java.io.BufferedInputStream.
(java.nio.channels.Channels/newInputStream socket)
- +charset+)
+ 4096)
+ proxy-authority (try (read-proxy-v2-authority! bis)
+ (catch Exception _ nil))
+ authority (or (and (string? proxy-authority)
+ (not (string/blank? proxy-authority))
+ proxy-authority)
+ (and (not (string/blank? +network-name-fallback+))
+ +network-name-fallback+))
+ network-id (when authority
+ (try (find-or-create-network!
+ (:conn components) authority)
+ (catch Exception _ nil)))
+ r (java.io.InputStreamReader. bis ^String +charset+)
w (java.nio.channels.Channels/newOutputStream socket)
b (char-array +buffer-size+)
conn-id (java.util.UUID/randomUUID)
client (atom {:nick nil :user nil :pass nil :registered? false
:w w :connection-id conn-id
+ :network-id network-id
:socket socket})]
+ ;; No authority resolved (no PROXY header and no PAPOD_NETWORK_NAME
+ ;; fallback): refuse the connection. papod no longer carries a
+ ;; default network — every client must declare which network they
+ ;; are connecting to via the SNI surfaced by untls.
+ (if-not network-id
+ (do (try
+ (.write w (.getBytes
+ "ERROR :Closing link: (No network)\r\n"
+ ^String +charset+))
+ (.flush w)
+ (catch Exception _))
+ (try (.close socket) (catch Exception _)))
+ (client-loop-body! r w b conn-id client components))))
+
+(defn- client-loop-body!
+ [^java.io.Reader r w b conn-id client components]
+ (let [socket (:socket @client)]
;; Record connection start
(when-let [n (:n-unreg components)]
(swap! n inc))
@@ -6828,14 +6965,8 @@
(str "datomic:mem://cracha-"
(java.util.UUID/randomUUID)))
fiinha-state)
- components (init! db-uri cracha-state)
- ;; Create default network for clients that don't send PASS
- default-net (java.util.UUID/randomUUID)
- _ @(d/transact (:conn components)
- [{:db/ensure :papod.network/attrs
- :papod.network/id default-net
- :papod.network/name +server-name+
- :papod.network/description ""
- :papod.network/type "public"
- :papod.network/created-at (java.util.Date.)}])]
- (start (assoc components :default-network-id default-net))))
+ components (init! db-uri cracha-state)]
+ ;; Networks are no longer pre-created. The authority surfaced by
+ ;; untls (or the PAPOD_NETWORK_NAME fallback) is looked up — and
+ ;; created on first use — at connect time inside client-loop!.
+ (start components)))
diff --git a/tests/unit.clj b/tests/unit.clj
index 21833e5..9eda963 100644
--- a/tests/unit.clj
+++ b/tests/unit.clj
@@ -1943,6 +1943,78 @@
(is (empty?
(filter #(re-find #" 475 " %) replies)))))))
+(def read-proxy-v2-authority! @#'papod/read-proxy-v2-authority!)
+
+(defn- bis-of
+ ^java.io.BufferedInputStream [^bytes b]
+ (java.io.BufferedInputStream.
+ (java.io.ByteArrayInputStream. b)
+ 4096))
+
+(defn- proxy-v2-bytes
+ "Construct a PROXY v2 LOCAL/AF_UNSPEC header carrying a single
+ AUTHORITY TLV. Mirrors what untls writes to the upstream socket."
+ ^bytes [^String authority]
+ (let [auth (.getBytes authority "UTF-8")
+ sig (byte-array
+ [(unchecked-byte 0x0D) (unchecked-byte 0x0A)
+ (unchecked-byte 0x0D) (unchecked-byte 0x0A)
+ (unchecked-byte 0x00) (unchecked-byte 0x0D)
+ (unchecked-byte 0x0A) (unchecked-byte 0x51)
+ (unchecked-byte 0x55) (unchecked-byte 0x49)
+ (unchecked-byte 0x54) (unchecked-byte 0x0A)])
+ tlv-len (+ 3 (alength auth))
+ out (java.io.ByteArrayOutputStream.)]
+ (.write out sig)
+ (.write out 0x21)
+ (.write out 0x00)
+ (.write out (bit-and (bit-shift-right tlv-len 8) 0xFF))
+ (.write out (bit-and tlv-len 0xFF))
+ (.write out 0x02)
+ (.write out (bit-and (bit-shift-right (alength auth) 8) 0xFF))
+ (.write out (bit-and (alength auth) 0xFF))
+ (.write out auth)
+ (.toByteArray out)))
+
+(deftest test_proxy-v2-parser
+ (testing "valid PROXY v2 with AUTHORITY returns the SNI"
+ (let [hdr (proxy-v2-bytes "papo.example.com")
+ tail (.getBytes "NICK alice\r\n" "UTF-8")
+ buf (byte-array (+ (alength hdr) (alength tail)))]
+ (System/arraycopy hdr 0 buf 0 (alength hdr))
+ (System/arraycopy tail 0 buf (alength hdr) (alength tail))
+ (let [bis (bis-of buf)
+ authority (read-proxy-v2-authority! bis)]
+ (is (= authority "papo.example.com"))
+ ;; The trailing IRC bytes should remain readable.
+ (let [out (byte-array (alength tail))
+ _ (.read bis out)]
+ (is (= (String. out "UTF-8") "NICK alice\r\n"))))))
+
+ (testing "no PROXY signature returns nil and preserves bytes"
+ (let [bis (bis-of (.getBytes "NICK alice\r\n" "UTF-8"))]
+ (is (nil? (read-proxy-v2-authority! bis)))
+ (let [out (byte-array 12)
+ _ (.read bis out)]
+ (is (= (String. out "UTF-8") "NICK alice\r\n")))))
+
+ (testing "PROXY v2 without AUTHORITY returns empty string"
+ (let [sig (byte-array
+ [(unchecked-byte 0x0D) (unchecked-byte 0x0A)
+ (unchecked-byte 0x0D) (unchecked-byte 0x0A)
+ (unchecked-byte 0x00) (unchecked-byte 0x0D)
+ (unchecked-byte 0x0A) (unchecked-byte 0x51)
+ (unchecked-byte 0x55) (unchecked-byte 0x49)
+ (unchecked-byte 0x54) (unchecked-byte 0x0A)])
+ out (java.io.ByteArrayOutputStream.)]
+ (.write out sig)
+ (.write out 0x21)
+ (.write out 0x00)
+ (.write out 0x00)
+ (.write out 0x00)
+ (let [bis (bis-of (.toByteArray out))]
+ (is (= (read-proxy-v2-authority! bis) ""))))))
+
(defn -main
[& _args]
(binding [*out* *err*]