commit f923af7325eaf5f762cf066f200aa8232f4ba5e8 Author: root Date: Thu Jul 30 13:09:31 2020 -0500 initial commit, moved from system repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c53038e --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/target +/classes +/checkouts +pom.xml +pom.xml.asc +*.jar +*.class +/.lein-* +/.nrepl-port +.hgignore +.hg/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a5c7bc4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Change Log +All notable changes to this project will be documented in this file. This change log follows the conventions of [keepachangelog.com](http://keepachangelog.com/). + +## [Unreleased] +### Changed +- Add a new arity to `make-widget-async` to provide a different widget shape. + +## [0.1.1] - 2018-05-22 +### Changed +- Documentation on how to make the widgets. + +### Removed +- `make-widget-sync` - we're all async, all the time. + +### Fixed +- Fixed widget maker to keep working when daylight savings switches over. + +## 0.1.0 - 2018-05-22 +### Added +- Files from the new template. +- Widget maker public API - `make-widget-sync`. + +[Unreleased]: https://github.com/your-name/dyndns-server/compare/0.1.1...HEAD +[0.1.1]: https://github.com/your-name/dyndns-server/compare/0.1.0...0.1.1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d921d3d --- /dev/null +++ b/LICENSE @@ -0,0 +1,214 @@ +THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE PUBLIC +LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM +CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + +1. DEFINITIONS + +"Contribution" means: + +a) in the case of the initial Contributor, the initial code and +documentation distributed under this Agreement, and + +b) in the case of each subsequent Contributor: + +i) changes to the Program, and + +ii) additions to the Program; + +where such changes and/or additions to the Program originate from and are +distributed by that particular Contributor. A Contribution 'originates' from +a Contributor if it was added to the Program by such Contributor itself or +anyone acting on such Contributor's behalf. Contributions do not include +additions to the Program which: (i) are separate modules of software +distributed in conjunction with the Program under their own license +agreement, and (ii) are not derivative works of the Program. + +"Contributor" means any person or entity that distributes the Program. + +"Licensed Patents" mean patent claims licensable by a Contributor which are +necessarily infringed by the use or sale of its Contribution alone or when +combined with the Program. + +"Program" means the Contributions distributed in accordance with this +Agreement. + +"Recipient" means anyone who receives the Program under this Agreement, +including all Contributors. + +2. GRANT OF RIGHTS + +a) Subject to the terms of this Agreement, each Contributor hereby grants +Recipient a non-exclusive, worldwide, royalty-free copyright license to +reproduce, prepare derivative works of, publicly display, publicly perform, +distribute and sublicense the Contribution of such Contributor, if any, and +such derivative works, in source code and object code form. + +b) Subject to the terms of this Agreement, each Contributor hereby grants +Recipient a non-exclusive, worldwide, royalty-free patent license under +Licensed Patents to make, use, sell, offer to sell, import and otherwise +transfer the Contribution of such Contributor, if any, in source code and +object code form. This patent license shall apply to the combination of the +Contribution and the Program if, at the time the Contribution is added by the +Contributor, such addition of the Contribution causes such combination to be +covered by the Licensed Patents. The patent license shall not apply to any +other combinations which include the Contribution. No hardware per se is +licensed hereunder. + +c) Recipient understands that although each Contributor grants the licenses +to its Contributions set forth herein, no assurances are provided by any +Contributor that the Program does not infringe the patent or other +intellectual property rights of any other entity. Each Contributor disclaims +any liability to Recipient for claims brought by any other entity based on +infringement of intellectual property rights or otherwise. As a condition to +exercising the rights and licenses granted hereunder, each Recipient hereby +assumes sole responsibility to secure any other intellectual property rights +needed, if any. For example, if a third party patent license is required to +allow Recipient to distribute the Program, it is Recipient's responsibility +to acquire that license before distributing the Program. + +d) Each Contributor represents that to its knowledge it has sufficient +copyright rights in its Contribution, if any, to grant the copyright license +set forth in this Agreement. + +3. REQUIREMENTS + +A Contributor may choose to distribute the Program in object code form under +its own license agreement, provided that: + +a) it complies with the terms and conditions of this Agreement; and + +b) its license agreement: + +i) effectively disclaims on behalf of all Contributors all warranties and +conditions, express and implied, including warranties or conditions of title +and non-infringement, and implied warranties or conditions of merchantability +and fitness for a particular purpose; + +ii) effectively excludes on behalf of all Contributors all liability for +damages, including direct, indirect, special, incidental and consequential +damages, such as lost profits; + +iii) states that any provisions which differ from this Agreement are offered +by that Contributor alone and not by any other party; and + +iv) states that source code for the Program is available from such +Contributor, and informs licensees how to obtain it in a reasonable manner on +or through a medium customarily used for software exchange. + +When the Program is made available in source code form: + +a) it must be made available under this Agreement; and + +b) a copy of this Agreement must be included with each copy of the Program. + +Contributors may not remove or alter any copyright notices contained within +the Program. + +Each Contributor must identify itself as the originator of its Contribution, +if any, in a manner that reasonably allows subsequent Recipients to identify +the originator of the Contribution. + +4. COMMERCIAL DISTRIBUTION + +Commercial distributors of software may accept certain responsibilities with +respect to end users, business partners and the like. While this license is +intended to facilitate the commercial use of the Program, the Contributor who +includes the Program in a commercial product offering should do so in a +manner which does not create potential liability for other Contributors. +Therefore, if a Contributor includes the Program in a commercial product +offering, such Contributor ("Commercial Contributor") hereby agrees to defend +and indemnify every other Contributor ("Indemnified Contributor") against any +losses, damages and costs (collectively "Losses") arising from claims, +lawsuits and other legal actions brought by a third party against the +Indemnified Contributor to the extent caused by the acts or omissions of such +Commercial Contributor in connection with its distribution of the Program in +a commercial product offering. The obligations in this section do not apply +to any claims or Losses relating to any actual or alleged intellectual +property infringement. In order to qualify, an Indemnified Contributor must: +a) promptly notify the Commercial Contributor in writing of such claim, and +b) allow the Commercial Contributor to control, and cooperate with the +Commercial Contributor in, the defense and any related settlement +negotiations. The Indemnified Contributor may participate in any such claim +at its own expense. + +For example, a Contributor might include the Program in a commercial product +offering, Product X. That Contributor is then a Commercial Contributor. If +that Commercial Contributor then makes performance claims, or offers +warranties related to Product X, those performance claims and warranties are +such Commercial Contributor's responsibility alone. Under this section, the +Commercial Contributor would have to defend claims against the other +Contributors related to those performance claims and warranties, and if a +court requires any other Contributor to pay any damages as a result, the +Commercial Contributor must pay those damages. + +5. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED ON +AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER +EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR +CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A +PARTICULAR PURPOSE. Each Recipient is solely responsible for determining the +appropriateness of using and distributing the Program and assumes all risks +associated with its exercise of rights under this Agreement , including but +not limited to the risks and costs of program errors, compliance with +applicable laws, damage to or loss of data, programs or equipment, and +unavailability or interruption of operations. + +6. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY +CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION +LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE +EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY +OF SUCH DAMAGES. + +7. GENERAL + +If any provision of this Agreement is invalid or unenforceable under +applicable law, it shall not affect the validity or enforceability of the +remainder of the terms of this Agreement, and without further action by the +parties hereto, such provision shall be reformed to the minimum extent +necessary to make such provision valid and enforceable. + +If Recipient institutes patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Program itself +(excluding combinations of the Program with other software or hardware) +infringes such Recipient's patent(s), then such Recipient's rights granted +under Section 2(b) shall terminate as of the date such litigation is filed. + +All Recipient's rights under this Agreement shall terminate if it fails to +comply with any of the material terms or conditions of this Agreement and +does not cure such failure in a reasonable period of time after becoming +aware of such noncompliance. If all Recipient's rights under this Agreement +terminate, Recipient agrees to cease use and distribution of the Program as +soon as reasonably practicable. However, Recipient's obligations under this +Agreement and any licenses granted by Recipient relating to the Program shall +continue and survive. + +Everyone is permitted to copy and distribute copies of this Agreement, but in +order to avoid inconsistency the Agreement is copyrighted and may only be +modified in the following manner. The Agreement Steward reserves the right to +publish new versions (including revisions) of this Agreement from time to +time. No one other than the Agreement Steward has the right to modify this +Agreement. The Eclipse Foundation is the initial Agreement Steward. The +Eclipse Foundation may assign the responsibility to serve as the Agreement +Steward to a suitable separate entity. Each new version of the Agreement will +be given a distinguishing version number. The Program (including +Contributions) may always be distributed subject to the version of the +Agreement under which it was received. In addition, after a new version of +the Agreement is published, Contributor may elect to distribute the Program +(including its Contributions) under the new version. Except as expressly +stated in Sections 2(a) and 2(b) above, Recipient receives no rights or +licenses to the intellectual property of any Contributor under this +Agreement, whether expressly, by implication, estoppel or otherwise. All +rights in the Program not expressly granted under this Agreement are +reserved. + +This Agreement is governed by the laws of the State of New York and the +intellectual property laws of the United States of America. No party to this +Agreement will bring a legal action under this Agreement more than one year +after the cause of action arose. Each party waives its rights to a jury trial +in any resulting litigation. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2814e7 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# dyndns-server + +A Clojure library designed to ... well, that part is up to you. + +## Usage + +FIXME + +## License + +Copyright © 2018 FIXME + +Distributed under the Eclipse Public License either version 1.0 or (at +your option) any later version. diff --git a/doc/intro.md b/doc/intro.md new file mode 100644 index 0000000..4f1867a --- /dev/null +++ b/doc/intro.md @@ -0,0 +1,3 @@ +# Introduction to dyndns-server + +TODO: write [great documentation](http://jacobian.org/writing/what-to-write/) diff --git a/project.clj b/project.clj new file mode 100644 index 0000000..5266e25 --- /dev/null +++ b/project.clj @@ -0,0 +1,38 @@ +(defproject fudo-server "0.1.0-SNAPSHOT" + :description "Fudo API Server" + :url "http://example.com/FIXME" + :license {:name "Eclipse Public License" + :url "http://www.eclipse.org/legal/epl-v10.html"} + :main fudo.server.core + :dependencies [[org.clojure/clojure "1.9.0"] + [ring "1.6.3"] + [ring/ring-spec "0.0.4"] + [compojure "1.6.1"] + [org.flatland/protobuf "0.7.1"] + [kovacnica/clojure.network.ip "0.1.2"] + [org.clojure/data.json "0.2.6"] + [com.stuartsierra/component "0.3.2"] + [org.clojure/java.jdbc "0.7.5"] + [org.apache.commons/commons-daemon "1.0.9"] + [org.postgresql/postgresql "42.1.4"] + [io.forward/yaml "1.0.8"] + [ring/ring-json "0.4.0"] + [org.clojure/tools.cli "0.3.7"] + [base64-clj "0.1.1"] + [slingshot "0.12.2"] + [brolog "0.0.1"] + [orchestra "2017.11.12-1"] + [org.clojure/test.check "0.10.0-alpha3"]] + :aot [fudo.server.core] + :ring {:handler fudo.server.api/handler} + :plugins [[lein-protobuf "0.1.1"] + [lein-ring "0.12.4"]] + :source-paths ["src/clj" "config" "src/common" "test"] + :test-paths ["test"] + :repositories [["snapshots" {:username :env/repository_username + :password :env/repository_password + :url "https://repo.fudo.org/repository/snapshots" + :sign-releases false}] + ["releases" {:username :env/repository_username + :password :env/repository_password + :url "https://repo.fudo.org/repository/internal"}]]) diff --git a/scripts/generate-keystore.rb b/scripts/generate-keystore.rb new file mode 100755 index 0000000..fa6bb42 --- /dev/null +++ b/scripts/generate-keystore.rb @@ -0,0 +1,97 @@ +#!/usr/bin/env ruby + +require "pathname" +require "tmpdir" +require "open3" +require "fileutils" + +require "/fudo/lib/fudo.rb" + +Fudo::require("fudo/config") +Fudo::require("network") + +config = Fudo::Config::default +hostname = Fudo::Network::hostname + +keystore = Pathname.new( + config.get("services::#{hostname}::service::keystore")) +passwd_file = Pathname.new( + config.get("services::#{hostname}::service::keystore_passwd_file")) + +if ARGV.length != 2 + puts "usage: #{$0} [KEY_FILE] [CERT_FILE]" + exit 1 +end + +KEYNAME = Pathname.new(ARGV[0]) +CERTNAME = Pathname.new(ARGV[1]) + +if not File::readable?(KEYNAME) + raise RuntimeError.new("Key #{KEYNAME.to_s} can't be read!") +end + +if not File::readable?(CERTNAME) + raise RuntimeError.new("Certificate #{CERTNAME.to_s} can't be read!") +end + +if not keystore.parent.writable? + raise RuntimeError.new("Can't write JKS to directory #{keystore.parent.to_s}") +end + +if keystore.exist? + raise RuntimeError.new("Keystore exists: #{keystore}! Aborting...") +end + +if passwd_file.exist? + raise RuntimeError.new("Keystore password file exists: #{passwd_file}! Aborting...") +end + +def exec_or_die(cmd) + out, err, status = Open3::capture3(cmd) + if status != 0 + puts err + raise RuntimeError.new(err) + end +end + +def randpass + (0...50).map { ('a'..'z').to_a[rand(26)] }.join +end + +finalpass = randpass + +Dir::mktmpdir { |tmp_0| + begin + PRIV = Pathname.new(tmp_0) + "private" + Dir::mkdir(PRIV) + File::chmod(0700, PRIV) + pkcs12 = PRIV + "key_crt.p12" + pem_to_pkcs12 = <> (gen/tuple (gen/not-empty (gen/string-alphanumeric)) (gen-string uri-chars)) + (gen/fmap (fn [[k v]] (str k "=" v))) + (gen/vector) + (gen/fmap #(str/join "&" %)))) + +(defn- gen-method [] + (gen/fmap keyword (gen/not-empty (gen-string lower-case-chars)))) + +(defn- gen-input-stream [] + (gen/fmap #(java.io.ByteArrayInputStream. %) (gen/bytes))) + +(defn- gen-exception [] + (gen/fmap (fn [s] (Exception. s)) (gen/string-alphanumeric))) + +;; Internal + +(s/def :ring.core/error + (-> #(instance? Throwable %) (s/with-gen gen-exception))) + +(s/def :ring.http/field-name + (-> (s/and string? not-empty field-name-chars?) + (s/with-gen #(gen/not-empty (gen-string field-name-chars))))) + +(s/def :ring.http/field-value + (-> (s/and string? field-value-chars? trimmed?) + (s/with-gen #(gen/fmap str/trim (gen-string field-value-chars*))))) + +;; Request + +(s/def :ring.request/server-port (s/int-in 1 65535)) +(s/def :ring.request/server-name string?) +(s/def :ring.request/remote-addr string?) + +(s/def :ring.request/uri + (-> (s/and string? #(str/starts-with? % "/")) + (s/with-gen (fn [] (gen/fmap #(str "/" %) (gen-string uri-chars)))))) + +(s/def :ring.request/query-string + (s/with-gen string? gen-query-string)) + +(s/def :ring.request/scheme #{:http :https}) + +(s/def :ring.request/request-method + (-> (s/and keyword? (comp lower-case? name)) + (s/with-gen gen-method))) + +(s/def :ring.request/protocol + (s/with-gen string? #(gen/return "HTTP/1.1"))) + +(s/def :ring.request/header-name + (-> (s/and :ring.http/field-name lower-case?) + (s/with-gen #(gen/fmap str/lower-case (s/gen :ring.http/field-name))))) + +(s/def :ring.request/header-value :ring.http/field-value) + +(s/def :ring.request/headers + (s/map-of :ring.request/header-name :ring.request/header-value)) + +(s/def :ring.request/body + (s/with-gen #(instance? java.io.InputStream %) gen-input-stream)) + +(s/def :ring.request/body-string string?) + +(s/def ::request + (s/keys :req-un [:ring.request/server-port + :ring.request/server-name + :ring.request/remote-addr + :ring.request/uri + :ring.request/scheme + :ring.request/protocol + :ring.request/headers + :ring.request/request-method] + :opt-un [:ring.request/query-string + :ring.request/body + :ring.request/body-string])) + +(s/def ::authenticated-request + (s/keys :req-un [:ring.request/server-port + :ring.request/server-name + :ring.request/remote-addr + :ring.request/uri + :ring.request/scheme + :ring.request/protocol + :ring.request/headers + :ring.request/request-method + :ring.request/request-entity] + :opt-un [:ring.request/query-string + :ring.request/body + :ring.request/body-string])) + +;; Response + +(s/def :ring.response/status (s/int-in 100 600)) + +(s/def :ring.response/header-name :ring.http/field-name) + +(s/def :ring.response/header-value + (s/or :one :ring.http/field-value :many (s/coll-of :ring.http/field-value))) + +(s/def :ring.response/headers + (s/map-of :ring.response/header-name :ring.response/header-value)) + +(s/def :ring.response/body + (-> #(satisfies? p/StreamableResponseBody %) + (s/with-gen #(gen/one-of [(gen/return nil) + (gen/string-ascii) + (gen/list (gen/string-ascii)) + (gen-input-stream)])))) + +(s/def ::response + (s/keys :req-un [:ring.response/status + :ring.response/headers] + :opt-un [:ring.response/body])) + +;; Handler + +(s/def :ring.sync.handler/args + (s/cat :request ::request)) + +(s/def :ring.async.handler/args + (s/cat :request ::request + :respond (s/fspec :args (s/cat :response ::response) :ret any?) + :raise (s/fspec :args (s/cat :error :ring.core/error) :ret any?))) + +(s/def :ring.sync.handler/ret ::response) +(s/def :ring.async.handler/ret any?) + +(s/fdef :ring.sync/handler + :args :ring.sync.handler/args + :ret :ring.sync.handler/ret) + +(s/fdef :ring.async/handler + :args :ring.async.handler/args + :ret :ring.async.handler/ret) + +(s/fdef :ring.sync+async/handler + :args (s/or :sync :ring.sync.handler/args :async :ring.async.handler/args) + :ret (s/or :sync :ring.sync.handler/ret :async :ring.async.handler/ret) + :fn (s/or :sync (s/keys :req-un [:ring.sync.handler/args :ring.sync.handler/ret]) + :async (s/keys :req-un [:ring.async.handler/args :ring.async.handler/ret]))) + +(s/def ::handler + (s/or :sync :ring.sync/handler + :async :ring.async/handler + :sync+async :ring.sync+async/handler)) diff --git a/src/clj/fudo/api/request.clj b/src/clj/fudo/api/request.clj new file mode 100644 index 0000000..8ba11de --- /dev/null +++ b/src/clj/fudo/api/request.clj @@ -0,0 +1,29 @@ +(ns fudo.api.request + (:require [fudo.api :as api] + [clojure.spec.alpha :as s] + [slingshot.slingshot :refer [throw+]])) + +(defn get-header [req header] + (if-let [res (get-in req [:headers (clojure.string/lower-case (name header))])] + res + (throw+ {:type :request/header-missing + :class :client-error + :msg (format "Missing header %s in headers %s" + header (:headers req))}))) +(s/fdef get-header + :args (s/cat :req ::api/request + :header string?) + :ret string?) + +(defn set-header [req header value] + (when (not (string? value)) + (throw+ {:type :request/invalid-header-value + :class :client-error + :msg (format "Invalid header value '%s', must be string!" + value)})) + (assoc-in req [:headers (clojure.string/lower-case (name header))] value)) +(s/fdef set-header + :args (s/cat :req ::api/request + :header string? + :value string?) + :ret ::api/request) diff --git a/src/clj/fudo/api/request/authenticator/auth_db.clj b/src/clj/fudo/api/request/authenticator/auth_db.clj new file mode 100644 index 0000000..2c8c22a --- /dev/null +++ b/src/clj/fudo/api/request/authenticator/auth_db.clj @@ -0,0 +1,149 @@ +(ns fudo.api.request.authenticator.auth-db + (:require [fudo.api.request.validator :as validator] + [fudo.api.request :as req] + [fudo.api :as api] + [fudo.utils :as util] + [fudo.crypto.signatures :as sig] + [fudo.db.auth-db :as auth-db] + [slingshot.slingshot :refer [throw+ try+]] + [com.stuartsierra.component :as component] + [clojure.spec.alpha :as s])) + +(defn- ensure-headers [req headers] + (doseq [header headers] + (when (nil? (get-in req [:headers header])) + (throw+ {:type :validator/invalid + :class :client-error + :msg (format "Missing required header: %s" header)})))) + +(defn- req-entity-name [req] + (req/get-header req "fudo-entity")) +(s/fdef req-entity-name + :args (s/cat :req ::api/request) + :ret string?) + +(defn- req-timestamp [req] + (java.lang.Long/parseLong (req/get-header req "fudo-timestamp"))) +(s/fdef req-timestamp + :args (s/cat :req ::api/request) + :ret integer?) + +(defn- req-key-id [req] + (req/get-header req "fudo-key-id")) +(s/fdef req-key-id + :args (s/cat :req ::api/request) + :ret string?) + +(defn- req-signature [req] + (req/get-header req "fudo-signature")) +(s/fdef req-signature + :args (s/cat :req ::api/request) + :ret string?) + +(defn- verify-request-age [req] + (let [server-timestamp (System/currentTimeMillis) + timestamp (req-timestamp req) + lag (- server-timestamp timestamp)] + (when (> lag (* 30 1000)) + (throw+ {:type :validator/invalid + :invalid-header :fudo-timestamp + :class :client-error + :msg (format "Timestamp too old (%s ms)" lag)})) + (when (< lag 0) + (throw+ {:type :validator/invalid + :class :service + :invalid-header :fudo-timestamp + :msg (format "Timestamp from the future! (%s ms)" (- lag))})) + req)) +(s/fdef verify-request-age + :args (s/cat :req ::api/request) + :ret ::api/request) + +(defn- verify-request-key-id [req auth-db] + (if (= (req-key-id req) + (auth-db/get-entity-key-id auth-db (req-entity-name req))) + req + (throw+ {:type :validator/invalid + :class :client-error + :invalid-header :fudo-key-id + :msg (format "Supplied key ID does not match stored entity key for %s" + (req-entity-name req))}))) +(s/fdef verify-request-key-id + :args (s/cat :req ::api/request + :auth-db ::auth-db/auth-db) + :ret ::api/request) + +(defn- verify-request-entity-exists [req auth-db] + (if-let [entity (auth-db/get-entity-key-id auth-db (req-entity-name req))] + req + (throw+ {:type :validator/invalid + :class :client-error + :invalid-header :fudo-entity + :msg (format "Entity does not exist: %s" + (req-entity-name req))}))) +(s/fdef verify-request-entity-exists + :args (s/cat :req ::api/request + :auth-db ::auth-db/auth-db) + :ret ::api/request) + +(defn- verify-request-signature [req auth-db] + (let [data (clojure.string/join [(str (req-entity-name req)) + (str (req-key-id req)) + (str (req-timestamp req)) + (str (:body-string req))]) + pubkey (auth-db/get-entity-key auth-db (req-entity-name req))] + (try+ + (if (sig/verify pubkey data (req-signature req)) + req + (throw+ {:type :validator/invalid + :class :client-error + :invalid-header :fudo-signature + :msg (format "Invalid signature!")})) + (catch [:type :signature/invalid] {:keys [msg]} + (throw+ {:type :validator/invalid + :class :client-error + :msg (format "Unexpected error while verifying signature: %s" + msg)}))))) +(s/fdef verify-request-entity-exists + :args (s/cat :req ::api/request + :auth-db ::auth-db/auth-db) + :ret ::api/request) + +(defn- authenticate-request-with-auth-db [req auth-db] + ;; TODO: Alternatively, accept a cookie + (ensure-headers req ["fudo-timestamp" + "fudo-entity" + "fudo-key-id" + "fudo-signature"]) + (-> req + (util/echo-through) + (verify-request-age) + (verify-request-entity-exists auth-db) + (verify-request-key-id auth-db) + (verify-request-signature auth-db) + (assoc :authenticator/authenticated true))) +(s/fdef authenticate-result-with-auth-db + :args (s/cat :req ::api/request + :auth-db ::auth-db/auth-db) + :ret ::api/request) + +(defrecord AuthDbRequestAuthenticator [debug config auth-db] + + component/Lifecycle + + (start [this] + this) + + (stop [this] + this) + + validator/RequestValidator + + (validate-request [this req] + (authenticate-request-with-auth-db req (:auth-db this)))) + +(defn init [debug] + (->AuthDbRequestAuthenticator debug nil nil)) + +(defn init-mock [debug config auth-db] + (->AuthDbRequestAuthenticator debug config auth-db)) diff --git a/src/clj/fudo/api/request/generator.clj b/src/clj/fudo/api/request/generator.clj new file mode 100644 index 0000000..320d7cb --- /dev/null +++ b/src/clj/fudo/api/request/generator.clj @@ -0,0 +1,85 @@ +(ns fudo.api.request.generator + (:require [fudo.api :as api] + [fudo.crypto.keys :as key] + [fudo.crypto.signatures :as sig] + [fudo.crypto.utils :as crypt] + [fudo.api.request :as req] + [clojure.spec.alpha :as s])) + +(defn- input-stream? [obj] + (instance? java.io.InputStream obj)) + +(defn- make-body [body] + (java.io.ByteArrayInputStream. (.getBytes body))) +(s/fdef make-body + :args (s/cat :body string?) + :ret input-stream?) + +(defn- generic-req [] + {:server-port 1234 + :server-name "test.target.fudo.org" + :remote-addr "1.2.3.4" + :scheme :https + :protocol "HTTP/1.1" + :headers {}}) + +(defn make-get [uri] + (assoc (generic-req) + :uri uri + :request-method :get)) +(s/fdef make-get + :args (s/cat :uri string?) + :ret ::api/request) + +(defn make-put [uri data] + (assoc (generic-req) + :uri uri + :request-method :put + :body-string data)) +(s/fdef make-get + :args (s/cat :uri string?) + :ret ::api/request) + +(defn add-host-header [req hostname] + (assoc-in req [:headers "fudo-entity"] hostname)) +(s/fdef add-host-header + :args (s/cat :req ::api/request :hostname string?) + :ret ::api/request) + +(defn add-fudo-secret [req secret] + (assoc-in req [:headers "fudo-secret"] secret)) +(s/fdef add-fudo-secret + :args (s/cat :req ::api/request :secret string?) + :ret ::api/request) + +(defn- generate-signature [req privkey] + (let [data (clojure.string/join [(req/get-header req "fudo-entity") + (req/get-header req "fudo-key-id") + (req/get-header req "fudo-timestamp") + (-> req :body-string)])] + (sig/sign privkey data))) +(s/fdef generate-signature + :args (s/cat :req ::api/request + :privkey ::key/private-key) + :ret string?) + +(defn authenticate-request [req entity privkey key-id] + (let [full-req (-> req + (req/set-header "fudo-entity" entity) + (req/set-header "fudo-key-id" key-id) + (req/set-header "fudo-timestamp" (str (System/currentTimeMillis)))) + sig (generate-signature full-req privkey)] + (req/set-header full-req "fudo-signature" sig))) +(s/fdef authenticate-request + :args (s/cat :req ::api/request + :entity string? + :privkey ::key/private-key + :key-id ::crypt/sha1-hash) + :ret ::api/request) + +(defn remove-header [req header] + (assoc req :headers (dissoc (:headers req) header))) +(s/fdef remove-header + :args (s/cat :req ::api/request + :header string?) + :ret ::api/request) diff --git a/src/clj/fudo/api/request/validator.clj b/src/clj/fudo/api/request/validator.clj new file mode 100644 index 0000000..ef830bd --- /dev/null +++ b/src/clj/fudo/api/request/validator.clj @@ -0,0 +1,9 @@ +(ns fudo.api.request.validator) + +(defprotocol RequestValidator + (validate-request [this req] + "Given a request, perform desired validation steps. If the request + is found to be invalid, throw an exception + of :type ::fudo.api.request.validation/invalid, with a :msg + specifying the issue. Otherwise return the request, optionally + attaching additional metadata.")) diff --git a/src/clj/fudo/api/response.clj b/src/clj/fudo/api/response.clj new file mode 100644 index 0000000..d2276a4 --- /dev/null +++ b/src/clj/fudo/api/response.clj @@ -0,0 +1,36 @@ +(ns fudo.api.response) + +(defn ok [body] + {:status 200 + :headers {"Content-Type" "text/plain"} + :body body}) + +(defn created [body] + {:status 201 + :headers {"Content-Type" "text/plain"} + :body body}) + +(defn missing [& [msg]] + {:status 404 + :headers {"Content-Type" "text/plain"} + :body (str msg)}) + +(defn internal-error [& [msg]] + {:status 500 + :headers {"Content-Type" "text/plain"} + :body (str msg)}) + +(defn maybe-missing [body & [missing-msg]] + (if (nil? body) + (missing missing-msg) + (ok body))) + +(defn unauth [body] + {:status 401 + :headers {"Content-Type" "text/plain"} + :body (str body)}) + +(defn client-error [& [msg]] + {:status 400 + :headers {"Content-Type" "text/plain"} + :body (str msg)}) diff --git a/src/clj/fudo/crypto/keys.clj b/src/clj/fudo/crypto/keys.clj new file mode 100644 index 0000000..ff9e3b8 --- /dev/null +++ b/src/clj/fudo/crypto/keys.clj @@ -0,0 +1,81 @@ +(ns fudo.crypto.keys + (:require [base64-clj.core :as base64] + [fudo.crypto.utils :as utils] + [clojure.spec.alpha :as s])) + +(def key-algorithm "RSA") + +(defn- get-key-factory [] + (java.security.KeyFactory/getInstance key-algorithm)) + +(defn public-key? [obj] + (instance? java.security.PublicKey obj)) + +(s/def ::public-key public-key?) + +(defn private-key? [obj] + (instance? java.security.PrivateKey obj)) + +(s/def ::private-key private-key?) + +(s/def ::key-pair (s/keys :req [::public-key ::private-key])) + +(defn generate-public-private-pair [] + (let [make-pair (fn [pair] + {::private-key (.getPrivate pair) + ::public-key (.getPublic pair)})] + (make-pair + (.generateKeyPair + (doto (java.security.KeyPairGenerator/getInstance key-algorithm) + (.initialize 2048)))))) +(s/fdef generate-public-private-pair + :ret ::key-pair) + +(defn pubkey->string [pubkey] + (-> (.. (get-key-factory) + (getKeySpec pubkey java.security.spec.X509EncodedKeySpec) + getEncoded) + base64/encode-bytes + (String. "UTF-8"))) + +(s/fdef pubkey->string + :args (s/cat :pubkey public-key?) + :ret string?) + +(defn privkey->string [privkey] + (-> (.. (get-key-factory) + (getKeySpec privkey java.security.spec.PKCS8EncodedKeySpec) + (getEncoded)) + base64/encode-bytes + (String. "UTF-8"))) + +(s/fdef privkey->string + :args (s/cat :privkey private-key?) + :ret string?) + +(defn string->privkey [str] + (. (get-key-factory) + generatePrivate + (java.security.spec.PKCS8EncodedKeySpec. + (base64/decode-bytes (.getBytes str "UTF-8"))))) + +(s/fdef string->privkey + :args (s/cat :key-str string?) + :ret ::private-key) + +(defn string->pubkey [str] + (. (get-key-factory) + generatePublic + (java.security.spec.X509EncodedKeySpec. + (base64/decode-bytes (.getBytes str "UTF-8"))))) + +(s/fdef string->pubkey + :args (s/cat :str string?) + :ret ::public-key) + +(defn public-key-id [pubkey] + (utils/sha1-hash (pubkey->string pubkey))) + +(s/fdef public-key-id + :args (s/cat :pubkey public-key?) + :ret utils/sha1-hash?) diff --git a/src/clj/fudo/crypto/signatures.clj b/src/clj/fudo/crypto/signatures.clj new file mode 100644 index 0000000..5abfa77 --- /dev/null +++ b/src/clj/fudo/crypto/signatures.clj @@ -0,0 +1,44 @@ +(ns fudo.crypto.signatures + (:require [fudo.crypto.keys :as key] + [base64-clj.core :as base64] + [slingshot.slingshot :refer [throw+]] + [clojure.spec.alpha :as s])) + +(def signature-algorithm "SHA256withRSA") + +(defn- get-signature-factory [] + (java.security.Signature/getInstance signature-algorithm)) + +(defn sign [privkey input] + (try + (-> (.. (doto (get-signature-factory) + (.initSign privkey) + (.update (.getBytes input "UTF-8"))) + (sign)) + (base64/encode-bytes) + (String. "UTF-8")) + (catch Exception e + (throw+ {:type :signature/failed + :msg (format "Failure attempting to sign input: %s" + (.getMessage e)) + :cause e})))) +(s/fdef sign + :args (s/cat :privkey ::key/private-key + :input string?) + :ret string?) + +(defn verify [pubkey input signature] + (try + (.. (doto (get-signature-factory) + (.initVerify pubkey) + (.update (.getBytes input "UTF-8"))) + (verify (base64/decode-bytes (.getBytes signature "UTF-8")))) + (catch Exception e + (throw+ {:type :signature/invalid + :msg (format "Failure attempting to validate signature: %s" + (.getMessage e)) + :cause e})))) +(s/fdef verify + :args (s/cat :pubkey ::key/public-key + :input string? + :signature string?)) diff --git a/src/clj/fudo/crypto/utils.clj b/src/clj/fudo/crypto/utils.clj new file mode 100644 index 0000000..bbb3af1 --- /dev/null +++ b/src/clj/fudo/crypto/utils.clj @@ -0,0 +1,20 @@ +(ns fudo.crypto.utils + (:require [base64-clj.core :as base64] + [clojure.spec.alpha :as s])) + +(def hash-algorithm "sha1") + +(defn sha1-hash? [str] + (and (string? str) + (= (.length str) 28))) + +(s/def ::sha1-hash sha1-hash?) + +(defn sha1-hash [data] + (-> (.digest (java.security.MessageDigest/getInstance hash-algorithm) + (.getBytes data "UTF-8")) + base64/encode-bytes + (String. "UTF-8"))) +(s/fdef sha1-hash + :args (s/cat :data string?) + :ret ::sha1-hash) diff --git a/src/clj/fudo/db/auth_db.clj b/src/clj/fudo/db/auth_db.clj new file mode 100644 index 0000000..dcfc935 --- /dev/null +++ b/src/clj/fudo/db/auth_db.clj @@ -0,0 +1,65 @@ +(ns fudo.db.auth-db + (:require [clojure.spec.alpha :as s] + [fudo.crypto.utils :as crypt] + [fudo.crypto.keys :as key] + [fudo.net :as net])) + +(defprotocol AuthDb + + ;; Okay, so now that we've got keys and signatures... + ;; + ;; - When a user registers, they generate two keys and pass their + ;; pubkey to the server. It stores that to authenticate. + ;; + ;; - The auth header is sha1sum of the pubkey. This is used to map + ;; to owned assets (eg. a dyndns hostname), as well as the + ;; pubkey. + ;; + ;; - The body of the request should be signed with the private key, + ;; and the signature should be in the 'Signature' header. + + ;; Headers: + ;; - fudo-key-id - hash of the user/host's pubkey + ;; - fudo-signature - signature of the body of the request + ;; - fudo-timestamp - timestamp of request in milliseconds + ;; - fudo-entity - the name of the entity who is contacting the service + + ;; The verify-request function: + ;; - Fetches the matching key-id + ;; - Checks that the signature matches the body, key-id, and timestamp + ;; - Checks that the timestamp is recent + + (initialize [this] + "Perform any necessary initialization steps necessary for the + database, and return an initialized instance.") + + (register-entity! [this entity pubkey] + "Registers an entity by name, along with a key.") + + (get-entity-key-id [this entity] + "Given an entity name, return the entity's key id.") + + (get-entity-key [this entity] + "Given an entity name, return the entity's key.")) + +(defn auth-db? [obj] + (satisfies? AuthDb obj)) + +(s/def ::auth-db auth-db?) + +(s/def ::entity-name net/textual-hostname?) +(s/def ::key-id crypt/sha1-hash?) +(s/def ::pubkey key/public-key?) + +(s/def ::entity (s/keys :req [::entity-name ::pubkey ::key-id])) + +(defn make-entity [name pubkey] + { + ::entity-name name + ::pubkey pubkey + ::key-id (crypt/sha1-hash (key/pubkey->string pubkey)) + }) +(s/fdef make-entity + :args (s/cat :name ::entity-name + :pubkey ::pubkey) + :ret ::entity) diff --git a/src/clj/fudo/db/dyndns_db.clj b/src/clj/fudo/db/dyndns_db.clj new file mode 100644 index 0000000..6e2fb3d --- /dev/null +++ b/src/clj/fudo/db/dyndns_db.clj @@ -0,0 +1,35 @@ +(ns fudo.db.dyndns-db + (:require [clojure.spec.alpha :as s] + [fudo.net.dns :as dns])) + +(defprotocol DynDnsDb + (initialize [this] + "Initialize the DynDnsDb connection.") + (get-host [this host] + "Return DynDNS info related to host.") + (get-domain [this] + "Return the domain managed by this DynDnsDb instance.") + (register-v4ip! [this host v4ip] + "Set host's registered v4ip.") + (register-v6ip! [this host v6ip] + "Set host's registered v6ip.") + (register-sshfps! [this host sshfp] + "Set host's registered SSH fingerprints.")) + +(defrecord Host [v4ip v6ip sshfp]) + +(defn dyndns-db? [obj] + (satisfies? DynDnsDb obj)) + +(s/def ::dyndns-db dyndns-db?) + +(defn to-host [v4ip v6ip sshfp] + (-> {} + (cond-> v4ip (assoc ::dns/v4ip v4ip)) + (cond-> v6ip (assoc ::dns/v6ip v6ip)) + (cond-> sshfp (assoc ::dns/sshfp sshfp)))) +(s/fdef to-host + :args (s/cat :v4ip (s/nilable ::dns/v4ip) + :v6ip (s/nilable ::dns/v6ip) + :sshfp (s/nilable ::dns/sshfp-records)) + :ret ::dns/host) diff --git a/src/clj/fudo/db/pg_auth_db.clj b/src/clj/fudo/db/pg_auth_db.clj new file mode 100644 index 0000000..3790f21 --- /dev/null +++ b/src/clj/fudo/db/pg_auth_db.clj @@ -0,0 +1,157 @@ +(ns fudo.db.pg-auth-db + (:require [fudo.db.auth-db :as auth-db] + [fudo.server.config :as config] + [fudo.crypto.utils :as crypt] + [fudo.crypto.keys :as key] + [fudo.crypto.signatures :as sig] + [fudo.server.request-validator :as validator] + [fudo.utils :as util] + [clojure.java.jdbc :as sql] + [slingshot.slingshot :refer [throw+]] + [com.stuartsierra.component :as component] + [clojure.string :refer [trim]] + [clojure.spec.alpha :as s])) + +(s/def ::dbtype #{"postgresql"}) +(s/def ::dbname string?) +(s/def ::user string?) +(s/def ::password string?) +(s/def ::host string?) + +(s/def ::db-spec + (s/keys :req-un [::dbtype + ::dbname + ::user + ::password + ::host])) + +(defn- pg-connection-spec [host dbname user passwd] + { + :dbtype "postgresql" + :dbname dbname + :user user + :password passwd + :host host + }) +(s/fdef pg-connection-spec + :args (s/cat :host string? + :dbname string? + :user string? + :passwd string?) + :ret ::db-spec) + +(defn- pg-connection-get-config [config] + (let [this-host (config/get-hostname) + config-path (format "services::%s::auth::auth_database" + this-host) + db-config (config/get-path config config-path)] + { + :host (config/get-env-or config + "FUDO_AUTH_DB_HOST" + (:host db-config)) + :user (config/get-env-or config + "FUDO_AUTH_DB_USER" + (:user db-config)) + :database (config/get-env-or config + "FUDO_AUTH_DB_DATABASE" + (:database db-config)) + :passwd (config/get-env-or config + "FUDO_AUTH_DB_PASSWD" + (slurp (:passwd_file db-config))) + })) + +(defn- pg-connect [cfg] + (pg-connection-spec (:host cfg) (:database cfg) (:user cfg) (:passwd cfg))) + +(defn- row->entity [row] + { + ::auth-db/entity-name (trim (:entity_name row)) + ::auth-db/key-id (trim (:key_id row)) + ::auth-db/pubkey (key/string->pubkey (trim (:public_key row))) + }) +(s/fdef row->entity + :args (s/cat :row (s/map-of simple-keyword? any?)) + :ret ::auth-db/entity) + +(defn- get-by-name [db-spec name] + (first + (map row->entity + (sql/query db-spec + ["SELECT entity_name, key_id, public_key FROM entities WHERE entity_name = ?" + name])))) +(s/fdef get-by-name + :args (s/cat :db-spec ::db-spec + :name ::auth-db/entity-name) + :ret ::auth-db/entity) + +(defn- insert-entity! [db-spec data] + (sql/insert! db-spec + :entities + {:entity_name (::auth-db/entity-name data) + :key_id (::auth-db/key-id data) + :public_key (key/pubkey->string (::auth-db/pubkey data))}) + (::auth-db/key-id data)) +(s/fdef insert-entity! + :args (s/cat :db-spec ::db-spec + :data ::auth-db/entity) + :ret ::auth-db/key-id) + +(defn- get-entity-key [db-spec name] + (::auth-db/pubkey (get-by-name db-spec name))) +(s/fdef get-entity-key + :args (s/cat :db-spec ::db-spec + :name ::auth-db/name) + ret ::auth-db/pubkey) + +(defn- initialize-connection [config] + (pg-connect (pg-connection-get-config config))) + +(s/def ::debug boolean?) + +(s/def ::initialized-pg-auth-db + (s/keys :req-un [::debug ::config/config ::db-spec])) + +(s/def ::uninitialized-pg-auth-db + (s/keys :req-un [::debug])) + +(defrecord PGAuthDb [debug config db-spec] + + component/Lifecycle + + (start [this] + (->PGAuthDb (:debug this) + (:config this) + (initialize-connection (:config this)))) + + (stop [this] this) + + auth-db/AuthDb + + validator/RequestValidator + + (initialize [this] + (->PGAuthDb (:debug this) + (:config this) + (initialize-connection (:config this)))) + + (register-entity! [this entity-name pubkey] + (let [key-id (insert-entity! (:db-spec this) + (auth-db/make-entity entity-name pubkey))] + (util/info (format "Inserted new key %s for entity %s" key-id entity-name)) + {::auth-db/key-id key-id})) + + (get-entity-key-id [this entity] + (some-> (:db-spec this) + (get-by-name entity) + ::auth-db/key-id)) + + (get-entity-key [this entity] + (some-> (:db-spec this) + (get-by-name entity) + ::auth-db/pubkey))) + +(defn init [debug] + (->PGAuthDb debug nil nil)) +(s/fdef init + :args (s/cat :debug boolean?) + :ret ::uninitialized-pg-auth-db) diff --git a/src/clj/fudo/db/pg_dyndns_db.clj b/src/clj/fudo/db/pg_dyndns_db.clj new file mode 100644 index 0000000..ae3b3d5 --- /dev/null +++ b/src/clj/fudo/db/pg_dyndns_db.clj @@ -0,0 +1,402 @@ +(ns fudo.db.pg-dyndns-db + (:require [fudo.db.dyndns-db :as dyndns-db] + [fudo.utils :as util] + [fudo.server.config :as config] + [fudo.net.ip :as ip] + [fudo.net :as net] + [fudo.net.dns :as dns] + [clojure.java.jdbc :as sql] + [clojure.set :as set] + [com.stuartsierra.component :as component] + [clojure.string :as str] + [clojure.spec.alpha :as s])) + +(s/def ::dbtype #{"postgresql"}) +(s/def ::dbname string?) +(s/def ::user string?) +(s/def ::password string?) +(s/def ::host string?) + +(s/def ::db-spec + (s/keys :req-un [::dbtype + ::dbname + ::user + ::password + ::host])) + +(defn- pg-connection-spec [host dbname user passwd] + { + :dbtype "postgresql" + :dbname dbname + :user user + :password passwd + :host host + }) +(s/fdef pg-connection-spec + :args (s/cat :host string? + :dbname string? + :user string? + :password string?) + :ret ::db-spec) + +(defn- get-config-domain [config] + (let [this-host (config/get-hostname)] + (config/get-path config (format "services::%s::dyndns::domain" + this-host)))) +(s/fdef get-config-domain + :args (s/cat :config ::config/config) + :ret ::net/domain) + +(defn- make-fqdn [host domain] + (format "%s.%s" host domain)) +(s/fdef make-fqdn + :args (s/cat :host string? :domain string?) + :ret ::net/host-fqdn + :fn (s/and #(str/starts-with? (:ret %) (-> % :args :host)) + #(str/ends-with? (:ret %) (-> % :args :domain)))) + +(defn- take-hostname [fqdn domain] + (let [domain-regex (re-pattern (format ".%s$" domain))] + (str/replace-first fqdn domain-regex ""))) +(s/fdef take-hostname + :args (s/cat :fqdn string? :domain string?) + :ret string? + :fn #(str/starts-with? (-> % :args :fqdn) (:ret %))) + +(defn positive-integer? [val] + (and (integer? val) (> val 0))) + +(s/def ::id positive-integer?) +(s/def ::domain-id positive-integer?) +(s/def ::type #{:SOA :SSHFP :A :AAAA :CNAME :NS :PTR :TXT :MX :SPF :RP :SRV}) +(s/def ::name net/textual-hostname?) +(s/def ::content string?) +(s/def ::record (s/cat :type ::type :name ::name :content ::content)) + +(defmulti valid-record first) +(defmethod valid-record :SOA [_] + (s/cat :type #{:SOA} + :name ::net/host-fqdn + :content ::valid-soa-record)) +(defmethod valid-record :SSHFP [_] + (s/cat :type #{:SSHFP} + :name ::net/host-fqdn + :content ::dns/sshfp-record)) +(defmethod valid-record :A [_] + (s/cat :type #{:A} + :name ::net/host-fqdn + :content ::ip/v4ip)) +(defmethod valid-record :AAAA [_] + (s/cat :type #{:AAAA} + :name ::net/host-fqdn + :content ::ip/v6ip)) +(defmethod valid-record :CNAME [_] + (s/cat :type #{:CNAME} + :name ::net/host-fqdn + :content ::net/host-fqdn)) +(defmethod valid-record :NS [_] + (s/cat :type #{:NS} + :name ::net/host-fqdn + :content ::net/host-fqdn)) +(defmethod valid-record :PTR [_] + (s/cat :type #{:PTR} + :name ::net/host-fqdn + :content ::valid-ptr-record)) +(defmethod valid-record :TXT [_] + (s/cat :type #{:TXT} + :name ::net/host-fqdn + :content string?)) +(defmethod valid-record :MX [_] + (s/cat :type #{:MX} + :name ::net/host-fqdn + :content ::valid-mx-record)) +(defmethod valid-record :RP [_] + (s/cat :type #{:RP} + :name ::net/host-fqdn + :content ::net/host-fqdn)) +(defmethod valid-record :SRV [_] + (s/cat :type #{:SRV} + :name :net/srv-name + :content ::valid-srv-record)) + +(defn valid-mx-record? [rec] + (and (string? rec) + (let [[pref host] (str/split rec #" ")] + (and (not (nil? (re-matches #"^[0-9]{1,2}$" pref))) + (net/textual-hostname? host))))) +(s/def ::valid-mx-record valid-mx-record?) + +(defn valid-ptr-record? [rec] + (let [v6-rx #"^([0-9a-f]\.){32}ip6.arpa\.?$" + v4-rx #"^(([0-9]|[0-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.){4}in-addr\.arpa\.?$"] + (and (string? rec) + (or (not (nil? (re-matches v6-rx rec))) + (not (nil? (re-matches v4-rx rec))))))) +(s/def ::valid-ptr-record valid-ptr-record?) + +(defn valid-soa-record? [rec] + (and (string? rec) + (let [[ns-host id-host] (str/split rec #" ")] + (and (net/textual-hostname? ns-host) + (net/textual-hostname? id-host))))) +(s/def ::valid-soa-record valid-soa-record?) + +(defn valid-srv-record? [rec] + (and (string? rec) + (let [[share-str host] (str/split rec #" ") + share (Integer. share-str)] + (and (> share 0) + (<= share 100) + (net/textual-hostname? host))))) +(s/def ::valid-srv-record valid-srv-record?) + +(s/def ::resource-record + (s/keys :req [::id ::domain-id ::name ::type ::content])) + +(defn- row->resource-record [rec] + { + ::id (-> rec :id) + ::domain-id (-> rec :domain_id) + ::name (-> rec :name) + ::type (-> rec :type keyword) + ::content (-> rec :content) + }) +(s/fdef row->resource-record + :args (s/cat :rec (s/map-of simple-keyword? any?)) + :ret ::resource-record) + +(defn- get-host-records [db-spec domain-id hostname] + (let [qry ["SELECT * FROM records WHERE domain_id = ? AND name = ?" + domain-id + hostname]] + (clojure.pprint/pprint qry) + (map row->resource-record + (sql/query db-spec + qry)))) +(s/fdef get-host-records + :args (s/cat :db-spec ::db-spec + :domain-id ::domain-id + :hostname ::net/host-fqdn) + :ret ::dns/host) + +(defn- pg-get-host [db-spec domain-id hostname] + (let [find-first (fn [fn lst] (first (filter fn lst))) + records (get-host-records db-spec domain-id hostname) + v4ip-rec (find-first #(= (::type %) :A) records) + v6ip-rec (find-first #(= (::type %) :AAAA) records) + sshfp-recs (filter #(= (::type %) :SSHFP) records)] + (dyndns-db/to-host (some-> v4ip-rec + ::content + ip/address) + (some-> v6ip-rec + ::content + ip/address) + (map :content sshfp-recs)))) +(s/fdef pg-get-host + :args (s/cat :db-spec ::db-spec + :domain-id ::domain-id + :hostname ::net/host-fqdn) + :ret ::dns/host) + +(defn- update-record-content-by-id! [db-spec record-id content] + (sql/update! db-spec + :records + {:content content} + ["id = ?" record-id]) + true) +(s/fdef update-record-content-by-id! + :args (s/cat :db-spec ::db-spec + :record-id ::id + :content string?) + :ret boolean?) + +(defn- delete-sshfp-records! [db-spec domain-id hostname] + (sql/delete! db-spec + :records + ["name = ? AND domain_id = ? AND type = 'SSHFP'" + hostname + domain-id]) + true) +(s/fdef delete-sshfp-records! + :args (s/cat :db-spec ::db-spec :domain-id ::domain-id :hostname ::net/host-fqdn) + :ret boolean?) + +(defn- insert-record-by-type! [db-spec domain-id hostname type content] + (sql/insert! db-spec + :records + {:name hostname + :type (name type) + :content content + :domain_id domain-id}) + true) +(s/fdef insert-record-by-type! + :args (s/cat :db-spec ::db-spec + :domain-id ::domain-id + :hostname ::net/host-fqdn + :type ::type + :content string?) + :ret boolean?) + +(defn- get-record-by-type [db-spec domain-id hostname type] + (some-> (sql/query db-spec + ["SELECT * FROM records WHERE name = ? AND type = ? AND domain_id = ?" + hostname + (name type) + domain-id]) + first + row->resource-record)) +(s/fdef get-record-by-type + :args (s/cat :db-spec ::db-spec + :domain-id ::domain-id + :hostname ::net/host-fqdn + :type ::type) + :ret (s/nilable ::resource-record)) + +(defn- maybe-update-host-content! [db-spec domain-id hostname rec-type content] + (if-let [rec (get-record-by-type db-spec domain-id hostname rec-type)] + (update-record-content-by-id! db-spec (::id rec) content) + (insert-record-by-type! db-spec domain-id hostname rec-type content))) +(s/fdef maybe-update-host-content! + :args (s/cat :db-spec ::db-spec + :domain-id ::domain-id + :hostname ::net/host-fqdn + :type ::type + :content string?) + :ret boolean?) + +(defn- pg-register-v4ip! [db-spec domain-id hostname v4ip] + (maybe-update-host-content! db-spec domain-id hostname :A (ip/to-string v4ip))) +(s/fdef pg-register-v4ip! + :args (s/cat :db-spec ::db-spec + :domain-id ::domain-id + :hostname ::net/host-fqdn + :v4ip ::ip/v4ip) + :ret boolean?) + +(defn- pg-register-v6ip! [db-spec domain-id hostname v6ip] + (maybe-update-host-content! db-spec domain-id hostname :AAAA (ip/to-string v6ip))) +(s/fdef pg-register-v6ip! + :args (s/cat :db-spec ::db-spec + :domain-id ::domain-id + :hostname ::net/host-fqdn + :v6ip ::ip/v6ip) + :ret boolean?) + +(defn- pg-register-sshfp! [db-spec domain-id hostname sshfps] + (do (delete-sshfp-records! db-spec domain-id hostname) + (doseq [sshfp sshfps] + (insert-record-by-type! db-spec domain-id hostname :SSHFP sshfp)))) +(s/fdef pg-register-sshfp! + :args (s/cat :db-spec ::db-spec + :domain-id ::domain-id + :hostname ::net/host-fqdn + :sshfps ::dns/sshfp-records) + :ret boolean?) + +(defn- pg-get-domain-id [db-spec domain] + (some-> (sql/query db-spec + ["SELECT id FROM domains WHERE name = ? AND type = 'MASTER'" + domain]) + first + :id)) +(s/fdef pg-get-domain-id + :args (s/cat :db-spec ::db-spec + :domain ::net/domain) + :ret (s/nilable ::domain-id)) + +(defn- get-sshfp [db-spec host] + (some-> (sql/query db-spec + ["SELECT content FROM domains WHERE name = "]))) +(s/fdef get-sshfp + :args (s/cat :db-spec ::db-spec + :host ::net/host-fqdn) + :ret ::dns/sshfp-records) + + +(defn- initialize-connection [config] + (let [this-host (config/get-hostname) + db-config-path (format "services::%s::dyndns::dns_database" + this-host) + db-config (config/get-path config db-config-path)] + (pg-connection-spec (config/get-env-or config + "FUDO_DYNDNS_DB_HOST" + (:host db-config)) + (config/get-env-or config + "FUDO_DYNDNS_DB_DATABASE" + (:database db-config)) + (config/get-env-or config + "FUDO_DYNDNS_DB_USER" + (:user db-config)) + (config/get-env-or config + "FUDO_DYNDNS_DB_PASSWD" + (slurp (:passwd_file db-config)))))) + +(defn- pg-sshfp-changed? [db-spec host domain-id sshfps] + (let [db-sshfps (::dyndns-db/sshfp (pg-get-host db-spec domain-id host))] + (not (empty? (set/difference sshfps db-sshfps))))) +(s/fdef pg-sshfp-changed? + :args (s/cat :db-spec ::db-spec + :host ::net/host-fqdn + :domain-id ::domain-id + :sshfps ::dns/sshfp-records)) + +(defrecord PGDynDnsDb [debug config conn domain] + + dyndns-db/DynDnsDb + + (initialize [this] + (->PGDynDnsDb (:debug this debug) + (:config this) + (initialize-connection config) + (get-config-domain config))) + + (get-host [this host] + (pg-get-host (:conn this) + (pg-get-domain-id (:conn this) (:domain this)) + (make-fqdn host (:domain this)))) + + (register-v4ip! [this host v4ip] + (let [result + (pg-register-v4ip! (:conn this) + (pg-get-domain-id (:conn this) (:domain this)) + (make-fqdn host (:domain this)) + v4ip)] + (util/info (format "Registered v4ip %s for host %s." + (ip/to-string v4ip) host)) + result)) + + (register-v6ip! [this host v6ip] + (let [result + (pg-register-v6ip! (:conn this) + (pg-get-domain-id (:conn this) (:domain this)) + (make-fqdn host (:domain this)) + v6ip)] + (util/info (format "Registered v6ip %s for host %s." + (ip/to-string v6ip) host)) + result)) + + (register-sshfps! [this host sshfps] + (if (pg-sshfp-changed? (:conn this) + host + (pg-get-domain-id (:conn this) (:domain this)) + sshfps) + (do (println "SSHFP changed! Inserting!") + (let [result + (pg-register-sshfp! (:conn this) + (pg-get-domain-id (:conn this) (:domain this)) + (make-fqdn host (:domain this)) + sshfps)] + (util/info (format "Registered new SSHFP records for host %s" + host)) + result)) + (do (println "SSHFP unchanged!") true))) + + (get-domain [this] + (get-config-domain (:config this)))) + +(defn init [debug config] + (->PGDynDnsDb debug config nil nil)) +(s/fdef create + :args (s/cat :debug boolean? + :config ::config/config) + :ret ::dyndns-db/dyndns-db) diff --git a/src/clj/fudo/db/transient_auth_db.clj b/src/clj/fudo/db/transient_auth_db.clj new file mode 100644 index 0000000..203230f --- /dev/null +++ b/src/clj/fudo/db/transient_auth_db.clj @@ -0,0 +1,36 @@ +(ns fudo.db.transient-auth-db + (:require [fudo.db.auth-db :as auth-db] + [fudo.crypto.keys :as key] + [fudo.crypto.utils :as crypt] + [clojure.spec.alpha :as s] + [com.stuartsierra.component :as component])) + +(defrecord TransientAuthDb [debug config keystore] + + component/Lifecycle + + (start [this] this) + + (stop [this] this) + + auth-db/AuthDb + + (initialize [this] this) + + (register-entity! [this entity-name pubkey] + (let [entity (auth-db/make-entity entity-name pubkey)] + (swap! (:keystore this) + (fn [store] + (assoc store entity-name entity))) + (::auth-db/key-id entity))) + + (get-entity-key-id [this entity] + (some-> (get @keystore entity) + ::auth-db/key-id)) + + (get-entity-key [this entity] + (some-> (get @keystore entity) + ::auth-db/pubkey))) + +(defn create [debug config auth-hosts] + (->TransientAuthDb debug config (atom auth-hosts))) diff --git a/src/clj/fudo/db/transient_dyndns_db.clj b/src/clj/fudo/db/transient_dyndns_db.clj new file mode 100644 index 0000000..f5978fc --- /dev/null +++ b/src/clj/fudo/db/transient_dyndns_db.clj @@ -0,0 +1,55 @@ +(ns fudo.db.transient-dyndns-db + (:require [fudo.db.dyndns-db :as dyndns-db] + [fudo.net.dns :as dns] + [fudo.net.ip :as ip] + [fudo.net :as net] + [clojure.spec.alpha :as s] + [com.stuartsierra.component :as component])) + +(s/def ::hostmap (s/map-of string? ::dns/host)) + +(defn pp [obj] + (clojure.pprint/pprint obj) + obj) + +(defrecord TransientDynDnsDb [debug config domain store] + + dyndns-db/DynDnsDb + + (initialize [this] this) + + (get-host [this hostname] + (let [host (get @(:store this) hostname)] + (dyndns-db/to-host (some-> host ::dns/v4ip) + (some-> host ::dns/v6ip) + (some-> host ::dns/sshfp)))) + + (register-v4ip! [this host v4ip] + (swap! (:store this) + (fn [old-store] + (assoc-in old-store [host :v4ip] v4ip)))) + + (register-v6ip! [this host v6ip] + (swap! (:store this) + (fn [old-store] + (assoc-in old-store [host :v6ip] v6ip)))) + + (register-sshfps! [this host sshfps] + (swap! (:store this) + (fn [old-store] + (assoc-in old-store [host :sshfps] sshfps)))) + + (get-domain [this] + (:domain this))) + +(defn create [debug config domain hostmap] + (->TransientDynDnsDb debug + config + domain + (atom hostmap))) +(s/fdef create + :args (s/cat :debug boolean? + :config any? + :domain ::net/domain + :hostmap ::hostmap) + :ret ::dyndns-db/dyndns-db) diff --git a/src/clj/fudo/net.clj b/src/clj/fudo/net.clj new file mode 100644 index 0000000..da837f7 --- /dev/null +++ b/src/clj/fudo/net.clj @@ -0,0 +1,34 @@ +(ns fudo.net + (:require [clojure.spec.alpha :as s])) + +(def host-element "([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])") + +(defn textual-hostname? [str] + (let [host-rx (re-pattern (format "^(%s\\.)*%s\\.?$" host-element host-element))] + (not (nil? (re-matches host-rx str))))) + +(defn host-fqdn? [str] + "A hosts' FQDN must contain at least three components, + since (realistically) any two-element hostname (eg. myhost.org) must + be a mistake in the context of dyndns." + (let [fqdn-rx (re-pattern (format "^(%s\\.){2,}%s\\.?$" host-element host-element))] + (not (nil? (re-matches fqdn-rx str))))) + +(defn domain? [str] + (let [domain-rx (re-pattern (format "^(%s\\.)*%s\\.?$" host-element host-element))] + (not (nil? (re-matches domain-rx str))))) + +(defn srv-name? [str] + (let [srv-name-rx (re-pattern (format "^_%s\\._(tcp|udp)\\.(%s\\.){1,}%s\\.?$" + host-element + host-element + host-element))] + (not (nil? (re-matches srv-name-rx str))))) + +(s/def ::hostname textual-hostname?) + +(s/def ::host-fqdn host-fqdn?) + +(s/def ::domain domain?) + +(s/def ::srv-name srv-name?) diff --git a/src/clj/fudo/net/dns.clj b/src/clj/fudo/net/dns.clj new file mode 100644 index 0000000..99a4274 --- /dev/null +++ b/src/clj/fudo/net/dns.clj @@ -0,0 +1,19 @@ +(ns fudo.net.dns + (:require [clojure.spec.alpha :as s] + [fudo.net.ip :as ip])) + +(defn sshfp-record? [rec] + (let [sshfp-rx #"^[0-5] [0-5] [0-9A-Za-z]{32,128}$"] + (and (string? rec) + (not (nil? (re-matches sshfp-rx rec)))))) + +(s/def ::sshfp-record sshfp-record?) + +(s/def ::v4ip (s/nilable ip/v4?)) + +(s/def ::v6ip (s/nilable ip/v6?)) + +(s/def ::sshfp-records (s/coll-of ::sshfp-record)) + +(s/def ::host + (s/keys :opt [::v4ip ::v6ip ::sshfp-records])) diff --git a/src/clj/fudo/net/ip.clj b/src/clj/fudo/net/ip.clj new file mode 100644 index 0000000..e4af57b --- /dev/null +++ b/src/clj/fudo/net/ip.clj @@ -0,0 +1,59 @@ +(ns fudo.net.ip + (:require [slingshot.slingshot :refer [try+ throw+]] + [clojure.spec.alpha :as s])) + +(defn v6? [addr] + (instance? java.net.Inet6Address addr)) + +(defn v4? [addr] + (instance? java.net.Inet4Address addr)) + +(defn address? [addr] + (or (v6? addr) (v4? addr))) + +(defn address [addr-str] + (try + (java.net.InetAddress/getByName addr-str) + (catch java.net.UnknownHostException ex + (throw+ {:type :ip/invalid-ip-address + :class :client-error + :address-string addr-str + :msg (format "Invalid IP address: %s" addr-str)})))) + +(defn valid-ip? [addr-string] + (try+ + (address addr-string) + true + (catch [:type ::invalid-ip-address] _ + false))) + +(defn v4ip? [str] + (and (valid-ip? str) + (v4? (address str)))) + +(defn v6ip? [str] + (and (valid-ip? str) + (v6? (address str)))) + +(s/def ::v4ip v4?) +(s/def ::v6ip v6?) +(s/fdef address + :args (s/cat :addr-str string?) + :ret address?) + +(s/def ::v4ip-str v4ip?) +(s/def ::v6ip-str v6ip?) + +(defn local? [addr] + (or (.isAnyLocalAddress addr) + (.isLoopbackAddress addr) + (.isSiteLocalAddress addr))) + +(defn global? [addr] + (not (local? addr))) + +(defn to-string [addr] + (.getHostAddress addr)) +(s/fdef to-string + :args (s/cat :addr address?) + :ret string?) diff --git a/src/clj/fudo/server.clj b/src/clj/fudo/server.clj new file mode 100644 index 0000000..91264d2 --- /dev/null +++ b/src/clj/fudo/server.clj @@ -0,0 +1,62 @@ +(ns fudo.server + (:require [fudo.server.config :as config] + [fudo.db.auth-db :as auth-db] + [fudo.server.service :as service] + [fudo.server.services :as services] + [fudo.server.middleware :as middleware] + [ring.adapter.jetty :refer [run-jetty]] + [slingshot.slingshot :refer [try+ throw+]] + [com.stuartsierra.component :as component] + [clojure.spec.alpha :as s])) + +(defn- get-file-contents [filename] + (when (not (.exists (clojure.java.io/as-file filename))) + (throw+ {:type :server/file-error + :class :server-error + :msg (format "File doesn't exist: %s" + filename)})) + (clojure.string/trim (slurp filename))) +(s/fdef get-file-contents + :args (s/cat :filename string?) + :ret (s/nilable string?)) + +(defn start-server [debug config fudo-services] + (let [api-handler (service/generate-handler fudo-services) + this-host (config/get-hostname) + service-config (config/get-path config (format "services::%s::service" this-host)) + server-port (config/get-env-or config + "FUDO_SERVICE_PORT" + (:port service-config)) + keystore (config/get-env-or config + "FUDO_SERVER_KEYSTORE" + (:keystore service-config)) + key-password (config/get-env-or config + "FUDO_SERVER_KEYSTORE_PASSWD" + (get-file-contents + (:keystore_passwd_file service-config)))] + (println (format "Starting server on port %s" server-port)) + (run-jetty api-handler + {:ssl-port server-port + :http? false + :ssl? true + :keystore keystore + :key-password key-password}))) + +(defrecord FudoServer [debug config fudo-services server] + + component/Lifecycle + + (start [this] + (->FudoServer (:debug this) + (:config this) + (:fudo-services this) + (start-server (:debug this) + (:config this) + (:fudo-services this)))) + + (stop [this] + (.stop (:server this)))) + +(defn init [debug] + (->FudoServer debug nil nil nil)) + diff --git a/src/clj/fudo/server/config.clj b/src/clj/fudo/server/config.clj new file mode 100644 index 0000000..68452c8 --- /dev/null +++ b/src/clj/fudo/server/config.clj @@ -0,0 +1,184 @@ +(ns fudo.server.config + (:require [clojure.string :as str] + [clojure.data.json :as json] + [yaml.core :as yaml] + [slingshot.slingshot :refer [throw+]] + [com.stuartsierra.component :as component] + [clojure.spec.alpha :as s])) + +(def DELIM #"::") + +(defn- load-env-config [prefix] + (into {} + (map (fn [key] + {(keyword key) (System/getenv key)})) + (filter #(str/starts-with? % prefix) + (keys (System/getenv))))) + +(defn- load-yaml-file [file] (yaml/from-file file)) + +(defn- load-json-file [file] (json/read-str (slurp file) + :key-fn keyword)) + +(defn- yaml-file? [file] (str/ends-with? (.getName file) ".yml")) + +(defn- json-file? [file] (str/ends-with? (.getName file) ".json")) + +(defn- make-key [file] + (if (.isDirectory file) + (keyword (.getName file)) + (keyword + (str/join "." (-> (.getName file) + (str/split #"\.") + butlast))))) + +(defn- load-file-config [path-file] + (when (not (.isDirectory path-file)) + (throw+ {:type :config/invalid-filesystem-path + :path path-file + :class :server-error + :msg (format "Not a valid configuration path: %s" + (.getPath path-file))})) + (let [path-contents (.listFiles path-file)] + (into {} + (map (fn [file] + (cond (.isDirectory file) {(make-key file) + (load-file-config file)} + (json-file? file) {(make-key file) + (load-json-file file)} + (yaml-file? file) {(make-key file) + (load-yaml-file file)} + :else {}))) + path-contents))) + +(defn- print-nested-config [config depth] + (defn- make-space [this-depth] + (str/join (map (fn [_] " ") (range this-depth)))) + (defn- print-el [el this-depth] + (cond (map? el) (print-nested-config el this-depth) + (sequential? el) (do + (println (format "%s>>" (make-space this-depth))) + (doseq [e0 el] (print-el e0 (+ this-depth 2)))) + :else (println (format "%s%s" + (make-space this-depth) + el)))) + (doseq [key (keys config)] + (println (format "%s%s::" (make-space depth) (name key))) + (print-el (get config key) (+ depth 2)))) + +(defn- print-config-and-env [config] + (println "ENV") + (let [env (:env config)] + (doseq [key (keys env)] + (println (format " %s: %s" key (get env key))))) + (println "CONFIG") + (print-nested-config (:config config) 2)) + +(defprotocol FudoConfig + "A protocol for reading configurations." + (get-path [self path]) + (get-env [self env-var]) + (get-env-or [self env-var else]) + (print-config [self]) + (refresh [self]) + (has-path? [self path])) + +(defrecord FudoServerConfig [debug config-path env-prefix config env] + component/Lifecycle + + (start [self] + (when debug (print "Loading server config...")) + (let [config (->FudoServerConfig debug + (:config-path self) + (:env-prefix self) + (load-file-config (:config-path self)) + (load-env-config (:env-prefix self)))] + (when debug (println "done.")) + config)) + + (stop [self] + (when debug (println "Dropping server config...done.")) + self) + + FudoConfig + + (get-path [self path] + (let [path-els (map keyword (str/split path DELIM))] + (if-let [value (get-in (:config self) path-els)] + value + (throw+ {:type ::invalid-path + :path path + :class :server-error + :msg (format "Invalid config path: %s" path)})))) + + (get-env [self env-var] + (get (:env self) (keyword env-var))) + + (get-env-or [self env-var else] + (if-let [val (get-env self env-var)] + val + else)) + + (print-config [self] + (print-config-and-env self)) + + (refresh [self] + (when debug (print "Reloading server config...")) + (->FudoServerConfig debug + (:config-path self) + (:env-prefix self) + (load-file-config (:config-path self)) + (load-env-config (:env-prefix self))) + (when debug (println "done."))) + + (has-path? [self path] + (let [path-els (map keyword (str/split path DELIM))] + (not (nil? (get-in (:config self) path-els))))) + + Object + + (toString [_] "")) + +(defrecord MockConfig [env] + + FudoConfig + + (get-path [self path] + (throw+ {:type :mock-not-implemented + :class :mock-error})) + + (get-env [self env-var] + (get (:env self) env-var)) + + (get-env-or [self env-var else] + (throw+ {:type :mock-not-implemented + :class :mock-error})) + + (print-config [self] + (throw+ {:type :mock-not-implemented + :class :mock-error})) + + (refresh [self] + (throw+ {:type :mock-not-implemented + :class :mock-error})) + + (has-path? [self path] + (throw+ {:type :mock-not-implemented + :class :mock-error}))) + +(defn config? [obj] + (satisfies? FudoConfig obj)) + +(s/def ::config config?) + +(defn init-config [debug config-path env-prefix] + (->FudoServerConfig debug (clojure.java.io/file config-path) env-prefix nil nil)) + +(defn init-mock-config [env] + (->MockConfig env)) + +(defn get-fqdn [] + (.getHostName (java.net.InetAddress/getLocalHost))) + +(defn get-hostname [] + (first (str/split (get-fqdn) #"\."))) diff --git a/src/clj/fudo/server/core.clj b/src/clj/fudo/server/core.clj new file mode 100644 index 0000000..0c7eef0 --- /dev/null +++ b/src/clj/fudo/server/core.clj @@ -0,0 +1,121 @@ +(ns fudo.server.core + (:import [org.apache.commons.daemon Daemon DaemonContext]) + (:gen-class :implements [org.apache.commons.daemon.Daemon]) + (:require [com.stuartsierra.component :as component] + [clojure.tools.cli :refer [parse-opts]] + [clojure.string :as str] + [fudo.server.config :as config] + [fudo.server.service :as service] + [fudo.server.services :as services] + [fudo.server.services.dyndns :as srv:dyndns] + [fudo.server.services.auth :as srv:auth] + [fudo.api.request.authenticator.auth-db :as authenticator] + [fudo.db.pg-auth-db :as pg-auth-db] + [fudo.db.dyndns-db :as dyndns-db] + [fudo.db.pg-dyndns-db :as pg-dyndns-db] + [fudo.server :as server] + [brolog.logging :as log] + [clojure.spec.alpha :as s] + [orchestra.spec.test :as spectest])) + +(spectest/instrument) + +;; Eventually, look at configs and shit. For now, hardcode. +(defn- initialize-service-manifest [debug config auth-db] + (let [dyndns-db (pg-dyndns-db/init debug config)] + [(srv:dyndns/create debug config (dyndns-db/initialize dyndns-db)) + (srv:auth/create debug config auth-db)])) +(s/fdef create-local-services + :args (s/cat :config ::config/config) + :ret (s/coll-of ::service/service)) + +(defrecord LocalServices [debug config auth-db services] + + component/Lifecycle + + (start [this] + (->LocalServices (:debug this) + (:config this) + (:auth-db this) + (initialize-service-manifest (:debug this) + (:config this) + (:auth-db this)))) + + (stop [this] this)) + +(defn- create-service-manifest [debug] + (->LocalServices debug nil nil nil)) + +(defn- make-service-system [debug config-path env-prefix] + (component/system-map + :config (config/init-config debug config-path env-prefix) + :auth-db (component/using (pg-auth-db/init debug) + [:config]) + :authenticator (component/using (authenticator/init debug) + [:config :auth-db]) + :local-services (component/using (create-service-manifest debug) + [:config :auth-db]) + :fudo-services (component/using (services/init debug) + [:config :local-services :authenticator]) + :fudo-server (component/using (server/init debug) + [:config :fudo-services]))) + +(def ^:dynamic *server* (atom nil)) + +(defn- directory? [filename] + (.isDirectory (clojure.java.io/file filename))) + +(defn- load-args [args] + (let [server-options + [["-c" "--config-path PATH" + "Path from which to load configuration." + :default "/fudo/config" + :validate [directory? "Config must point to a valid filesystem path."]] + ["-e" "--env-prefix PREFIX" + "Prefix of environment variables to be captured as configuration." + :default "FUDO"] + ["-d" "--debug" + "Set the debug flag to request more verbose output."]] + args (parse-opts args server-options)] + (when (:errors args) + (throw (java.lang.IllegalArgumentException. + (format "Failure parsing args %s: \n%s" + (str/join " " args) + (str/join "\n" (:errors args)))))) + (:options args))) + +(defn- init [args] + (println "Initializing Fudo Server") + (log/init-logging "fudo-service" brolog.constants/log-local3) + (let [args (load-args args) + debug (or (:debug args) + (not (nil? (System/getenv "DEBUG"))))] + (swap! *server* + (fn [_] + (make-service-system debug + (:config-path args) + (:env-prefix args)))))) + +(defn- start [] + (println "Starting Fudo Server") + (swap! *server* + (fn [system] + (component/start system)))) + +(defn- stop [] + (swap! *server* + (fn [system] + (component/stop system)))) + +(defn -init [this ^DaemonContext context] + (init (.getArguments context))) + +(defn -start [this] + (future (start))) + +(defn -stop [this] + (stop)) + +(defn -main [& args] + (init args) + (start)) diff --git a/src/clj/fudo/server/middleware.clj b/src/clj/fudo/server/middleware.clj new file mode 100644 index 0000000..2ee3fff --- /dev/null +++ b/src/clj/fudo/server/middleware.clj @@ -0,0 +1,169 @@ +(ns fudo.server.middleware + (:require [fudo.db.auth-db :as auth-db] + [fudo.api.request.validator :as validator] + [fudo.api.response :as response] + [fudo.utils :as util] + [ring.util.request :as request] + [slingshot.slingshot :refer [try+ throw+]] + [clojure.data.json :as json])) + +(defn validation-middleware [validator handler] + (fn + ([request] + (try+ + (handler + (validator/validate-request validator request)) + (catch [:type :validator/invalid] {:keys [msg]} + {:status 400 + :headers {} + :body msg}))) + + ([request respond raise] + (handler + (try+ + (validator/validate-request validator request) + (catch [:type :validator/invalid] {:keys [msg]} + (respond {:status 400 + :headers {} + :body msg}))) + respond + raise)))) + +(defn- print-data [debug title data] + (when debug + (println title) + (clojure.pprint/pprint data)) + data) +(defn- pp [obj] + (clojure.pprint/pprint obj) + obj) + +(defn echo-middleware [debug handler] + (fn + ([request] (print-data debug "RESPONSE:" + (handler (print-data debug "REQUEST:" request)))) + ([request respond raise] + (handler (print-data debug "REQUEST:" request) + #(respond (print-data debug "RESPONSE:" %)) + raise)))) + +(defn json-wrapper [handler] + (let [empty->nil (fn [input] (if (empty? input) nil input)) + decode-body (fn [req] + (try+ + (assoc req :body-string + (some-> (:body-string req) empty->nil json/read-str)) + (catch Exception e + (throw+ {:type :middleware/json-parse-error + :body (:body-string req) + :class :client-error + :msg (format "Failed to decode message body: %s" + (:body-string req)) + :ex e})))) + encode-body (fn [resp] + (try+ + (assoc resp :body + (some-> (:body resp) empty->nil json/write-str)) + (catch Exception e + (throw+ {:type :middleware/json-write-error + :body (:body resp) + :class :server-error + :msg (format "Failed to encode message body: %s" + (:body resp)) + :ex e})))) + add-header (fn [resp] + (assoc-in resp [:headers "Content-Type"] + "application/json"))] + (fn + ([request] + (encode-body (handler (decode-body request)))) + ([request respond raise] + (handler (decode-body request) + #(respond (encode-body %)) + raise))))) + +(defn wrap-body-string [handler] + (letfn + [(body->string [req] + (if-let [body-string (request/body-string req)] + (assoc req :body-string body-string) + req))] + (fn + ([request] + (handler (body->string request))) + ([request respond raise] + (handler (body->string request) + respond + raise))))) + +(defn verify-secret [fudo-secret handler] + (letfn [(check-secret-header [req] + (let [req-secret (get-in req [:headers "fudo-secret"])] + (cond (nil? req-secret) + (throw+ {:type :fudo/missing-secret + :class :client-error + :msg "Required fudo-secret header missing!"}) + + (not (= req-secret fudo-secret)) + (throw+ {:type :fudo/invalid-secret + :class :client-error + :msg "Supplied fudo-secret invalid!"}) + + (= req-secret fudo-secret) req)))] + (fn + ([request] + (handler (check-secret-header request))) + ([request respond raise] + (handler (check-secret-header request) respond raise))))) + +(defn- exception-to-string [ex] + (let [string-writer (java.io.StringWriter.)] + (.printStackTrace ex (java.io.PrintWriter. string-writer)) + (.toString string-writer))) + +(defn wrap-errors [debug handler] + (letfn [(client-error + [type msg] + (when debug + (util/err (format "Client error: bad request (%s): %s" type msg))) + (response/client-error + (format "Client error: bad request (%s): %s" type msg))) + (server-error + [type msg] + (util/err (format "Internal server error (%s): %s" type msg)) + (response/internal-error + (format "Internal server error (%s): %s" + type msg))) + (unexpected-exception + [e] + (util/err (format "UNEXPECTED EXCEPTION (%s): %s\n%s" + (.. e getClass getName) + (.getMessage e) + (exception-to-string e))) + (response/internal-error + "Unexpected internal error!"))] + (fn + ([request] + (try+ (handler request) + (catch [:class :client-error] {:keys [type msg]} + (client-error type msg)) + (catch [:class :server-error] {:keys [type msg]} + (server-error type msg)) + (catch Exception e + (unexpected-exception e)))) + ([request respond raise] + (try+ (handler request respond raise) + (catch [:class :client-error] {:keys [type msg]} + (server-error type msg)) + (catch [:class :server-error] {:keys [type msg]} + (server-error type msg)) + (catch Exception e + (unexpected-exception e))))))) + +(defn sanitize-request [handler] + (letfn [(sanitize [req] + (apply dissoc req + (for [[k v] req :when (nil? v)] k)))] + (fn + ([req] (handler (sanitize req))) + ([req respond raise] (handler (sanitize req) respond raise))))) diff --git a/src/clj/fudo/server/request_validator.clj b/src/clj/fudo/server/request_validator.clj new file mode 100644 index 0000000..c01b4f0 --- /dev/null +++ b/src/clj/fudo/server/request_validator.clj @@ -0,0 +1,6 @@ +(ns fudo.server.request-validator) + +(defprotocol RequestValidator + + (validate-request [validator entity req] + "Given a request, check to make sure it's valid.")) diff --git a/src/clj/fudo/server/service.clj b/src/clj/fudo/server/service.clj new file mode 100644 index 0000000..dab0c90 --- /dev/null +++ b/src/clj/fudo/server/service.clj @@ -0,0 +1,28 @@ +(ns fudo.server.service + (:require [clojure.spec.alpha :as s])) + +(defprotocol FudoServiceComponent + "Represents a single API service component (eg. auth or dyndns)." + + (public? [self] + "Indicate whether requests must be authenticated.")) + +(defprotocol FudoService + "Represents a Fudo service in a composable way--representing either + one component or all components." + + (initialize [this] + "Perform any required initialization, and return an initialized + instance of the service.") + + (enabled? [self] + "Is this service enabled on the current host?") + + (generate-handler [self] + "Generate API routes to be added to the server.")) + +(defn fudo-service? [obj] + (and (satisfies? FudoService obj) + (satisfies? FudoServiceComponent obj))) + +(s/def ::service fudo-service?) diff --git a/src/clj/fudo/server/services.clj b/src/clj/fudo/server/services.clj new file mode 100644 index 0000000..2e9ef81 --- /dev/null +++ b/src/clj/fudo/server/services.clj @@ -0,0 +1,82 @@ + (ns fudo.server.services + (:require [fudo.server.config :as config] + [fudo.api :as api] + [fudo.server.middleware :as middleware] + [fudo.server.service :as service] + [compojure.core :refer [routes context]] + [compojure.route :as route] + [com.stuartsierra.component :as component] + [clojure.spec.alpha :as s])) + +(defn- pp [obj] + (println (format "DEBUG: %s" obj)) + obj) + +(defn- generate-api-handler [debug config services authenticator] + (let [fudo-secret (config/get-env config "FUDO_SECRET") + fudo-secret-middleware (partial middleware/verify-secret fudo-secret) + authenticator-middleware (partial middleware/validation-middleware + authenticator) + echo-middleware (partial middleware/echo-middleware debug) + error-wrapper (partial middleware/wrap-errors debug) + ;; TODO: Changing the logic here to assume the services are + ;; previously initialized (really, they should be initialized + ;; on creation). + public-routes (apply routes (map service/generate-handler + (filter service/public? services))) + private-routes (apply routes (map service/generate-handler + (filter #(not (service/public? %)) + services)))] + (routes + (context "/api" [] + (context "/public" [] + (-> public-routes + (middleware/json-wrapper) + (echo-middleware) + (middleware/wrap-body-string) + (fudo-secret-middleware) + (middleware/sanitize-request) + (error-wrapper))) + (context "/private" [] + (-> private-routes + (middleware/json-wrapper) + (authenticator-middleware) + (echo-middleware) + (middleware/wrap-body-string) + (fudo-secret-middleware) + (middleware/sanitize-request) + (error-wrapper))) + (context "/" [] (route/not-found "Not found."))) + (context "/" [] (route/not-found "Not found."))))) +(s/fdef generate-service-handler + :args (s/cat :debug boolean? + :config ::config/config + :services (s/coll-of ::service/service) + :authenticator (s/fspec :args (s/cat :req ::api/request) + :ret ::api/authenticated-request)) + :ret ::api/handler) + +(defrecord FudoServices [debug config local-services authenticator] + + component/Lifecycle + + (start [this] this) + + (stop [this] this) + + service/FudoService + + (enabled? [this] + (any? service/enabled? (:local-services this))) + + (generate-handler [this] + (generate-api-handler (:debug this) + (:config this) + (-> this :local-services :services) + (:authenticator this)))) + +(defn init [debug] + (->FudoServices debug nil nil nil)) + +(defn init-mock [debug config local-services authenticator] + (->FudoServices debug config local-services authenticator)) diff --git a/src/clj/fudo/server/services/auth.clj b/src/clj/fudo/server/services/auth.clj new file mode 100644 index 0000000..79398d2 --- /dev/null +++ b/src/clj/fudo/server/services/auth.clj @@ -0,0 +1,86 @@ +(ns fudo.server.services.auth + (:require [fudo.server.service :as service] + [fudo.server.config :as config] + [fudo.api.response :as response] + [fudo.crypto.keys :as key] + [fudo.db.auth-db :as auth-db] + [fudo.db.pg-auth-db :as pg-auth-db] + [compojure.core :refer [routes context GET PUT]] + [compojure.route :as route] + [fudo.api :as api] + [clojure.spec.alpha :as s])) + +(defn- get-entity-key-id [auth-db entity] + (-> (auth-db/get-entity-key-id auth-db entity) + (response/maybe-missing + (format "Entity not found: %s" + entity)))) +(s/fdef get-entity-key-id + :args (s/cat :auth-db ::auth-db/auth-db :entity ::auth-db/entity-name) + :ret ::api/response) + +(defn- get-entity-key [auth-db entity] + (-> (some-> (auth-db/get-entity-key auth-db entity) + key/pubkey->string) + (response/maybe-missing (format "Entity not found: %s" + entity)))) +(s/fdef get-entity-key + :args (s/cat :auth-db ::auth-db/auth-db :entity ::auth-db/entity-name) + :ret ::api/response) + +(defn- register-entity! [auth-db entity pubkey-str] + (-> (auth-db/register-entity! auth-db entity + (key/string->pubkey pubkey-str)) + (::auth-db/key-id) + (response/ok))) +(s/fdef register-entity! + :args (s/cat :auth-db ::auth-db/auth-db + :entity ::auth-db/entity-name + :pubkey-str string?) + :ret ::api/response) + +(defn- generate-auth-handler [debug auth-db] + (context "/auth" [] + (context "/entity" [] + (PUT "/:entity/key" [entity :as {body :body-string}] + (response/created + (register-entity! auth-db + entity + body))) + (GET "/:entity/key-id" [entity] + (get-entity-key-id auth-db entity)) + (GET "/:entity/key" [entity] + (get-entity-key auth-db entity)) + (GET "/ping" [] + (response/ok "pong")) + (route/not-found "Entity object not found.")) + (context "/" [] (route/not-found "Object not found.")))) +(s/fdef generate-dyndns-handler + :args (s/cat :debug boolean? :auth-db ::auth-db/auth-db) + :ret ::api/handler) + +(defrecord AuthService [debug config auth-db] + + service/FudoService + + (enabled? [this] + (-> config + (config/get-path (format "services::%s::auth" + (config/get-hostname))) + :auth + (comp not nil?))) + + (generate-handler [this] + (generate-auth-handler (:debug this) (:auth-db this))) + + (initialize [this] + (->AuthService (:debug this) + (:config this) + (auth-db/initialize (:auth-db this)))) + + service/FudoServiceComponent + + (public? [this] true)) + +(defn create [debug config auth-db] + (->AuthService debug config auth-db)) diff --git a/src/clj/fudo/server/services/dyndns.clj b/src/clj/fudo/server/services/dyndns.clj new file mode 100644 index 0000000..80c0cb6 --- /dev/null +++ b/src/clj/fudo/server/services/dyndns.clj @@ -0,0 +1,187 @@ +(ns fudo.server.services.dyndns + (:require [fudo.server.service :as service] + [fudo.server.config :as config] + [fudo.net.ip :as ip] + [fudo.net.dns :as dns] + [fudo.net :as net] + [fudo.api :as api] + [fudo.api.request :as req] + [fudo.api.response :as response] + [fudo.db.auth-db :as auth-db] + [fudo.db.dyndns-db :as dyndns-db] + [fudo.net.ip :as ip] + [compojure.core :refer [routes context GET PUT]] + [compojure.route :as route] + [clojure.spec.alpha :as s] + [slingshot.slingshot :refer [throw+]])) + +(defn- get-host-v4ip [dyndns-db host] + (-> (some-> (dyndns-db/get-host dyndns-db host) + ::dns/v4ip + ip/to-string) + (response/maybe-missing (format "Host v4ip not found: %s" host)))) +(s/fdef get-host-v4ip + :args (s/cat :dyndns-db ::dyndns-db/dyndns-db + :host ::net/hostname) + :ret ::api/response) + +(defn- get-host-v6ip [dyndns-db host] + (-> (some-> (dyndns-db/get-host dyndns-db host) + ::dns/v6ip + ip/to-string) + (response/maybe-missing (format "Host v6ip not found: %s" + host)))) +(s/fdef get-host-v6ip + :args (s/cat :dyndns-db ::dyndns-db/dyndns-db :host ::net/hostname) + :ret ::api/response) + +(defn- get-host-sshfp [dyndns-db host] + (response/maybe-missing (some->> (::dns/sshfp (dyndns-db/get-host dyndns-db host)) + (clojure.string/join "\n")) + (format "Host sshfp records not found: %s" host))) +(s/fdef get-host-sshfp + :args (s/cat :dyndns-db ::dyndns-db/dyndns-db :host ::net/hostname) + :ret ::api/response) + +(defn- check-v4ip [v4ip] + (let [ip (ip/address v4ip)] + (when (not (ip/v4? ip)) + (throw+ {:type :dyndns/invalid-address + :class :client-error + :msg (format "Not a v4 IP: %s" v4ip)})) + ip)) + +(defn- check-v6ip [v6ip] + (let [ip (ip/address v6ip)] + (when (not (ip/v6? ip)) + (throw+ {:type :dyndns/invalid-address + :class :client-error + :msg (format "Not a v6 IP: %s" v6ip)})) + ip)) + +(defn- put-host-v4ip [dyndns-db host body] + (dyndns-db/register-v4ip! dyndns-db host (check-v4ip body)) + (response/ok (format "Registered v4 address %s for host %s." + body host))) +(s/fdef put-host-v4ip + :args (s/cat :dyndns-db ::dyndns-db/dyndns-db + :host ::net/hostname + :body ::ip/v4ip-str) + :ret ::api/response) + +(defn- put-host-v6ip [dyndns-db host body] + (dyndns-db/register-v6ip! dyndns-db host (check-v6ip body)) + (response/ok (format "Registered v6 address %s for host %s." + body host))) +(s/fdef put-host-v6ip + :args (s/cat :dyndns-db ::dyndns-db/dyndns-db + :host ::net/hostname + :body ::ip/v6ip-str) + :ret ::api/response) + +(defn- put-host-sshfp [dyndns-db host body] + (dyndns-db/register-sshfps! dyndns-db host body) + (response/ok (format "Registered sshfp for host %s." + host))) +(s/fdef put-host-sshfp + :args (s/cat :dyndns-db ::dyndns-db/dyndns-db + :host ::net/hostname + :body (s/coll-of ::dns/sshfp-record)) + :ret ::api/response) + +(defn- entity [req] + (req/get-header req "fudo-entity")) +(s/fdef entity + :args (s/cat :req ::api/request) + :ret ::auth-db/entity-name) + +(defn- authorized? [req host] + ;; Since the entity is already validated vs. the key, it's safe to + ;; just take the entity + (= host (entity req))) +(s/fdef authorized? + :args (s/cat :req ::api/request + :host ::net/hostname) + :ret boolean?) + +(defn- get-domain [dyndns-db] + (response/ok (dyndns-db/get-domain dyndns-db))) +(s/fdef get-domain + :args (s/cat :dyndns-db ::dyndns-db/dyndns-db) + :ret ::api/response) + +(defn- generate-dyndns-handler [debug dyndns-db] + (context "/dyndns" [] + (GET "/domain" [] + (get-domain dyndns-db)) + (GET "/:host/v4ip" [host] + (get-host-v4ip dyndns-db host)) + (GET "/:host/v6ip" [host] + (get-host-v6ip dyndns-db host)) + (GET "/:host/sshfp" [host] + (get-host-sshfp dyndns-db host)) + (PUT "/:host/v4ip" [host :as req] + (if (authorized? req host) + (put-host-v4ip dyndns-db + host + (:body-string req)) + (response/unauth + (format "%s not authorized to modify host %s" + (entity req) host)))) + (PUT "/:host/v6ip" [host :as req] + (if (authorized? req host) + (put-host-v6ip dyndns-db + host + (:body-string req)) + (response/unauth + (format "%s not authorized to modify host %s" + (entity req) host)))) + (PUT "/:host/sshfp" [host :as req] + (if (authorized? req host) + (put-host-sshfp dyndns-db + host + (clojure.string/split (:body-string req) + #"\n")) + (response/unauth + (format "%s not authorized to modify host %s" + (entity req) host)))) + (GET "/ping" [] + (response/ok "pong")) + (route/not-found "Host object not found"))) +(s/fdef generate-dyndns-handler + :args (s/cat :debug boolean? + :dyndns-db ::dyndns-db/dyndns-db) + ;;:ret ::api/handler + :ret any?) + +(s/def ::debug boolean?) + +(defrecord DynDnsService [debug config dyndns-db] + + service/FudoService + + (enabled? [this] + (-> config + (config/get-path (format "services::%s::dyndns" + (config/get-hostname))) + :dyndns + (comp not nil?))) + + (generate-handler [this] + (generate-dyndns-handler (:debug this) (:dyndns-db this))) + + (initialize [this] + (->DynDnsService (:debug this) (:config this) + (dyndns-db/initialize (:dyndns-db this)))) + + service/FudoServiceComponent + + (public? [this] false)) + +(defn create [debug config dyndns-db] + (->DynDnsService debug config dyndns-db)) +(s/fdef create + :args (s/cat :debug boolean? + :config ::config/config + :dyndns-db ::dyndns-db/dyndns-db) + :ret (s/keys :req-un [::debug ::config/config ::dyndns-db/dyndns-db])) diff --git a/src/clj/fudo/utils.clj b/src/clj/fudo/utils.clj new file mode 100644 index 0000000..21e4ca5 --- /dev/null +++ b/src/clj/fudo/utils.clj @@ -0,0 +1,30 @@ +(ns fudo.utils + (:require [brolog.logging :as log] + [clojure.spec.alpha :as s])) + +(defn echo-through [data] + (println "\n\nDATA") + (clojure.pprint/pprint data) + (println "\n\n") + data) + +(defn err [msg] + (binding [*out* *err*] + (println msg)) + (log/error msg)) + +(defn info [msg] + (println msg) + (log/info msg)) + +(defn add-current-namespace [kwd] + (keyword (name (ns-name *ns*)) (name (name kwd)))) +(s/fdef add-current-namespace + :args (s/cat :kwd simple-keyword?) + :ret qualified-keyword?) + +(defn canonicalize-keys [pairs] + (into {} (map (fn [[k v]] {(add-current-namespace k) v}) pairs))) +(s/fdef canonicalize-keys + :args (s/cat :pairs (s/map-of simple-keyword? any?)) + :ret (s/map-of qualified-keyword? any?)) diff --git a/test/fudo/server_test.clj b/test/fudo/server_test.clj new file mode 100644 index 0000000..ac20f40 --- /dev/null +++ b/test/fudo/server_test.clj @@ -0,0 +1,315 @@ +(ns fudo.server-test + (:require [fudo.api.request.generator :as gen] + [fudo.api.request :as req] + [fudo.server.config :as config] + [fudo.server.services.dyndns :as dyndns] + [fudo.server.services.auth :as auth] + [fudo.server.service :as service] + [fudo.server.services :as services] + [fudo.crypto.utils :as crypt] + [fudo.crypto.keys :as key] + [fudo.db.dyndns-db :as dyndns-db] + [fudo.db.transient-dyndns-db :as test-dyndns-db] + [fudo.db.auth-db :as auth-db] + [fudo.db.transient-auth-db :as test-auth-db] + [fudo.net.ip :as ip] + [fudo.net.dns :as dns] + [fudo.api.request.authenticator.auth-db :as authenticator] + [clojure.data.json :as json] + [clojure.test :refer [deftest is]] + [orchestra.spec.test :as spectest])) + +(defn pp [obj] + (println (format "TEST_DBG: %s" obj)) + obj) + +(spectest/instrument) + +(let [test-domain "test.com" + test-host "my-host" + config (config/init-mock-config {}) + test-v4ip "4.3.2.1" + test-v6ip "6::2" + test-sshfp ["0 0 b9c019543f72777aee2d9b4ba883856f2f1f83e4e62ee8717" + "1 1 00a8f69cddae085451dec5d7b54e36e0bedb42241728751b81355f7abe8f99ffb"] + test-hosts { + "my-v4ip-host" {::dns/v4ip (ip/address test-v4ip)} + "my-v6ip-host" {::dns/v6ip (ip/address test-v6ip)} + "my-sshfp-host" {::dns/sshfp test-sshfp} + } + dyndns-db (test-dyndns-db/create true config test-domain test-hosts) + dyndns-service (service/initialize (dyndns/create true config dyndns-db)) + dyndns-handler (service/generate-handler dyndns-service)] + (deftest dyndns-domain + (is (= test-domain + (-> (gen/make-get "/dyndns/domain") + (gen/add-host-header test-host) + dyndns-handler + :body + clojure.string/trim)))) + (deftest dyndns-domain-success + (is (= 200 + (-> (gen/make-get "/dyndns/domain") + (gen/add-host-header test-host) + dyndns-handler + :status)))) + + (deftest insert-v4ip + (is (= 200 + (-> (gen/make-put (format "/dyndns/%s/v4ip" test-host) "4.3.2.1") + (gen/add-host-header test-host) + dyndns-handler + :status)))) + (deftest read-v4ip + (is (= (ip/address test-v4ip) + (-> (gen/make-get (format "/dyndns/my-v4ip-host/v4ip")) + (gen/add-host-header test-host) + dyndns-handler + :body + clojure.string/trim + ip/address)))) + (deftest read-v4ip-success + (is (= 200 + (-> (gen/make-get (format "/dyndns/my-v4ip-host/v4ip")) + (gen/add-host-header test-host) + dyndns-handler + :status)))) + + (deftest insert-v6ip + (is (= 200 + (-> (gen/make-put (format "/dyndns/%s/v6ip" test-host) "4::1") + (gen/add-host-header test-host) + dyndns-handler + :status)))) + (deftest read-v6ip + (is (= (ip/address test-v6ip) + (-> (gen/make-get (format "/dyndns/my-v6ip-host/v6ip")) + (gen/add-host-header test-host) + dyndns-handler + :body + clojure.string/trim + ip/address)))) + (deftest read-v6ip-success + (is (= 200 + (-> (gen/make-get (format "/dyndns/my-v6ip-host/v6ip")) + (gen/add-host-header test-host) + dyndns-handler + :status)))) + + (deftest insert-sshfp + (is (= 200 + (-> (gen/make-put (format "/dyndns/%s/sshfp" test-host) + (clojure.string/join "\n" test-sshfp)) + (gen/add-host-header test-host) + dyndns-handler + :status)))) + (deftest read-sshfp + (is (= test-sshfp + (-> (gen/make-get (format "/dyndns/my-sshfp-host/sshfp")) + (gen/add-host-header test-host) + dyndns-handler + :body + clojure.string/trim + (clojure.string/split #"\n"))))) + (deftest read-sshfp-success + (is (= 200 + (-> (gen/make-get (format "/dyndns/my-sshfp-host/sshfp")) + (gen/add-host-header test-host) + dyndns-handler + :status)))) + + (deftest read-missing-v4ip + (is (= 404 + (-> (gen/make-get "/dyndns/nonexistent-host/v4ip") + (gen/add-host-header test-host) + dyndns-handler + :status)))) + + (deftest read-missing-v6ip + (is (= 404 + (-> (gen/make-get "/dyndns/nonexistent-host/v6ip") + (gen/add-host-header test-host) + dyndns-handler + :status)))) + + (deftest read-missing-sshfp + (is (= 404 + (-> (gen/make-get "/dyndns/nonexistent-host/sshfp") + (gen/add-host-header test-host) + dyndns-handler + :status))))) + +(let [config (config/init-mock-config {}) + keypair (key/generate-public-private-pair) + pubkey (-> keypair ::key/public-key) + key-id (-> pubkey key/pubkey->string crypt/sha1-hash) + test-host "test-host" + auth-hosts {test-host (auth-db/make-entity test-host pubkey)} + auth-db (test-auth-db/create true config auth-hosts) + auth-service (auth/create true config auth-db) + auth-handler (service/generate-handler auth-service)] + + (deftest get-key + (is (= (key/pubkey->string pubkey) + (-> (gen/make-get (format "/auth/entity/%s/key" test-host)) + (gen/add-host-header test-host) + auth-handler + :body + clojure.string/trim)))) + (deftest get-key-success + (is (= 200 + (-> (gen/make-get (format "/auth/entity/%s/key" test-host)) + (gen/add-host-header test-host) + auth-handler + :status)))) + (deftest get-missing-key + (is (= 404 + (-> (gen/make-get "/auth/entity/nonexistent-host/key") + (gen/add-host-header test-host) + auth-handler + :status)))) + + (deftest get-key-id + (is (= key-id + (-> (gen/make-get (format "/auth/entity/%s/key-id" test-host)) + (gen/add-host-header test-host) + auth-handler + :body + clojure.string/trim)))) + (deftest get-key-id-success + (is (= 200 + (-> (gen/make-get (format "/auth/entity/%s/key-id" test-host)) + (gen/add-host-header test-host) + auth-handler + :status)))) + (deftest get-missing-key-id + (is (= 404 + (-> (gen/make-get "/auth/entity/nonexistent-host/key-id") + (gen/add-host-header test-host) + auth-handler + :status))))) + +(let [debug false + test-domain "test.com" + test-host "my-host" + config (config/init-mock-config {"FUDO_SECRET" "test-secret"}) + keypair (key/generate-public-private-pair) + privkey (-> keypair ::key/private-key) + pubkey (-> keypair ::key/public-key) + key-id (-> pubkey key/pubkey->string crypt/sha1-hash) + auth-hosts {test-host (auth-db/make-entity test-host pubkey)} + auth-db (test-auth-db/create debug config auth-hosts) + auth-service (auth/create debug config auth-db) + test-v4ip "4.3.2.1" + test-v6ip "6::2" + test-sshfp ["0 0 b9c019543f72777aee2d9b4ba883856f2f1f83e4e62ee8717" + "1 1 00a8f69cddae085451dec5d7b54e36e0bedb42241728751b81355f7abe8f99ffb"] + test-hosts { + "my-v4ip-host" {::dns/v4ip (ip/address test-v4ip)} + "my-v6ip-host" {::dns/v6ip (ip/address test-v6ip)} + "my-sshfp-host" {::dns/sshfp test-sshfp} + } + dyndns-db (test-dyndns-db/create debug config test-domain test-hosts) + dyndns-service (service/initialize (dyndns/create debug config dyndns-db)) + authenticator (authenticator/init-mock debug config auth-db) + local-services {:services [auth-service dyndns-service]} + services (services/init-mock debug config local-services authenticator) + handler (service/generate-handler services) + get-result (fn [url] + (-> (gen/make-get url) + (gen/add-fudo-secret "test-secret") + (gen/authenticate-request test-host privkey key-id) + handler)) + get-status (fn [url] (:status (get-result url))) + get-body (fn [url] (json/read-str (:body (get-result url)))) + put-result (fn [url data] + (-> (gen/make-put url (json/write-str data)) + (gen/add-fudo-secret "test-secret") + (gen/authenticate-request test-host privkey key-id) + handler)) + put-status (fn [url data] (:status (pp (put-result url data))))] + + (deftest authenticated-get-v4ip-success + (is (= 200 (get-status "/api/private/dyndns/my-v4ip-host/v4ip")))) + + (deftest authenticated-get-v4ip + (is (= (ip/address test-v4ip) + (ip/address (get-body (format "/api/private/dyndns/my-v4ip-host/v4ip")))))) + + (deftest authenticated-get-missing-v4ip + (is (= 404 (get-status "/api/private/dyndns/nonexistent-host/v4ip")))) + + (deftest authenticated-put-new-v4ip-unauthorized + (is (= 401 (put-status "/api/private/dyndns/new-host/v4ip" "4.5.3.6")))) + + (deftest authenticated-put-new-v4ip-authorized + (is (= 200 (put-status (format "/api/private/dyndns/%s/v4ip" test-host) + "4.5.3.6")))) + + (deftest authenticated-put-new-v6ip-unauthorized + (is (= 401 (put-status "/api/private/dyndns/other-host/v6ip" "5::3")))) + + (deftest authenticated-put-new-v6ip-authorized + (is (= 200 (put-status (format "/api/private/dyndns/%s/v6ip" test-host) "5::3")))) + + (deftest authenticated-get-v6ip-success + (is (= 200 (get-status "/api/private/dyndns/my-v6ip-host/v6ip")))) + + (deftest authenticated-get-v6ip + (is (= (ip/address test-v6ip) + (ip/address (get-body "/api/private/dyndns/my-v6ip-host/v6ip"))))) + + (deftest authenticated-get-missing-v6ip + (is (= 404 (get-status "/api/private/dyndns/nonexistent-host/v6ip")))) + + (deftest missing-fudo-secret + (is (= 400 + (-> (gen/make-get "/api/private/dyndns/my-v4ip-host/v4ip") + (gen/authenticate-request test-host privkey key-id) + handler + :status)))) + + (deftest missing-fudo-entity-header + (is (= 400 + (-> (gen/make-get "/api/private/dyndns/my-v4ip-host/v4ip") + (gen/add-fudo-secret "test-secret") + (gen/authenticate-request test-host privkey key-id) + (gen/remove-header "fudo-entity") + handler + :status)))) + + (deftest missing-fudo-key-id-header + (is (= 400 + (-> (gen/make-get "/api/private/dyndns/my-v4ip-host/v4ip") + (gen/add-fudo-secret "test-secret") + (gen/authenticate-request test-host privkey key-id) + (gen/remove-header "fudo-key-id") + handler + :status)))) + + (deftest missing-fudo-timestamp-header + (is (= 400 + (-> (gen/make-get "/api/private/dyndns/my-v4ip-host/v4ip") + (gen/add-fudo-secret "test-secret") + (gen/authenticate-request test-host privkey key-id) + (gen/remove-header "fudo-timestamp") + handler + :status)))) + + (deftest missing-fudo-signature-header + (is (= 400 + (-> (gen/make-get "/api/private/dyndns/my-v4ip-host/v4ip") + (gen/add-fudo-secret "test-secret") + (gen/authenticate-request test-host privkey key-id) + (gen/remove-header "fudo-signature") + handler + :status)))) + + (deftest bad-fudo-signature-header + (is (= 400 + (-> (gen/make-get "/api/private/dyndns/my-v4ip-host/v4ip") + (gen/add-fudo-secret "test-secret") + (gen/authenticate-request test-host privkey key-id) + (req/set-header "fudo-signature" "gobbledigook") + handler + :status)))))