diff options
| -rw-r--r-- | src/papod.clj | 167 | ||||
| -rw-r--r-- | tests/unit.clj | 72 |
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*] |
